Le preguntas a Julián cómo funciona la barbería y responde: "Ofrezco cortes, barba y combos. Cada barbero maneja su silla. Los clientes agendan un servicio con un barbero a una hora, pagan anticipo, y a veces cancelan". En esas tres frases está todo el modelo de datos.
Modelar datos es escuchar al negocio: los sustantivos se vuelven tablas, las relaciones se vuelven llaves foráneas, y las reglas se vuelven restricciones.
1.1. El método: sustantivos, relaciones, reglas
-
Subraya los sustantivos → candidatos a tabla
"Cortes, barba y combos" →
servicios. "Cada barbero" →barberos. "Los clientes agendan" →perfiles(ya existe) ycitas. -
Detecta las relaciones → llaves foráneas
Una cita pertenece a un cliente, un barbero y un servicio. Un barbero tiene muchas citas. Eso es una relación uno-a-muchos: la tabla
citasguarda los ids de las otras tres. -
Convierte las reglas → restricciones
"Un barbero no atiende dos citas a la misma hora" → restricción
unique. "El precio nunca es negativo" →check. "Una cita siempre tiene estado válido" →checkcon lista de estados.
1.2. El modelo de ReservaYa
| Tabla | Qué guarda | Relaciones clave |
|---|---|---|
| perfiles | Clientes y admin (módulo 2) | 1 perfil → muchas citas |
| servicios | Corte, barba, combo: precio y duración | 1 servicio → muchas citas |
| barberos | Nombre, especialidad, activo/inactivo | 1 barbero → muchas citas |
| citas | Quién, con quién, qué servicio, cuándo, estado | Une a las otras tres |
Cada regla de negocio que puedas expresar como restricción de base de datos, exprésala ahí. El código puede tener bugs y saltarse validaciones; la base de datos es la última línea de defensa que nunca deja pasar un dato inválido.
Ejecuta este script en el SQL Editor de Supabase. Léelo con calma: cada restricción es una regla de la barbería.
-- ─── SERVICIOS: lo que la barbería vende ───
create table public.servicios (
id uuid primary key default gen_random_uuid(),
nombre text not null,
descripcion text,
precio_cop integer not null check (precio_cop > 0), -- regla: sin precios negativos
duracion_min integer not null check (duracion_min > 0), -- corte=30, combo=60...
activo boolean not null default true, -- se ocultan, nunca se borran
creado_en timestamptz not null default now()
);
-- ─── BARBEROS: quién atiende ───
create table public.barberos (
id uuid primary key default gen_random_uuid(),
nombre text not null,
especialidad text,
activo boolean not null default true
);
-- ─── CITAS: el corazón del negocio ───
create table public.citas (
id uuid primary key default gen_random_uuid(),
cliente_id uuid not null references public.perfiles(id),
barbero_id uuid not null references public.barberos(id),
servicio_id uuid not null references public.servicios(id),
inicia_en timestamptz not null,
estado text not null default 'pendiente_pago'
check (estado in ('pendiente_pago','confirmada','completada','cancelada')),
precio_pagado_cop integer, -- se congela el precio al reservar
creado_en timestamptz not null default now(),
-- REGLA DE ORO: un barbero no puede tener dos citas a la misma hora
unique (barbero_id, inicia_en)
);
-- Índice para la consulta más frecuente: agenda del día por barbero
create index idx_citas_barbero_fecha on public.citas (barbero_id, inicia_en);
Si mañana Julián sube el corte de $25.000 a $30.000, las citas ya reservadas deben conservar el precio que el cliente aceptó. Por eso precio_pagado_cop se copia al crear la cita en lugar de consultarse siempre del servicio. Es un patrón clásico de lógica de negocio: congelar los datos del momento de la transacción (igual hacen las facturas con los precios de los productos).
2.1. Row Level Security: permisos dentro de la base de datos
Supabase expone la base de datos directamente al navegador, así que la seguridad se define en la propia base con RLS: reglas SQL que filtran qué filas puede ver o tocar cada usuario.
-- Activar RLS en todas las tablas (sin políticas, nadie accede)
alter table public.servicios enable row level security;
alter table public.barberos enable row level security;
alter table public.citas enable row level security;
-- Cualquiera puede VER servicios y barberos activos (catálogo público)
create policy "catalogo publico" on public.servicios
for select using (activo = true);
create policy "barberos publicos" on public.barberos
for select using (activo = true);
-- Cada cliente ve SOLO sus propias citas
create policy "mis citas" on public.citas
for select using (auth.uid() = cliente_id);
-- Cada cliente crea citas SOLO a su nombre
create policy "crear mi cita" on public.citas
for insert with check (auth.uid() = cliente_id);
-- El admin ve y modifica todo
create policy "admin total citas" on public.citas
for all using (
exists (select 1 from public.perfiles
where id = auth.uid() and rol = 'admin')
);
Con este módulo, ReservaYa valida en tres niveles: el formulario (experiencia de usuario), la Server Action (lógica de negocio) y RLS + constraints (última línea). Un atacante tendría que romper las tres para dañar tus datos.
CRUD = Create, Read, Update, Delete. El panel de Julián necesita el CRUD de servicios; el cliente necesita crear citas y leer las suyas. Veamos ambos.
3.1. Leer: el catálogo de servicios
// app/reservar/page.tsx — Server Component: consulta directa, sin API
import { createClient } from "@/lib/supabase/server";
export default async function ReservarPage() {
const supabase = await createClient();
// RLS ya filtra: solo llegan servicios activos
const { data: servicios } = await supabase
.from("servicios")
.select("id, nombre, descripcion, precio_cop, duracion_min")
.order("precio_cop", { ascending: true });
return (
<main className="max-w-2xl mx-auto p-6">
<h1 className="text-3xl font-black">Elige tu servicio</h1>
<ul className="mt-6 space-y-3">
{servicios?.map((s) => (
<li key={s.id} className="border border-zinc-800 rounded-xl p-4">
<div className="flex justify-between">
<strong>{s.nombre}</strong>
<span>${s.precio_cop.toLocaleString("es-CO")} COP</span>
</div>
<p className="text-sm text-zinc-400">
{s.descripcion} · {s.duracion_min} min
</p>
</li>
))}
</ul>
</main>
);
}
3.2. Crear: la reserva de la cita
// app/reservar/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function crearCita(formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
const servicioId = String(formData.get("servicio_id"));
const barberoId = String(formData.get("barbero_id"));
const iniciaEn = String(formData.get("inicia_en"));
// Regla de negocio: el precio se lee de la BD, NUNCA del formulario
const { data: servicio } = await supabase
.from("servicios")
.select("precio_cop")
.eq("id", servicioId)
.single();
if (!servicio) redirect("/reservar?error=servicio-invalido");
const { error } = await supabase.from("citas").insert({
cliente_id: user.id,
barbero_id: barberoId,
servicio_id: servicioId,
inicia_en: iniciaEn,
precio_pagado_cop: servicio.precio_cop, // precio congelado
});
// El unique (barbero_id, inicia_en) dispara error si el turno se ocupó
if (error?.code === "23505") redirect("/reservar?error=horario-ocupado");
if (error) redirect("/reservar?error=intenta-de-nuevo");
revalidatePath("/mis-citas");
redirect("/mis-citas?nueva=1"); // en el módulo 4: redirect al pago
}
Si tu formulario envía precio como campo oculto, cualquier cliente puede editarlo con las herramientas de desarrollador y pagarte $1. El servidor recibe el id del servicio y consulta el precio real en la base de datos. Este error existe en tiendas reales — no lo repitas.
3.3. Actualizar y borrar: el panel del admin
// app/admin/servicios/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
export async function actualizarServicio(formData: FormData) {
const supabase = await createClient();
// RLS verifica el rol admin; igual validamos entrada
const precio = Number(formData.get("precio_cop"));
if (!Number.isInteger(precio) || precio <= 0) return { error: "Precio inválido" };
await supabase
.from("servicios")
.update({ precio_cop: precio, nombre: String(formData.get("nombre")) })
.eq("id", String(formData.get("id")));
revalidatePath("/admin/servicios");
}
export async function desactivarServicio(id: string) {
const supabase = await createClient();
// Borrado suave: el servicio sale del catálogo pero las citas viejas
// conservan su referencia (integridad histórica del negocio)
await supabase.from("servicios").update({ activo: false }).eq("id", id);
revalidatePath("/admin/servicios");
}
En apps de negocio casi nunca se hace delete de verdad: si borras el servicio "Corte clásico", las 500 citas históricas que lo referencian quedan huérfanas y tus reportes de ventas mienten. El patrón profesional es el borrado suave: una columna activo que lo oculta del catálogo sin destruir el historial.
Validar a mano con if funciona, pero se vuelve inmanejable con formularios grandes. Zod define la forma válida de los datos una sola vez y la reutiliza en frontend y backend:
npm install zod
// lib/validaciones/cita.ts — el contrato de datos de una reserva
import { z } from "zod";
export const esquemaCita = z.object({
servicio_id: z.string().uuid("Selecciona un servicio válido"),
barbero_id: z.string().uuid("Selecciona un barbero"),
inicia_en: z.coerce
.date()
.refine((d) => d > new Date(), "La cita debe ser en el futuro")
.refine(
(d) => d.getHours() >= 9 && d.getHours() < 20,
"Horario de atención: 9:00 a 20:00"
),
telefono: z
.string()
.regex(/^3\d{9}$/, "Celular colombiano de 10 dígitos, ej: 3001234567"),
});
export type DatosCita = z.infer<typeof esquemaCita>;
Y en la Server Action, el esquema reemplaza los if sueltos:
// dentro de crearCita(), antes de tocar la base de datos:
const parseo = esquemaCita.safeParse({
servicio_id: formData.get("servicio_id"),
barbero_id: formData.get("barbero_id"),
inicia_en: formData.get("inicia_en"),
telefono: formData.get("telefono"),
});
if (!parseo.success) {
// Errores legibles por campo para mostrar en el formulario
return { errores: parseo.error.flatten().fieldErrors };
}
const datos = parseo.data; // tipado y validado
Fíjate que el esquema no solo valida formato: codifica reglas del negocio ("la cita es en el futuro", "horario de 9 a 20"). Cuando la regla cambie — Julián abre los domingos hasta las 22 — se cambia en un solo archivo.
- Guardar todo en una sola tabla gigante. Solución: una tabla por entidad del negocio, unidas por llaves foráneas. Si una celda contiene listas ("corte,barba,combo"), falta una tabla.
- Confiar en que "el formulario ya validó". Solución: toda Server Action valida con Zod aunque el frontend ya lo haya hecho. Las peticiones pueden fabricarse sin pasar por tu formulario.
- Olvidar activar RLS en una tabla nueva.
Solución:
enable row level securityes lo primero después decreate table. Supabase te avisa en el dashboard con una alerta roja — no la ignores. - Borrar registros con historial asociado.
Solución: borrado suave con columna
activo. Los datos históricos son los reportes de tu negocio.
"Kliss Moda" es una tienda en Quito que vende por Instagram y quiere su propia web. La dueña te explica: "Vendo prendas; cada prenda tiene tallas (S, M, L) con stock distinto por talla. Los clientes arman un pedido con varias prendas y lo pagan contra entrega o por transferencia. Necesito saber qué pedidos están pendientes de despacho".
- Subraya los sustantivos y define las tablas (pista: son 4 — una de ellas conecta pedidos con prendas).
- Escribe el
create tablede cada una con sus llaves foráneas. - Convierte en restricciones: "el stock nunca es negativo", "un pedido tiene estado pendiente/despachado/entregado", "la cantidad pedida es al menos 1".
Usa la plantilla de modelado de datos de los recursos del módulo: te guía sustantivo por sustantivo.
4.1. Mini-proyecto del módulo
Tu app ahora tiene: las tablas servicios, barberos y citas con sus reglas y RLS; el catálogo público en /reservar; el flujo de creación de cita validado con Zod; y el CRUD de servicios en /admin/servicios. Inserta 3 servicios y 2 barberos de prueba, crea una cita como cliente e intenta crear otra con el mismo barbero a la misma hora: debe fallar con "horario ocupado".
- Modelar = escuchar: los sustantivos del negocio son tablas, las relaciones son llaves foráneas, las reglas son restricciones.
- La base de datos es la última defensa: constraints (
check,unique) y RLS aplican las reglas aunque el código falle. - CRUD con Server Actions: leer en Server Components, escribir en actions, y el precio siempre se consulta en la BD — nunca se recibe del navegador.
- Zod centraliza la validación: un esquema que codifica formato y reglas de negocio, compartido entre cliente y servidor.