# CRM Clinicas SaaS — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a complete multi-tenant CRM SaaS for medical clinics in Mexico, self-hosted on Ubuntu 24.04 VM with Docker. **Architecture:** Next.js 15 App Router monolith with Supabase self-hosted (PostgreSQL + Auth + Storage + Realtime). Multi-tenancy via Row-Level Security. Docker Compose orchestrates all services behind Nginx reverse proxy. **Tech Stack:** Next.js 15, TypeScript, Tailwind CSS, shadcn/ui, Supabase (self-hosted), PostgreSQL, Docker, Nginx **Design Doc:** `docs/plans/2026-03-02-crm-clinicas-design.md` --- ## Phase 1: Foundation (Tasks 1-12) ### Task 1: Install Docker and Docker Compose **Files:** None (system setup) **Step 1: Install Docker Engine** Run: ```bash sudo apt-get update sudo apt-get install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` **Step 2: Verify Docker** Run: `docker --version && docker compose version` Expected: Docker 27.x and Docker Compose v2.x **Step 3: Commit** ```bash # Nothing to commit yet — system-level install ``` --- ### Task 2: Initialize Next.js project with TypeScript and Tailwind **Files:** - Create: `package.json`, `tsconfig.json`, `next.config.ts`, `tailwind.config.ts`, `postcss.config.mjs` - Create: `src/app/layout.tsx`, `src/app/page.tsx`, `src/app/globals.css` **Step 1: Create Next.js app** Run: ```bash cd /root/CrmClinicas npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm --no-turbopack ``` When prompted, accept defaults. If it asks about overwriting existing files, say yes. **Step 2: Verify it builds** Run: `cd /root/CrmClinicas && npm run build` Expected: Build succeeds with no errors **Step 3: Initialize git repository** Run: ```bash cd /root/CrmClinicas git init cat > .gitignore << 'GITEOF' node_modules/ .next/ out/ .env .env.local .env.production *.log .DS_Store .claude-flow/ claude-scientific-skills/ GITEOF git add -A git commit -m "feat: initialize Next.js 15 project with TypeScript and Tailwind" ``` --- ### Task 3: Install and configure shadcn/ui **Files:** - Modify: `tailwind.config.ts` - Create: `src/lib/utils.ts` - Create: `components.json` **Step 1: Initialize shadcn/ui** Run: ```bash cd /root/CrmClinicas npx shadcn@latest init -d ``` Accept defaults (New York style, Zinc base color, CSS variables). **Step 2: Add core UI components** Run: ```bash npx shadcn@latest add button input label card dialog table badge dropdown-menu separator avatar sheet tabs textarea select toast sonner command popover calendar form scroll-area tooltip checkbox switch ``` **Step 3: Verify build still works** Run: `npm run build` Expected: Build succeeds **Step 4: Commit** ```bash git add -A git commit -m "feat: add shadcn/ui with core components" ``` --- ### Task 4: Set up Supabase self-hosted with Docker Compose **Files:** - Create: `docker/docker-compose.yml` - Create: `docker/.env.supabase` - Create: `docker/volumes/` directories **Step 1: Create docker directory structure** Run: ```bash mkdir -p /root/CrmClinicas/docker/volumes/db/data mkdir -p /root/CrmClinicas/docker/volumes/storage ``` **Step 2: Create Supabase environment file** Create `docker/.env.supabase`: ```env # --- Secrets (change in production!) --- POSTGRES_PASSWORD=your-super-secret-db-password-change-me JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU DASHBOARD_USERNAME=supabase DASHBOARD_PASSWORD=your-dashboard-password-change-me # --- General --- SITE_URL=http://localhost:3000 API_EXTERNAL_URL=http://localhost:8000 SUPABASE_PUBLIC_URL=http://localhost:8000 # --- Database --- POSTGRES_HOST=db POSTGRES_PORT=5432 POSTGRES_DB=postgres # --- Auth (GoTrue) --- GOTRUE_SITE_URL=http://localhost:3000 GOTRUE_URI_ALLOW_LIST= GOTRUE_DISABLE_SIGNUP=false GOTRUE_EXTERNAL_EMAIL_ENABLED=true GOTRUE_MAILER_AUTOCONFIRM=true GOTRUE_SMS_AUTOCONFIRM=true # --- Studio --- STUDIO_DEFAULT_ORGANIZATION=CRM Clinicas STUDIO_DEFAULT_PROJECT=CRM Clinicas STUDIO_PORT=3001 ``` **Step 3: Create Docker Compose file** Create `docker/docker-compose.yml`: ```yaml version: "3.8" services: # --- PostgreSQL --- db: image: supabase/postgres:15.6.1.143 ports: - "5432:5432" environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} JWT_SECRET: ${JWT_SECRET} volumes: - ./volumes/db/data:/var/lib/postgresql/data restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U supabase_admin -d postgres"] interval: 10s timeout: 5s retries: 5 # --- Auth (GoTrue) --- auth: image: supabase/gotrue:v2.164.0 ports: - "9999:9999" depends_on: db: condition: service_healthy environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: 9999 API_EXTERNAL_URL: ${API_EXTERNAL_URL} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB} GOTRUE_SITE_URL: ${GOTRUE_SITE_URL} GOTRUE_URI_ALLOW_LIST: ${GOTRUE_URI_ALLOW_LIST} GOTRUE_DISABLE_SIGNUP: ${GOTRUE_DISABLE_SIGNUP} GOTRUE_JWT_ADMIN_ROLES: service_role GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_EXP: 3600 GOTRUE_JWT_SECRET: ${JWT_SECRET} GOTRUE_EXTERNAL_EMAIL_ENABLED: ${GOTRUE_EXTERNAL_EMAIL_ENABLED} GOTRUE_MAILER_AUTOCONFIRM: ${GOTRUE_MAILER_AUTOCONFIRM} GOTRUE_SMS_AUTOCONFIRM: ${GOTRUE_SMS_AUTOCONFIRM} restart: unless-stopped # --- REST API (PostgREST) --- rest: image: postgrest/postgrest:v12.2.3 ports: - "3100:3000" depends_on: db: condition: service_healthy environment: PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB} PGRST_DB_SCHEMAS: public,storage,graphql_public PGRST_DB_ANON_ROLE: anon PGRST_JWT_SECRET: ${JWT_SECRET} PGRST_DB_USE_LEGACY_GUCS: "false" PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} PGRST_APP_SETTINGS_JWT_EXP: 3600 restart: unless-stopped # --- Realtime --- realtime: image: supabase/realtime:v2.30.34 ports: - "4000:4000" depends_on: db: condition: service_healthy environment: PORT: 4000 DB_HOST: db DB_PORT: ${POSTGRES_PORT} DB_USER: supabase_admin DB_PASSWORD: ${POSTGRES_PASSWORD} DB_NAME: ${POSTGRES_DB} DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime" DB_ENC_KEY: supabaserealtime API_JWT_SECRET: ${JWT_SECRET} SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq ERL_AFLAGS: -proto_dist inet_tcp DNS_NODES: "''" RLIMIT_NOFILE: "10000" APP_NAME: realtime SEED_SELF_HOST: "true" restart: unless-stopped # --- Storage --- storage: image: supabase/storage-api:v1.11.13 ports: - "5000:5000" depends_on: db: condition: service_healthy environment: ANON_KEY: ${ANON_KEY} SERVICE_KEY: ${SERVICE_ROLE_KEY} POSTGREST_URL: http://rest:3000 PGRST_JWT_SECRET: ${JWT_SECRET} DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB} FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: stub REGION: stub GLOBAL_S3_BUCKET: stub IS_MULTITENANT: "false" volumes: - ./volumes/storage:/var/lib/storage restart: unless-stopped # --- Kong API Gateway --- kong: image: kong:2.8.1 ports: - "8000:8000" - "8443:8443" depends_on: auth: condition: service_started rest: condition: service_started realtime: condition: service_started storage: condition: service_started environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml KONG_DNS_ORDER: LAST,A,CNAME KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k volumes: - ./kong.yml:/home/kong/kong.yml:ro restart: unless-stopped # --- Supabase Studio --- studio: image: supabase/studio:20241202-71e5240 ports: - "3001:3000" depends_on: kong: condition: service_started environment: STUDIO_PG_META_URL: http://meta:8080 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} SUPABASE_URL: http://kong:8000 SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} AUTH_JWT_SECRET: ${JWT_SECRET} LOGFLARE_API_KEY: fake-logflare-key LOGFLARE_URL: http://localhost:4000 NEXT_PUBLIC_ENABLE_LOGS: "false" NEXT_ANALYTICS_BACKEND_PROVIDER: postgres restart: unless-stopped # --- Postgres Meta (for Studio) --- meta: image: supabase/postgres-meta:v0.84.2 ports: - "8080:8080" depends_on: db: condition: service_healthy environment: PG_META_PORT: 8080 PG_META_DB_HOST: db PG_META_DB_PORT: ${POSTGRES_PORT} PG_META_DB_NAME: ${POSTGRES_DB} PG_META_DB_USER: supabase_admin PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} restart: unless-stopped ``` **Step 4: Create Kong configuration** Create `docker/kong.yml`: ```yaml _format_version: "2.1" _transform: true services: # --- Auth --- - name: auth-v1-open url: http://auth:9999/verify routes: - name: auth-v1-open strip_path: true paths: - /auth/v1/verify plugins: - name: cors - name: auth-v1-open-callback url: http://auth:9999/callback routes: - name: auth-v1-open-callback strip_path: true paths: - /auth/v1/callback plugins: - name: cors - name: auth-v1-open-authorize url: http://auth:9999/authorize routes: - name: auth-v1-open-authorize strip_path: true paths: - /auth/v1/authorize plugins: - name: cors - name: auth-v1 _comment: "GoTrue: /auth/v1/* -> http://auth:9999/*" url: http://auth:9999/ routes: - name: auth-v1-all strip_path: true paths: - /auth/v1/ plugins: - name: cors - name: key-auth config: hide_credentials: false key_names: - apikey # --- REST --- - name: rest-v1 _comment: "PostgREST: /rest/v1/* -> http://rest:3000/*" url: http://rest:3000/ routes: - name: rest-v1-all strip_path: true paths: - /rest/v1/ plugins: - name: cors - name: key-auth config: hide_credentials: false key_names: - apikey # --- Realtime --- - name: realtime-v1 _comment: "Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*" url: http://realtime:4000/socket/ routes: - name: realtime-v1-all strip_path: true paths: - /realtime/v1/ plugins: - name: cors - name: key-auth config: hide_credentials: false key_names: - apikey # --- Storage --- - name: storage-v1 _comment: "Storage: /storage/v1/* -> http://storage:5000/*" url: http://storage:5000/ routes: - name: storage-v1-all strip_path: true paths: - /storage/v1/ plugins: - name: cors consumers: - username: ANON keyauth_credentials: - key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 - username: SERVICE_ROLE keyauth_credentials: - key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU ``` **Step 5: Start Supabase stack** Run: ```bash cd /root/CrmClinicas/docker docker compose --env-file .env.supabase up -d ``` **Step 6: Verify all services are running** Run: `docker compose --env-file .env.supabase ps` Expected: All services show "running" or "healthy" Verify API responds: Run: `curl -s http://localhost:8000/rest/v1/ -H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" | head -5` Expected: JSON response (empty object or array) **Step 7: Commit** ```bash cd /root/CrmClinicas git add docker/ git commit -m "feat: add Supabase self-hosted Docker Compose stack" ``` --- ### Task 5: Configure Next.js Supabase client **Files:** - Create: `src/lib/supabase/client.ts` - Create: `src/lib/supabase/server.ts` - Create: `src/lib/supabase/admin.ts` - Create: `src/lib/supabase/middleware.ts` - Create: `src/middleware.ts` - Create: `.env.local` - Modify: `package.json` (add dependencies) **Step 1: Install Supabase packages** Run: ```bash cd /root/CrmClinicas npm install @supabase/supabase-js @supabase/ssr ``` **Step 2: Create environment file** Create `.env.local`: ```env NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000 NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU ``` **Step 3: Create browser client** Create `src/lib/supabase/client.ts`: ```typescript import { createBrowserClient } from "@supabase/ssr"; export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); } ``` **Step 4: Create server client** Create `src/lib/supabase/server.ts`: ```typescript import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; export async function createClient() { const cookieStore = await cookies(); return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll(); }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ); } catch { // Called from Server Component — ignore } }, }, } ); } ``` **Step 5: Create admin client (service role)** Create `src/lib/supabase/admin.ts`: ```typescript import { createClient as createSupabaseClient } from "@supabase/supabase-js"; export function createAdminClient() { return createSupabaseClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { autoRefreshToken: false, persistSession: false, }, } ); } ``` **Step 6: Create middleware for auth session refresh** Create `src/lib/supabase/middleware.ts`: ```typescript import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ request }); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return request.cookies.getAll(); }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value) ); supabaseResponse = NextResponse.next({ request }); cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options) ); }, }, } ); const { data: { user }, } = await supabase.auth.getUser(); // Redirect unauthenticated users to login (except auth pages) if ( !user && !request.nextUrl.pathname.startsWith("/login") && !request.nextUrl.pathname.startsWith("/register") && !request.nextUrl.pathname.startsWith("/auth") && request.nextUrl.pathname !== "/" ) { const url = request.nextUrl.clone(); url.pathname = "/login"; return NextResponse.redirect(url); } return supabaseResponse; } ``` Create `src/middleware.ts`: ```typescript import { updateSession } from "@/lib/supabase/middleware"; import { type NextRequest } from "next/server"; export async function middleware(request: NextRequest) { return await updateSession(request); } export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; ``` **Step 7: Verify build** Run: `npm run build` Expected: Build succeeds **Step 8: Commit** ```bash git add src/lib/supabase/ src/middleware.ts .env.local git commit -m "feat: configure Supabase client (browser, server, admin) with auth middleware" ``` --- ### Task 6: Create TypeScript types for database schema **Files:** - Create: `src/types/database.ts` **Step 1: Create comprehensive database types** Create `src/types/database.ts`: ```typescript export type UserRole = "owner" | "admin" | "doctor" | "receptionist"; export type AppointmentStatus = "scheduled" | "confirmed" | "in_progress" | "completed" | "cancelled" | "no_show"; export type AppointmentType = "primera_vez" | "seguimiento" | "urgencia"; export type CfdiStatus = "draft" | "stamped" | "cancelled"; export type PaymentStatus = "pending" | "partial" | "paid"; export type PaymentMethod = "cash" | "card" | "transfer" | "other"; export type InventoryMovementType = "entrada" | "salida" | "ajuste" | "merma"; export type ProductCategory = "medicamento" | "insumo" | "material" | "equipo"; export type SubscriptionPlan = "basico" | "pro" | "enterprise"; export type SubscriptionStatus = "active" | "trial" | "suspended" | "cancelled"; export interface Clinic { id: string; name: string; slug: string; logo_url: string | null; address: string | null; phone: string | null; email: string | null; rfc: string | null; razon_social: string | null; regimen_fiscal: string | null; codigo_postal: string | null; pac_provider: string | null; pac_api_key: string | null; subscription_plan: SubscriptionPlan; subscription_status: SubscriptionStatus; trial_ends_at: string | null; created_at: string; } export interface User { id: string; clinic_id: string; full_name: string; email: string; phone: string | null; role: UserRole; specialty: string | null; license_number: string | null; color: string | null; is_active: boolean; avatar_url: string | null; created_at: string; } export interface Address { calle: string; colonia: string; cp: string; ciudad: string; estado: string; } export interface EmergencyContact { nombre: string; telefono: string; parentesco: string; } export interface Patient { id: string; clinic_id: string; first_name: string; last_name: string; date_of_birth: string | null; gender: "M" | "F" | "otro" | null; curp: string | null; phone: string | null; email: string | null; address: Address | null; blood_type: string | null; allergies: string[]; emergency_contact: EmergencyContact | null; notes: string | null; source: string | null; created_at: string; } export interface DoctorSchedule { id: string; clinic_id: string; doctor_id: string; day_of_week: number; // 0=monday ... 6=sunday start_time: string; end_time: string; slot_duration: number; // minutes is_active: boolean; } export interface Appointment { id: string; clinic_id: string; patient_id: string; doctor_id: string; starts_at: string; ends_at: string; status: AppointmentStatus; type: AppointmentType; reason: string | null; notes: string | null; reminder_sent: boolean; created_by: string; created_at: string; // Joined fields patient?: Patient; doctor?: User; } export interface VitalSigns { peso?: number; talla?: number; ta_sistolica?: number; ta_diastolica?: number; fc?: number; fr?: number; temp?: number; spo2?: number; } export interface Diagnosis { code: string; // CIE-10 description: string; type: "principal" | "secundario"; } export interface MedicalRecord { id: string; clinic_id: string; patient_id: string; family_history: { enfermedad: string; parentesco: string }[]; personal_history: { enfermedad: string; anio_diagnostico: string; tratamiento: string }[]; surgical_history: { cirugia: string; fecha: string; notas: string }[]; current_medications: { medicamento: string; dosis: string; frecuencia: string }[]; immunizations: { vacuna: string; fecha: string }[]; created_at: string; updated_at: string; } export interface ConsultationNote { id: string; clinic_id: string; patient_id: string; doctor_id: string; appointment_id: string | null; subjective: string | null; objective: string | null; assessment: string | null; plan: string | null; vital_signs: VitalSigns | null; diagnoses: Diagnosis[]; is_signed: boolean; signed_at: string | null; created_at: string; } export interface PrescriptionItem { medicamento: string; dosis: string; via: string; frecuencia: string; duracion: string; indicaciones: string; } export interface Prescription { id: string; clinic_id: string; consultation_id: string; patient_id: string; doctor_id: string; items: PrescriptionItem[]; pharmacy_notes: string | null; pdf_url: string | null; created_at: string; } export interface ServiceCatalog { id: string; clinic_id: string; name: string; description: string | null; price: number; sat_product_key: string | null; sat_unit_key: string | null; tax_rate: number; is_active: boolean; created_at: string; } export interface Invoice { id: string; clinic_id: string; patient_id: string; invoice_number: string; cfdi_uuid: string | null; cfdi_status: CfdiStatus; cfdi_xml_url: string | null; cfdi_pdf_url: string | null; uso_cfdi: string | null; forma_pago: string | null; metodo_pago: string | null; subtotal: number; tax_amount: number; total: number; payment_status: PaymentStatus; paid_amount: number; due_date: string | null; created_by: string; created_at: string; // Joined patient?: Patient; items?: InvoiceItem[]; } export interface InvoiceItem { id: string; invoice_id: string; service_id: string | null; product_id: string | null; description: string; quantity: number; unit_price: number; tax_rate: number; total: number; } export interface Payment { id: string; clinic_id: string; invoice_id: string; amount: number; payment_method: PaymentMethod; reference: string | null; received_by: string; created_at: string; } export interface Product { id: string; clinic_id: string; sku: string | null; name: string; description: string | null; category: ProductCategory; unit: string; purchase_price: number; sale_price: number; min_stock: number; is_active: boolean; created_at: string; } export interface InventoryStock { id: string; clinic_id: string; product_id: string; current_stock: number; last_updated: string; } export interface InventoryMovement { id: string; clinic_id: string; product_id: string; type: InventoryMovementType; quantity: number; reason: string | null; reference_id: string | null; created_by: string; created_at: string; } export interface Branch { id: string; clinic_id: string; name: string; address: string | null; phone: string | null; is_main: boolean; is_active: boolean; } ``` **Step 2: Verify build** Run: `npm run build` Expected: Build succeeds **Step 3: Commit** ```bash git add src/types/ git commit -m "feat: add TypeScript types for complete database schema" ``` --- ### Task 7: Create database migrations — core tables with RLS **Files:** - Create: `supabase/migrations/001_clinics_and_users.sql` - Create: `supabase/migrations/002_patients.sql` - Create: `supabase/migrations/003_appointments.sql` - Create: `supabase/migrations/004_medical_records.sql` - Create: `supabase/migrations/005_billing.sql` - Create: `supabase/migrations/006_inventory.sql` - Create: `supabase/migrations/007_audit_log.sql` **Step 1: Create migrations directory** Run: `mkdir -p /root/CrmClinicas/supabase/migrations` **Step 2: Create migration 001 — clinics and users** Create `supabase/migrations/001_clinics_and_users.sql`: ```sql -- Enable necessary extensions CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ============================================ -- CLINICS (tenant table) -- ============================================ CREATE TABLE clinics ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, logo_url TEXT, address TEXT, phone TEXT, email TEXT, rfc TEXT, razon_social TEXT, regimen_fiscal TEXT, codigo_postal TEXT, pac_provider TEXT CHECK (pac_provider IN ('facturama', 'sw_sapien')), pac_api_key TEXT, subscription_plan TEXT NOT NULL DEFAULT 'trial' CHECK (subscription_plan IN ('basico', 'pro', 'enterprise', 'trial')), subscription_status TEXT NOT NULL DEFAULT 'trial' CHECK (subscription_status IN ('active', 'trial', 'suspended', 'cancelled')), trial_ends_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '14 days'), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- ============================================ -- USERS (linked to auth.users) -- ============================================ CREATE TABLE users ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, full_name TEXT NOT NULL, email TEXT NOT NULL, phone TEXT, role TEXT NOT NULL DEFAULT 'receptionist' CHECK (role IN ('owner', 'admin', 'doctor', 'receptionist')), specialty TEXT, license_number TEXT, color TEXT DEFAULT '#3B82F6', is_active BOOLEAN NOT NULL DEFAULT TRUE, avatar_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_users_clinic ON users(clinic_id); -- ============================================ -- BRANCHES -- ============================================ CREATE TABLE branches ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, name TEXT NOT NULL, address TEXT, phone TEXT, is_main BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE ); CREATE INDEX idx_branches_clinic ON branches(clinic_id); -- ============================================ -- Helper function: get current user's clinic_id -- ============================================ CREATE OR REPLACE FUNCTION auth.clinic_id() RETURNS UUID AS $$ SELECT clinic_id FROM public.users WHERE id = auth.uid() $$ LANGUAGE SQL STABLE SECURITY DEFINER; -- ============================================ -- RLS Policies -- ============================================ -- Clinics: users can only see their own clinic ALTER TABLE clinics ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view own clinic" ON clinics FOR SELECT USING (id = auth.clinic_id()); CREATE POLICY "Owners can update own clinic" ON clinics FOR UPDATE USING (id = auth.clinic_id()); -- Users: users can only see users in their clinic ALTER TABLE users ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic members" ON users FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Owners/admins can insert users" ON users FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); CREATE POLICY "Owners/admins can update users" ON users FOR UPDATE USING (clinic_id = auth.clinic_id()); -- Branches ALTER TABLE branches ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic branches" ON branches FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Owners/admins can manage branches" ON branches FOR ALL USING (clinic_id = auth.clinic_id()); ``` **Step 3: Create migration 002 — patients** Create `supabase/migrations/002_patients.sql`: ```sql CREATE TABLE patients ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, first_name TEXT NOT NULL, last_name TEXT NOT NULL, date_of_birth DATE, gender TEXT CHECK (gender IN ('M', 'F', 'otro')), curp TEXT, phone TEXT, email TEXT, address JSONB, blood_type TEXT, allergies TEXT[] DEFAULT '{}', emergency_contact JSONB, notes TEXT, source TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_patients_clinic ON patients(clinic_id); CREATE INDEX idx_patients_name ON patients(clinic_id, last_name, first_name); CREATE INDEX idx_patients_phone ON patients(clinic_id, phone); CREATE INDEX idx_patients_curp ON patients(clinic_id, curp); ALTER TABLE patients ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic patients" ON patients FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can insert clinic patients" ON patients FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); CREATE POLICY "Users can update clinic patients" ON patients FOR UPDATE USING (clinic_id = auth.clinic_id()); ``` **Step 4: Create migration 003 — appointments** Create `supabase/migrations/003_appointments.sql`: ```sql CREATE TABLE doctor_schedules ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, doctor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), start_time TIME NOT NULL, end_time TIME NOT NULL, slot_duration INT NOT NULL DEFAULT 30, is_active BOOLEAN NOT NULL DEFAULT TRUE, UNIQUE(doctor_id, day_of_week) ); CREATE INDEX idx_schedules_doctor ON doctor_schedules(doctor_id); CREATE TABLE appointments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE, doctor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show')), type TEXT NOT NULL DEFAULT 'seguimiento' CHECK (type IN ('primera_vez', 'seguimiento', 'urgencia')), reason TEXT, notes TEXT, reminder_sent BOOLEAN NOT NULL DEFAULT FALSE, created_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_appointments_clinic ON appointments(clinic_id); CREATE INDEX idx_appointments_doctor_date ON appointments(doctor_id, starts_at); CREATE INDEX idx_appointments_patient ON appointments(patient_id); CREATE INDEX idx_appointments_status ON appointments(clinic_id, status); -- RLS ALTER TABLE doctor_schedules ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic schedules" ON doctor_schedules FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can manage clinic schedules" ON doctor_schedules FOR ALL USING (clinic_id = auth.clinic_id()); ALTER TABLE appointments ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic appointments" ON appointments FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can insert clinic appointments" ON appointments FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); CREATE POLICY "Users can update clinic appointments" ON appointments FOR UPDATE USING (clinic_id = auth.clinic_id()); ``` **Step 5: Create migration 004 — medical records** Create `supabase/migrations/004_medical_records.sql`: ```sql CREATE TABLE medical_records ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE, family_history JSONB DEFAULT '[]', personal_history JSONB DEFAULT '[]', surgical_history JSONB DEFAULT '[]', current_medications JSONB DEFAULT '[]', immunizations JSONB DEFAULT '[]', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(patient_id) ); CREATE TABLE consultation_notes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE, doctor_id UUID NOT NULL REFERENCES users(id), appointment_id UUID REFERENCES appointments(id), subjective TEXT, objective TEXT, assessment TEXT, plan TEXT, vital_signs JSONB, diagnoses JSONB DEFAULT '[]', is_signed BOOLEAN NOT NULL DEFAULT FALSE, signed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_consultations_patient ON consultation_notes(patient_id, created_at DESC); CREATE INDEX idx_consultations_doctor ON consultation_notes(doctor_id, created_at DESC); CREATE INDEX idx_consultations_clinic ON consultation_notes(clinic_id); CREATE TABLE prescriptions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, consultation_id UUID NOT NULL REFERENCES consultation_notes(id) ON DELETE CASCADE, patient_id UUID NOT NULL REFERENCES patients(id), doctor_id UUID NOT NULL REFERENCES users(id), items JSONB NOT NULL DEFAULT '[]', pharmacy_notes TEXT, pdf_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE medical_files ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE, consultation_id UUID REFERENCES consultation_notes(id), file_name TEXT NOT NULL, file_type TEXT NOT NULL DEFAULT 'other' CHECK (file_type IN ('lab_result', 'imaging', 'referral', 'other')), storage_path TEXT NOT NULL, uploaded_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- RLS ALTER TABLE medical_records ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic medical records" ON medical_records FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Doctors can insert medical records" ON medical_records FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); CREATE POLICY "Doctors can update medical records" ON medical_records FOR UPDATE USING (clinic_id = auth.clinic_id()); ALTER TABLE consultation_notes ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic consultations" ON consultation_notes FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Doctors can insert consultations" ON consultation_notes FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); CREATE POLICY "Doctors can update own consultations" ON consultation_notes FOR UPDATE USING (clinic_id = auth.clinic_id() AND doctor_id = auth.uid()); ALTER TABLE prescriptions ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic prescriptions" ON prescriptions FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Doctors can insert prescriptions" ON prescriptions FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); ALTER TABLE medical_files ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic files" ON medical_files FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can insert clinic files" ON medical_files FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); ``` **Step 6: Create migration 005 — billing** Create `supabase/migrations/005_billing.sql`: ```sql CREATE TABLE services_catalog ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL DEFAULT 0, sat_product_key TEXT, sat_unit_key TEXT DEFAULT 'E48', tax_rate DECIMAL(4,2) NOT NULL DEFAULT 0.16, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_services_clinic ON services_catalog(clinic_id); CREATE TABLE invoices ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, patient_id UUID NOT NULL REFERENCES patients(id), invoice_number TEXT NOT NULL, cfdi_uuid TEXT, cfdi_status TEXT NOT NULL DEFAULT 'draft' CHECK (cfdi_status IN ('draft', 'stamped', 'cancelled')), cfdi_xml_url TEXT, cfdi_pdf_url TEXT, uso_cfdi TEXT DEFAULT 'S01', forma_pago TEXT DEFAULT '01', metodo_pago TEXT DEFAULT 'PUE', subtotal DECIMAL(10,2) NOT NULL DEFAULT 0, tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0, total DECIMAL(10,2) NOT NULL DEFAULT 0, payment_status TEXT NOT NULL DEFAULT 'pending' CHECK (payment_status IN ('pending', 'partial', 'paid')), paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0, due_date DATE, created_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(clinic_id, invoice_number) ); CREATE INDEX idx_invoices_clinic ON invoices(clinic_id); CREATE INDEX idx_invoices_patient ON invoices(patient_id); CREATE INDEX idx_invoices_status ON invoices(clinic_id, payment_status); CREATE TABLE invoice_items ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, service_id UUID REFERENCES services_catalog(id), product_id UUID, -- FK added after products table description TEXT NOT NULL, quantity DECIMAL(10,2) NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL DEFAULT 0, tax_rate DECIMAL(4,2) NOT NULL DEFAULT 0.16, total DECIMAL(10,2) NOT NULL DEFAULT 0 ); CREATE TABLE payments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, amount DECIMAL(10,2) NOT NULL, payment_method TEXT NOT NULL DEFAULT 'cash' CHECK (payment_method IN ('cash', 'card', 'transfer', 'other')), reference TEXT, received_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_payments_invoice ON payments(invoice_id); -- RLS ALTER TABLE services_catalog ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic services" ON services_catalog FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can manage clinic services" ON services_catalog FOR ALL USING (clinic_id = auth.clinic_id()); ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic invoices" ON invoices FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can insert clinic invoices" ON invoices FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); CREATE POLICY "Users can update clinic invoices" ON invoices FOR UPDATE USING (clinic_id = auth.clinic_id()); ALTER TABLE invoice_items ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view invoice items" ON invoice_items FOR SELECT USING ( EXISTS (SELECT 1 FROM invoices WHERE invoices.id = invoice_items.invoice_id AND invoices.clinic_id = auth.clinic_id()) ); CREATE POLICY "Users can manage invoice items" ON invoice_items FOR ALL USING ( EXISTS (SELECT 1 FROM invoices WHERE invoices.id = invoice_items.invoice_id AND invoices.clinic_id = auth.clinic_id()) ); ALTER TABLE payments ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic payments" ON payments FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can insert clinic payments" ON payments FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); ``` **Step 7: Create migration 006 — inventory** Create `supabase/migrations/006_inventory.sql`: ```sql CREATE TABLE products ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, sku TEXT, name TEXT NOT NULL, description TEXT, category TEXT NOT NULL DEFAULT 'insumo' CHECK (category IN ('medicamento', 'insumo', 'material', 'equipo')), unit TEXT NOT NULL DEFAULT 'pieza', purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0, min_stock INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_products_clinic ON products(clinic_id); -- Add FK from invoice_items to products ALTER TABLE invoice_items ADD CONSTRAINT fk_invoice_items_product FOREIGN KEY (product_id) REFERENCES products(id); CREATE TABLE inventory_stock ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, current_stock DECIMAL(10,2) NOT NULL DEFAULT 0, last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(clinic_id, product_id) ); CREATE TABLE inventory_movements ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('entrada', 'salida', 'ajuste', 'merma')), quantity DECIMAL(10,2) NOT NULL, reason TEXT, reference_id TEXT, created_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_inventory_stock_clinic ON inventory_stock(clinic_id); CREATE INDEX idx_movements_product ON inventory_movements(product_id, created_at DESC); -- Trigger: update stock on movement CREATE OR REPLACE FUNCTION update_inventory_stock() RETURNS TRIGGER AS $$ BEGIN INSERT INTO inventory_stock (clinic_id, product_id, current_stock, last_updated) VALUES (NEW.clinic_id, NEW.product_id, NEW.quantity, NOW()) ON CONFLICT (clinic_id, product_id) DO UPDATE SET current_stock = inventory_stock.current_stock + NEW.quantity, last_updated = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; CREATE TRIGGER trg_update_stock AFTER INSERT ON inventory_movements FOR EACH ROW EXECUTE FUNCTION update_inventory_stock(); -- RLS ALTER TABLE products ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic products" ON products FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can manage clinic products" ON products FOR ALL USING (clinic_id = auth.clinic_id()); ALTER TABLE inventory_stock ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic stock" ON inventory_stock FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can manage clinic stock" ON inventory_stock FOR ALL USING (clinic_id = auth.clinic_id()); ALTER TABLE inventory_movements ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view clinic movements" ON inventory_movements FOR SELECT USING (clinic_id = auth.clinic_id()); CREATE POLICY "Users can insert clinic movements" ON inventory_movements FOR INSERT WITH CHECK (clinic_id = auth.clinic_id()); ``` **Step 8: Create migration 007 — audit log** Create `supabase/migrations/007_audit_log.sql`: ```sql CREATE TABLE audit_log ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), clinic_id UUID REFERENCES clinics(id), user_id UUID REFERENCES auth.users(id), table_name TEXT NOT NULL, record_id UUID, action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')), old_data JSONB, new_data JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_audit_clinic ON audit_log(clinic_id, created_at DESC); CREATE INDEX idx_audit_table ON audit_log(table_name, record_id); -- Generic audit trigger function CREATE OR REPLACE FUNCTION audit_trigger_func() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN INSERT INTO audit_log (clinic_id, user_id, table_name, record_id, action, new_data) VALUES (NEW.clinic_id, auth.uid(), TG_TABLE_NAME, NEW.id, 'INSERT', to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'UPDATE' THEN INSERT INTO audit_log (clinic_id, user_id, table_name, record_id, action, old_data, new_data) VALUES (NEW.clinic_id, auth.uid(), TG_TABLE_NAME, NEW.id, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'DELETE' THEN INSERT INTO audit_log (clinic_id, user_id, table_name, record_id, action, old_data) VALUES (OLD.clinic_id, auth.uid(), TG_TABLE_NAME, OLD.id, 'DELETE', to_jsonb(OLD)); RETURN OLD; END IF; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- Attach audit triggers to critical tables CREATE TRIGGER audit_patients AFTER INSERT OR UPDATE OR DELETE ON patients FOR EACH ROW EXECUTE FUNCTION audit_trigger_func(); CREATE TRIGGER audit_appointments AFTER INSERT OR UPDATE OR DELETE ON appointments FOR EACH ROW EXECUTE FUNCTION audit_trigger_func(); CREATE TRIGGER audit_consultation_notes AFTER INSERT OR UPDATE OR DELETE ON consultation_notes FOR EACH ROW EXECUTE FUNCTION audit_trigger_func(); CREATE TRIGGER audit_invoices AFTER INSERT OR UPDATE OR DELETE ON invoices FOR EACH ROW EXECUTE FUNCTION audit_trigger_func(); CREATE TRIGGER audit_inventory_movements AFTER INSERT ON inventory_movements FOR EACH ROW EXECUTE FUNCTION audit_trigger_func(); -- RLS: only admins/owners can view audit log ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; CREATE POLICY "Admins can view clinic audit log" ON audit_log FOR SELECT USING (clinic_id = auth.clinic_id()); ``` **Step 9: Run migrations against Supabase** Run: ```bash cd /root/CrmClinicas for f in supabase/migrations/*.sql; do echo "Running $f..." docker exec -i docker-db-1 psql -U supabase_admin -d postgres < "$f" done ``` Expected: Each migration runs without errors. **Step 10: Verify tables exist** Run: ```bash docker exec docker-db-1 psql -U supabase_admin -d postgres -c "\dt public.*" ``` Expected: Lists all tables (clinics, users, patients, appointments, etc.) **Step 11: Commit** ```bash git add supabase/ git commit -m "feat: add database migrations with RLS policies and audit triggers" ``` --- ### Task 8: Create dashboard layout with sidebar navigation **Files:** - Create: `src/components/layout/sidebar.tsx` - Create: `src/components/layout/header.tsx` - Create: `src/components/layout/user-nav.tsx` - Create: `src/app/(dashboard)/layout.tsx` - Create: `src/app/(dashboard)/page.tsx` - Modify: `src/app/layout.tsx` **Step 1: Install icons package** Run: `cd /root/CrmClinicas && npm install lucide-react` **Step 2: Create sidebar component** Create `src/components/layout/sidebar.tsx`: ```tsx "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; import { LayoutDashboard, Users, Calendar, FileText, Receipt, Package, Settings, } from "lucide-react"; const navigation = [ { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { name: "Pacientes", href: "/patients", icon: Users }, { name: "Agenda", href: "/appointments", icon: Calendar }, { name: "Expedientes", href: "/medical-records", icon: FileText }, { name: "Facturacion", href: "/billing", icon: Receipt }, { name: "Inventario", href: "/inventory", icon: Package }, { name: "Configuracion", href: "/settings", icon: Settings }, ]; export function Sidebar() { const pathname = usePathname(); return ( ); } ``` **Step 3: Create header component** Create `src/components/layout/header.tsx`: ```tsx import { UserNav } from "./user-nav"; export function Header() { return (
); } ``` **Step 4: Create user navigation (avatar + logout)** Create `src/components/layout/user-nav.tsx`: ```tsx "use client"; import { useRouter } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { LogOut, User } from "lucide-react"; export function UserNav() { const router = useRouter(); const supabase = createClient(); async function handleSignOut() { await supabase.auth.signOut(); router.push("/login"); } return ( Mi cuenta Cerrar sesion ); } ``` **Step 5: Create dashboard layout** Create `src/app/(dashboard)/layout.tsx`: ```tsx import { Sidebar } from "@/components/layout/sidebar"; import { Header } from "@/components/layout/header"; export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ``` **Step 6: Create dashboard home page** Create `src/app/(dashboard)/page.tsx`: ```tsx export const dynamic = "force-dynamic"; export default function DashboardPage() { return (

Dashboard

Bienvenido al CRM de tu clinica.

); } ``` **Step 7: Verify build** Run: `npm run build` Expected: Build succeeds **Step 8: Commit** ```bash git add src/components/layout/ src/app/\(dashboard\)/ git commit -m "feat: add dashboard layout with sidebar navigation" ``` --- ### Task 9: Create authentication pages (login + register) **Files:** - Create: `src/app/(auth)/login/page.tsx` - Create: `src/app/(auth)/register/page.tsx` - Create: `src/app/(auth)/layout.tsx` **Step 1: Create auth layout (centered card)** Create `src/app/(auth)/layout.tsx`: ```tsx export default function AuthLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ``` **Step 2: Create login page** Create `src/app/(auth)/login/page.tsx`: ```tsx "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { createClient } from "@/lib/supabase/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; export default function LoginPage() { const router = useRouter(); const supabase = createClient(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); setLoading(true); const { error } = await supabase.auth.signInWithPassword({ email, password, }); if (error) { setError(error.message); setLoading(false); return; } router.push("/dashboard"); } return ( CRM Clinicas Inicia sesion en tu cuenta
{error && (
{error}
)}
setEmail(e.target.value)} required />
setPassword(e.target.value)} required />

No tienes cuenta?{" "} Registrate

); } ``` **Step 3: Create register page (clinic + owner signup)** Create `src/app/(auth)/register/page.tsx`: ```tsx "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { createClient } from "@/lib/supabase/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; export default function RegisterPage() { const router = useRouter(); const supabase = createClient(); const [formData, setFormData] = useState({ clinicName: "", fullName: "", email: "", password: "", }); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); function updateField(field: string, value: string) { setFormData((prev) => ({ ...prev, [field]: value })); } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); setLoading(true); // 1. Sign up auth user const { data: authData, error: authError } = await supabase.auth.signUp({ email: formData.email, password: formData.password, }); if (authError || !authData.user) { setError(authError?.message || "Error al crear cuenta"); setLoading(false); return; } // 2. Create clinic + user profile via service role (RPC or direct insert) // For MVP: use a server action or edge function // For now, we insert with the anon key since we have RLS INSERT policies const slug = formData.clinicName .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); const { data: clinic, error: clinicError } = await supabase .from("clinics") .insert({ name: formData.clinicName, slug }) .select() .single(); if (clinicError) { setError("Error al crear clinica: " + clinicError.message); setLoading(false); return; } const { error: userError } = await supabase.from("users").insert({ id: authData.user.id, clinic_id: clinic.id, full_name: formData.fullName, email: formData.email, role: "owner", }); if (userError) { setError("Error al crear perfil: " + userError.message); setLoading(false); return; } router.push("/dashboard"); } return ( Crear cuenta Registra tu clinica y comienza gratis
{error && (
{error}
)}
updateField("clinicName", e.target.value)} required />
updateField("fullName", e.target.value)} required />
updateField("email", e.target.value)} required />
updateField("password", e.target.value)} minLength={6} required />

Ya tienes cuenta?{" "} Inicia sesion

); } ``` **Step 4: Update root page to redirect** Modify `src/app/page.tsx` to redirect to login: ```tsx import { redirect } from "next/navigation"; export default function Home() { redirect("/login"); } ``` **Step 5: Verify build** Run: `npm run build` Expected: Build succeeds **Step 6: Commit** ```bash git add src/app/\(auth\)/ src/app/page.tsx git commit -m "feat: add login and register pages with Supabase auth" ``` --- ### Task 10: Create patients CRUD **Files:** - Create: `src/app/(dashboard)/patients/page.tsx` - Create: `src/app/(dashboard)/patients/new/page.tsx` - Create: `src/app/(dashboard)/patients/[id]/page.tsx` - Create: `src/app/(dashboard)/patients/columns.tsx` - Create: `src/components/patients/patient-form.tsx` - Create: `src/lib/actions/patients.ts` This is a large task — see Phase 2 details. For now, the files above outline the structure. Each file follows the same pattern: Server Component page that fetches data via Supabase server client, Client Components for forms and interactivity, Server Actions for mutations. **Step 1: Create server actions for patients** Create `src/lib/actions/patients.ts`: ```typescript "use server"; import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; import type { Patient } from "@/types/database"; export async function getPatients(search?: string) { const supabase = await createClient(); let query = supabase .from("patients") .select("*") .eq("is_active", true) .order("created_at", { ascending: false }); if (search) { query = query.or( `first_name.ilike.%${search}%,last_name.ilike.%${search}%,phone.ilike.%${search}%,curp.ilike.%${search}%` ); } const { data, error } = await query; if (error) throw error; return data as Patient[]; } export async function getPatient(id: string) { const supabase = await createClient(); const { data, error } = await supabase .from("patients") .select("*") .eq("id", id) .single(); if (error) throw error; return data as Patient; } export async function createPatient(formData: FormData) { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) throw new Error("No autenticado"); // Get user's clinic_id const { data: profile } = await supabase .from("users") .select("clinic_id") .eq("id", user.id) .single(); if (!profile) throw new Error("Perfil no encontrado"); const patient = { clinic_id: profile.clinic_id, first_name: formData.get("first_name") as string, last_name: formData.get("last_name") as string, date_of_birth: (formData.get("date_of_birth") as string) || null, gender: (formData.get("gender") as string) || null, curp: (formData.get("curp") as string) || null, phone: (formData.get("phone") as string) || null, email: (formData.get("email") as string) || null, blood_type: (formData.get("blood_type") as string) || null, notes: (formData.get("notes") as string) || null, source: (formData.get("source") as string) || null, }; const { error } = await supabase.from("patients").insert(patient); if (error) throw error; revalidatePath("/patients"); } export async function updatePatient(id: string, formData: FormData) { const supabase = await createClient(); const updates = { first_name: formData.get("first_name") as string, last_name: formData.get("last_name") as string, date_of_birth: (formData.get("date_of_birth") as string) || null, gender: (formData.get("gender") as string) || null, curp: (formData.get("curp") as string) || null, phone: (formData.get("phone") as string) || null, email: (formData.get("email") as string) || null, blood_type: (formData.get("blood_type") as string) || null, notes: (formData.get("notes") as string) || null, source: (formData.get("source") as string) || null, }; const { error } = await supabase .from("patients") .update(updates) .eq("id", id); if (error) throw error; revalidatePath("/patients"); revalidatePath(`/patients/${id}`); } ``` **Step 2-6: Create patient list page, form component, detail page** _(These follow the same pattern as the server actions above — Server Components for pages, Client Components for forms, using shadcn/ui components for UI.)_ **Step 7: Commit** ```bash git add src/app/\(dashboard\)/patients/ src/components/patients/ src/lib/actions/ git commit -m "feat: add patients CRUD with search and server actions" ``` --- ### Task 11: Create Nginx reverse proxy configuration **Files:** - Create: `docker/nginx.conf` - Modify: `docker/docker-compose.yml` (add nginx + nextjs services) **Step 1: Create Nginx config** Create `docker/nginx.conf`: ```nginx upstream nextjs { server nextjs:3000; } upstream supabase_kong { server kong:8000; } upstream supabase_studio { server studio:3000; } server { listen 80; server_name _; # Next.js app location / { proxy_pass http://nextjs; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } # Supabase API location /supabase/ { rewrite ^/supabase/(.*) /$1 break; proxy_pass http://supabase_kong; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Supabase Studio (admin only) location /studio/ { rewrite ^/studio/(.*) /$1 break; proxy_pass http://supabase_studio; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Health check location /health { return 200 'OK'; add_header Content-Type text/plain; } } ``` **Step 2: Add Nginx and Next.js to Docker Compose** Add to `docker/docker-compose.yml`: ```yaml # --- Next.js App --- nextjs: build: context: .. dockerfile: Dockerfile ports: - "3000:3000" environment: - NEXT_PUBLIC_SUPABASE_URL=http://kong:8000 - NEXT_PUBLIC_SUPABASE_ANON_KEY=${ANON_KEY} - SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY} depends_on: kong: condition: service_started restart: unless-stopped # --- Nginx --- nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - nextjs - kong restart: unless-stopped ``` **Step 3: Create Dockerfile for Next.js** Create `Dockerfile`: ```dockerfile FROM node:18-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci FROM node:18-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:18-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public EXPOSE 3000 CMD ["node", "server.js"] ``` **Step 4: Update next.config for standalone output** Modify `next.config.ts` to add: ```typescript const nextConfig = { output: "standalone", }; ``` **Step 5: Commit** ```bash git add docker/nginx.conf Dockerfile next.config.ts git commit -m "feat: add Nginx reverse proxy and Docker build for Next.js" ``` --- ### Task 12: Create backup script and cron job **Files:** - Create: `scripts/backup.sh` **Step 1: Create backup script** Create `scripts/backup.sh`: ```bash #!/bin/bash BACKUP_DIR="/root/CrmClinicas/backups" TIMESTAMP=$(date +%Y%m%d_%H%M%S) KEEP_DAYS=7 mkdir -p "$BACKUP_DIR" # Dump PostgreSQL docker exec docker-db-1 pg_dump -U supabase_admin -d postgres \ --format=custom \ --file=/tmp/backup_${TIMESTAMP}.dump docker cp docker-db-1:/tmp/backup_${TIMESTAMP}.dump \ "$BACKUP_DIR/db_${TIMESTAMP}.dump" docker exec docker-db-1 rm /tmp/backup_${TIMESTAMP}.dump # Remove old backups find "$BACKUP_DIR" -name "db_*.dump" -mtime +${KEEP_DAYS} -delete echo "[$(date)] Backup completed: db_${TIMESTAMP}.dump" ``` **Step 2: Make executable and add cron** Run: ```bash chmod +x /root/CrmClinicas/scripts/backup.sh (crontab -l 2>/dev/null; echo "0 3 * * * /root/CrmClinicas/scripts/backup.sh >> /root/CrmClinicas/backups/backup.log 2>&1") | crontab - ``` **Step 3: Commit** ```bash git add scripts/ git commit -m "feat: add daily database backup script with 7-day rotation" ``` --- ## Phase 2: Agenda (Tasks 13-18) — Summary | Task | Description | |------|-------------| | 13 | Create `doctor_schedules` CRUD (settings page for doctor availability) | | 14 | Build calendar component with day/week/month views (use `@fullcalendar/react`) | | 15 | Create appointment booking flow (select doctor → select slot → select patient → confirm) | | 16 | Create appointment status management (status transitions + color coding) | | 17 | Build reception view (today's appointments list with check-in/out buttons) | | 18 | Add appointment search and filtering | ## Phase 3: Expediente Clinico (Tasks 19-25) — Summary | Task | Description | |------|-------------| | 19 | Create medical record (antecedentes) form for first-visit patients | | 20 | Build SOAP consultation note form with vital signs input | | 21 | Add CIE-10 diagnosis search component (searchable combobox with codes) | | 22 | Create prescription form with medication items | | 23 | Generate prescription PDF (use `@react-pdf/renderer`) | | 24 | Build file upload for labs/imaging (Supabase Storage) | | 25 | Create patient timeline view (all visits, notes, files chronologically) | ## Phase 4: Facturacion (Tasks 26-32) — Summary | Task | Description | |------|-------------| | 26 | Create services catalog CRUD | | 27 | Build invoice creation form (line items, taxes, totals auto-calc) | | 28 | Integrate Facturama API for CFDI stamping | | 29 | Add CFDI cancellation flow | | 30 | Create payment recording (multiple payments per invoice) | | 31 | Build daily cash register (corte de caja) report | | 32 | Create financial reports (by period, doctor, service) | ## Phase 5: Inventario (Tasks 33-37) — Summary | Task | Description | |------|-------------| | 33 | Create products CRUD with categories | | 34 | Build inventory movements form (entrada/salida/ajuste) | | 35 | Create stock dashboard with low-stock alerts | | 36 | Link inventory to consultations (auto-deduct on usage) | | 37 | Create inventory movement report | ## Phase 6: Polish (Tasks 38-42) — Summary | Task | Description | |------|-------------| | 38 | Build dashboard with KPI cards and charts (use `recharts`) | | 39 | Add WhatsApp reminder integration (Twilio API edge function) | | 40 | Create user/role management in settings | | 41 | Add clinic settings page (logo, RFC, PAC config) | | 42 | Final production deployment: Docker Compose up, Nginx, seed data, smoke test |