Initial commit

This commit is contained in:
Tiago
2025-11-29 21:18:53 -03:00
commit 86d13bee5d
14 changed files with 4216 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
client/node_modules
data/agenda.db
npm-debug.log
.DS_Store

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules
client/node_modules
dist
client/dist
data/agenda.db
.env
npm-debug.log
.DS_Store

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY client/package.json client/
RUN cd client && npm install
COPY client client
RUN cd client && npm run build
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY server server
COPY --from=builder /app/client/dist client/dist
EXPOSE 4000
CMD ["npm", "start"]

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# OdontoFlow Scheduler
Dashboard de agendamento para clínicas odontológicas com front-end em React e back-end Node/PostgreSQL prontos para rodar em Docker.
## Estrutura
- `client/`: app React (Vite) reproduzindo a experiência solicitada com agenda, filtros e modais.
- `server/`: API Express conectada a um banco PostgreSQL com CRUD completo de pacientes, agendamentos e prontuários.
- `docker-compose.yml`: sobe o app, o banco PostgreSQL e o Adminer para gerenciamento visual.
## Comandos de desenvolvimento
1. Instale dependências do servidor:
```bash
npm install
```
2. No diretório `client`, instale o front-end:
```bash
cd client && npm install
```
3. Garanta que exista um PostgreSQL disponível (por exemplo `docker compose up postgres -d`). Configure a URL em `DATABASE_URL` ou use `postgresql://agenda:agenda@localhost:5432/agenda`.
4. Em uma janela, rode o servidor:
```bash
npm run start
```
5. Em outra, inicie o front-end em modo dev:
```bash
cd client && npm run dev
```
O front-end irá consumir a API em `http://localhost:4000/api` automaticamente.
## Construindo e executando via Docker
```bash
docker compose up --build
```
A aplicação ficará disponível em `http://localhost:4000`. O PostgreSQL roda no serviço `postgres` (persistido em `postgres_data`) e o Adminer para inspeção está em `http://localhost:8080` (use `agenda` / `agenda` / banco `agenda`).
## Notas
- Os seeds criam três médicos e alguns agendamentos iniciais na primeira execução do banco.
- O servidor serve os assets estáticos de `client/dist`; rode `npm run build` dentro de `client` antes de subir a imagem ou deixe o Docker cuidar disso.
- Utilize as rotas REST em `/api/appointments`, `/api/patients`, `/api/records` e `/api/doctors` para integrar outros sistemas.
## Integração via N8N
Há um endpoint único pensado para automações low-code. Ele concentra todas as ações necessárias para criar, consultar, alterar ou remover agendamentos e cadastrar pacientes usando o N8N (ou qualquer outro orquestrador).
### Endpoint
- **URL:** `POST /api/integrations/n8n`
- **Corpo:** JSON com as chaves:
- `action`: string informando qual operação executar (ver tabela abaixo).
- `data`: objeto com os campos necessários para a ação escolhida.
### Ações suportadas
| Ação (`action`) | Dados mínimos em `data` | O que faz |
|-----------------|-------------------------|-----------|
| `create_appointment` | `patient`, `doctorId`, `date`, `time` (opcionais: `phone`, `type`, `status`, `notes`) | Cria um agendamento e retorna o registro completo |
| `list_appointments` | (opcional) `date`, `doctorId`, `status`, `patient` | Lista agendamentos aplicando filtros |
| `get_appointment` | `id` | Busca um agendamento específico com dados do médico |
| `update_appointment` | `id` + campos a atualizar (`patient`, `doctorId`, `date`, `time`, etc.) | Atualiza todas as informações do agendamento |
| `reschedule_appointment` | `id`, `date`, `time` (opcional `doctorId`) | Atualiza apenas data/horário e, opcionalmente, o médico |
| `delete_appointment` | `id` | Remove o agendamento informado |
| `create_patient` | `name`, `phone` (opcionais: `email`, `cpf`, `cep`, `address`, `birthdate`) | Cadastra um novo paciente |
Caso `action` seja inválido, o servidor responde com HTTP 400 e mensagem listando as opções válidas.
### Exemplo de uso (cURL ou HTTP Request node no N8N)
```bash
curl -X POST http://localhost:4000/api/integrations/n8n \
-H "Content-Type: application/json" \
-d '{
"action": "create_appointment",
"data": {
"patient": "João Silva",
"phone": "+55 11 9999-9999",
"doctorId": "dr-ana",
"date": "2024-10-01",
"time": "09:00",
"type": "Avaliação",
"status": "pending"
}
}'
```
Resposta típica:
```json
{
"result": {
"id": 123,
"patient": "João Silva",
"phone": "+55 11 9999-9999",
"doctor_id": "dr-ana",
"date": "2024-10-01",
"time": "09:00",
"type": "Avaliação",
"status": "pending",
"notes": "",
"createdAt": "2024-09-10T12:34:56.000Z",
"doctorId": "dr-ana"
}
}
```
No N8N, basta configurar um nó HTTP Request em `POST`, apontando para a URL acima e enviando o JSON conforme a ação desejada.

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OdontoFlow</title>
<link rel="icon" type="image/svg+xml" href="https://lucide.dev/icons/calendar-days.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

20
client/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "agenda-client",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.278.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.1.2"
},
"type": "module"
}

1268
client/src/App.css Normal file

File diff suppressed because it is too large Load Diff

1972
client/src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

10
client/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './App.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

12
client/vite.config.mjs Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:4000',
},
},
});

0
data/.gitkeep Normal file
View File

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
postgres:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_DB: agenda
POSTGRES_USER: agenda
POSTGRES_PASSWORD: agenda
volumes:
- postgres_data:/var/lib/postgresql/data
adminer:
image: adminer:4
restart: unless-stopped
ports:
- "8080:8080"
environment:
- ADMINER_DEFAULT_SERVER=postgres
depends_on:
- postgres
agenda:
build: .
ports:
- "4000:4000"
restart: unless-stopped
environment:
- PORT=4000
- DATABASE_URL=postgresql://agenda:agenda@postgres:5432/agenda
depends_on:
- postgres
volumes:
postgres_data:

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "agenda-dashboard",
"version": "0.1.0",
"private": true,
"description": "Node + React dashboard para clínicas odontológicas com SQLite e Docker.",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node server/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"morgan": "^1.10.0",
"pg": "^8.11.3"
},
"type": "commonjs"
}

739
server/index.js Normal file
View File

@@ -0,0 +1,739 @@
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const path = require('path');
const fs = require('fs');
const { Pool } = require('pg');
const PORT = process.env.PORT || 4000;
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://agenda:agenda@postgres:5432/agenda';
const pool = new Pool({ connectionString: DATABASE_URL });
const DEFAULT_SERVICE_TYPES = [
'Avaliação',
'Limpeza',
'Restauração',
'Extração',
'Canal',
'Manutenção Ortodôntica',
'Cirurgia',
];
async function safeQuery(query) {
try {
await pool.query(query);
} catch (err) {
if (process.env.DEBUG_SQL) {
console.warn('Ignored migration statement:', err.message);
}
}
}
async function setupDatabase() {
await pool.query(`
CREATE TABLE IF NOT EXISTS doctors (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialty TEXT NOT NULL,
color TEXT NOT NULL
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS patients (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT,
cpf TEXT,
cep TEXT,
address TEXT,
birthdate TEXT,
createdAt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS appointments (
id SERIAL PRIMARY KEY,
patient TEXT NOT NULL,
phone TEXT,
doctor_id TEXT NOT NULL REFERENCES doctors(id),
date TEXT NOT NULL,
time TEXT NOT NULL,
type TEXT,
status TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
createdAt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS records (
id SERIAL PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id),
date TEXT NOT NULL,
professional_name TEXT,
professional_registration TEXT,
clinic_info TEXT,
chief_complaint TEXT,
medical_history TEXT,
dental_history TEXT,
clinical_exam TEXT,
diagnosis TEXT,
treatment_plan TEXT,
consent_notes TEXT,
evolution TEXT,
prescriptions TEXT,
communications TEXT,
attachments TEXT,
createdAt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS service_types (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS clinic_settings (
id SERIAL PRIMARY KEY,
clinic_name TEXT,
phone TEXT,
email TEXT,
address TEXT,
working_hours TEXT,
notes TEXT,
updatedAt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await safeQuery('ALTER TABLE appointments RENAME COLUMN "doctorid" TO doctor_id;');
await safeQuery('ALTER TABLE records RENAME COLUMN "patientid" TO patient_id;');
await safeQuery('ALTER TABLE records RENAME COLUMN professionalname TO professional_name;');
await safeQuery('ALTER TABLE records RENAME COLUMN professionalregistration TO professional_registration;');
await safeQuery('ALTER TABLE records RENAME COLUMN clinicinfo TO clinic_info;');
await safeQuery('ALTER TABLE records RENAME COLUMN chiefcomplaint TO chief_complaint;');
await safeQuery('ALTER TABLE records RENAME COLUMN medicalhistory TO medical_history;');
await safeQuery('ALTER TABLE records RENAME COLUMN dentalhistory TO dental_history;');
await safeQuery('ALTER TABLE records RENAME COLUMN clinicalexam TO clinical_exam;');
await safeQuery('ALTER TABLE records RENAME COLUMN treatmentplan TO treatment_plan;');
await safeQuery('ALTER TABLE records RENAME COLUMN consentnotes TO consent_notes;');
const { rows: doctorCountRows } = await pool.query('SELECT COUNT(*)::int AS total FROM doctors');
if ((doctorCountRows[0]?.total || 0) === 0) {
const doctors = [
{ id: 'dr-ana', name: 'Dra. Ana Silva', specialty: 'Ortodontia', color: '#f97316' },
{ id: 'dr-carlos', name: 'Dr. Carlos Mendes', specialty: 'Implantodontia', color: '#0ea5e9' },
{ id: 'dr-beatriz', name: 'Dra. Beatriz Costa', specialty: 'Clínica Geral', color: '#10b981' },
];
for (const doc of doctors) {
await pool.query('INSERT INTO doctors (id, name, specialty, color) VALUES ($1,$2,$3,$4)', [
doc.id,
doc.name,
doc.specialty,
doc.color,
]);
}
}
const { rows: appointmentCountRows } = await pool.query('SELECT COUNT(*)::int AS total FROM appointments');
if ((appointmentCountRows[0]?.total || 0) === 0) {
const sampleAppointments = [
['Ana Silva', '(11) 99111-2233', 'dr-ana', '2024-07-09', '09:00', 'Avaliação', 'confirmed', 'Primeira visita'],
['Pedro Oliveira', '(21) 98888-1111', 'dr-carlos', '2024-07-09', '09:30', 'Implante', 'pending', 'Pacote de implantes'],
['Marina Tavares', '(31) 99999-2222', 'dr-beatriz', '2024-07-09', '10:00', 'Limpeza', 'pending', 'Retorno rotina'],
['Rafael Gomes', '(41) 97777-3333', 'dr-ana', '2024-07-09', '10:30', 'Ortodontia', 'confirmed', ''],
['Fernanda Lima', '(51) 98888-4444', 'dr-carlos', '2024-07-09', '11:00', 'Avaliação', 'pending', 'Novo paciente'],
['Bruno Santana', '(21) 96666-5555', 'dr-beatriz', '2024-07-09', '11:30', 'Manutenção Ortodôntica', 'confirmed', ''],
];
for (const sample of sampleAppointments) {
await pool.query(
`INSERT INTO appointments (patient, phone, doctor_id, date, time, type, status, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
sample,
);
}
}
const { rows: serviceTypeRows } = await pool.query('SELECT COUNT(*)::int AS total FROM service_types');
if ((serviceTypeRows[0]?.total || 0) === 0) {
for (const name of DEFAULT_SERVICE_TYPES) {
await pool.query('INSERT INTO service_types (name) VALUES ($1)', [name]);
}
}
const { rows: clinicRows } = await pool.query('SELECT COUNT(*)::int AS total FROM clinic_settings');
if ((clinicRows[0]?.total || 0) === 0) {
await pool.query(
`INSERT INTO clinic_settings (clinic_name, phone, email, address, working_hours, notes)
VALUES ($1,$2,$3,$4,$5,$6)`,
['OdontoFlow', '(00) 0000-0000', 'contato@odontoflow.com', 'Rua Exemplo, 123 - Centro', 'Seg à Sex · 08h às 18h', 'Atualize os dados conforme a clínica.'],
);
}
}
const app = express();
app.use(cors());
app.use(express.json());
app.use(morgan('tiny'));
const asyncHandler =
(fn) =>
(...args) =>
fn(...args).catch((err) => {
console.error(err);
const res = args[1];
if (!res.headersSent) {
res.status(500).json({ error: 'Erro inesperado no servidor.' });
}
});
app.get(
'/api/doctors',
asyncHandler(async (req, res) => {
const { rows } = await pool.query('SELECT * FROM doctors ORDER BY name');
res.json(rows);
}),
);
const slugify = (value) =>
value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
app.post(
'/api/doctors',
asyncHandler(async (req, res) => {
const { id, name, specialty, color } = req.body;
if (!name) {
return res.status(400).json({ error: 'Nome do doutor é obrigatório.' });
}
const generatedId = id || slugify(name) || `doctor-${Date.now()}`;
await pool.query('INSERT INTO doctors (id, name, specialty, color) VALUES ($1,$2,$3,$4)', [
generatedId,
name,
specialty || '',
color || '#0ea5e9',
]);
res.status(201).json({ id: generatedId, name, specialty: specialty || '', color: color || '#0ea5e9' });
}),
);
app.delete(
'/api/doctors/:id',
asyncHandler(async (req, res) => {
const { id } = req.params;
const { rowCount } = await pool.query('DELETE FROM doctors WHERE id = $1', [id]);
if (rowCount === 0) {
return res.status(404).json({ error: 'Doutor não encontrado.' });
}
res.json({ success: true });
}),
);
app.get(
'/api/appointments',
asyncHandler(async (req, res) => {
const { date, doctorId, status } = req.query;
let query = `
SELECT
a.id,
a.patient,
a.phone,
a.doctor_id AS "doctorId",
a.date,
a.time,
a.type,
a.status,
a.notes,
a.createdat AS "createdAt",
d.name AS "doctorName",
d.specialty,
d.color
FROM appointments a
JOIN doctors d ON d.id = a.doctor_id
WHERE 1=1
`;
const params = [];
if (date) {
params.push(date);
query += ` AND a.date = $${params.length}`;
}
if (doctorId) {
params.push(doctorId);
query += ` AND a.doctor_id = $${params.length}`;
}
if (status) {
params.push(status);
query += ` AND a.status = $${params.length}`;
}
query += ' ORDER BY a.date DESC, a.time ASC';
const { rows } = await pool.query(query, params);
res.json(rows);
}),
);
app.post(
'/api/appointments',
asyncHandler(async (req, res) => {
const { patient, phone, doctorId, date, time, type, status, notes } = req.body;
if (!patient || !doctorId || !date || !time) {
return res.status(400).json({ error: 'Campos obrigatórios: patient, doctorId, date, time.' });
}
const { rows } = await pool.query(
`INSERT INTO appointments (patient, phone, doctor_id, date, time, type, status, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
[patient, phone || '', doctorId, date, time, type || '', status || 'pending', notes || ''],
);
res.status(201).json({ ...rows[0], doctorId });
}),
);
app.put(
'/api/appointments/:id',
asyncHandler(async (req, res) => {
const { id } = req.params;
const { patient, phone, doctorId, date, time, type, status, notes } = req.body;
const { rowCount, rows } = await pool.query(
`UPDATE appointments
SET patient=$1, phone=$2, doctor_id=$3, date=$4, time=$5, type=$6, status=$7, notes=$8
WHERE id=$9
RETURNING *`,
[patient, phone || '', doctorId, date, time, type || '', status || 'pending', notes || '', id],
);
if (rowCount === 0) {
return res.status(404).json({ error: 'Agendamento não encontrado.' });
}
res.json({ ...rows[0], doctorId });
}),
);
app.patch(
'/api/appointments/:id/status',
asyncHandler(async (req, res) => {
const { id } = req.params;
const { status } = req.body;
const valid = ['pending', 'confirmed', 'cancelled'];
if (!valid.includes(status)) {
return res.status(400).json({ error: `Status inválido: ${status}` });
}
const { rowCount } = await pool.query('UPDATE appointments SET status=$1 WHERE id=$2', [status, id]);
if (rowCount === 0) {
return res.status(404).json({ error: 'Agendamento não encontrado.' });
}
res.json({ success: true });
}),
);
app.delete(
'/api/appointments/:id',
asyncHandler(async (req, res) => {
const { id } = req.params;
const { rowCount } = await pool.query('DELETE FROM appointments WHERE id=$1', [id]);
if (rowCount === 0) {
return res.status(404).json({ error: 'Agendamento não encontrado.' });
}
res.json({ success: true });
}),
);
app.get(
'/api/patients',
asyncHandler(async (req, res) => {
const { rows } = await pool.query('SELECT * FROM patients ORDER BY createdAt DESC');
res.json(rows);
}),
);
app.post(
'/api/patients',
asyncHandler(async (req, res) => {
const { name, phone, email, cpf, cep, address, birthdate } = req.body;
if (!name || !phone) {
return res.status(400).json({ error: 'Campos obrigatórios: name, phone.' });
}
const { rows } = await pool.query(
`INSERT INTO patients (name, phone, email, cpf, cep, address, birthdate)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *`,
[name, phone, email || '', cpf || '', cep || '', address || '', birthdate || ''],
);
res.status(201).json(rows[0]);
}),
);
app.put(
'/api/patients/:id',
asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, phone, email, cpf, cep, address, birthdate } = req.body;
const { rowCount, rows } = await pool.query(
`UPDATE patients
SET name=$1, phone=$2, email=$3, cpf=$4, cep=$5, address=$6, birthdate=$7
WHERE id=$8
RETURNING *`,
[name, phone, email || '', cpf || '', cep || '', address || '', birthdate || '', id],
);
if (rowCount === 0) {
return res.status(404).json({ error: 'Paciente não encontrado.' });
}
res.json(rows[0]);
}),
);
app.delete(
'/api/patients/:id',
asyncHandler(async (req, res) => {
const { id } = req.params;
const { rowCount } = await pool.query('DELETE FROM patients WHERE id=$1', [id]);
if (rowCount === 0) {
return res.status(404).json({ error: 'Paciente não encontrado.' });
}
res.json({ success: true });
}),
);
app.get(
'/api/records',
asyncHandler(async (req, res) => {
const { patientId } = req.query;
let query = `
SELECT
r.id,
r.patient_id AS "patientId",
r.date,
r.professional_name AS "professionalName",
r.professional_registration AS "professionalRegistration",
r.clinic_info AS "clinicInfo",
r.chief_complaint AS "chiefComplaint",
r.medical_history AS "medicalHistory",
r.dental_history AS "dentalHistory",
r.clinical_exam AS "clinicalExam",
r.diagnosis,
r.treatment_plan AS "treatmentPlan",
r.consent_notes AS "consentNotes",
r.evolution,
r.prescriptions,
r.communications,
r.attachments,
r.createdAt AS "createdAt",
p.name AS "patientName",
p.cpf,
p.phone
FROM records r
JOIN patients p ON p.id = r.patient_id
WHERE 1=1
`;
const params = [];
if (patientId) {
params.push(patientId);
query += ` AND r.patient_id = $${params.length}`;
}
query += ' ORDER BY r.date DESC, r.createdAt DESC';
const { rows } = await pool.query(query, params);
res.json(rows);
}),
);
app.post(
'/api/records',
asyncHandler(async (req, res) => {
const {
patientId,
date,
professionalName,
professionalRegistration,
clinicInfo,
chiefComplaint,
medicalHistory,
dentalHistory,
clinicalExam,
diagnosis,
treatmentPlan,
consentNotes,
evolution,
prescriptions,
communications,
attachments,
} = req.body;
if (!patientId || !date) {
return res.status(400).json({ error: 'Campos obrigatórios: patientId, date.' });
}
const { rows } = await pool.query(
`INSERT INTO records
(patient_id, date, professional_name, professional_registration, clinic_info, chief_complaint,
medical_history, dental_history, clinical_exam, diagnosis, treatment_plan, consent_notes, evolution,
prescriptions, communications, attachments)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING *`,
[
patientId,
date,
professionalName || '',
professionalRegistration || '',
clinicInfo || '',
chiefComplaint || '',
medicalHistory || '',
dentalHistory || '',
clinicalExam || '',
diagnosis || '',
treatmentPlan || '',
consentNotes || '',
evolution || '',
prescriptions || '',
communications || '',
attachments || '',
],
);
const record = rows[0];
const { rows: patientRows } = await pool.query('SELECT name AS "patientName", cpf, phone FROM patients WHERE id=$1', [
patientId,
]);
res.status(201).json({ ...record, ...patientRows[0], patientId });
}),
);
app.get(
'/api/service-types',
asyncHandler(async (req, res) => {
const { rows } = await pool.query('SELECT * FROM service_types ORDER BY name');
res.json(rows);
}),
);
app.post(
'/api/service-types',
asyncHandler(async (req, res) => {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Informe o nome do tipo de atendimento.' });
}
const { rows } = await pool.query('INSERT INTO service_types (name) VALUES ($1) RETURNING *', [name]);
res.status(201).json(rows[0]);
}),
);
app.delete(
'/api/service-types/:id',
asyncHandler(async (req, res) => {
const { id } = req.params;
const { rowCount } = await pool.query('DELETE FROM service_types WHERE id=$1', [id]);
if (rowCount === 0) {
return res.status(404).json({ error: 'Tipo de atendimento não encontrado.' });
}
res.json({ success: true });
}),
);
const normalizeAction = (action) => (action || '').toLowerCase();
app.post(
'/api/integrations/n8n',
asyncHandler(async (req, res) => {
const action = normalizeAction(req.body.action);
const data = req.body.data || {};
switch (action) {
case 'create_appointment': {
const { patient, phone, doctorId, date, time, type, status, notes } = data;
if (!patient || !doctorId || !date || !time) {
return res.status(400).json({ error: 'Campos obrigatórios: patient, doctorId, date, time.' });
}
const { rows } = await pool.query(
`INSERT INTO appointments (patient, phone, doctor_id, date, time, type, status, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
[patient, phone || '', doctorId, date, time, type || '', status || 'pending', notes || ''],
);
return res.status(201).json({ result: { ...rows[0], doctorId } });
}
case 'list_appointments': {
const { date, doctorId, status, patient } = data;
let query = `
SELECT
a.id,
a.patient,
a.phone,
a.doctor_id AS "doctorId",
a.date,
a.time,
a.type,
a.status,
a.notes,
a.createdat AS "createdAt",
d.name AS "doctorName",
d.specialty,
d.color
FROM appointments a
JOIN doctors d ON d.id = a.doctor_id
WHERE 1=1
`;
const params = [];
if (date) {
params.push(date);
query += ` AND a.date = $${params.length}`;
}
if (doctorId) {
params.push(doctorId);
query += ` AND a.doctor_id = $${params.length}`;
}
if (status) {
params.push(status);
query += ` AND a.status = $${params.length}`;
}
if (patient) {
params.push(`%${patient.toLowerCase()}%`);
query += ` AND LOWER(a.patient) LIKE $${params.length}`;
}
query += ' ORDER BY a.date DESC, a.time ASC';
const { rows } = await pool.query(query, params);
return res.json({ result: rows });
}
case 'get_appointment': {
const { id } = data;
if (!id) return res.status(400).json({ error: 'Informe o id do agendamento.' });
const { rows } = await pool.query(
`
SELECT
a.id,
a.patient,
a.phone,
a.doctor_id AS "doctorId",
a.date,
a.time,
a.type,
a.status,
a.notes,
a.createdat AS "createdAt",
d.name AS "doctorName",
d.specialty,
d.color
FROM appointments a
JOIN doctors d ON d.id = a.doctor_id
WHERE a.id = $1
`,
[id],
);
if (!rows.length) {
return res.status(404).json({ error: 'Agendamento não encontrado.' });
}
return res.json({ result: rows[0] });
}
case 'update_appointment': {
const { id, patient, phone, doctorId, date, time, type, status, notes } = data;
if (!id) return res.status(400).json({ error: 'Informe o id do agendamento.' });
const { rowCount, rows } = await pool.query(
`UPDATE appointments
SET patient=$1, phone=$2, doctor_id=$3, date=$4, time=$5, type=$6, status=$7, notes=$8
WHERE id=$9
RETURNING *`,
[patient, phone || '', doctorId, date, time, type || '', status || 'pending', notes || '', id],
);
if (rowCount === 0) {
return res.status(404).json({ error: 'Agendamento não encontrado.' });
}
return res.json({ result: { ...rows[0], doctorId } });
}
case 'reschedule_appointment': {
const { id, date, time, doctorId } = data;
if (!id || !date || !time) {
return res.status(400).json({ error: 'Campos obrigatórios: id, date, time.' });
}
const { rowCount, rows } = await pool.query(
`UPDATE appointments
SET date=$1, time=$2, doctor_id=COALESCE($3, doctor_id)
WHERE id=$4
RETURNING *`,
[date, time, doctorId || null, id],
);
if (rowCount === 0) {
return res.status(404).json({ error: 'Agendamento não encontrado.' });
}
return res.json({ result: { ...rows[0], doctorId: doctorId || rows[0].doctor_id } });
}
case 'delete_appointment': {
const { id } = data;
if (!id) return res.status(400).json({ error: 'Informe o id do agendamento.' });
const { rowCount } = await pool.query('DELETE FROM appointments WHERE id=$1', [id]);
if (rowCount === 0) {
return res.status(404).json({ error: 'Agendamento não encontrado.' });
}
return res.json({ result: { success: true } });
}
case 'create_patient': {
const { name, phone, email, cpf, cep, address, birthdate } = data;
if (!name || !phone) {
return res.status(400).json({ error: 'Campos obrigatórios: name, phone.' });
}
const { rows } = await pool.query(
`INSERT INTO patients (name, phone, email, cpf, cep, address, birthdate)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *`,
[name, phone, email || '', cpf || '', cep || '', address || '', birthdate || ''],
);
return res.status(201).json({ result: rows[0] });
}
default:
return res.status(400).json({
error: 'Ação inválida. Utilize create_appointment, list_appointments, get_appointment, update_appointment, reschedule_appointment, delete_appointment ou create_patient.',
});
}
}),
);
app.get(
'/api/settings/clinic',
asyncHandler(async (req, res) => {
const { rows } = await pool.query('SELECT * FROM clinic_settings ORDER BY id LIMIT 1');
res.json(rows[0] || {});
}),
);
app.put(
'/api/settings/clinic',
asyncHandler(async (req, res) => {
const { clinicName, phone, email, address, workingHours, notes } = req.body;
const { rows } = await pool.query(
`UPDATE clinic_settings
SET clinic_name=$1, phone=$2, email=$3, address=$4, working_hours=$5, notes=$6, updatedAt=NOW()
WHERE id = (SELECT id FROM clinic_settings ORDER BY id LIMIT 1)
RETURNING *`,
[clinicName || '', phone || '', email || '', address || '', workingHours || '', notes || ''],
);
res.json(rows[0]);
}),
);
const clientDist = path.join(__dirname, '..', 'client', 'dist');
app.use(express.static(clientDist, { maxAge: '1h' }));
app.get('*', (req, res) => {
if (req.path.startsWith('/api')) {
return res.status(404).send({ error: 'Endpoint não encontrado.' });
}
const fallback = path.join(clientDist, 'index.html');
if (fs.existsSync(fallback)) {
return res.sendFile(fallback);
}
res.send('Front-end ainda não foi buildado. Rode `npm --prefix client install && npm --prefix client run build`.');
});
setupDatabase()
.then(() => {
app.listen(PORT, () => {
console.log(`Servidor rodando na porta ${PORT}`);
});
})
.catch((error) => {
console.error('Falha ao iniciar o banco de dados', error);
process.exit(1);
});