Files
CrmClinicas/docs/plans/2026-03-02-crm-clinicas-implementation.md
Consultoria AS 79b5d86325 feat: CRM Clinicas SaaS - MVP completo
- Auth: Login/Register con creacion de clinica
- Dashboard: KPIs reales, graficas recharts
- Pacientes: CRUD completo con busqueda
- Agenda: FullCalendar, drag-and-drop, vista recepcion
- Expediente: Notas SOAP, signos vitales, CIE-10
- Facturacion: Facturas con IVA, campos CFDI SAT
- Inventario: Productos, stock, movimientos, alertas
- Configuracion: Clinica, equipo, catalogo servicios
- Supabase self-hosted: 18 tablas con RLS multi-tenant
- Docker + Nginx para produccion

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-03 07:04:14 +00:00

71 KiB

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:

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

# 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:

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:

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:

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:

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

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:

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:

# --- 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:

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:

_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:

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

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:

cd /root/CrmClinicas
npm install @supabase/supabase-js @supabase/ssr

Step 2: Create environment file

Create .env.local:

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:

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:

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:

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:

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:

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

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:

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

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:

-- 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:

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:

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:

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:

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:

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:

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:

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:

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

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:

"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 (
    <aside className="flex h-full w-64 flex-col border-r bg-card">
      <div className="flex h-16 items-center border-b px-6">
        <h1 className="text-xl font-bold">CRM Clinicas</h1>
      </div>
      <nav className="flex-1 space-y-1 p-4">
        {navigation.map((item) => {
          const isActive = pathname.startsWith(item.href);
          return (
            <Link
              key={item.href}
              href={item.href}
              className={cn(
                "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
                isActive
                  ? "bg-primary text-primary-foreground"
                  : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
              )}
            >
              <item.icon className="h-5 w-5" />
              {item.name}
            </Link>
          );
        })}
      </nav>
    </aside>
  );
}

Step 3: Create header component

Create src/components/layout/header.tsx:

import { UserNav } from "./user-nav";

export function Header() {
  return (
    <header className="flex h-16 items-center justify-between border-b px-6">
      <div />
      <UserNav />
    </header>
  );
}

Step 4: Create user navigation (avatar + logout)

Create src/components/layout/user-nav.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 (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" className="relative h-9 w-9 rounded-full">
          <Avatar className="h-9 w-9">
            <AvatarFallback>
              <User className="h-4 w-4" />
            </AvatarFallback>
          </Avatar>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56" align="end" forceMount>
        <DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuItem onClick={handleSignOut}>
          <LogOut className="mr-2 h-4 w-4" />
          Cerrar sesion
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Step 5: Create dashboard layout

Create src/app/(dashboard)/layout.tsx:

import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex h-screen">
      <Sidebar />
      <div className="flex flex-1 flex-col overflow-hidden">
        <Header />
        <main className="flex-1 overflow-y-auto p-6">{children}</main>
      </div>
    </div>
  );
}

Step 6: Create dashboard home page

Create src/app/(dashboard)/page.tsx:

export const dynamic = "force-dynamic";

export default function DashboardPage() {
  return (
    <div>
      <h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
      <p className="text-muted-foreground">
        Bienvenido al CRM de tu clinica.
      </p>
    </div>
  );
}

Step 7: Verify build

Run: npm run build Expected: Build succeeds

Step 8: Commit

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:

export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex min-h-screen items-center justify-center bg-muted/50 p-4">
      <div className="w-full max-w-md">{children}</div>
    </div>
  );
}

Step 2: Create login page

Create src/app/(auth)/login/page.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<string | null>(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 (
    <Card>
      <CardHeader className="text-center">
        <CardTitle className="text-2xl">CRM Clinicas</CardTitle>
        <CardDescription>Inicia sesion en tu cuenta</CardDescription>
      </CardHeader>
      <form onSubmit={handleSubmit}>
        <CardContent className="space-y-4">
          {error && (
            <div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
              {error}
            </div>
          )}
          <div className="space-y-2">
            <Label htmlFor="email">Correo electronico</Label>
            <Input
              id="email"
              type="email"
              placeholder="doctor@clinica.com"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Contrasena</Label>
            <Input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
            />
          </div>
        </CardContent>
        <CardFooter className="flex flex-col gap-4">
          <Button type="submit" className="w-full" disabled={loading}>
            {loading ? "Iniciando sesion..." : "Iniciar sesion"}
          </Button>
          <p className="text-sm text-muted-foreground">
            No tienes cuenta?{" "}
            <Link href="/register" className="text-primary underline">
              Registrate
            </Link>
          </p>
        </CardFooter>
      </form>
    </Card>
  );
}

Step 3: Create register page (clinic + owner signup)

Create src/app/(auth)/register/page.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<string | null>(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 (
    <Card>
      <CardHeader className="text-center">
        <CardTitle className="text-2xl">Crear cuenta</CardTitle>
        <CardDescription>Registra tu clinica y comienza gratis</CardDescription>
      </CardHeader>
      <form onSubmit={handleSubmit}>
        <CardContent className="space-y-4">
          {error && (
            <div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
              {error}
            </div>
          )}
          <div className="space-y-2">
            <Label htmlFor="clinicName">Nombre de la clinica</Label>
            <Input
              id="clinicName"
              placeholder="Clinica San Jose"
              value={formData.clinicName}
              onChange={(e) => updateField("clinicName", e.target.value)}
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="fullName">Tu nombre completo</Label>
            <Input
              id="fullName"
              placeholder="Dr. Juan Perez"
              value={formData.fullName}
              onChange={(e) => updateField("fullName", e.target.value)}
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="email">Correo electronico</Label>
            <Input
              id="email"
              type="email"
              placeholder="doctor@clinica.com"
              value={formData.email}
              onChange={(e) => updateField("email", e.target.value)}
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Contrasena</Label>
            <Input
              id="password"
              type="password"
              placeholder="Minimo 6 caracteres"
              value={formData.password}
              onChange={(e) => updateField("password", e.target.value)}
              minLength={6}
              required
            />
          </div>
        </CardContent>
        <CardFooter className="flex flex-col gap-4">
          <Button type="submit" className="w-full" disabled={loading}>
            {loading ? "Creando cuenta..." : "Crear cuenta"}
          </Button>
          <p className="text-sm text-muted-foreground">
            Ya tienes cuenta?{" "}
            <Link href="/login" className="text-primary underline">
              Inicia sesion
            </Link>
          </p>
        </CardFooter>
      </form>
    </Card>
  );
}

Step 4: Update root page to redirect

Modify src/app/page.tsx to redirect to login:

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

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:

"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

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:

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:

  # --- 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:

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:

const nextConfig = {
  output: "standalone",
};

Step 5: Commit

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:

#!/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:

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

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