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

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);
});