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

2454 lines
71 KiB
Markdown

# 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 (
<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`:
```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`:
```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`:
```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`:
```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**
```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 (
<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`:
```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`:
```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:
```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 |