尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗Plataforma de agendamentos organizada como monólito modular: uma única API central (Node.js + TypeScript + Prisma + PostgreSQL) consumida por uma app Web (Next.js) e uma app Mobile (Expo + React Native).
SLOTIX/
├── apps/
│ ├── api/ Node.js + TypeScript + Express + Prisma — o núcleo do sistema
│ ├── web/ Next.js — consumidor da API, sem acesso direto à BD
│ └── mobile/ Expo + React Native — consumidor da API, sem acesso direto à BD
└── packages/
├── types/ Tipos/DTOs partilhados entre API, Web e Mobile
└── validation/ Schemas Zod partilhados (contratos de API)
apps/api, organizado em módulos por domínio
(auth, users, businesses, clients, professionals, services,
availability, appointments, payments, notifications, favorites,
reviews, gallery, search)./api/v1.Authorization: Bearer.*.controller.ts → *.service.ts →
*.repository.ts, com validação Zod em *.schema.ts.gallery) usa Supabase Storage.Só é preciso o Docker Desktop (ou Docker Engine com Compose). Não é preciso instalar Node nem PostgreSQL.
git clone https://github.com/FTMonteiro/Agenda-.git
cd Agenda-
docker compose up --build
A primeira vez demora alguns minutos. Depois:
[email protected] / password123/api/v1/docs)localhost:5433, utilizador/password/base agendaA base de dados é local e começa com os dados de exemplo do seed. Os dados ficam guardados
entre arranques; docker compose down -v apaga-os e recomeça do zero. Depois de mudar
código, volte a correr docker compose up --build.
Login com Google, upload de imagens e mapa precisam de chaves próprias: copie
.env.docker.example para .env na raiz e preencha. Sem elas, o resto funciona.
App mobile: corre fora do Docker. Com o Docker ligado, em apps/mobile:
npm install
# no .env da app, o IP do computador na rede (não "localhost", que no telemóvel é o próprio telemóvel):
# EXPO_PUBLIC_API_URL=http://192.168.x.x:3000/api/v1
npx expo start
Requisitos: Node.js 20+, PostgreSQL, npm (workspaces).
npm install
cp apps/api/.env.example apps/api/.env # preencher DATABASE_URL, segredos JWT e (opcional) Supabase
cp apps/web/.env.example apps/web/.env
cp apps/mobile/.env.example apps/mobile/.env
SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY são opcionais — a API arranca
sem eles, só os endpoints de /gallery (upload) precisam.
cd apps/api
npx prisma migrate dev # cria as tabelas
npm run seed # cria negócio/owner/employee/service de exemplo
Contas criadas pelo seed (password password123):
[email protected], [email protected], [email protected].
npm run dev:api # Express em http://localhost:3000
npm run dev:web # Next.js em http://localhost:3001
npm run dev:mobile # Expo (usa o app Expo Go ou um simulador)
npm run test:api
Cobre as regras críticas de auth (registo duplicado, credenciais inválidas),
availability/scheduling (cálculo puro de slots livres, horário de
funcionamento, bloqueios), appointments (serviço/profissional inexistente
ou inativo, horário indisponível — incluindo fora de horas e bloqueios —,
permissões, transições de estado), reviews (só é possível avaliar um
agendamento próprio e concluído, uma única vez), favorites (sem
duplicados), businesses (filtros e cálculo de rating/paginação), users
(soft delete), payments (valor sempre vindo da appointment nunca do
cliente, duplicados, ownership, transições de estado válidas/inválidas) e
gallery (tipo de ficheiro, limite de imagens, ownership, falha
best-effort do storage — com o cliente Supabase mockado) e search
(correspondência por palavra, nearest sem localização, ordenação em
cada modo, paginação).
Respostas de item único:
{ "success": true, "data": {} }
{ "success": false, "error": { "code": "APPOINTMENT_NOT_AVAILABLE", "message": "..." } }
Respostas de lista (GET /businesses) incluem paginação:
{ "success": true, "data": [], "meta": { "page": 1, "limit": 20, "total": 100 } }
Rotas principais (ver apps/api/src/app/routes.ts):
/api/v1/auth (register, login, refresh, logout, me),
/api/v1/users (me — GET/PATCH/DELETE, me/password),
/api/v1/businesses (com filtros
?category=&search=&minRating=&minPrice=&maxPrice=&latitude=&longitude=&radiusKm=&page=&limit=,
mais /:id/services, /:id/professionals, /:id/reviews, /:id/hours — GET público, PUT do OWNER),
/api/v1/search?q=...&latitude=&longitude=&sortBy=recommended|nearest|bestPrice|topRated&page=&limit=
(pesquisa livre — ver secção Search abaixo),
/api/v1/clients, /api/v1/professionals (mais /:id/blocks — folgas/bloqueios,
geridos pelo OWNER ou pelo próprio profissional), /api/v1/services,
/api/v1/availability?businessId=&professionalId=&serviceId=&date=YYYY-MM-DD
(devolve os horários livres, já a considerar horário de funcionamento,
bloqueios e agendamentos existentes),
/api/v1/appointments (mais /:id/payment — GET do pagamento da appointment),
/api/v1/favorites (POST/DELETE /:businessId),
/api/v1/reviews, /api/v1/notifications,
/api/v1/payments (POST cria; GET lista os próprios ou ?businessId= para
o OWNER; GET /:id; PATCH /:id/pay, /:id/fail, /:id/refund — restritos
ao OWNER do negócio ou a um ADMIN),
/api/v1/businesses/:businessId/gallery (GET público; POST — multipart/form-data,
campo file + caption opcional, OWNER; DELETE /:id, OWNER).
DELETE /users/me é soft delete (marca deletedAt, revoga refresh tokens) —
appointments/reviews/negócios existentes não são apagados nem ficam órfãos.
Um access token já emitido continua válido até expirar (15 min por omissão);
só o login/refresh ficam bloqueados de imediato.
Um negócio sem horário configurado é tratado como aberto todos os dias (para
não quebrar negócios criados antes desta funcionalidade); assim que um dia é
configurado, os restantes dias sem linhas próprias passam a "fechado".
appointments.create/reschedule usam exatamente o mesmo cálculo de
shared/utils/scheduling.ts que /availability, por isso um horário aceite
na criação é sempre um que /availability já tinha listado como livre.
Domínio de pagamentos interno — sem gateway externo integrado ainda
(nem Multicaixa Express, nem Stripe, nem cartões reais). Um Payment é
1:1 com a Appointment (appointmentId é único): em vez de criar novas
linhas, o mesmo registo transita entre estados, preservando o histórico.
Estados e transições válidas:
PENDING → PAID
PENDING → FAILED
PAID → REFUNDED
Qualquer outra transição (PENDING → REFUNDED, FAILED → PAID,
REFUNDED → *, etc.) é rejeitada com PAYMENT_INVALID_STATUS,
PAYMENT_ALREADY_PAID ou PAYMENT_ALREADY_REFUNDED. paidAt é sempre
gerado pelo backend ao marcar como pago e nunca é apagado num reembolso.
amount, currency, userId e businessId vêm sempre da Appointment
associada — nunca do corpo do pedido, mesmo que o cliente os envie. A
moeda por omissão é AOA (shared/constants), não fixa na lógica de
negócio. Só o OWNER do negócio (ou um ADMIN) pode marcar como
pago/falhado/reembolsado; o cliente só pode criar e consultar os seus
próprios pagamentos. PaymentPaid/PaymentRefunded disparam notificações
via o mesmo event bus interno usado pelos appointments.
Imagens dos espaços, guardadas no Supabase Storage (bucket
SUPABASE_STORAGE_BUCKET, por omissão slotix-gallery — cria o bucket
como público no projeto Supabase antes de usar). O upload é
multipart/form-data (via multer, buffer em memória — nunca escrito em
disco, porque a API pode correr num filesystem efémero) com um limite de
5MB e apenas JPEG/PNG/WEBP/GIF; cada negócio tem um limite de 20 imagens.
Só o OWNER do negócio pode fazer upload/apagar; a listagem é pública.
A base de dados guarda o url público e o path (a chave no bucket, para
conseguir apagar o ficheiro depois) — nunca o ficheiro em si. Apagar uma
imagem remove primeiro a linha da BD (a fonte de verdade para a UI) e só
depois tenta apagar do storage; se isso falhar, fica só um ficheiro órfão
no bucket, nunca uma linha partida na BD.
Sem SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY configuradas, o resto da
API funciona normalmente e só o upload falha com 500, sem detalhes de
configuração na resposta (só nos logs do servidor).
Um negócio criado via POST /businesses fica imediatamente visível pela
mesma API a qualquer consumidor (Web ou Mobile) — não há um passo de
"publicação" separado nem um cadastro/rota diferente para o mobile.
latitude/longitude já existiam em Business desde a Fase 1 e não são
limitadas a Angola (confirmado com um teste real Lisboa↔Luanda, ~5773km).
GET /search?q=... combina texto com localização:
q=cortar cabelo
divide em ["cortar", "cabelo"] e encontra qualquer negócio ou serviço
ativo cujo nome/descrição/categoria contenha pelo menos uma dessas
palavras — assim "Corte de Cabelo" é encontrado mesmo sem a forma exata
"cortar" aparecer no texto. Sem motor de pesquisa nem extensão
PostgreSQL nova, só contains do Prisma.matchedService: cada resultado traz o serviço mais relevante do
negócio (o que tem mais palavras da pesquisa em comum; empate resolvido
pelo mais barato), com nome e preço — ex. {"name":"Corte Premium","price":12000}.distanceKm: calculado com Haversine simples (sem PostGIS) só
quando latitude/longitude são passadas no pedido — nunca guardadas,
a localização do utilizador só é usada nesse pedido.sortBy tem 4 modos previsíveis, cada um a ordenar por exatamente
uma dimensão (só recommended combina sinais, com critérios de
desempate claros):
recommended (omissão): rating ponderado → distância → nº de reviews.nearest: distância ascendente — exige latitude+longitude
(400 SEARCH_LOCATION_REQUIRED se faltarem).bestPrice: preço do matchedService ascendente.topRated: rating ponderado descendente.shared/utils/ranking.ts): uma única avaliação de
5 estrelas não ultrapassa um negócio com centenas de avaliações
consistentes de 4.6 — a média é "encolhida" para um prior neutro (3.5)
até haver reviews suficientes para confiar nela. Isto só afeta a
ordenação; o ratingAvg/ratingCount mostrados ao utilizador
continuam a ser a média real, exatamente como antes. O módulo reviews
em si não foi alterado.GET /businesses ganhou os mesmos latitude/longitude/radiusKm
opcionais (para o ecrã de "explorar", sem termo de pesquisa) — a ordem por
omissão (mais recentes primeiro) não mudou; só passa a incluir
distanceKm e, com radiusKm, filtra o que estiver fora do raio.
A pesquisa/descoberta não recalcula disponibilidade: matchedService
mostra o preço, mas confirmar um horário continua a ser exclusivamente
GET /availability + POST /appointments, tal como já existiam — a
Fase de pesquisa foi verificada em ponta-a-ponta ao vivo exatamente
por este caminho (criar negócio → aparece na pesquisa → /availability
→ /appointments, sem tocar em nenhum dos dois módulos).
O alojamento ainda não está definido. Seja qual for, a configuração de segurança abaixo é
obrigatória antes do lançamento. Nenhum destes valores deve ser escrito em ficheiros
versionados (o apps/web/.env.production só tem URLs públicos).
apps/api)| Variável | Produção |
|---|---|
NODE_ENV | production (liga HSTS, desliga o Swagger e o CORS aberto de desenvolvimento) |
JWT_ACCESS_SECRET, JWT_REFRESH_SECRET | 32+ caracteres aleatórios, diferentes entre si (a API avisa no arranque se não forem) |
JWT_OAUTH_SECRET | Opcional, 32+ caracteres; sem ele é derivado de JWT_ACCESS_SECRET |
INTERNAL_PROXY_SECRET | 32+ caracteres, diferente dos JWT, igual ao da web (ver abaixo) |
WEB_URL | Origem pública do painel, ex. https://painel.exemplo.ao (também usada no retorno do login Google) |
CORS_ORIGINS | Só se a versão web da app Expo for publicada: a sua origem. O painel e as apps nativas não precisam |
TRUST_PROXY | Número de proxies à frente da API (ex. 1), só se a API não for acessível sem eles |
ENABLE_API_DOCS | Deixar vazio (Swagger desligado) |
JWT_ACCESS_EXPIRES_IN | 15m recomendado. A app mobile antiga não renova tokens: um valor longo aumenta a janela de um token roubado |
Variáveis definidas mas vazias contam como não definidas; valores inválidos impedem o arranque.
apps/web) — variáveis de servidor, nunca NEXT_PUBLIC_*| Variável | Produção |
|---|---|
INTERNAL_PROXY_SECRET | O mesmo valor da API. Assina os pedidos que o servidor do painel reencaminha, para a API contar os limites por IP de cada utilizador |
TRUSTED_CLIENT_IP_HEADER | O cabeçalho que a plataforma define com o IP real e que o cliente não consegue forjar (ex. x-real-ip na Vercel). Sem ele, os limites por IP não se aplicam aos pedidos do painel (os limites por conta continuam) |
refresh_tokens que já existe (cada linha é uma sessão; o seu id vai nos tokens).-- contas cujo email só difere nas maiúsculas (o login recusa-as por ambiguidade)
SELECT lower(email), count(*) FROM users GROUP BY 1 HAVING count(*) > 1;
-- refresh tokens com o mesmo hash (anteriores ao jti)
SELECT "tokenHash", count(*) FROM refresh_tokens GROUP BY 1 HAVING count(*) > 1;
O logout passa a terminar a sessão também no servidor; isso só chega aos utilizadores numa nova
versão da app. "Terminar todas as sessões" (painel) e a mudança de password terminam as
sessões iniciadas depois deste deploy. Tokens emitidos antes dele não pertencem a nenhuma
sessão e só deixam de valer quando expiram (JWT_ACCESS_EXPIRES_IN).