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