Imported from Laza223/turno-gol-repo (
.claude/skills/convenciones-stack/SKILL.md). Install upstream withnpx skills add Laza223/turno-gol-repo --skill convenciones-stack. Copyright stays with the author.
Convenciones del stack
Invariantes globales
- Montos en centavos ARS, integer. Nunca decimal ni float.
- Timestamps UTC en DB; conversión a ART solo en frontend.
- UUIDs como PK. TypeScript strict, jamás
any. - Vocabulario de enums:
canceledcon una L (canceled_refunded,canceled_no_refund).cancelledno existe en este repo. - Turnos de 60 min fijos:
SLOT_DURATION_MINUTESensrc/shared/constants.ts:8. El slot 23:00→medianoche se guarda contime_end='24:00'. Cierres post-medianoche: usar los helpers desrc/shared/time/operating-day.ts— nunca reimplementar esa aritmética. - Mutaciones de UI = Server Actions. Route Handlers SOLO para webhooks de MP,
/api/public/*y auth callbacks. - El día NUNCA se deriva en UTC.
new Date().toISOString().slice(0,10)devuelve el día siguiente entre las 21:00 y medianoche ART: un default de listado o un bucketing de métricas que pase por ahí salta de día 3 horas por noche. UsarartTodayStr()(@/shared/dates/art) para "hoy",artDateOf(@/shared/time/art-date) para el día ART de un instante, uoperatingDateOf(@/shared/time/operating-day) cuando el día es el OPERATIVO del complejo. Bloqueado por ESLint (no-restricted-syntax).
Plata en la UI
- Todo campo de monto es
MoneyInput(@/components/ui/money-input), nunca untype="number"suelto: pesos enteros, separador de miles mientras se tipea y relectura en palabras arriba deMONEY_WORDS_THRESHOLD_CENTS. El parseo vive en@/lib/moneyy es la única fuente. - El campo se reformatea en CADA tecla, así que el parser tiene que sobrevivir a strings a medio tipear — no alcanza con que parsee bien el string final. (pasó:
parsePesosToCentsborraba todo lo no-dígito, así que la coma se comía en el acto y los dígitos de los centavos se pegaban al entero en la tecla siguiente:1500,50terminaba valiendo $150.050. Afectaba los 7 campos de plata del admin, incluido el precio de cancha, que publicaba el error 100× en el portal público. El test que lo agarra tipea tecla por tecla; los que parseaban el string entero pasaban en verde.) - Desambiguación es-AR: el último separador es decimal solo si lo siguen 0, 1 o 2 dígitos; con 3 es grupo de miles (
35,000son treinta y cinco mil). El caso de CERO dígitos es el instante en que se acaba de tipear la coma y es tan importante como los otros. - Escribir en un día de caja ya cerrado exige
type='adjustment'víaallowClosedDayenCreateCashFlowInput, y ninguna Server Action lo expone. Verdocs/decisions/2026-08-28-sena-cobrada-con-la-caja-cerrada.md. (pasó: elcatchdeDayAlreadyClosedErroravisaba al dueño pero no escribía nada, así que la reserva decía "pagada" y la plata no figuraba en Caja.) - Saltear el chequeo de cierre no es saltear
assertDayOpen: ahí adentro está elpg_advisory_xact_lockque serializa contra uncloseDailyRegisterconcurrente. Pasarle el flag, no evitar la llamada. - El cash_flow de la seña tiene TRES emisores, no uno:
recordManualBookingDepositCashFlow(booking.service.ts),recordManualDepositCashFlowyrecordDepositCashFlow(payment.service.ts). Están duplicados a propósito, así que un arreglo en uno NO llega a los otros — listarlos congrep -rn "depositCashFlowDescription" src/antes de dar por cerrado cualquier bug de señas. (pasó: el fix de la caja cerrada tapó una sola de las tres puertas y se dio por cerrado; las otras dos siguieron tirando la plata al vacío hasta que un barrido de la clase las encontró.) - Un
mockRejectedValuepelado sobre el colaborador que falló también hace fallar el camino de recuperación, y el test rojo parece un bug del fix. El mock tiene que imitar la REGLA, no el resultado:mockImplementationque rechaza sólo mientras no venga el flag de escape. (pasó:createCashFlowmockeado conmockRejectedValue(new DayAlreadyClosedError())rechazaba también la escritura del ajuste, que es justo lo que el caso prueba.)
Rutas públicas y status HTTP
- Un
loading.tsxcubre su segmento Y todo lo que cuelga debajo. Abre un<Suspense>, Next arranca a streamear con el 200 ya en los headers, y cualquiernotFound()posterior cambia el cuerpo pero no el status: soft-404 indexable. Unlayout.tsxqueda fuera del boundary de SU segmento, no del de un ancestro. (pasó dos veces: primero con las pages de/{slug}/*, arreglado moviendo el gate a[slug]/layout.tsx; después con un gate en[slug]/torneos/layout.tsx, que seguía adentro del boundary delloading.tsxdel perfil. La salida fue encerrar la page del perfil y su skeleton en un grupo(perfil), que no cambia la URL y deja a cada hermano decidiendo su status.) - El status se mide, no se deduce:
fetch(url, {redirect:'manual'})repetido, y con control negativo (la misma URL en la condición contraria). Un cuerpo que dice "no encontrado" no prueba que el status sea 404, y una sola medición puede caer del lado bueno por timing de compilación.
Server Actions y forms
- Retorno:
ActionResultde@/shared/types/action-result—{ success: true } & TExtra | { success: false; error: string }. Nada de{ success: boolean; error?: string }: esa forma no discrimina, deja compilar un fallo sin motivo y termina mostrándole al usuario un error genérico donde había uno real. - La action llega por PROP tipada (
import type), no por import de valor: importar un módulo'use server'desde un componente cliente arrastra drizzle ynode:async_hooksal bundle del browser y rompe Storybook. - Nada de wrapper
ActionForm: el patrón del repo esuseActionState+ action por prop +SubmitButtonde@/components/ui/submit-button(useFormStatus→ deshabilitado +aria-busy, evita el doble submit). Los forms de auth tienen botón propio a propósito (sistema visual distinto). - El error SIEMPRE se muestra: un form que descarta el
{ success: false }deja al usuario apretando Guardar sin feedback.ConfirmDialogya lo hace solo si el handler devuelve elActionResult.
Efectos externos y transacciones
- Una llamada a MercadoPago, Resend o R2 NUNCA va adentro de una transacción SQL. Si la tx aborta después de la llamada, el reintento duplica el efecto externo (pasó: doble reembolso) y encima la conexión queda tomada durante toda la latencia de red. Patrón: preparar en la tx → commitear → llamar afuera → registrar el resultado en una segunda tx (Saga).
- Al revés también: no commitear una escritura local dando por hecho que el efecto externo salió bien (pasó: alta de staff commiteada con el email de invitación fallado).
- Un
try/catchagregado a un middleware que compone DENTRO dewithTenantContext/withPlayerContextconvierte un throw en un valor resuelto, ydb.transactionhace COMMIT en vez de ROLLBACK aunque el handler haya fallado a mitad de una escritura multi-paso — sin ningún cambio visible en la respuesta al cliente, así que no lo delata ningún test que solo mire el body HTTP. El catch de "loguear + devolverinternal()" solo es seguro en la capa que envuelve la llamada awithTenantContext(...)DESDE AFUERA (ahí el rollback de Drizzle ya corrió antes de que la excepción llegue al catch) — nunca en una capa intermedia que corre adentro de esa tx (with-role.tses justo eso: compone siempre dentro dewithTenant). Antes de agregar un catch a cualquier wrapper desrc/server/middleware/, verificar congrep -n "withTenantContext\|withPlayerContext" src/server/middleware/*.tscuál de los dos lados de la tx es. (pasó: 2026-09-02, verdocs/gtm/ejecucion/10-aprendizajes.md— un fix de mensajes de error le agregó try/catch a los 4 wrappers por igual; un revisor adversarial de contexto fresco lo agarró antes de mergear, corrigiéndolo solo enwith-role.ts.)
Drizzle
- jsonb: importar
jsonbdesdesrc/shared/db/jsonb.ts, NUNCA desdedrizzle-orm/pg-core. El de pg-core doble-codifica con postgres-js y corrompe el dato at-rest (queda string escalar;columna->>'campo'devuelve NULL). - Merge de jsonb en
sql``: pasar el objeto crudo, NUNCAJSON.stringify(mismo doble-encode). Ojo con||sobre un array: concatena en vez de reemplazar. - Subquery correlacionado dentro de
sql`` en.select()no califica columnas de la tabla externa → usar LEFT JOIN con tabla derivada. - Migraciones: fuente de verdad
src/shared/db/migrations/NNN_nombre.sql(numeración secuencial; la última se lee conls src/shared/db/migrations | tail -1— no la fijes acá, un número que se incrementa solo queda viejo y se cita como vigente). Después de crear una:pnpm db:sync-supabasegenera el espejo ensupabase/migrations/, ypnpm supabase:resetla aplica localmente. NUNCA editar una migración ya existente. Detalle endocs/operations/MIGRATIONS.md. - Drizzle 0.45 envuelve todo error de postgres-js en
DrizzleQueryError:code/constraint_nameviajan enerr.cause, nunca enerr. Uncatchque miraerr.codeen el nivel superior nunca ve un 23505/23503 si el INSERT que falló vino de.insert(tabla).values().returning()(query builder) — sí lo ve si vino detx.execute(sql\...`)crudo, porque ahí no hay wrapping. Helper único que camina la cadena decause:isUniqueViolation/isForeignKeyViolationensrc/shared/db/pg-errors.ts(movido demodules/tournaments/acá porque ya lo usan 2+ módulos). (pasó dos veces: primero en tournaments, cazado por un test de integración y no por el typecheck; después encashflow/daily-close.service.ts` — el mismo patrón naive sobrevivió sin que nadie lo mirara hasta la campaña de mutación de 2026-09-03, porque el test que lo debía agarrar solo afirmaba "4 de 5 fallaron", no CON QUÉ error.)
Multi-tenant / RLS
- Contexto por request (helpers en
src/shared/db/client.ts, hacen elSET LOCALcorrecto): staff →withTenantContext(tenantId, tx => ...); jugador →withPlayerContext(playerId, ...)(cross-tenant: NO setear tenant_id). JamásSETsin LOCAL. - Defensa en profundidad: además de RLS, SIEMPRE filtro explícito
WHERE tenant_id = .../WHERE player_id = .... En dev la app conecta como superusuario → RLS no aplica y el filtro explícito es la única barrera (así se cerraron leaks reales de revenue cross-tenant). (pasó: 2026-09-03, campaña de mutación — el patrón "INSERT ... ON CONFLICT (client_idempotency_key) DO NOTHING" + SELECT de fallback para recuperar la fila apareció 6 veces sinAND tenant_id = ...—cashflow.service.tsy sus dos gemelos encanteen-tab.service.ts/canteen-sale.service.ts— porque el índice único declient_idempotency_keyes GLOBAL, no por tenant: al copiar el patrón de un módulo a otro, el WHERE se copió incompleto las tres veces.) - Workers/jobs: nunca
getDb()— usargetWorkerDb()/getWorkerSql()(pool conWORKER_DATABASE_URL, rol BYPASSRLS) para barridos cross-tenant. Una mutación sobre UN tenant conocido vuelve porwithTenantContexten el pool normal. Bloqueado por ESLint ensrc/shared/jobs/**(bloqueturnogol/jobs-worker-pool), porque el modo de falla es silencioso: el pool de la app no tira error, devuelve cero filas y el job reporta éxito. - Roles staff: el rol NUNCA sale del JWT. Guards de
src/modules/staff/guards.ts:requireOperatorStaff()(admin+manager: grilla, reservas, caja, jugadores) yrequireAdminStaff()/requireAdminStaffAction()(solo admin: configuración, equipo, MP/facturación). - Checklist tabla tenant-aislada nueva: columna
tenant_id+ policy RLS +FORCE ROW LEVEL SECURITY+ DELETE manual ensrc/shared/jobs/workers/data-retention-cleanup.worker.ts(el wipe NO cascadea: el tenant se soft-anonimiza, no se borra la fila) + caso entests/integration/isolation.test.ts. - Nunca exportar helpers de servidor desde un archivo
'use client': vitest pasa igual y revienta solo en runtime real.
pg-boss
- Workers en
src/shared/jobs/workers/, nombres de cola ensrc/shared/jobs/queue-names.ts, proceso conpnpm jobs:start. - Todo job debe ser idempotente (webhooks deduplican vía
processed_webhooks/ idempotency keys en cash_flows). - Efectos con horario (push en madrugada 00–08): patrón
startAfterdesrc/modules/notifications/push-quiet-hours.ts, nunca sleep.
MercadoPago
- Dev/E2E:
MP_MOCK_MODE=1en.env.localactivaLocalMockGateway(src/modules/payments/mock-mp.ts) → checkout local en/mock-mp/checkout, webhook procesado inline sin pg-boss. Hard-gated a no-producción: no intentar "arreglar" ese gate. - NUNCA llamar la API real de MP desde tests ni dev. Replay manual de webhooks:
pnpm webhook:replay. - Webhooks entrantes verifican firma HMAC en
src/modules/payments/webhook-auth.ts(gotcha:data.idva en minúsculas al construir el manifest). - Llamadas salientes pasan por
withCircuitBreaker(src/modules/payments/mp-breaker.gateway.ts) — no bypassearlo con un fetch directo. - Tokens OAuth por tenant (
mp_access_token/mp_refresh_token) están cifrados at-rest y el cifrado es la ÚNICA barrera (tablatenantses global, sin RLS). Jamás loguearlos ni devolverlos en payloads.