Crear Páginas Web con Lógica de NegocioBeta

0%
Bloque Técnico-Operativo

Módulo 03

Base de datos: el corazón de tu lógica de negocio

2.5hPostgreSQLSupabaseZodNext.js

Modelar los datos del negocio, crear tablas con las reglas incluidas, el CRUD completo desde Next.js y validación de formularios con Zod.

Recursos descargables

Plantilla de Modelado de Datos

Método guiado en 8 pasos: de la descripción del negocio en palabras a las tablas, llaves foráneas, restricciones, estados y políticas RLS

Markdown
En este módulo
01
Modelar el negocio: de la conversación a las tablas Cómo traducir "así funciona mi negocio" a un modelo de datos

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

  1. Subraya los sustantivos → candidatos a tabla

    "Cortes, barba y combos" → servicios. "Cada barbero" → barberos. "Los clientes agendan" → perfiles (ya existe) y citas.

  2. 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 citas guarda los ids de las otras tres.

  3. 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" → check con lista de estados.

1.2. El modelo de ReservaYa

TablaQué guardaRelaciones clave
perfilesClientes y admin (módulo 2)1 perfil → muchas citas
serviciosCorte, barba, combo: precio y duración1 servicio → muchas citas
barberosNombre, especialidad, activo/inactivo1 barbero → muchas citas
citasQuién, con quién, qué servicio, cuándo, estadoUne a las otras tres
La regla del modelado

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.

02
Crear tablas y relaciones en Supabase El SQL completo del modelo, con las reglas del negocio incluidas

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);
⚙ ¿Por qué el precio se copia a la cita?

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')
  );
Tres capas de defensa

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.

03
CRUD desde Next.js: crear, leer, actualizar, borrar Las 4 operaciones que usarás en toda app, aplicadas a la agenda

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
}
El precio jamás viene del navegador

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");
}
⚙ Borrado suave vs. borrado real

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.

04
Validación de formularios con Zod Un solo esquema que valida en el navegador y en el servidor

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
Reglas de negocio dentro del validador

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.

✗ Errores comunes con bases de datos
  • 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 security es lo primero después de create 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.
✏ Ejercicio: modela la tienda de ropa

"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".

  1. Subraya los sustantivos y define las tablas (pista: son 4 — una de ellas conecta pedidos con prendas).
  2. Escribe el create table de cada una con sus llaves foráneas.
  3. 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

🔨
ReservaYa · Entrega 3

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".

✓ Lo que aprendiste en este módulo
  • 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.