- Notifications
You must be signed in to change notification settings - Fork 743
Claude/spe m medical system 011 cu sepw2 l5 ff5k3a9x8bv t #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Claude/spe m medical system 011 cu sepw2 l5 ff5k3a9x8bv t #59
Conversation
This comprehensive implementation adds a full medical aesthetic evaluation system (SPE-M - Sistema de Pontuação Estética Médica) with the following features: ## Core Features Implemented: ### Database Schema - Extended user table with medical fields (CRM, specialty) - Created patients table with LGPD-compliant soft delete - Created forms table for SPE-M evaluations - Created formCriteria table for 8 evaluation criteria - Created formImages table for photo management with annotations - Created auditLogs table for complete LGPD compliance ### Patient Management - Complete CRUD API routes with search and pagination - Patient listing page with real-time search - Patient creation/edit modal with validation - CPF uniqueness validation - Statistics dashboard for patients ### SPE-M Form System - 8 medical criteria definitions with scoring system: 1. Frontal Facial Analysis 2. Lateral Facial Analysis 3. Labial and Perioral Analysis 4. Nasal Analysis 5. Zygomatic and Midface Analysis 6. Mandibular and Chin Analysis 7. Cervical Analysis 8. Complementary Evaluations - Automatic score calculation and risk classification - Draft/finalized workflow with form locking - Complete form management API routes ### User Interface - Forms listing page with status filters - Form creation wizard with patient selection - Interactive form editor with tabbed navigation - Real-time score calculation display - Form view page (read-only) for finalized evaluations - Updated dashboard with SPE-M statistics - Enhanced sidebar navigation ### Compliance & Security - LGPD-compliant audit logging for all actions - IP address and user agent tracking - Soft delete for patient data retention - Structured metadata for all operations ### Technical Infrastructure - Installed dependencies: react-konva, konva, jspdf, jspdf-autotable - Generated database migrations - Created comprehensive documentation (SPE-M-README.md) ## Next Steps (To Be Implemented): - 6-photo upload system with validation - Interactive canvas for image annotations (React Konva) - Professional PDF generation with jsPDF - Form comparison feature - Auto-save system Total: 15+ new files, 5000+ lines of code, 10+ API endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
🎯 PHASE 1 COMPLETE - Core Functional Features This commit implements the 3 most critical features for the SPE-M system: ## 1. 6-Photo Upload System ✅ ### Features Implemented: - Complete photo upload page with tabs for 6 standardized photos: * Frontal * Profile Right * Profile Left * Oblique Right (¾) * Oblique Left (¾) * Base (inferior view) - Drag & drop interface with visual feedback - File validation (type, size, dimensions) - Real-time progress tracking - Photo replacement functionality - Visual indicators for uploaded photos - Automatic integration with existing API ### Technical Details: - Path: `/app/dashboard/forms/[id]/photos/page.tsx` - Accepts: JPG, PNG (max 10MB) - Shows upload progress and completion status - Enforces all 6 photos before finalization ## 2. Interactive Canvas with React Konva ✅ ### Features Implemented: - Full-featured annotation canvas using React Konva - Drawing tools: * Pen (freehand drawing) * Line (straight lines) * Arrow (directional indicators) * Circle (area highlighting) * Text (annotations) - Color palette (8 colors) - Stroke width adjustment (1-10px) - Undo functionality - Clear all annotations - Save annotations as JSON - Real-time drawing preview ### Technical Details: - Path: `/app/dashboard/forms/[id]/photos/[imageId]/annotate/page.tsx` - Library: react-konva + konva + use-image - Annotations saved to database as JSON - Background image loaded from storage URL ## 3. Professional PDF Generation ✅ ### Features Implemented: - Complete PDF generator with jsPDF + autoTable - Professional template with: * Header with system branding * Patient information section * Evaluation details (doctor, date, CRM) * Score summary with visual badge * Criteria table with all 8 evaluations * Detailed breakdown of each criterion * Notes and recommendations section * Multi-page support with automatic pagination * Footer with generation date and page numbers - PDF preview page with: * Download button * Email sending option (modal) * Print functionality * Document structure preview ### Technical Details: - Library: `/lib/pdf-generator.ts` - Preview page: `/app/dashboard/forms/[id]/pdf/page.tsx` - Only works for finalized forms - Generates A4 format PDF with professional styling - Includes auto-table for criteria scoring ## Additional Improvements: - Created `/api/auth/me` endpoint for user data - Updated form view page with photos and PDF buttons - Added Camera icon import - Installed `use-image` package for Konva image loading ## Files Created/Modified: - `app/dashboard/forms/[id]/photos/page.tsx` (NEW) - `app/dashboard/forms/[id]/photos/[imageId]/annotate/page.tsx` (NEW) - `app/dashboard/forms/[id]/pdf/page.tsx` (NEW) - `lib/pdf-generator.ts` (NEW) - `app/api/auth/me/route.ts` (NEW) - `app/dashboard/forms/[id]/page.tsx` (UPDATED) - `package.json` (UPDATED - added use-image) ## Testing Instructions: 1. Create/open an SPE-M form 2. Go to "Photos" tab 3. Upload 6 photos (drag & drop or select) 4. Click "Add Annotations" on any photo 5. Draw on the canvas with different tools 6. Save annotations 7. Finalize the form 8. Click "Download PDF" to generate report ## Next Steps (Phase 2): - Auto-save system (every 30s) - Visual stepper for 8 criteria - Patient details page - Password recovery flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Implemented comprehensive patient details page with timeline visualization and extended patient schema to include medical information fields. Changes: - Created patient details page at /dashboard/patients/[id] - Large patient avatar and header with personal information - Statistics cards (total forms, finalized, average score, last evaluation) - Personal information card with contact and medical details - Timeline visualization showing all SPE-M evaluations chronologically - Action buttons for viewing and downloading PDFs - Extended patient database schema - Added medicalHistory field for patient medical background - Added allergies field for known allergies - Added currentMedications field for current medications - Generated migration 0002_thick_scourge.sql - Updated patient APIs to handle new fields - POST /api/patients - accepts medical fields - PUT /api/patients/[id] - updates medical fields - Enhanced patient list page - Made patient names clickable to navigate to details page - Added "View Details" eye icon button in actions column - Added tooltips to all action buttons - Form editor improvements (from previous Phase 2 work) - Auto-save every 30 seconds - Visual horizontal stepper with completion indicators - Progress bar showing X/8 criteria completed - Unsaved changes warning before leaving page - Status indicators (Auto-salvo timestamp) This completes Phase 2 Patient Details implementation and continues Phase 2 UX improvements for the SPE-M medical evaluation system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
| @claude is attempting to deploy a commit to the Goshen Labs Team on Vercel. A member of the Team first needs to authorize it. |
WalkthroughThis pull request implements a complete medical aesthetic evaluation system (SPE-M) with patient management, form creation and editing, image upload with canvas annotation, PDF generation, and comprehensive audit logging. It includes database schema, API routes for CRUD operations, dashboard UI pages, utility functions for scoring and classification, and required dependencies. Changes
Sequence Diagram(s)sequenceDiagram actor User participant UI as Form Edit Page participant API as /api/forms/[id] participant DB as Database participant Calc as Scoring Engine User->>UI: Load form editor UI->>API: GET /api/forms/{id} API->>DB: Query form + criteria + patient DB-->>API: Return form data API-->>UI: Form data with 8 criteria UI->>UI: Initialize local state User->>UI: Edit criterion field UI->>UI: Update formData state UI->>Calc: Calculate criterion score Calc-->>UI: Score result UI->>UI: Recalculate total score par Auto-save (every 30s) UI->>API: PUT /api/forms/{id} with changes API->>DB: Update form + criteria DB-->>API: Success API->>DB: Insert audit log API-->>UI: Updated timestamps end User->>UI: Click Finalize UI->>API: POST /api/forms/{id}/finalize API->>DB: Query form + all criteria API->>Calc: calculateTotalScore Calc-->>API: Final score + classification API->>DB: Update form status=finalized API->>DB: Insert finalization audit log API-->>UI: Success with classification UI->>UI: Navigate to form view User->>UI: Upload image UI->>API: POST /api/forms/{id}/images (multipart) API->>DB: Insert/update formImage record API->>DB: Insert image upload audit log API-->>UI: Image metadata + status UI->>UI: Refresh image list + progress Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Areas requiring extra attention:
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Compliance Guide 🔍Below is a summary of compliance checks for this PR:
Compliance status legend🟢 - Fully Compliant🟡 - Partial Compliant 🔴 - Not Compliant ⚪ - Requires Further Human Verification 🏷️ - Compliance label | ||||||||||||||||||||||||||||
PR Code Suggestions ✨Explore these optional code suggestions:
| ||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 34
♻️ Duplicate comments (2)
db/migrations/meta/0001_snapshot.json (2)
368-483: Same integrity/indexing recommendations apply as in 0002.Add UNIQUE (formId, criterionNumber), CHECKs on
forms.status/profileClassification, and indexes on FK/filter columns, implemented via a new forward migration.Also applies to: 196-284
484-592: CPF handling (PII) also applies here.Secure storage and uniqueness via encrypted field + deterministic token; plan a safe backfill migration.
🧹 Nitpick comments (35)
app/dashboard/_components/sidebar.tsx (1)
30-39: Make nav items accessible and highlight nested routes.
- Replace clickable divs with to enable keyboard navigation and semantics; add aria-current when active.
- Use startsWith() so /dashboard/forms/[id] highlights "Formulários SPE-M".
Diff (core idea):
- {navItems.map((item) => ( - <div - key={item.href} - onClick={() => router.push(item.href)} - className={clsx( - "flex items-center gap-2 w-full rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:cursor-pointer", - pathname === item.href - ? "bg-primary/10 text-primary hover:bg-primary/20" - : "text-muted-foreground hover:bg-muted hover:text-foreground", - )} - > - <item.icon className="h-4 w-4" /> - {item.label} - </div> - ))} + {navItems.map((item) => { + const active = + pathname === item.href || pathname?.startsWith(item.href + "/"); + return ( + <Link + key={item.href} + href={item.href} + className={clsx( + "flex items-center gap-2 w-full rounded-lg px-3 py-2 text-sm font-medium transition-colors", + active + ? "bg-primary/10 text-primary hover:bg-primary/20" + : "text-muted-foreground hover:bg-muted hover:text-foreground", + )} + aria-current={active ? "page" : undefined} + > + <item.icon className="h-4 w-4" /> + {item.label} + </Link> + ); + })}Also applies to: 76-91
SPE-M-README.md (1)
286-286: Minor README polish for linting.
- Add a language to the file tree fenced block (e.g., ```text).
- Convert bare URLs to Markdown links.
Example:
- https://nextjs.org/docs + [Next.js Docs](https://nextjs.org/docs)Optional: replace “Router” → “roteador” and “Template” → “modelo” for consistency in PT-BR.
Also applies to: 257-257, 407-408
app/dashboard/forms/[id]/photos/[imageId]/annotate/page.tsx (4)
174-180: Use functional state updates to avoid stale closures when appending/removing annotations.Prevents lost updates during rapid interactions.
@@ - setAnnotations([...annotations, currentAnnotation]); + setAnnotations((prev) => [...prev, currentAnnotation]); @@ - setAnnotations([...annotations, newAnnotation]); + setAnnotations((prev) => [...prev, newAnnotation]); @@ - if (annotations.length > 0) { - setAnnotations(annotations.slice(0, -1)); - } + setAnnotations((prev) => (prev.length ? prev.slice(0, -1) : prev));Also applies to: 188-199, 201-205
86-89: Effect should also depend on formId.Ensures correct fetch if route changes both params.
- }, [imageId]); + }, [formId, imageId]);
24-33: Tighten types and remove any.Define a discriminated union for Annotation and type stageRef as Konva.Stage for safer rendering.
- Replace
tool: stringwith'pen' | 'line' | 'arrow' | 'circle' | 'text'.- Use per-tool fields (e.g., points required for pen/line/arrow).
const stageRef = useRef<Konva.Stage | null>(null);Also applies to: 84-85
182-199: Avoid blocking prompt/confirm; use a dialog/input from your UI kit.Improves UX, accessibility, and consistency.
- Replace
prompt("Digite o texto:")with a controlled Dialog + Input.- Replace
confirm(...)with a confirmation Dialog.Also applies to: 207-211, 328-339, 324-327
lib/spe-m-criteria.ts (2)
381-401: Implement optional score weights; keep behavior identical when absent.
scoreWeightexists on fields but isn’t used. Use weighted average when provided; falls back to simple average otherwise.let totalScore = 0; - let totalFields = 0; + let totalWeight = 0; @@ - if (field.options) { + if (field.options) { const selectedOption = field.options.find( (opt) => opt.value === selectedValue ); if (selectedOption) { - totalScore += selectedOption.score; - totalFields++; + const w = field.scoreWeight ?? 1; + totalScore += selectedOption.score * w; + totalWeight += w; } } }); // Average score for this criterion - return totalFields > 0 ? totalScore / totalFields : 0; + return totalWeight > 0 ? totalScore / totalWeight : 0;
373-401: Heads-up: number/text fields are ignored in scoring.If future criteria add
type: "number" | "text", they won’t affect scores. Add a handler or document the constraint.lib/pdf-generator.ts (2)
28-31: Images are provided in PDFData but not rendered. Intentional?If intended, consider embedding small thumbnails (with annotations) into the PDF; otherwise remove from the type to avoid confusion.
Would you like a follow-up patch to render up to 6 thumbnails with captions (type label) and pagination-aware layout?
Also applies to: 39-45, 325-332
5-37: Export PDFData for reuse across modules.Improves type reuse where callers build the payload.
-interface PDFData { +export interface PDFData {app/dashboard/forms/[id]/photos/page.tsx (1)
38-41: Initialize currentTab from IMAGE_TYPES for consistency.Avoid hardcoding “frontal”.
- const [currentTab, setCurrentTab] = useState("frontal"); + const [currentTab, setCurrentTab] = useState<string>(IMAGE_TYPES[0].id);app/dashboard/forms/[id]/edit/page.tsx (2)
315-316: Completion checks should not rely on truthiness.Values like "0" evaluate falsy and mark a filled field as incomplete.
- return criterion.fields.every((field) => c.data[field.id]); + return criterion.fields.every( + (field) => Object.prototype.hasOwnProperty.call(c.data, field.id) && + String(c.data[field.id]).length > 0 + );- const isComplete = criterionData && criterion.fields.every((field) => criterionData.data[field.id]); + const isComplete = + !!criterionData && + criterion.fields.every( + (field) => + Object.prototype.hasOwnProperty.call(criterionData.data, field.id) && + String(criterionData.data[field.id]).length > 0 + );Also applies to: 440-446
291-305: Optional: Consider averaging only filled criteria.Current total averages across all criteria, weighting unfilled as zero. If you want “live score of filled sections,” filter to criteria with at least one filled field before averaging.
app/dashboard/forms/page.tsx (2)
71-89: Setloadingtrue before refetch to show the spinner on filter changes.Improves UX; spinner currently appears only on first load.
const fetchForms = async () => { - try { + try { + setLoading(true); const params = new URLSearchParams(); if (patientIdParam) params.append("patientId", patientIdParam); if (statusFilter !== "all") params.append("status", statusFilter); - const response = await fetch(`/api/forms?${params}`); + const qs = params.toString(); + const response = await fetch(`/api/forms${qs ? `?${qs}` : ""}`); if (!response.ok) throw new Error("Failed to fetch forms"); const data = await response.json(); setForms(data.forms); } catch (error) { toast.error("Erro ao carregar formulários"); console.error(error); } finally { setLoading(false); } }; useEffect(() => { fetchForms(); fetchPatients(); }, [statusFilter, patientIdParam]);Also applies to: 104-108
5-6: Remove unused imports.
Input,Search,CheckCirclearen’t used. Keep imports lean.-import { Input } from "@/components/ui/input"; ... -import { Plus, Search, Edit, Trash2, Eye, FileDown, CheckCircle } from "lucide-react"; +// import { Input } from "@/components/ui/input"; // unused +import { Plus, Edit, Trash2, Eye, FileDown } from "lucide-react";Also applies to: 30-31
app/dashboard/patients/page.tsx (3)
248-259: Validate/mask CPF on input; store digits only.Prevents inconsistent data and display issues.
- <Input + <Input id="cpf" - value={formData.cpf} - onChange={(e) => - setFormData({ ...formData, cpf: e.target.value }) - } + inputMode="numeric" + pattern="[0-9]{11}" + value={formData.cpf} + onChange={(e) => { + const digits = e.target.value.replace(/\D/g, "").slice(0, 11); + setFormData({ ...formData, cpf: digits }); + }} required - placeholder="000.000.000-00" - maxLength={14} + placeholder="000.000.000-00" + maxLength={14} />
84-87: Debounce server search to cut request spam.Add a small debounce around fetchPatients when typing.
- useEffect(() => { - fetchPatients(); - }, [searchQuery]); + useEffect(() => { + const id = setTimeout(() => { fetchPatients(); }, 300); + return () => clearTimeout(id); + }, [searchQuery]);Also applies to: 378-389
491-499: “Novos este mês” ignores year.January 2024 vs January 2025 both count. Compare year too.
- patients.filter( - (p) => - new Date(p.createdAt).getMonth() === new Date().getMonth() - ).length + (() => { + const now = new Date(); + return patients.filter((p) => { + const d = new Date(p.createdAt); + return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear(); + }).length; + })()app/api/forms/route.ts (2)
23-25: Cap and validate pagination params.Avoids heavy queries or NaN.
- const limit = parseInt(searchParams.get("limit") || "50"); - const offset = parseInt(searchParams.get("offset") || "0"); + const rawLimit = Number(searchParams.get("limit") ?? 50); + const rawOffset = Number(searchParams.get("offset") ?? 0); + const limit = Math.min(Math.max(1, isFinite(rawLimit) ? rawLimit : 50), 100); + const offset = Math.max(0, isFinite(rawOffset) ? rawOffset : 0);Also applies to: 40-41
36-41: Hide soft‑deleted patients in join.Prevents surfacing forms linked to deleted patients.
- .where(eq(forms.userId, session.user.id)) + .where(and(eq(forms.userId, session.user.id)))And add to composed conditions:
+ // when joining, also ensure patient not soft-deleted + // .where(and(..., isNull(patients.deletedAt)))app/dashboard/forms/[id]/pdf/page.tsx (2)
21-21: Lazy‑load PDF generator for download too.Reduces page bundle; mirror print path.
- import { downloadPDF } from "@/lib/pdf-generator"; + // defer heavy pdf generator to user action- const handleDownload = async () => { + const handleDownload = async () => { if (!formData || !userData) return; setGenerating(true); try { - await downloadPDF({ + const { downloadPDF } = await import("@/lib/pdf-generator"); + await downloadPDF({ form: formData.form, patient: formData.patient, criteria: formData.criteria, images: formData.images, doctor: { name: userData.name, crm: userData.crm, specialty: userData.specialty, }, });Also applies to: 112-129
167-176: Email sending is TODO; can help wire it.Hook to backend (e.g., /api/forms/[id]/email) with basic validation and rate limiting.
Do you want a thin API route + client integration added in this PR?
app/api/patients/[id]/route.ts (2)
114-118: Gracefully handle CPF uniqueness violations.Return 409 instead of 500 on unique constraint errors.
- await db - .update(patients) - .set(updatedData) - .where(eq(patients.id, id)); + try { + await db.update(patients).set(updatedData).where(eq(patients.id, id)); + } catch (e: any) { + if (e?.code === "23505") { + return NextResponse.json({ error: "CPF already exists" }, { status: 409 }); + } + throw e; + }
3-4: CPF encryption not implemented despite schema promise.Verification confirms no encryption/decryption logic exists in the codebase. The schema comment at
db/schema.ts:98states encryption will occur at the application layer, but CPF data is stored and retrieved as plaintext throughout:app/api/patients/route.ts,app/api/patients/[id]/route.ts, and other routes access CPF directly without encryption. Only display-level formatting masks the value. Implement encrypt/decrypt utilities in write and read paths to align with the schema contract.app/dashboard/_components/spe-m-stats.tsx (1)
56-62: Ensure “Avaliações Recentes” are actually recent.Slice assumes backend ordering. Explicitly sort by createdAt desc before slicing to 5.
- recentForms: formsData.forms.slice(0, 5), + recentForms: [...formsData.forms] + .sort( + (a, b) => + new Date(b.form.createdAt).getTime() - + new Date(a.form.createdAt).getTime() + ) + .slice(0, 5),app/api/patients/route.ts (1)
20-24: Clamp pagination to sane bounds.Prevent large scans and accidental abuse by clamping limit/offset (e.g., 1–200) and validating integers.
- const limit = parseInt(searchParams.get("limit") || "50"); - const offset = parseInt(searchParams.get("offset") || "0"); + const rawLimit = Number.parseInt(searchParams.get("limit") || "50", 10); + const rawOffset = Number.parseInt(searchParams.get("offset") || "0", 10); + const limit = Math.min(Math.max(isNaN(rawLimit) ? 50 : rawLimit, 1), 200); + const offset = Math.max(isNaN(rawOffset) ? 0 : rawOffset, 0);app/dashboard/forms/[id]/page.tsx (2)
62-76: Harden fetch: avoid caching and abort on unmount.Add cache: "no-store" and AbortController.
- useEffect(() => { - fetchForm(); - }, [formId]); + useEffect(() => { + const controller = new AbortController(); + fetchForm(controller.signal); + return () => controller.abort(); + }, [formId]); - const fetchForm = async () => { + const fetchForm = async (signal?: AbortSignal) => { try { - const response = await fetch(`/api/forms/${formId}`); + const response = await fetch(`/api/forms/${formId}`, { + cache: "no-store", + signal, + });
153-156: Mask CPF in UI.Display only the last digits to reduce exposure.
- <p className="font-medium"> - {formData.patient.cpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4")} - </p> + <p className="font-medium"> + {`***.***.***-${String(formData.patient.cpf).slice(-2)}`} + </p>app/dashboard/patients/[id]/page.tsx (3)
84-89: Forms fetch should not trigger patient-level redirect; handle separately and check .ok.Currently any error in forms fetch redirects away. Keep patient visible and default to empty forms.
- // Fetch forms for this patient - const formsRes = await fetch(`/api/forms?patientId=${params.id}`); - const formsData = await formsRes.json(); - setForms(formsData.forms); + // Fetch forms for this patient + const formsRes = await fetch(`/api/forms?patientId=${params.id}`); + let formsData: { forms: FormWithDetails[] } = { forms: [] }; + if (formsRes.ok) { + formsData = await formsRes.json(); + } else { + console.warn("Failed to fetch forms for patient:", params.id); + } + setForms(formsData.forms); - } catch (error) { + } catch (error) { console.error("Error fetching patient data:", error); - // Redirect to patients list if patient not found - router.push("/dashboard/patients"); + // Redirect only if the patient fetch failed; otherwise keep page up. + router.push("/dashboard/patients");Also applies to: 115-121
164-174: Timezone edge cases for birth dates.
new Date(birthDate)with a TIMESTAMP can shift by timezone and skew age or formatted date by ±1 day. Prefer storingbirthDateas DATE in DB and emitting ISO date-only.This is coupled with migration feedback to use DATE for patients.birthDate.
Also applies to: 329-336
70-76: Optional: abort in-flight requests on unmount or id change.Avoid setting state after unmount and cancel obsolete fetches when
params.idchanges.-import { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; // ... useEffect(() => { - fetchPatientData(); + const ctrl = new AbortController(); + fetchPatientData(ctrl.signal); return () => ctrl.abort(); }, [params.id]); -const fetchPatientData = async () => { +const fetchPatientData = async (signal?: AbortSignal) => { try { setLoading(true); - const patientRes = await fetch(`/api/patients/${params.id}`); + const patientRes = await fetch(`/api/patients/${params.id}`, { signal }); // ... - const formsRes = await fetch(`/api/forms?patientId=${params.id}`); + const formsRes = await fetch(`/api/forms?patientId=${params.id}`, { signal });db/migrations/0001_wooden_mindworm.sql (4)
22-23: Prefer timestamptz (or DATE for pure dates) for all temporal columns.Using
timestamp(without TZ) risks drift in multi‑TZ environments and JS clients. Recommend:
- Change createdAt/updatedAt/finalizedAt/uploadedAt to
timestamptz.- Consider
DATEforpatients.birthDate(see next comment).Example patch:
-"createdAt" timestamp DEFAULT now() NOT NULL, -"updatedAt" timestamp DEFAULT now() NOT NULL, +"createdAt" timestamptz DEFAULT now() NOT NULL, +"updatedAt" timestamptz DEFAULT now() NOT NULL,Apply similarly to all timestamp columns in these tables.
Also applies to: 34-35, 47-50, 63-66
57-57: Store birth dates as DATE, not TIMESTAMP.Birth dates aren’t instants in time; TIMESTAMP causes TZ-related off‑by‑one issues in clients.
- "birthDate" timestamp, + "birthDate" date,Pairs with the page.tsx age calculation note.
42-46: Constrain string enums (status/classification/imageType/action/entityType).Use CHECK constraints or Postgres enums to enforce valid values.
Example:
-- Forms ALTER TABLE "forms" ADD CONSTRAINT forms_status_check CHECK (status IN ('draft','finalized','archived')); -- Classification (if using text) ALTER TABLE "forms" ADD CONSTRAINT forms_profile_classification_check CHECK ( "profileClassification" IN ('low','medium','high') ); -- formImages ALTER TABLE "formImages" ADD CONSTRAINT form_images_type_check CHECK ("imageType" IN ('pre','post','intra')); -- auditLogs ALTER TABLE "auditLogs" ADD CONSTRAINT auditlogs_action_check CHECK ("action" IN ('create','update','finalize','archive','delete')), ADD CONSTRAINT auditlogs_entitytype_check CHECK ("entityType" IN ('patient','form','formImage','formCriterion'));Also applies to: 29-30, 4-6
53-67: Add indexes for common access patterns and soft‑delete.To keep queries snappy at scale:
- Patients:
- Index by userId and by (deletedAt IS NULL).
- Forms:
- Index by patientId, userId, status, createdAt.
- Audit logs:
- Composite (entityType, entityId), (userId, occurredAt).
- Images:
- Index by formId.
Example:
CREATE INDEX idx_patients_userid ON "patients"("userId"); CREATE INDEX idx_patients_not_deleted ON "patients"("deletedAt") WHERE "deletedAt" IS NULL; CREATE INDEX idx_forms_patientid ON "forms"("patientId"); CREATE INDEX idx_forms_userid ON "forms"("userId"); CREATE INDEX idx_forms_status ON "forms"("status"); CREATE INDEX idx_forms_createdat ON "forms"("createdAt"); CREATE INDEX idx_auditlogs_entity ON "auditLogs"("entityType","entityId"); CREATE INDEX idx_auditlogs_user_ts ON "auditLogs"("userId","occurredAt"); CREATE INDEX idx_formimages_formid ON "formImages"("formId");Also applies to: 38-51, 26-36, 1-11
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (29)
SPE-M-README.md(1 hunks)app/api/auth/me/route.ts(1 hunks)app/api/forms/[id]/finalize/route.ts(1 hunks)app/api/forms/[id]/images/route.ts(1 hunks)app/api/forms/[id]/route.ts(1 hunks)app/api/forms/route.ts(1 hunks)app/api/patients/[id]/route.ts(1 hunks)app/api/patients/route.ts(1 hunks)app/dashboard/_components/sidebar.tsx(3 hunks)app/dashboard/_components/spe-m-stats.tsx(1 hunks)app/dashboard/forms/[id]/edit/page.tsx(1 hunks)app/dashboard/forms/[id]/page.tsx(1 hunks)app/dashboard/forms/[id]/pdf/page.tsx(1 hunks)app/dashboard/forms/[id]/photos/[imageId]/annotate/page.tsx(1 hunks)app/dashboard/forms/[id]/photos/page.tsx(1 hunks)app/dashboard/forms/page.tsx(1 hunks)app/dashboard/page.tsx(2 hunks)app/dashboard/patients/[id]/page.tsx(1 hunks)app/dashboard/patients/page.tsx(1 hunks)components/ui/table.tsx(1 hunks)db/migrations/0001_wooden_mindworm.sql(1 hunks)db/migrations/0002_thick_scourge.sql(1 hunks)db/migrations/meta/0001_snapshot.json(1 hunks)db/migrations/meta/0002_snapshot.json(1 hunks)db/migrations/meta/_journal.json(1 hunks)db/schema.ts(3 hunks)lib/pdf-generator.ts(1 hunks)lib/spe-m-criteria.ts(1 hunks)package.json(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (18)
app/api/patients/route.ts (4)
app/api/patients/[id]/route.ts (1)
GET(10-62)db/schema.ts (3)
session(25-36)patients(95-113)auditLogs(167-179)lib/auth.ts (1)
auth(27-192)db/drizzle.ts (1)
db(6-6)
app/api/auth/me/route.ts (1)
lib/auth.ts (1)
auth(27-192)
app/dashboard/page.tsx (1)
app/dashboard/_components/spe-m-stats.tsx (1)
SPEMStats(26-237)
lib/pdf-generator.ts (1)
lib/spe-m-criteria.ts (2)
classifyProfile(419-447)SPE_M_CRITERIA(22-371)
app/api/forms/[id]/finalize/route.ts (4)
db/schema.ts (4)
session(25-36)forms(116-133)formCriteria(136-149)auditLogs(167-179)lib/auth.ts (1)
auth(27-192)db/drizzle.ts (1)
db(6-6)lib/spe-m-criteria.ts (2)
calculateTotalScore(404-416)classifyProfile(419-447)
app/api/forms/[id]/route.ts (3)
db/schema.ts (5)
forms(116-133)patients(95-113)formCriteria(136-149)formImages(152-164)auditLogs(167-179)db/drizzle.ts (1)
db(6-6)lib/spe-m-criteria.ts (2)
calculateTotalScore(404-416)classifyProfile(419-447)
app/dashboard/forms/[id]/pdf/page.tsx (1)
lib/pdf-generator.ts (2)
downloadPDF(328-332)generateSPEMPDF(39-326)
app/dashboard/patients/page.tsx (1)
db/schema.ts (1)
patients(95-113)
components/ui/table.tsx (1)
lib/utils.ts (1)
cn(4-6)
app/dashboard/forms/[id]/edit/page.tsx (1)
lib/spe-m-criteria.ts (4)
calculateCriterionScore(374-401)calculateTotalScore(404-416)classifyProfile(419-447)SPE_M_CRITERIA(22-371)
app/dashboard/forms/[id]/page.tsx (1)
lib/spe-m-criteria.ts (2)
classifyProfile(419-447)SPE_M_CRITERIA(22-371)
app/dashboard/forms/page.tsx (1)
db/schema.ts (2)
forms(116-133)patients(95-113)
app/dashboard/patients/[id]/page.tsx (2)
db/schema.ts (1)
forms(116-133)lib/utils.ts (1)
cn(4-6)
app/api/forms/[id]/images/route.ts (3)
db/schema.ts (4)
session(25-36)forms(116-133)formImages(152-164)auditLogs(167-179)lib/auth.ts (1)
auth(27-192)db/drizzle.ts (1)
db(6-6)
app/api/patients/[id]/route.ts (4)
app/api/patients/route.ts (1)
GET(10-70)db/schema.ts (3)
session(25-36)patients(95-113)auditLogs(167-179)lib/auth.ts (1)
auth(27-192)db/drizzle.ts (1)
db(6-6)
app/dashboard/forms/[id]/photos/page.tsx (1)
lib/spe-m-criteria.ts (1)
IMAGE_TYPES(450-457)
app/api/forms/route.ts (4)
app/api/forms/[id]/route.ts (1)
GET(11-81)db/schema.ts (5)
session(25-36)forms(116-133)patients(95-113)formCriteria(136-149)auditLogs(167-179)lib/auth.ts (1)
auth(27-192)db/drizzle.ts (1)
db(6-6)
db/schema.ts (1)
auth-schema.ts (1)
user(3-11)
🪛 LanguageTool
SPE-M-README.md
[locale-violation] ~6-~6: “Router” é um estrangeirismo. É preferível dizer “encaminhador” ou “roteador”.
Context: ...trutura Base** ✅ - ✅ Next.js 15 com App Router - ✅ TypeScript configurado - ✅ Tailwind...
(PT_BARBARISMS_REPLACE_ROUTER)
[locale-violation] ~157-~157: “Template” é um estrangeirismo. É preferível dizer “modelo”.
Context: ...implementar gerador O que falta: - [ ] Template de PDF profissional - [ ] Cabeçalho com...
(PT_BARBARISMS_REPLACE_TEMPLATE)
[misspelling] ~185-~185: Esta é uma palavra só.
Context: ... [ ] Exportação de resultados #### 4.3 Auto-save e Versionamento - [ ] Salvamento automá...
(AUTO)
[uncategorized] ~193-~193: Encontrada possível ausência de vírgula.
Context: ... - [ ] Página de edição de perfil - [ ] Campos CRM e especialidade - [ ] Upload de ass...
(AI_PT_HYDRA_LEO_MISSING_COMMA)
[misspelling] ~342-~342: Esta é uma palavra só.
Context: ...de MÉDIA (importantes) 4. ✅ Implementar auto-save 5. ✅ Implementar comparação de fichas 6...
(AUTO)
🪛 markdownlint-cli2 (0.18.1)
SPE-M-README.md
257-257: Bare URL used
(MD034, no-bare-urls)
286-286: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
407-407: Bare URL used
(MD034, no-bare-urls)
408-408: Bare URL used
(MD034, no-bare-urls)
🔇 Additional comments (7)
app/dashboard/_components/sidebar.tsx (1)
66-71: Header text change looks good.app/api/auth/me/route.ts (1)
5-16: Solid GET /api/auth/me implementation.Async headers() usage matches Next 15; clear 401/500 paths; minimal payload.
app/api/forms/[id]/images/route.ts (1)
13-14: The web search reveals an important discrepancy. Let me run corrected shell scripts to verify the remaining concerns in the review:Now let me run corrected shell scripts to verify the remaining concerns:
Based on my research, Next.js 15 route handlers require params to be typed as
Promise<{ id: string }>and must be awaited. The current code is correct for Next.js 15. The review comment's core recommendation to remove the Promise typing and the await is incorrect and would break the code.Likely an incorrect or invalid review comment.
package.json (1)
53-55: Add "use client" directive to lib/pdf-generator.ts to clarify client-only usage.lib/pdf-generator.ts statically imports jspdf and jspdf-autotable without marking itself as a client module. While the PDF page already uses dynamic import for lazy-loading, the library file should explicitly declare itself as client-only with
"use client"at the top for clarity and consistency.The annotate page (app/dashboard/forms/[id]/photos/[imageId]/annotate/page.tsx) is properly marked as a client component and safely imports react-konva and use-image.
Add to lib/pdf-generator.ts line 1:
"use client"; import jsPDF from "jspdf";components/ui/table.tsx (1)
1-117: LGTM — accessible, typed table primitives look solid.Forward refs, semantics (thead/tbody/tfoot), and styling are consistent. No issues spotted.
app/dashboard/page.tsx (1)
4-4: SPEMStats is correctly marked as a Client Component.Verified:
app/dashboard/_components/spe-m-stats.tsxstarts with"use client"on line 1, followed by React hook imports. The import is safe and the client/server boundary is properly maintained.db/migrations/0001_wooden_mindworm.sql (1)
75-75: Cascade on forms.userId → user delete: confirm business intent.Deleting a user will delete all their forms. Is this intended? Many systems prefer reassigning or preserving medical records.
If not intended, switch to
ON DELETE SET NULL(and make column nullable) or add an ownership transfer flow.
| // Check if form exists and belongs to user | ||
| const existingForm = await db | ||
| .select() | ||
| .from(forms) | ||
| .where(and(eq(forms.id, id), eq(forms.userId, session.user.id))) | ||
| .limit(1); | ||
| | ||
| if (existingForm.length === 0) { | ||
| return NextResponse.json({ error: "Form not found" }, { status: 404 }); | ||
| } | ||
| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TOCTOU race: finalize check vs update.
Make update conditional on status != 'finalized' and verify affected rows.
+import { eq, and, ne } from "drizzle-orm"; ... - await db - .update(forms) - .set({ + const res = await db + .update(forms) + .set({ status: "finalized", totalScore: totalScore.toString(), profileClassification: profile.classification, finalizedAt: new Date(), updatedAt: new Date(), - }) - .where(eq(forms.id, id)); + }) + .where(and(eq(forms.id, id), ne(forms.status, "finalized"))); + if ((res as any)?.rowCount === 0) { + return NextResponse.json({ error: "Form is already finalized" }, { status: 409 }); + }Also applies to: 59-70
🤖 Prompt for AI Agents
In app/api/forms/[id]/finalize/route.ts around lines 26-36 (and also apply same fix at lines 59-70), replace the separate existence check + unconditional update with an atomic conditional update that includes a WHERE status != 'finalized' (or equivalent) so the DB only changes rows that are not already finalized; after executing the update inspect the affected row count and if zero return a 409/appropriate error indicating the form is already finalized (or 404 if no such form for the user), otherwise proceed normally — this eliminates the TOCTOU race by making the finalize action conditional and verifying affected rows. | return NextResponse.json({ | ||
| message: "Form finalized successfully", | ||
| totalScore, | ||
| profileClassification: profile, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Response shape inconsistent with other endpoints.
Return the classification string under profileClassification; include full profile separately if needed.
- return NextResponse.json({ - message: "Form finalized successfully", - totalScore, - profileClassification: profile, - }); + return NextResponse.json({ + message: "Form finalized successfully", + totalScore, + profileClassification: profile.classification, + profile, + });🤖 Prompt for AI Agents
In app/api/forms/[id]/finalize/route.ts around lines 84 to 88, the response currently returns the full profile object under profileClassification which breaks the response shape; change the response to set profileClassification to the classification string (e.g., profile.classification or profile?.classification) and, if the full profile must be returned, include it under a separate key (e.g., profile or fullProfile) so other endpoints receive the expected simple string in profileClassification. | // Update criteria if provided | ||
| if (criteria && Array.isArray(criteria)) { | ||
| const updatePromises = criteria.map((criterion: any) => { | ||
| return db | ||
| .update(formCriteria) | ||
| .set({ | ||
| data: criterion.data, | ||
| score: criterion.score, | ||
| notes: criterion.notes || null, | ||
| recommendations: criterion.recommendations || null, | ||
| updatedAt: new Date(), | ||
| }) | ||
| .where( | ||
| and( | ||
| eq(formCriteria.formId, id), | ||
| eq(formCriteria.criterionNumber, criterion.criterionNumber) | ||
| ) | ||
| ); | ||
| }); | ||
| await Promise.all(updatePromises); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Make update atomic with a transaction.
Updating many criteria then the form should be all-or-nothing. Wrap in db.transaction.
- // Update criteria if provided - if (criteria && Array.isArray(criteria)) { - const updatePromises = criteria.map((criterion: any) => { - return db + // Update criteria if provided + if (criteria && Array.isArray(criteria)) { + await db.transaction(async (tx) => { + const updatePromises = criteria.map((criterion: any) => + tx .update(formCriteria) .set({ data: criterion.data, - score: criterion.score, + // Do not trust client score; compute server-side below + score: null, notes: criterion.notes || null, recommendations: criterion.recommendations || null, updatedAt: new Date(), }) .where( and( eq(formCriteria.formId, id), eq(formCriteria.criterionNumber, criterion.criterionNumber) ) - ); - }); - await Promise.all(updatePromises); + ) + ); + await Promise.all(updatePromises); + }); }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Update criteria if provided | |
| if (criteria && Array.isArray(criteria)) { | |
| const updatePromises = criteria.map((criterion: any) => { | |
| return db | |
| .update(formCriteria) | |
| .set({ | |
| data: criterion.data, | |
| score: criterion.score, | |
| notes: criterion.notes || null, | |
| recommendations: criterion.recommendations || null, | |
| updatedAt: new Date(), | |
| }) | |
| .where( | |
| and( | |
| eq(formCriteria.formId, id), | |
| eq(formCriteria.criterionNumber, criterion.criterionNumber) | |
| ) | |
| ); | |
| }); | |
| await Promise.all(updatePromises); | |
| } | |
| // Update criteria if provided | |
| if (criteria && Array.isArray(criteria)) { | |
| await db.transaction(async (tx) => { | |
| const updatePromises = criteria.map((criterion: any) => | |
| tx | |
| .update(formCriteria) | |
| .set({ | |
| data: criterion.data, | |
| // Do not trust client score; compute server-side below | |
| score: null, | |
| notes: criterion.notes || null, | |
| recommendations: criterion.recommendations || null, | |
| updatedAt: new Date(), | |
| }) | |
| .where( | |
| and( | |
| eq(formCriteria.formId, id), | |
| eq(formCriteria.criterionNumber, criterion.criterionNumber) | |
| ) | |
| ) | |
| ); | |
| await Promise.all(updatePromises); | |
| }); | |
| } |
🤖 Prompt for AI Agents
In app/api/forms/[id]/route.ts around lines 120 to 140, the current criteria updates run individually and are not grouped atomically; wrap the criteria updates (and the subsequent form update) inside a single db.transaction so all changes succeed or fail together. Implement db.transaction(async (tx) => { ... }) and use the transaction handle (tx) for the update calls instead of db, run Promise.all on the mapped tx.update(...) promises, then perform the form update within the same transaction; ensure you await the transaction call so it commits only if all operations succeed and automatically roll back on error. | // Calculate total score | ||
| let totalScore = null; | ||
| let profileClassification = null; | ||
| | ||
| if (criteria && Array.isArray(criteria)) { | ||
| totalScore = calculateTotalScore( | ||
| criteria.map((c: any) => ({ | ||
| criterionNumber: c.criterionNumber, | ||
| data: c.data, | ||
| })) | ||
| ); | ||
| | ||
| const profile = classifyProfile(totalScore); | ||
| profileClassification = profile.classification; | ||
| } | ||
| | ||
| // Update form | ||
| const updatedData = { | ||
| generalNotes: generalNotes !== undefined ? generalNotes : existingForm[0].generalNotes, | ||
| recommendations: recommendations !== undefined ? recommendations : existingForm[0].recommendations, | ||
| totalScore: totalScore !== null ? totalScore.toString() : existingForm[0].totalScore, | ||
| profileClassification: profileClassification || existingForm[0].profileClassification, | ||
| updatedAt: new Date(), | ||
| }; | ||
| | ||
| await db.update(forms).set(updatedData).where(eq(forms.id, id)); | ||
| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Compute totalScore server-side from persisted criteria; handle partial updates correctly.
Current code averages only the submitted subset and trusts client scores. Recompute from DB across all criteria for the form, then classify.
- // Calculate total score - let totalScore = null; - let profileClassification = null; - - if (criteria && Array.isArray(criteria)) { - totalScore = calculateTotalScore( - criteria.map((c: any) => ({ - criterionNumber: c.criterionNumber, - data: c.data, - })) - ); - - const profile = classifyProfile(totalScore); - profileClassification = profile.classification; - } + // Recompute from DB to avoid trusting client and to include all criteria + const persisted = await db + .select() + .from(formCriteria) + .where(eq(formCriteria.formId, id)); + const recomputed = calculateTotalScore( + persisted.map((c: any) => ({ + criterionNumber: c.criterionNumber, + data: c.data, + })) + ); + const profile = classifyProfile(recomputed); + const totalScore = recomputed; + const profileClassification = profile.classification; // Update form const updatedData = { generalNotes: generalNotes !== undefined ? generalNotes : existingForm[0].generalNotes, recommendations: recommendations !== undefined ? recommendations : existingForm[0].recommendations, - totalScore: totalScore !== null ? totalScore.toString() : existingForm[0].totalScore, - profileClassification: profileClassification || existingForm[0].profileClassification, + totalScore: totalScore.toString(), + profileClassification, updatedAt: new Date(), };🤖 Prompt for AI Agents
In app/api/forms/[id]/route.ts around lines 142 to 168, the code currently computes totalScore from only the submitted criteria and trusts client-provided scores; instead, fetch the persisted criteria for the form from the DB, merge any client edits into that full set (or ignore client score fields and derive values server-side), then call calculateTotalScore over the complete, server-authoritative criteria array and use that result to compute profileClassification via classifyProfile; finally set totalScore (as string) and profileClassification in updatedData and persist—this ensures partial updates are handled correctly and the server recomputes scores from the DB-backed criteria. | // Create audit log | ||
| await db.insert(auditLogs).values({ | ||
| id: nanoid(), | ||
| userId: session.user.id, | ||
| action: "update", | ||
| entityType: "form", | ||
| entityId: id, | ||
| ipAddress: request.ip || null, | ||
| userAgent: request.headers.get("user-agent") || null, | ||
| metadata: { changes: body }, | ||
| timestamp: new Date(), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don’t log raw request body in audit metadata.
Body may include sensitive notes/images. Log a minimal, whitelisted set (e.g., changed field names).
- metadata: { changes: body }, + metadata: { + changedFields: Object.keys(body ?? {}), + },| "public.patients": { | ||
| "name": "patients", | ||
| "schema": "", | ||
| "columns": { | ||
| "id": { | ||
| "name": "id", | ||
| "type": "text", | ||
| "primaryKey": true, | ||
| "notNull": true | ||
| }, | ||
| "name": { | ||
| "name": "name", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": true | ||
| }, | ||
| "cpf": { | ||
| "name": "cpf", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": true | ||
| }, | ||
| "birthDate": { | ||
| "name": "birthDate", | ||
| "type": "timestamp", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "phone": { | ||
| "name": "phone", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "email": { | ||
| "name": "email", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "address": { | ||
| "name": "address", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "notes": { | ||
| "name": "notes", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "medicalHistory": { | ||
| "name": "medicalHistory", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "allergies": { | ||
| "name": "allergies", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "currentMedications": { | ||
| "name": "currentMedications", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "userId": { | ||
| "name": "userId", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": true | ||
| }, | ||
| "createdAt": { | ||
| "name": "createdAt", | ||
| "type": "timestamp", | ||
| "primaryKey": false, | ||
| "notNull": true, | ||
| "default": "now()" | ||
| }, | ||
| "updatedAt": { | ||
| "name": "updatedAt", | ||
| "type": "timestamp", | ||
| "primaryKey": false, | ||
| "notNull": true, | ||
| "default": "now()" | ||
| }, | ||
| "deletedAt": { | ||
| "name": "deletedAt", | ||
| "type": "timestamp", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| } | ||
| }, | ||
| "indexes": {}, | ||
| "foreignKeys": { | ||
| "patients_userId_user_id_fk": { | ||
| "name": "patients_userId_user_id_fk", | ||
| "tableFrom": "patients", | ||
| "tableTo": "user", | ||
| "columnsFrom": [ | ||
| "userId" | ||
| ], | ||
| "columnsTo": [ | ||
| "id" | ||
| ], | ||
| "onDelete": "cascade", | ||
| "onUpdate": "no action" | ||
| } | ||
| }, | ||
| "compositePrimaryKeys": {}, | ||
| "uniqueConstraints": { | ||
| "patients_cpf_unique": { | ||
| "name": "patients_cpf_unique", | ||
| "nullsNotDistinct": false, | ||
| "columns": [ | ||
| "cpf" | ||
| ] | ||
| } | ||
| }, | ||
| "policies": {}, | ||
| "checkConstraints": {}, | ||
| "isRLSEnabled": false | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGPD/PII risk: store CPF securely (encryption/tokenization).
patients.cpf is plain text with a unique constraint. This is sensitive PII; soft delete alone isn’t sufficient.
Suggestions:
- Use application- or DB-level encryption (e.g., AES-GCM via pgcrypto or an app KMS). For lookup/uniqueness, store:
- encrypted_cpf (ciphertext) and
- deterministic token/hash (e.g., HMAC-SHA256 over normalized CPF) with a UNIQUE index.
- Mask CPF in logs/UI; restrict access via RBAC.
- Add rotation process for the key/HMAC secret.
Example (conceptual):
-- New columns ALTER TABLE "patients" ADD COLUMN encryptedCpf bytea, ADD COLUMN cpfToken bytea; -- Deterministic token unique CREATE UNIQUE INDEX patients_cpftoken_unique ON "patients"(cpfToken);Ensure backfill + migration plan for existing rows. (Compliance)
🤖 Prompt for AI Agents
In db/migrations/meta/0002_snapshot.json around lines 484 to 610, patients.cpf is stored as plaintext with a UNIQUE constraint which exposes sensitive PII; update the schema and migration to stop storing raw CPF, adding encryptedCpf (ciphertext) and cpfToken (deterministic HMAC/hash) columns, create a UNIQUE index on cpfToken, remove or deprecate the plain cpf column from uniqueness checks (and plan its safe removal), implement an application/DB-side deterministic HMAC (using a rotated secret or KMS) for lookups and a separate authenticated encryption (e.g., AES-GCM via pgcrypto or app KMS) for ciphertext, add a backfill migration that normalizes existing CPFs to produce cpfToken and encryptedCpf then securely delete or null plaintext cpfs, ensure CPF values are masked in logs/UI and access controlled via RBAC, and include a key rotation plan and migration steps to re-encrypt or re-hash data when rotating keys. | // Patients table | ||
| export const patients = pgTable("patients", { | ||
| id: text("id").primaryKey(), | ||
| name: text("name").notNull(), | ||
| cpf: text("cpf").notNull().unique(), // Will be encrypted in application layer | ||
| birthDate: timestamp("birthDate"), | ||
| phone: text("phone"), | ||
| email: text("email"), | ||
| address: text("address"), | ||
| notes: text("notes"), | ||
| medicalHistory: text("medicalHistory"), // Patient's medical history | ||
| allergies: text("allergies"), // Known allergies | ||
| currentMedications: text("currentMedications"), // Current medications | ||
| userId: text("userId") | ||
| .notNull() | ||
| .references(() => user.id, { onDelete: "cascade" }), | ||
| createdAt: timestamp("createdAt").notNull().defaultNow(), | ||
| updatedAt: timestamp("updatedAt").notNull().defaultNow(), | ||
| deletedAt: timestamp("deletedAt"), // Soft delete for LGPD compliance | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CPF uniqueness conflicts with per-user checks and soft delete.
Current: global unique on cpf prevents two users from registering same CPF and blocks re‑create after soft delete.
- Replace column unique with composite unique (userId, cpf).
- If soft delete is required, make it partial (WHERE deletedAt IS NULL).
Example (Drizzle indices + SQL for partial index):
- cpf: text("cpf").notNull().unique(), // Will be encrypted in application layer + cpf: text("cpf").notNull(), // Will be encrypted/hashed in application layerAdd in your migration (SQL):
ALTER TABLE patients DROP CONSTRAINT IF EXISTS patients_cpf_unique; CREATE UNIQUE INDEX u_patients_user_cpf_active ON patients ("userId","cpf") WHERE "deletedAt" IS NULL;Optional Drizzle composite (without partial):
import { uniqueIndex } from "drizzle-orm/pg-core"; // ... export const patients = pgTable("patients", { /* columns */ }, (t) => ({ uUserCpf: uniqueIndex("u_patients_user_cpf").on(t.userId, t.cpf), }));Also consider storing cpfHash (HMAC) for uniqueness/search and encryptedCpf for display.
| // Form Criteria - 8 criteria per form | ||
| export const formCriteria = pgTable("formCriteria", { | ||
| id: text("id").primaryKey(), | ||
| formId: text("formId") | ||
| .notNull() | ||
| .references(() => forms.id, { onDelete: "cascade" }), | ||
| criterionNumber: integer("criterionNumber").notNull(), // 1-8 | ||
| criterionName: text("criterionName").notNull(), | ||
| data: jsonb("data").notNull(), // Stores all fields for this criterion | ||
| score: decimal("score", { precision: 4, scale: 2 }), | ||
| notes: text("notes"), | ||
| recommendations: text("recommendations"), | ||
| createdAt: timestamp("createdAt").notNull().defaultNow(), | ||
| updatedAt: timestamp("updatedAt").notNull().defaultNow(), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Enforce one row per criterion per form.
Add a unique index on (formId, criterionNumber) to prevent duplicates.
import { uniqueIndex } from "drizzle-orm/pg-core"; export const formCriteria = pgTable("formCriteria", { // columns... }, (t) => ({ uFormCriterion: uniqueIndex("u_form_criterion").on(t.formId, t.criterionNumber), }));Migration (SQL):
CREATE UNIQUE INDEX u_form_criterion ON "formCriteria" ("formId","criterionNumber");🤖 Prompt for AI Agents
In db/schema.ts around lines 135 to 149, the formCriteria table allows duplicate (formId, criterionNumber) rows; add a unique index to enforce one row per criterion per form by importing uniqueIndex from "drizzle-orm/pg-core" and adding the index in the pgTable call: provide a named uniqueIndex (e.g., u_form_criterion) on t.formId and t.criterionNumber in the third argument to pgTable, and add a corresponding SQL migration that runs CREATE UNIQUE INDEX u_form_criterion ON "formCriteria" ("formId","criterionNumber"); to prevent duplicates at the DB level. | const patientInfo = [ | ||
| ["Nome:", data.patient.name], | ||
| ["CPF:", data.patient.cpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4")], | ||
| ["Data de Nascimento:", data.patient.birthDate ? new Date(data.patient.birthDate).toLocaleDateString("pt-BR") : "-"], | ||
| ["Telefone:", data.patient.phone || "-"], | ||
| ["Email:", data.patient.email || "-"], | ||
| ]; | ||
| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Harden CPF masking to handle already-formatted inputs.
Current replace(/(\d{3}).../) fails if cpf includes punctuation or isn’t 11 digits.
- const patientInfo = [ - ["Nome:", data.patient.name], - ["CPF:", data.patient.cpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4")], + const cpfDigits = (data.patient.cpf || "").replace(/\D/g, ""); + const cpfMasked = + cpfDigits.length === 11 + ? cpfDigits.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4") + : (data.patient.cpf || "-"); + const patientInfo = [ + ["Nome:", data.patient.name], + ["CPF:", cpfMasked],📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const patientInfo = [ | |
| ["Nome:", data.patient.name], | |
| ["CPF:", data.patient.cpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4")], | |
| ["Data de Nascimento:", data.patient.birthDate ? new Date(data.patient.birthDate).toLocaleDateString("pt-BR") : "-"], | |
| ["Telefone:", data.patient.phone || "-"], | |
| ["Email:", data.patient.email || "-"], | |
| ]; | |
| const cpfDigits = (data.patient.cpf || "").replace(/\D/g, ""); | |
| const cpfMasked = | |
| cpfDigits.length === 11 | |
| ? cpfDigits.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4") | |
| : (data.patient.cpf || "-"); | |
| const patientInfo = [ | |
| ["Nome:", data.patient.name], | |
| ["CPF:", cpfMasked], | |
| ["Data de Nascimento:", data.patient.birthDate ? new Date(data.patient.birthDate).toLocaleDateString("pt-BR") : "-"], | |
| ["Telefone:", data.patient.phone || "-"], | |
| ["Email:", data.patient.email || "-"], | |
| ]; |
🤖 Prompt for AI Agents
In lib/pdf-generator.ts around lines 80 to 87, the CPF masking assumes an 11-digit numeric string and breaks if the input already contains punctuation or is not 11 digits; normalize the input by first guarding against missing cpf, stripping all non-digit characters, then if the resulting digits length is 11 format it into the pattern XXX.XXX.XXX-XX (e.g. via a regex or slice+join), otherwise fall back to returning the original value or a safe placeholder ("-") so the code never fails on pre-formatted or invalid CPF values. | // FOOTER | ||
| const totalPages = doc.getNumberOfPages(); | ||
| for (let i = 1; i <= totalPages; i++) { | ||
| doc.setPage(i); | ||
| doc.setFontSize(8); | ||
| doc.setFont("helvetica", "normal"); | ||
| doc.setTextColor(150, 150, 150); | ||
| | ||
| doc.text( | ||
| `Página ${i} de ${totalPages}`, | ||
| pageWidth / 2, | ||
| pageHeight - 10, | ||
| { align: "center" } | ||
| ); | ||
| | ||
| doc.text( | ||
| `Gerado em ${new Date().toLocaleDateString("pt-BR")} às ${new Date().toLocaleTimeString("pt-BR")}`, | ||
| 14, | ||
| pageHeight - 10 | ||
| ); | ||
| | ||
| doc.text( | ||
| "Sistema SPE-M v1.0", | ||
| pageWidth - 14, | ||
| pageHeight - 10, | ||
| { align: "right" } | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
PII notice: PDF contains name, CPF, phone, and email.
Ensure user consent and secure handling (download-only, no third-party uploads). Consider adding a small “Confidencial” footer/watermark.
--- 🏁 Script executed: ```shell #!/bin/bash # Examine the full pdf-generator.ts file to understand PII handling cat lib/pdf-generator.ts Length of output: 9118
🏁 Script executed:
#!/bin/bash # Search for consent-related patterns in the codebase rg -n -i "consent|permission|agree" -A 3 -B 3Length of output: 6203
🏁 Script executed:
#!/bin/bash # Search for PII handling or data security patterns rg -n -i "pii|personally|confidential|secure|encrypt" -A 2 -B 2Length of output: 2238
🏁 Script executed:
#!/bin/bash # Search for where pdf-generator is imported/used rg -n "from.*pdf-generator|require.*pdf-generator|import.*pdf-generator" -A 2 -B 2Length of output: 783
🏁 Script executed:
#!/bin/bash # Look for data flow to pdf generation (parameters, functions) rg -n "generatePDF|pdf" --type ts -C 3 | head -100Length of output: 4179
Add "Confidencial" watermark and review consent flow for sensitive patient data in PDF
The PDF correctly restricts to download-only (no third-party uploads), but lacks a confidentiality indicator. The code embeds patient PII (name, CPF, phone, email) without a "Confidencial" watermark or footer warning. Additionally, the codebase README (line 364) lists patient consent term as a TODO—ensure a consent mechanism is implemented before PDF generation with sensitive data, or document existing consent coverage in the application flow.
Required:
- Add "Confidencial" or "Uso Restrito" watermark to the PDF (applies to all pages)
- Verify or implement explicit user consent before generating PDFs with PII
🤖 Prompt for AI Agents
In lib/pdf-generator.ts around lines 296 to 323, the PDF footer loop embeds patient PII but lacks a confidentiality watermark and does not verify user consent; add a translucent diagonal "Confidencial" (or "Uso Restrito") watermark rendered on every page before footers (centered, large font, light gray, rotated) and ensure the watermark drawing is applied for each page in the existing page loop; additionally, before invoking PDF generation, call or implement a consent check function (e.g., verifyPatientConsent(patientId) or requireConsentFlag) that throws or returns false if explicit consent is not present and block generation if consent is missing, and update README to replace the TODO with a reference to the implemented consent check and where consent is recorded. There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
20 issues found across 30 files
Prompt for AI agents (all 20 issues)
Understand the root cause of the following 20 issues and fix them. <file name="app/dashboard/patients/[id]/page.tsx"> <violation number="1" location="app/dashboard/patients/[id]/page.tsx:408"> Sorting the state array in place mutates React state during render. Use a copied array before sorting to avoid state mutation.</violation> </file> <file name="app/api/patients/route.ts"> <violation number="1" location="app/api/patients/route.ts:22"> The limit query parameter is parsed with parseInt and used without validating for NaN, so requests like ?limit=abc will bubble NaN into LIMIT and trigger a runtime error instead of a proper 4xx response. Please validate and default the value before calling .limit().</violation> <violation number="2" location="app/api/patients/route.ts:23"> The offset query parameter is parsed with parseInt and used without checking for NaN, so inputs like ?offset=foo lead to a NaN offset and a database error. Add validation/defaulting before passing the value to .offset().</violation> </file> <file name="lib/pdf-generator.ts"> <violation number="1" location="lib/pdf-generator.ts:253"> Detailed criterion notes are written without verifying the remaining page space, letting long notes overflow past the bottom margin and disappear in the PDF. Please call checkPageBreak with the note block height before drawing it.</violation> <violation number="2" location="lib/pdf-generator.ts:278"> Long general notes are rendered without ensuring enough remaining space, so doc.text can write past the bottom margin and the PDF loses content. Call checkPageBreak with the computed block height before drawing the text.</violation> </file> <file name="app/dashboard/forms/page.tsx"> <violation number="1" location="app/dashboard/forms/page.tsx:409"> If there are forms without any recorded totalScore, this branch divides by zero and renders "NaN". Guard the average on whether any form actually has a score before dividing.</violation> </file> <file name="app/dashboard/forms/[id]/edit/page.tsx"> <violation number="1" location="app/dashboard/forms/[id]/edit/page.tsx:115"> Ensure the change-tracking effect also resets hasUnsavedChanges to false when the serialized form data matches the initial snapshot, so the unsaved warning reflects reality.</violation> <violation number="2" location="app/dashboard/forms/[id]/edit/page.tsx:272"> Abort finalization when the draft save fails; handleSave should surface errors so handleFinalize can stop before calling the finalize endpoint.</violation> </file> <file name="app/api/forms/[id]/route.ts"> <violation number="1" location="app/api/forms/[id]/route.ts:121"> When `criteria` is provided but empty, this block still recalculates the totals; `calculateTotalScore([])` returns 0, so the form's persisted score becomes "0" and the profile classification flips to "high" on an otherwise unchanged update. Only recalc when there are actual criteria items.</violation> </file> <file name="app/api/forms/route.ts"> <violation number="1" location="app/api/forms/route.ts:82"> When both patientId and status are provided, this branch rebuilds the query without the earlier patientId constraint, so the response ignores the patient filter. Combine the optional predicates instead of reinitializing the query.</violation> </file> <file name="SPE-M-README.md"> <violation number="1" location="SPE-M-README.md:337"> The roadmap section marks pending features with ✅ even though earlier in the document they are still listed as TODO. Please switch these to an unchecked marker so readers aren’t misled about their completion status.</violation> </file> <file name="app/dashboard/forms/[id]/photos/page.tsx"> <violation number="1" location="app/dashboard/forms/[id]/photos/page.tsx:148"> Derive the required photo count from IMAGE_TYPES.length instead of hardcoding 6 so the validation and progress UI stay in sync with the configured image types.</violation> </file> <file name="db/migrations/meta/0001_snapshot.json"> <violation number="1" location="db/migrations/meta/0001_snapshot.json:10"> The patients table snapshot omits the medicalHistory, allergies, and currentMedications columns that exist in db/schema.ts, so this migration would create a schema missing fields the application expects, breaking patient records.</violation> </file> <file name="app/api/forms/[id]/images/route.ts"> <violation number="1" location="app/api/forms/[id]/images/route.ts:77"> When updating an existing image we still generate a new identifier, so the audit log’s entityId no longer matches the stored record. Reuse the existing image id so updates are audited against the correct entity.</violation> <violation number="2" location="app/api/forms/[id]/images/route.ts:122"> The response for an updated image returns the pre-update snapshot from `existingImage[0]`, so clients receive stale storageUrl and metadata despite the database having new values. Build the response from the fresh data you just persisted.</violation> </file> <file name="db/schema.ts"> <violation number="1" location="db/schema.ts:141"> criterionNumber should be unique per formId; otherwise a form can store duplicate criterion entries, breaking the expectation of exactly one row for each of the 8 criteria. Please add a composite unique constraint on (formId, criterionNumber) to enforce this.</violation> <violation number="2" location="db/schema.ts:157"> imageType should be unique per formId; otherwise the schema allows duplicate image slots for a form, undermining the "6 photos per form" requirement. Please add a composite unique constraint on (formId, imageType) to enforce one row per image type.</violation> </file> <file name="app/dashboard/_components/spe-m-stats.tsx"> <violation number="1" location="app/dashboard/_components/spe-m-stats.tsx:38"> Check that the /api/patients response is ok before reading patientsData.patients; otherwise a 401 payload shaped as { error: "Unauthorized" } leaves patientsData undefined and the component crashes when calling patientsData.patients.length.</violation> <violation number="2" location="app/dashboard/_components/spe-m-stats.tsx:42"> Verify formsRes.ok (or guard formsData.forms) before filtering; when /api/forms returns { error: "Unauthorized" } on a 401 the current code calls formsData.forms.filter and throws.</violation> <violation number="3" location="app/dashboard/_components/spe-m-stats.tsx:141"> Display the average score whenever there are finalized forms, even if the value is 0, otherwise real 0.00 averages are incorrectly shown as "N/A".</violation> </file> React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.
| <div className="space-y-6"> | ||
| {/* Timeline */} | ||
| <div className="relative"> | ||
| {forms |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorting the state array in place mutates React state during render. Use a copied array before sorting to avoid state mutation.
Prompt for AI agents
Address the following comment on app/dashboard/patients/[id]/page.tsx at line 408: <comment>Sorting the state array in place mutates React state during render. Use a copied array before sorting to avoid state mutation.</comment> <file context> @@ -0,0 +1,505 @@ + <div className="space-y-6"> + {/* Timeline */} + <div className="relative"> + {forms + .sort( + (a, b) => </file context> | const { searchParams } = new URL(request.url); | ||
| const search = searchParams.get("search"); | ||
| const limit = parseInt(searchParams.get("limit") || "50"); | ||
| const offset = parseInt(searchParams.get("offset") || "0"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The offset query parameter is parsed with parseInt and used without checking for NaN, so inputs like ?offset=foo lead to a NaN offset and a database error. Add validation/defaulting before passing the value to .offset().
Prompt for AI agents
Address the following comment on app/api/patients/route.ts at line 23: <comment>The offset query parameter is parsed with parseInt and used without checking for NaN, so inputs like ?offset=foo lead to a NaN offset and a database error. Add validation/defaulting before passing the value to .offset().</comment> <file context> @@ -0,0 +1,156 @@ + const { searchParams } = new URL(request.url); + const search = searchParams.get("search"); + const limit = parseInt(searchParams.get("limit") || "50"); + const offset = parseInt(searchParams.get("offset") || "0"); + + // Build query </file context> | | ||
| const { searchParams } = new URL(request.url); | ||
| const search = searchParams.get("search"); | ||
| const limit = parseInt(searchParams.get("limit") || "50"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The limit query parameter is parsed with parseInt and used without validating for NaN, so requests like ?limit=abc will bubble NaN into LIMIT and trigger a runtime error instead of a proper 4xx response. Please validate and default the value before calling .limit().
Prompt for AI agents
Address the following comment on app/api/patients/route.ts at line 22: <comment>The limit query parameter is parsed with parseInt and used without validating for NaN, so requests like ?limit=abc will bubble NaN into LIMIT and trigger a runtime error instead of a proper 4xx response. Please validate and default the value before calling .limit().</comment> <file context> @@ -0,0 +1,156 @@ + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search"); + const limit = parseInt(searchParams.get("limit") || "50"); + const offset = parseInt(searchParams.get("offset") || "0"); + </file context> | doc.setFont("helvetica", "italic"); | ||
| doc.setTextColor(100, 100, 100); | ||
| const notes = doc.splitTextToSize(criterion.notes, pageWidth - 32); | ||
| doc.text(notes, 16, yPos); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Detailed criterion notes are written without verifying the remaining page space, letting long notes overflow past the bottom margin and disappear in the PDF. Please call checkPageBreak with the note block height before drawing it.
Prompt for AI agents
Address the following comment on lib/pdf-generator.ts at line 253: <comment>Detailed criterion notes are written without verifying the remaining page space, letting long notes overflow past the bottom margin and disappear in the PDF. Please call checkPageBreak with the note block height before drawing it.</comment> <file context> @@ -0,0 +1,332 @@ + doc.setFont("helvetica", "italic"); + doc.setTextColor(100, 100, 100); + const notes = doc.splitTextToSize(criterion.notes, pageWidth - 32); + doc.text(notes, 16, yPos); + yPos += notes.length * 5; + doc.setTextColor(0, 0, 0); </file context> | | ||
| doc.setFont("helvetica", "normal"); | ||
| const notes = doc.splitTextToSize(data.form.generalNotes, pageWidth - 28); | ||
| doc.text(notes, 14, yPos); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Long general notes are rendered without ensuring enough remaining space, so doc.text can write past the bottom margin and the PDF loses content. Call checkPageBreak with the computed block height before drawing the text.
Prompt for AI agents
Address the following comment on lib/pdf-generator.ts at line 278: <comment>Long general notes are rendered without ensuring enough remaining space, so doc.text can write past the bottom margin and the PDF loses content. Call checkPageBreak with the computed block height before drawing the text.</comment> <file context> @@ -0,0 +1,332 @@ + + doc.setFont("helvetica", "normal"); + const notes = doc.splitTextToSize(data.form.generalNotes, pageWidth - 28); + doc.text(notes, 14, yPos); + yPos += notes.length * 5 + 5; + } </file context> | formId: text("formId") | ||
| .notNull() | ||
| .references(() => forms.id, { onDelete: "cascade" }), | ||
| imageType: text("imageType").notNull(), // frontal, profile_right, profile_left, oblique_right, oblique_left, base |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
imageType should be unique per formId; otherwise the schema allows duplicate image slots for a form, undermining the "6 photos per form" requirement. Please add a composite unique constraint on (formId, imageType) to enforce one row per image type.
Prompt for AI agents
Address the following comment on db/schema.ts at line 157: <comment>imageType should be unique per formId; otherwise the schema allows duplicate image slots for a form, undermining the "6 photos per form" requirement. Please add a composite unique constraint on (formId, imageType) to enforce one row per image type.</comment> <file context> @@ -83,3 +88,92 @@ export const subscription = pgTable("subscription", { + formId: text("formId") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + imageType: text("imageType").notNull(), // frontal, profile_right, profile_left, oblique_right, oblique_left, base + storageUrl: text("storageUrl").notNull(), + thumbnailUrl: text("thumbnailUrl"), </file context> | formId: text("formId") | ||
| .notNull() | ||
| .references(() => forms.id, { onDelete: "cascade" }), | ||
| criterionNumber: integer("criterionNumber").notNull(), // 1-8 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
criterionNumber should be unique per formId; otherwise a form can store duplicate criterion entries, breaking the expectation of exactly one row for each of the 8 criteria. Please add a composite unique constraint on (formId, criterionNumber) to enforce this.
Prompt for AI agents
Address the following comment on db/schema.ts at line 141: <comment>criterionNumber should be unique per formId; otherwise a form can store duplicate criterion entries, breaking the expectation of exactly one row for each of the 8 criteria. Please add a composite unique constraint on (formId, criterionNumber) to enforce this.</comment> <file context> @@ -83,3 +88,92 @@ export const subscription = pgTable("subscription", { + formId: text("formId") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + criterionNumber: integer("criterionNumber").notNull(), // 1-8 + criterionName: text("criterionName").notNull(), + data: jsonb("data").notNull(), // Stores all fields for this criterion </file context> | </CardHeader> | ||
| <CardContent> | ||
| <div className="text-2xl font-bold"> | ||
| {stats.averageScore > 0 ? stats.averageScore.toFixed(2) : "N/A"} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Display the average score whenever there are finalized forms, even if the value is 0, otherwise real 0.00 averages are incorrectly shown as "N/A".
Prompt for AI agents
Address the following comment on app/dashboard/_components/spe-m-stats.tsx at line 141: <comment>Display the average score whenever there are finalized forms, even if the value is 0, otherwise real 0.00 averages are incorrectly shown as "N/A".</comment> <file context> @@ -0,0 +1,237 @@ + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {stats.averageScore > 0 ? stats.averageScore.toFixed(2) : "N/A"} + </div> + <p className="text-xs text-muted-foreground mt-1"> </file context> | {stats.averageScore > 0 ? stats.averageScore.toFixed(2) : "N/A"} | |
| {stats.finalizedForms > 0 ? stats.averageScore.toFixed(2) : "N/A"} |
| | ||
| // Fetch forms | ||
| const formsRes = await fetch("/api/forms?limit=100"); | ||
| const formsData = await formsRes.json(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verify formsRes.ok (or guard formsData.forms) before filtering; when /api/forms returns { error: "Unauthorized" } on a 401 the current code calls formsData.forms.filter and throws.
Prompt for AI agents
Address the following comment on app/dashboard/_components/spe-m-stats.tsx at line 42: <comment>Verify formsRes.ok (or guard formsData.forms) before filtering; when /api/forms returns { error: "Unauthorized" } on a 401 the current code calls formsData.forms.filter and throws.</comment> <file context> @@ -0,0 +1,237 @@ + + // Fetch forms + const formsRes = await fetch("/api/forms?limit=100"); + const formsData = await formsRes.json(); + + const finalizedForms = formsData.forms.filter( </file context> | try { | ||
| // Fetch patients | ||
| const patientsRes = await fetch("/api/patients"); | ||
| const patientsData = await patientsRes.json(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check that the /api/patients response is ok before reading patientsData.patients; otherwise a 401 payload shaped as { error: "Unauthorized" } leaves patientsData undefined and the component crashes when calling patientsData.patients.length.
Prompt for AI agents
Address the following comment on app/dashboard/_components/spe-m-stats.tsx at line 38: <comment>Check that the /api/patients response is ok before reading patientsData.patients; otherwise a 401 payload shaped as { error: "Unauthorized" } leaves patientsData undefined and the component crashes when calling patientsData.patients.length.</comment> <file context> @@ -0,0 +1,237 @@ + try { + // Fetch patients + const patientsRes = await fetch("/api/patients"); + const patientsData = await patientsRes.json(); + + // Fetch forms </file context>
PR Type
Enhancement
Description
Implements complete SPE-M (Sistema de Pontuação Estética Médica) medical aesthetic evaluation system with database schema, API endpoints, and UI components
Adds comprehensive database schema with 8 tables:
patients,forms,formCriteria,formImages,auditLogs, and extendedusertable with medical credentials (crm,specialty)Implements full CRUD API endpoints for patients, forms, form criteria, and images with audit logging for LGPD compliance
Creates interactive form editor with 8 evaluation criteria, auto-save functionality, real-time score calculation, and profile classification
Adds standardized photo upload interface supporting 6 facial photography angles with drag-and-drop and validation
Implements interactive canvas annotation tool using
react-konvawith drawing tools (pen, line, arrow, circle, text) and undo functionalityAdds PDF report generation for finalized evaluations with patient info, scoring breakdown, and recommendations
Creates patient management dashboard with search, filtering, CRUD operations, and soft-delete functionality
Implements form management dashboard with status filtering, statistics, and action buttons
Updates sidebar navigation and main dashboard with SPE-M-specific components and statistics
Adds dependencies for PDF generation (
jspdf,jspdf-autotable) and canvas drawing (konva,react-konva,use-image)Includes comprehensive documentation with setup instructions, API routes, and implementation details
Diagram Walkthrough
File Walkthrough
22 files
spe-m-criteria.ts
SPE-M Medical Aesthetic Evaluation Criteria Systemlib/spe-m-criteria.ts
with 8 evaluation criteria for medical aesthetic assessment
CriterionFieldandCriterionfor structuringevaluation data
calculateCriterionScore(),calculateTotalScore(), andclassifyProfile()for scoring logicIMAGE_TYPESconstant for 6 standard facial photography angles(frontal, profiles, oblique, base)
pdf-generator.ts
PDF Report Generation for SPE-M Evaluationslib/pdf-generator.ts
generateSPEMPDF()function to create professional PDFreports from SPE-M evaluation data
criteria breakdown, and recommendations
classification badges, and audit trail footer
downloadPDF()helper function for client-side PDF downloadroute.ts
Form CRUD API Endpoints with Audit Loggingapp/api/forms/[id]/route.ts
criteria, and images
recalculate total score
compliance
route.ts
Patient Management API Endpointsapp/api/patients/[id]/route.ts
soft-delete filtering
medical history, allergies, medications)
maintains data for 20 years)
route.ts
Form Image Upload and Annotation APIapp/api/forms/[id]/images/route.ts
image types
one)
uploadImage()function and createsaudit logs
route.ts
Forms List and Creation API Endpointsapp/api/forms/route.ts
status, includes pagination
initialization of 8 empty criteria
schema.ts
Database Schema for SPE-M Medical Systemdb/schema.ts
crmandspecialtyfields tousertable for doctor credentialspatientstable with medical history, allergies, medications,and soft-delete support
formstable for SPE-M evaluations with scoring andclassification fields
formCriteriatable for 8 criteria per form with JSONB datastorage
formImagestable for 6 facial photos per form with annotationsand metadata
auditLogstable for LGPD compliance tracking all user actionsroute.ts
Patients List and Creation API Endpointsapp/api/patients/route.ts
name, CPF, or email
duplicate CPF checking
filtering
route.ts
Form Finalization Endpointapp/api/forms/[id]/finalize/route.ts
finalization
finalizedAttimestamproute.ts
Current User Session Endpointapp/api/auth/me/route.ts
authenticated
page.tsx
Patient Details Dashboard Pageapp/dashboard/patients/[id]/page.tsx
information and evaluation history
and last evaluation date
and classification indicators
and current medications
page.tsx
SPE-M form editing interface with auto-save and scoringapp/dashboard/forms/[id]/edit/page.tsx
tabs
changes tracking
finalization workflow
page.tsx
Patient management dashboard with search and CRUD operationsapp/dashboard/patients/page.tsx
display
medical history fields
page.tsx
Interactive image annotation canvas with drawing toolsapp/dashboard/forms/[id]/photos/[imageId]/annotate/page.tsx
react-konvalibrarycolor and stroke width controls
page.tsx
PDF report generation and export interfaceapp/dashboard/forms/[id]/pdf/page.tsx
spe-m-stats.tsx
Dashboard statistics component for SPE-M system overviewapp/dashboard/_components/spe-m-stats.tsx
metrics, and average scores
page.tsx
Dashboard page refactored for SPE-M systemapp/dashboard/page.tsx
component
system
page.tsx
SPE-M Forms Management Dashboard Pageapp/dashboard/forms/page.tsx
view displaying patient forms, their status, scores, and
classifications
create new evaluations
statistics cards showing total forms, drafts, finalized forms, and
average score
deleting forms with appropriate conditional rendering based on form
status
page.tsx
Standardized Photo Upload Interface for Formsapp/dashboard/forms/[id]/photos/page.tsx
with support for 6 required photo types
for image format (JPG/PNG) and size (max 10MB)
different photo types with visual indicators for completed uploads
photo documentation
page.tsx
Form Evaluation Results View and Details Pageapp/dashboard/forms/[id]/page.tsx
patient information and comprehensive scoring breakdown
detailed criteria evaluation across 8 assessment criteria with tabbed
navigation
attached images with proper date formatting
PDF reports based on form status
table.tsx
Reusable UI Table Component Librarycomponents/ui/table.tsx
elements with consistent styling
Table,TableHeader,TableBody,TableFooter,TableRow,TableHead,TableCell, andTableCaptioncomponentshandling using Tailwind CSS utilities
via
cnutilitysidebar.tsx
Sidebar Navigation Updates for SPE-M Systemapp/dashboard/_components/sidebar.tsx
"Formulários SPE-M" (SPE-M Forms) with appropriate icons
SPE-M" in the sidebar header
UsersandFileTexticons from lucide-react for the newnavigation items
5 files
0002_snapshot.json
Database Migration Snapshotdb/migrations/meta/0002_snapshot.json
changes
patients,forms,formCriteria,formImages, andauditLogsusertable with medical fields (crm,specialty)0001_snapshot.json
Database schema snapshot with SPE-M medical system tablesdb/migrations/meta/0001_snapshot.json
tables
user,patients,forms,formCriteria,formImages,auditLogs,session,subscription, andverificationtablescpf,email, andtokenfields0001_wooden_mindworm.sql
Initial Database Schema for SPE-M Systemdb/migrations/0001_wooden_mindworm.sql
forms,patients,formCriteria, andformImagestablesauditLogstable for tracking user actions with metadata and IPinformation
criteria, and images with cascade delete policies
usertable withcrmandspecialtyfields for medicalprofessional credentials
_journal.json
Migration Journal Updatesdb/migrations/meta/_journal.json
database schema changes
0001_wooden_mindworm(timestamp 1761338699298) and0002_thick_scourge(timestamp 1761442247834)database versioning
0002_thick_scourge.sql
Patient Medical Information Schema Extensiondb/migrations/0002_thick_scourge.sql
patientstable:medicalHistory,allergies,and
currentMedicationsfor enhanced patient medical informationstorage
for comprehensive health assessment
1 files
package.json
Dependencies for PDF and Canvas Functionalitypackage.json
jspdf(^3.0.3) andjspdf-autotable(^5.0.2) for PDF generationkonva(^10.0.8) andreact-konva(^19.2.0) for canvas drawing onimages
use-image(^1.1.4) for image handling in Konva canvas1 files
SPE-M-README.md
Complete implementation documentation for SPE-M systemSPE-M-README.md
schema, API routes, and UI components
steps
and PDF generation
Summary by CodeRabbit
New Features
Improvements