openjutsu
영상에서 원클릭으로 인물 교체, ComfyUI·MiniMax H3 기반 오픈소스 로컬 힉스필드 겐주츠 대안, FLUX.3 Klein 재조명 지원
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Sistema web de reservación de entradas de cine (Universidad Galileo – Práctica del Desarrollo de Software I).
Stack: Next.js (App Router) + React · Tailwind CSS v4 + shadcn/ui · Supabase (PostgreSQL + Auth) · Vercel · pdf-lib + qrcode para el boleto · Brevo para correo transaccional.
bun install
cp .env.example .env.local # completar con las llaves de Supabase
bun run dev # http://localhost:3000
Next.js se sigue ejecutando con Node (v20+); Bun se usa como gestor de paquetes y para correr los scripts.
El envío del boleto usa la API transaccional de Brevo. Configura estas variables solo en el servidor, en .env.local para desarrollo o en las variables de entorno del despliegue:
BREVO_API_KEY=tu_api_key_de_brevo
[email protected]
BREVO_FROM_NAME="Órbita Cinema"
BREVO_API_KEY y BREVO_FROM_EMAIL son necesarias para enviar; BREVO_FROM_NAME es opcional y por defecto usa Órbita Cinema. El remitente debe estar registrado y verificado en Brevo. Nunca publiques ni subas la API key al repositorio. Las credenciales SMTP usadas por Supabase Auth para recuperar contraseñas son independientes de estas variables.
Después de que Supabase confirma la compra, el servidor obtiene los datos persistidos, genera el PDF y envía ese mismo archivo como adjunto a través de Brevo. Un fallo o timeout del proveedor no revierte la compra: se registra en los logs y el cliente todavía puede descargar el PDF desde la confirmación. Si faltan las variables requeridas, el correo se omite y la pantalla muestra el aviso correspondiente.
supabase/sprint1.sql (columnas nuevas, políticas RLS de lectura, bucket posters). Es idempotente..env.local (incluye SUPABASE_SERVICE_ROLE_KEY, solo servidor)./ lee pelicula, funcion y sala de Supabase; filtros por fecha, rango horario, idioma y clasificación./pelicula/[slug] muestra las funciones por fecha y sala./admin/peliculas registra (con póster), lista y activa/desactiva películas mediante Server Actions.Hasta el Sprint 2 (login) el panel admin no tiene autenticación: no lo desplieguen públicamente con
AUTH_GUARD=off.
AUTH_GUARD=off (por defecto) desactiva el control general del middleware para /admin/*. /perfil siempre exige una sesión para mostrar datos personales o de compras; las consultas y acciones del perfil operan con el usuario autenticado. Con AUTH_GUARD=on, src/middleware.ts también exige sesión y rol. La fuente única del rol es la BD (public.usuarios.id_rol → public.rol, ver supabase/sprint2-auth.sql): getRol() consulta por request y devuelve Cliente | Colaborador | Administrador. El rol NO se lee del JWT ni de metadata editable por el usuario.
En /perfil el cliente puede editar nombre, apellido y teléfono; consultar sus reservas con asientos y estado, volver a descargar el boleto de reservas confirmadas o usadas, y ver el saldo y los movimientos de puntos (1 punto = Q1). El correo se muestra como dato de la cuenta y no se edita desde esta pantalla.
Despliegue en Vercel: la variable
AUTH_GUARDdebe estar enonen Settings → Environment Variables (Production y Preview). Sin ella el control de acceso no se aplica y cualquier usuario, incluso Cliente o Colaborador, puede abrir/admin/*y los reportes. Después de agregarla o cambiarla hay que hacer Redeploy, porque solo aplica a despliegues nuevos.
supabase/sprint2-funciones.sql (después de sprint1.sql). Asegura pelicula.duracion_min (no la cambia si ya existe), completa la duración de las 6 películas de prueba que no la tengan y crea un índice para validar cruces. Es idempotente./admin/funciones (Administrador y Colaborador, módulo "Funciones" del panel) lista las funciones desde hoy, filtra por fecha y película, y crea, modifica y cancela funciones con Server Actions (assertRol en lib/auth/guard.ts).funcion.activa = false). Pendiente del Sprint 3: impedir editar o cancelar funciones con reservas (hay un TODO en actions.ts).duracion_min (null) se trata como de 120 minutos al calcular cruces.Una función ocupa su sala en el intervalo [inicio, inicio + duracion_min + 15 min) (15 min de limpieza). No se registra si se traslapa con otra función activa de la misma sala. Se compara en minutos absolutos (fecha + hora), así que también detecta cruces con funciones que pasan de medianoche; por eso la consulta revisa el día anterior y el siguiente (buscarCruce en lib/db/funciones.ts). La validación es solo del servidor.
Límite conocido: es una validación de aplicación, no de la base. Si dos personas guardan exactamente al mismo tiempo funciones que se cruzan, ambas pueden pasar. Tampoco se revalidan las funciones ya programadas si después se cambia la duración de su película.
Mejora futura (alternativa B): guardar el fin de cada función en funcion (p. ej. inicio/fin como timestamp calculados al crear) y agregar en Postgres una restricción de exclusión por sala (create extension btree_gist + exclude using gist (id_sala with =, tsrange(inicio, fin) with &&) where (activa)). La base rechazaría los traslapes aunque lleguen al mismo tiempo, y cambiar la duración de una película no movería las funciones ya programadas.
La matriz de permisos vive en src/lib/permisos.ts (única fuente de verdad):
| Zona / módulo | Cliente | Colaborador | Administrador |
|---|---|---|---|
Sitio público, compra, /perfil | ✔ | ✔ | ✔ |
/admin (inicio del panel) | ✔ | ✔ | |
/admin/funciones | ✔ | ✔ | |
/admin/peliculas | consulta | ✔ | |
/admin/salas (y asientos) | consulta | ✔ |
Consulta = el Colaborador entra y ve la información (listado de películas, salas y plano de asientos), pero no ve los botones de crear, editar, dar de baja, eliminar ni activar/desactivar, y el plano de asientos no se puede modificar. Es solo la parte visual (esSoloLectura / puedeModificar en lib/permisos.ts); quien rechaza de verdad es el servidor: las acciones de películas y salas siguen exigiendo Administrador.
Se aplica en cuatro capas, todas con la misma matriz y el rol leído de la BD (usuarios.id_rol), nunca del navegador:
src/middleware.ts): sin sesión → /login?next=...; con sesión sin permiso → /acceso-denegado.exigirRol en lib/auth/pagina.ts): segunda barrera en el servidor.assertAdmin / assertRol): rechazan la acción aunque se invoque sin pasar por la pantalla.id_rol.El menú y las tarjetas de /admin se filtran por rol, y el encabezado muestra nombre y rol. Todo requiere AUTH_GUARD=on (con off no se valida nada: solo para desarrollo sin login). Para proteger un módulo nuevo: agregar su regla en REGLAS_RUTA (antes de /admin) y su entrada en lib/admin-modulos.ts.
src/
├─ app/ # Frontend + rutas (App Router)
│ ├─ layout.tsx, globals.css # shell, fuentes (next/font), design system
│ ├─ page.tsx # Cartelera (cartelera-client.tsx = filtros/estado)
│ ├─ pelicula/[slug]/ # Detalle, salas y horarios
│ ├─ entradas/ # Cantidad y tipo de entrada (máx. 10)
│ ├─ asientos/ # Mapa interactivo de butacas
│ ├─ pago/ # Pasarela prototipo (recargo 5 %)
│ ├─ login/, perfil/ # Cuenta de cliente
│ ├─ admin/peliculas/ # Panel Administrador (page + client + actions.ts), sin enlace en la navegación (RNF-015)
│ ├─ admin/funciones/ # Gestión de funciones, Administrador y Colaborador (SCRUM-111)
│ └─ api/ # (por crear) Route Handlers: bloqueo de asientos, pago, boleto, correo
├─ components/
│ ├─ cinema/ # SiteHeader, Modal, FunctionHeader
│ └─ ui/ # shadcn/ui
├─ lib/
│ ├─ db/cartelera.ts # consultas a Supabase (cartelera, detalle, listado admin)
│ ├─ db/funciones.ts # consultas de /admin/funciones y validación de cruces
│ ├─ cartelera.ts # tipos y filtros puros (servidor y cliente)
│ ├─ cinema-data.ts # datos estáticos: solo los usan entradas/asientos/pago (sprints siguientes)
│ ├─ search-params.ts # parseo/armado del query string del flujo de compra
│ ├─ roles.ts # roles del sistema
│ └─ supabase/{client,server}.ts
└─ middleware.ts # control de acceso por rol
public/posters/ # pósters (antes venían de *.asset.json de Lovable)
Cada pantalla con estado se divide en page.tsx (Server Component: metadata y lectura de searchParams) y *-client.tsx ("use client": la UI original de Lovable).
| TanStack Start | Next.js |
|---|---|
routes/index.tsx | app/page.tsx |
routes/pelicula.$slug.tsx | app/pelicula/[slug]/page.tsx |
routes/admin.peliculas.tsx | app/admin/peliculas/page.tsx |
createFileRoute().head() | export const metadata / generateMetadata |
validateSearch + Route.useSearch() | searchParams (async) + lib/search-params.ts |
<Link to=… search=…> | next/link con href={toHref(…)} |
__root.tsx (shell, 404, error) | layout.tsx, not-found.tsx, error.tsx |
<img> | next/image |
Google Fonts por <link> | next/font/google |
@tailwindcss/vite | @tailwindcss/postcss |
Se eliminó lo específico de Lovable/Vite/Nitro (server.ts, start.ts, router.tsx, routeTree.gen.ts, reporte de errores de Lovable, vite.config.ts, bunfig.toml, @tanstack/*).
La lista de pendientes que sigue en este README conserva notas históricas de sprints anteriores; para el estado actual de compra, boletos y correo, consulta las secciones específicas de Sprint 3 y de configuración de correo.
Estas decisiones ya están tomadas. No volver a abrirlas sin revisarlas.
No existe borrado real de una película. El panel solo tiene un interruptor que activa y desactiva. Quien eliminaba con el 🗑 idéntico a desactivar y además ocultaba la película del panel, dejándola irrecuperable desde la aplicación (había que volver a crearla, y quedaba el duplicado con el slug -2).
pelicula.activa = false. La fila nunca se borra.listPeliculasAdmin() trae también las inactivas, ordenando las activas primero. Si algún día se vuelve a filtrar por activa = true, se pierde esta capacidad y vuelve el problema.Al desactivar no se tocan las reservas ni los boletos ya emitidos. Un boleto vendido es una promesa: la baja solo impide vender nuevas entradas, nunca invalida las anteriores.
Eliminar apaga las funciones de la película en cascada. eliminarPelicula pone funcion.activa = false en todas las funciones de la película antes de marcar eliminada = true. Nunca es un DELETE: las filas se conservan por auditoría (se recuperan con select * from funcion where id_pelicula = X) y para no invalidar reservas o boletos ya emitidos. El orden de las escrituras es funciones → película, para que un fallo deje la película reintentable en el panel. Reactivar la película más adelante NO reactiva sus funciones automáticamente.
Cuando exista el flujo de compra hay que validar activa al escribir, no solo al leer. Ocultar la película de la cartelera no basta: alguien puede tener la pantalla de pago abierta cuando el admin la desactiva. Al crear la reserva y al confirmar el pago hay que releer pelicula.activa y funcion.activa contra la base y rechazar con un mensaje claro si ya no está disponible. El interruptor de Estado (desactivar) no apaga las funciones en cascada: al reactivar la película, sus funciones siguen ahí. Por eso hay que releer las dos. En cambio, el botón Eliminar sí las apaga todas (ver arriba).
Registrar una película con el título de una que ya está activa se rechaza (crearPelicula). Una película dada de baja sí admite una nueva con el mismo título, porque el objetivo es activar la existente.
Editar el título y el slug. El slug se genera solo al crear y es la URL pública (/pelicula/[slug]). Si el Sprint 7 permite cambiar el título, hay que decidir si el slug se recalcula: hacerlo sin más rompe los enlaces ya compartidos.
asiento es el plano FÍSICO de cada sala, no la venta. Cada fila es un espacio de la sala (fila A..Z, numero, estado). El estado describe la naturaleza del espacio, no si está vendido:
normal = asiento real que se puede vender.pasillo = pasillo, nunca es asiento.deshabilitado = posición bloqueada o rota, no se vende.Un asiento pertenece a la sala (id_sala → sala), no a una función: es el mismo plano para todas las funciones de esa sala. (Esto corrige el diagrama ER inicial, donde asiento apuntaba a funcion.)
La capacidad se deriva de la distribución. Cuando una sala ya tiene plano, sala.capacidad se recalcula sola como la cantidad de asientos normal (los pasillos y deshabilitados no cuentan) y el campo del formulario queda en solo lectura. Mientras la sala no tenga plano, la capacidad sigue siendo la que se escribe a mano.
No se re-configura el plano si la sala tiene funciones activas. Guardar la distribución hace borrado + reinserción de todo el plano; hacerlo con una función futura activa podría mover o pasillizar un asiento ya implicado. El configurador queda en solo lectura con un aviso. (Es la versión actual de "ya tiene reservas" del ticket; cuando exista reserva se refina a "funciones con reservas".)
Al presionar Continuar en /asientos, los asientos elegidos quedan bloqueados 5 minutos a nombre de ese cliente. Antes de usarlo hay que ejecutar una vez supabase/sprint3-bloqueo-asientos.sql en el SQL Editor de Supabase (agrega reserva.token_sesion, un índice único parcial y cinco funciones; es idempotente). Sin ese script, "Continuar" muestra un error.
reserva en pendiente_pago (su fecha_expiracion es now() + 5 min) con un detalle_reserva en bloqueado por asiento. Los tipos de entrada se traducen a los valores de la BD (tercera → tercera_edad, ninos → nino; lib/bloqueo.ts).httpOnly (orbita_bloqueo, un uuid aleatorio) que crea el servidor y se guarda en reserva.token_sesion. Vale para cliente registrado e invitado; nunca se confía en un id que venga de la URL.bloquear_asientos hace todo en una transacción y el índice único (id_funcion, id_asiento) sobre bloqueado/reservado impide que dos clientes tomen el mismo asiento; si otro se adelantó, el cliente recibe un aviso y el mapa se recarga.asientos_no_disponibles cuenta como ocupados los reservados y los bloqueos que todavía no vencen. Un bloqueo vencido no cuenta, así que los asientos quedan libres a la hora exacta sin depender de ningún proceso. La próxima compra de esa función, o liberar_bloqueos_vencidos(), además marca la reserva como expirada (al final del script hay un pg_cron opcional para correrla cada minuto)./pago solo abre con un bloqueo vigente de ese navegador y muestra un contador regresivo (los segundos los calcula la BD). Al llegar a cero, o si se intenta Pagar con el bloqueo vencido, avisa y regresa a /asientos?vencido=1./pago, y volver a /asientos, llaman a liberar_bloqueo y los asientos se liberan al instante./pago recoge los datos del comprador invitado o usa los de su cuenta, y confirmar_compra confirma reserva, asientos, pago y boleto dentro de una transacción. La pasarela de tarjeta sigue siendo simulada: no se contacta a un banco real.Después de confirmar una compra, src/app/pago/actions.ts obtiene los datos definitivos de la reserva, crea el PDF desde src/lib/pdf.ts y lo envía a la dirección guardada del comprador mediante src/lib/correo.ts. El mensaje incluye número de reserva, película, función, sala, asientos y total pagado; adjunta el PDF y muestra el QR. El PDF usa la misma información e incluye la clasificación y el tipo de entrada de cada asiento.
El envío tiene un timeout. Si Brevo rechaza el mensaje o ocurre un error, la compra permanece confirmada y el mismo PDF sigue disponible para descargar en la pantalla de confirmación. La pantalla indica el estado del correo; los detalles del fallo quedan en los logs del servidor.
Los boletos PDF se generan al confirmar la compra y se entregan en ese momento por descarga y, si el envío tiene éxito, como adjunto de correo. No se guardan en Supabase Storage: la columna boleto.ruta_pdf existe por diseño, pero esta implementación no la rellena. Por ahora se pospone la persistencia; si luego se requiere volver a descargar desde el perfil, conviene evaluar un bucket privado y enlaces firmados de duración limitada, revisando las cuotas vigentes del plan.
BREVO_API_KEY, BREVO_FROM_EMAIL y opcionalmente BREVO_FROM_NAME en .env.local, como se indica en Correo de confirmación. No uses el prefijo NEXT_PUBLIC_ ni compartas la API key.gmail.com porque pertenece a Google..env.local para que Next.js cargue las variables nuevas.Si el correo no se envía, la compra sigue confirmada. Descarga el PDF desde la pantalla de confirmación y revisa los logs del servidor para ver el error; no vuelvas a comprar solo para reintentar el correo.
No requiere script SQL nuevo: se apoya en lo de SCRUM-119 (supabase/sprint3-bloqueo-asientos.sql).
/asientos está abierto, el cliente consulta cada 3 segundos la acción de servidor consultarOcupacion (y también al volver a la pestaña). Un asiento que otro cliente bloquea o reserva pasa a "Ocupado" sin recargar; si su bloqueo vence, vuelve a verse libre, porque la BD compara con su propio reloj. Se eligió sondeo y no Supabase Realtime a propósito: detalle_reserva no es legible con la clave pública, y abrirla solo para esto expondría datos de compras. La acción devuelve únicamente ids de asiento.uq_detalle_reserva_asiento_funcion_activo (id_funcion, id_asiento donde el detalle está bloqueado o reservado) hace que la BD rechace un segundo registro sobre el mismo asiento, aunque falle cualquier validación de la aplicación. Si dos clientes pulsan Continuar a la vez, bloquear_asientos deja pasar solo a uno; el otro recibe "Alguno de los asientos acaba de ser tomado" y el mapa se actualiza al instante.Antes de desplegar estos cambios en una base existente, ejecutar supabase/sprint6-mapa-asientos.sql en Supabase > SQL Editor. El script agrega la marca asiento.retirado, reemplaza las RPC del mapa de compra y ocupación, crea el guardado transaccional de distribuciones y recarga el esquema de PostgREST. Los asientos históricos ya no se borran al reconfigurar una sala: las filas referenciadas por reservas se conservan y se retiran del plano activo, protegiendo boletos y reportes anteriores.
La compra convierte las etiquetas visibles (que no cuentan pasillos) a la columna física correspondiente; los boletos usan la misma numeración visible. El reporte incluye la distribución física y distingue butacas, pasillos y posiciones deshabilitadas sin contarlas como capacidad vendible.
Pantalla /admin/entradas/validar (módulo Entradas del panel; Colaborador y Administrador). Antes de usarla hay que ejecutar una vez supabase/sprint4-validar-entrada.sql en el SQL Editor de Supabase (agrega boleto.validado_por y la función validar_entrada; es idempotente) y correr bun install (nueva dependencia jsqr, el lector de QR).
boleto.id_boleto), número de reserva (acepta ORB-000123 o 123) o el código del QR (útil con lectores USB, que "escriben" el código). El QR es el codigo_qr que ya genera confirmar_compra; esta historia no genera ni duplica QR.validarEntrada exige rol Colaborador/Administrador (assertRol), valida el formato de lo recibido y llama a la función SQL validar_entrada. Un Cliente o alguien sin sesión no puede validar ni llamando a la acción directamente.confirmada). Cada rechazo muestra su motivo.p_confirmar = false, no modifica nada) y se muestran los datos; la reserva se marca como utilizada únicamente al pulsar «Validar entrada y marcar como usada» (p_confirmar = true, atómico). La búsqueda manual es por ID de entrada o número de reserva. Función ya terminada: no se puede validar (ni se muestra el botón); el fin es fecha + hora + duración de la película (120 min si no la tiene), en hora de Guatemala, y se comprueba en la base de datos al consultar y al confirmar. Tras cambios en validar_entrada hay que volver a ejecutar supabase/sprint4-validar-entrada.sql y luego notify pgrst, 'reload schema';.validar_entrada lee la reserva con FOR UPDATE, así que dos validaciones simultáneas se serializan; la segunda ve usada y recibe "ya validada". Si ocurre un error, PostgreSQL revierte y la reserva no queda como utilizada.reserva.estado = 'usada' y boleto.fecha_uso = now() para toda la reserva (un solo QR por reserva); los asientos siguen reservado. Se muestra el ID de compra (ORB-000123, RF-060), el ID de entrada, comprador, función, asientos y total.Pantalla /admin/reportes (solo Administrador; Colaborador y Cliente no entran ni pueden pedir datos o la descarga directamente al servidor). Antes de usarla hay que ejecutar una vez supabase/sprint4-reportes.sql en el SQL Editor de Supabase (crea la función de solo lectura reporte_ventas_reservas; es idempotente) y correr bun install (nueva dependencia exceljs).
pago.monto aprobado (los pagos rechazados o pendientes no suman, y hay un solo aprobado por reserva).reserva, detalle_reserva, pago, funcion, pelicula y sala; el reporte solo hace SELECT./admin/reportes/descargar recibe los mismos filtros que la pantalla, verifica el rol Administrador en el servidor y arma el .xlsx con las mismas columnas, valores y totales que la tabla (lib/reportes.ts), con los filtros aplicados escritos arriba del archivo.activa = false no aparecen ni se cuentan (igual que en la validación de entradas), porque el panel no las pasa a cancelada_funcion al cancelar la función.Pantalla /admin/reportes/ocupacion (pestaña «Ocupación por sala» dentro de Reportes; solo Administrador). Antes de usarla hay que ejecutar una vez supabase/sprint4-reporte-ocupacion.sql en el SQL Editor de Supabase (crea la función de solo lectura reporte_ocupacion_funcion; es idempotente). Usa exceljs, que ya se instala con bun install desde SCRUM-142.
normal de la sala (más cualquier asiento con reserva en esa función aunque el plano se haya reconfigurado después). Ocupado = detalle reservado, o bloqueado de una compra en curso que aún no vence (igual que el mapa que ven los clientes); un bloqueo vencido, cancelado o liberado deja el asiento libre. Capacidad = ocupados + disponibles siempre, porque todo se cuenta sobre las mismas filas del detalle; además la app verifica la coherencia antes de mostrar o exportar./admin/reportes/ocupacion/descargar?funcion=ID vuelve a ejecutar la misma consulta de esa función y genera la hoja «Resumen» (función e indicadores) y la hoja «Asientos» (fila, número, asiento, estado, detalle). Verifica el rol Administrador en el servidor; Colaborador y Cliente reciben 401/403 sin datos.reserva — para el Sprint 3 (aún no existe)Anotado aquí para retomarlo, sin crear la tabla todavía porque sus reglas aún no están definidas.
La disponibilidad real de un show no se guarda en asiento: se calcula restando. Para una función concreta:
disponibles = asientos de la sala con estado 'normal'
MENOS los que ya figuran en `reserva` de esa función
Antes de crear reserva hay dos cosas abiertas que aclarar con el ingeniero (ya se conversaron, quedan pendientes de confirmar):
pelicula.activa / funcion.activa solo controlan la cartelera; cuando exista el flujo de compra hay que releer ambos al crear la reserva y al confirmar el pago (ver la nota de películas más arriba).La tabla tendrá al menos id_funcion, el id_asiento estable (el de la tabla física) y un estado de venta (reservado | vendido | libre), con un único (id_funcion, id_asiento) para no vender dos veces el mismo asiento en el mismo show. El detalle fino (detalle_reserva, pago, boleto) pertenece a los sprints 3 y 4.
Puntos detectados revisando el proyecto. Están anotados para retomarlos, no son urgent: hoy pelicula tiene 8 filas, funcion 88 y sala 6, así que nada de esto se nota todavía. Se anotan porque el coste crece con el tamaño de los datos.
src/components/cinema/PosterImage.tsx termina con:
unoptimized={!src.startsWith("/")}
Los pósters de Supabase son URLs remotas (https://…supabase.co/...), no empiezan con /, así que todas caen en unoptimized = true y el navegador descarga el JPG original sin redimensionar. En la cartelera son 6 pósteres de ~500 KB cada uno: alrededor de 3 MB por visita para mostrar miniaturas.
Por qué está así: el comentario del código explica que es para no registrar el dominio en next.config.ts. Es un trade-off legítimo.
Cómo se resolvería: registrar el dominio de Supabase en images.remotePatterns de next.config.ts y quitar el unoptimized, de modo que los posters se sirvan redimensionados y en WebP. Antes de hacerlo hay que medir el tamaño real de los JPG y confirmar que el optimizador de Next (que en Vercel tiene límites) da mejor resultado que la URL directa.
funcion — el más escalablegetCartelera() (la ruta más golpeada del sitio) consulta:
supabase.from("funcion").select("*")
.eq("activa", true).gte("fecha", ahora.fecha).order("fecha").order("hora");
Sin un índice que cubra ese filtro, Postgres hace seq scan (recorre la tabla entera) en cada visita. Con las ~90 funciones actuales es instantáneo; con 50 películas × 30 funciones × 7 días serían unas 10 000 filas.
Ya está resuelto en supabase/sprint1.sql (punto 6): dos índices compuestos, uno para la cartelera y otro para el detalle de película. Son idempotentes, se aplican corriendo el script.
Cuando se defina el módulo de funciones, revisar si el índice sigue siendo el adecuado: si el filtrado real termina siendo por
id_salau otro criterio, puede hacer falta otro o cambiar el orden de las columnas.
select("*") innecesarioLas consultas de cartelera.ts piden todas las columnas, pero el código solo usa algunas. Nombrarlas explícitamente reduce el peso de los datos que cruzan la red en cada request y mejora el tipado si más adelante se generan los tipos con supabase gen types. No es urgente, es buena práctica.
getCartelera() es force-dynamic, lo cual es correcto porque depende de la fecha y hora actuales. Pero getPeliculaBySlug() solo usa cache() de React, que deduplica dentro de un mismo render, no entre peticiones: recargar la página vuelve a consultar la base.
Cuando se conecte el flujo de compra el usuario va a recargar el detalle varias veces seguidas. Ahí conviene revisar un revalidate con tags de Next, o unstable_cache con una invalidación explícita desde las Server Actions.
Dejar para cuando exista el flujo de compra, para no cachear de más una pantalla que aún cambia.
Pantalla /admin/usuarios para consultar y gestionar los usuarios registrados (Clientes, Colaboradores y Administradores), cambiar sus roles y crear cuentas individuales de personal.
supabase/sprint4-usuarios.sql — función RPC admin_listar_usuarios() (security definer) que une usuarios + auth.users + rol para listar nombre, correo, rol y estado de confirmación. Solo ejecutable por service_role.src/lib/db/usuarios.ts — listarUsuariosAdmin() que llama a la RPC.src/app/admin/usuarios/page.tsx — Server Component: exige rol (Colaborador o Administrador), lista usuarios, pasa soloLectura a la UI.src/app/admin/usuarios/admin-usuarios-client.tsx — tabla con buscador, cambio de rol por fila, modal de nueva cuenta (nombre, apellido, correo, teléfono, rol Colaborador/Administrador, sin contraseña).src/app/admin/usuarios/actions.ts — Server Actions: crearCuentaPersonal (invita por correo vía inviteUserByEmail, asigna rol, sin duplicados) y cambiarRol (con protección de la propia cuenta de Administrador).src/app/aceptar-invitacion/page.tsx + aceptar-invitacion-client.tsx — acepta la invitación (hash del enlace o ?code=), establece contraseña y redirige a /login?invitacion=1.src/lib/permisos.ts — /admin/usuarios entra con ROLES_PANEL (Colaborador: solo lectura); se modifica con ROLES_ADMIN (solo Administrador).src/lib/admin-modulos.ts — el módulo "Usuarios" pasa a disponible: true en el menú y tarjetas del panel.src/app/login/page.tsx y login-client.tsx — aceptan ?invitacion=1 y muestran un banner de éxito.README.md — esta sección.supabase/sprint4-usuarios.sql en Supabase > SQL Editor.TU_DOMINIO/aceptar-invitacion a Redirect URLs.AUTH_GUARD=on en .env.local para que el control de acceso entre en vigor.| Rol | Puede entrar a /admin/usuarios | Puede crear cuentas | Puede cambiar roles |
|---|---|---|---|
| Administrador | ✅ | ✅ | ✅ |
| Colaborador | ✅ (solo lectura) | ❌ | ❌ |
| Cliente | ❌ | ❌ | ❌ |
service_role puede escribir id_rol en public.usuarios (los grants de PostgreSQL lo impiden para usuarios autenticados).funcion.activa = false./admin/entradas; hoy el menú apunta a /admin/entradas/validar.