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