Synaplan
开源 AI 软件平台,面向企业:自托管 AI 代理,符合 GDPR 的聊天机器人小部件,RAG 知识库,WhatsApp 与 Outlook AI,MCP,多模型路由。支持 Docker、Kubernetes 或在 Germ 托管。
AI 翻译
尚未检测到更新。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).