Compare commits

...

2 Commits

Author SHA1 Message Date
LakG 800ec60a53 Cambios verificados (working tree previamente probado)
- src/lib/text.ts: helper truncate() reutilizable.
- SolicitudCart, DesbanearButton, vistas admin y endpoint solicitudes:
  ajustes usando el helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-09 09:04:38 -07:00
LakG 3cccf758a8 Bitácora: v1.7 en producción (overlay + baneos + carrito persistente + fix upload) 2026-08-27 10:23:16 -07:00
8 changed files with 122 additions and 59 deletions
+13
View File
@@ -258,6 +258,19 @@ Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/p
- **Verificación**: `npm run build` limpio (1.52s). Deploy: 2 commits (migración + código), push con token efímero, `ssh buglabs '... set -a && . .env.production && set +a && docker compose up -d --build'` (mismo patrón que 0004). Container healthy en 23s. Smoke test post-deploy: `/login` → 200; `/audio/sonido_notificacion.mp3` → 200 con `content-type: audio/mpeg` (audio sirve correctamente); BD verifica `profiles_rol_check` incluye docente, 3 maestros existentes con `es_tutor` booleano correcto.
- **Deploy completado**: **v1.6 en producción en https://prestamos.buglabs.dev**. El usuario se encarga de: (a) desactivar Cloudflare Web Analytics para el dominio (bug de sha512/CORS); (b) marcar como docente vía SQL a las cuentas que corresponda.
- **2026-08-27 — v1.7: overlay de carga, lista negra de cuentas, carrito persistente + fix definitivo del upload**. 3 features + 1 cambio UX + 2 bugs residuales. Fase 1 con Bash/grep + logs de Storage en prod (sin Explore agents — bug root cause obvio en los logs), Fase 2 sin Plan agent (contexto claro), 2 preguntas al usuario (modelo de baneos, estrategia fix upload), plan escrito y aprobado, ejecución con migración yo + 3 agentes paralelos.
- **Bug del upload — root cause definitivo (via logs storage de prod)**: `"role":"anon"` + `"error":"new row violates row-level security policy"` (código 42501). Causa real: `browserClient()` en `src/lib/supabase.ts` usa `createBrowserClient` de `@supabase/ssr` — en el browser NO puede leer las cookies de auth porque están seteadas con `httpOnly: true` (correcto por seguridad, JS del browser no las puede leer nunca). Al hacer `.storage.from(bucket).upload()`, la request sale con `Authorization: Bearer <anon_key>` (sin JWT del user), Storage la evalúa como `role: 'anon'`, y RLS rechaza. **Los fixes previos (Dockerfile ARG PUBLIC_*, guard typeof process en supabase.ts) NO tocaban este bug** — solo aseguraban que la URL del Supabase estuviera horneada en el bundle, pero el bundle nunca pudo autenticar contra Storage. Fix elegido: **endpoints proxy server-side** que reciben multipart, validan la sesión con la cookie httpOnly (server sí la lee via `serverClient(cookies)`), y suben con `serviceClient()` (bypass RLS). Cookies siguen httpOnly — cero riesgo XSS.
- **Decisiones tomadas con el usuario**: (1) baneos = **tabla separada** `prestamos.baneos(profile_id, razon, banned_at, banned_by, expires_at, unbanned_at, unbanned_by)` — historial completo + soporte para baneos temporales (aunque UI solo expone permanentes esta ronda); (2) fix upload = endpoints proxy; (3) overlay de carga literal (blur + spinner) según petición del usuario — no ClientRouter (evita side effects en islands y Realtime).
- **Migración `0007_baneos.sql`** aplicada limpia: nueva tabla `baneos` con partial unique index `where unbanned_at is null` (garantiza máximo 1 baneo activo por profile), RLS `baneos_admin_all` (admin CRUD) + `baneos_read_self` (user ve los suyos para /banned), helper SQL `prestamos.is_banned(uid) returns boolean` `security definer stable` que retorna true si existe baneo activo no expirado — invocable por el middleware vía `.rpc('is_banned', {p_uid})`.
- **3 agentes paralelos** con contratos aislados — **los 3 completos limpios en un pase** (a diferencia de v1.6 donde P y H cortaron por límite de sesión):
- **B · Baneos + panel + middleware guard** (9 archivos): `banned.astro` (pantalla completa patrón /403, muestra razón + fecha + expires + quién baneó + botón signout), `POST /api/admin/baneos` (guards admin + no-self-ban + razón 5-500 chars + expires_at futuro opcional, 23505 → 409), `POST /api/admin/baneos/[id]/desbanear` (update idempotente con `where unbanned_at is null`, 404 si ya inactivo), `BanearForm.tsx` (dialog con textarea razón + contador; deshabilitado con tooltip si adminSelf), `DesbanearButton.tsx` (window.confirm simple), `/admin/usuarios.astro` (2 queries paralelas profiles + baneos activos, cruce en memoria, tabla desktop + cards mobile), middleware con nuevo array `BANNED_ALLOWED = ['/banned', '/api/auth/signout']` para escapar del rewrite y evitar loop, RPC `is_banned` solo se llama si `profile && rol !== 'admin'` (evita costo en admin/no-login), nav admin `+ Usuarios`.
- **U · Uploads proxy** (4 archivos): `POST /api/upload/material-foto/[id].ts` (guard admin, valida mime `/^image\//` + `size <= 2MB`, path `${id}/${Date.now()}.${ext}`, sube con `serviceClient().storage.from('materiales-fotos').upload({upsert:true, contentType})`, actualiza `materiales.imagen_path` con locals.supabase), `POST /api/upload/avatar.ts` (guard user autenticado, path `${uid}/${Date.now()}.${ext}` al bucket `avatares`, actualiza `profiles.foto_path`), refactor `MaterialForm.tsx` `uploadFotoFor` para fetch multipart al endpoint (eliminó import de browserClient y del PATCH cliente del imagen_path — el endpoint ya lo persiste, evita doble escritura), refactor `PerfilForm.tsx` mismo patrón. Decisión no obvia: `uploadFotoFor` mantiene el patrón toast+return-null (no throw) del original — preserva la UX de "material creado sin foto" si el upload falla, en vez de bloquear la creación completa. Anotado en comentario: el "Quitar foto" en edit-mode nunca se propagaba a la BD (bug preexistente fuera de scope).
- **X · UX chico** (3 archivos): `Layout.astro` con `<div id="page-loader" hidden>` + estilos `position:fixed backdrop-filter:blur(8px)` + spinner mono animado (respeta prefers-reduced-motion) + script inline que captura clicks en `<a href>` y submits de `<form>` mismo origen (guardas: modifier keys, target=_blank, anchor#, javascript:/mailto:/tel:, cross-origin, defaultPrevented — este último es clave: los forms fetch-managed llaman preventDefault en su onSubmit React, así que el overlay no se dispara falsamente para ellos), `pageshow` limpia el overlay por bfcache. `SolicitudCart.tsx` con useEffect hidratar+persistir `labre:cart:v1` en localStorage + guard `cartHydrated` (evita que la primera pasada del effect pise el localStorage antes de leerlo) + botón Vaciar en footer del checkout con `window.confirm` (solo dentro del bloque perfilCompleto). `admin/inventario/index.astro` eliminado el `<script>` inline de auto-submit debounced que se había agregado en v1.5.
- **Verificación combinada**: `npm run build` limpio (2.74s en dev; 2.64s en el agente B). Sin conflictos de merge. Smoke test público post-deploy: `/login` → 200, `/banned` → 302 (protegido, redirige a login sin sesión), `/admin/usuarios` → 302 (protegido), `/api/upload/avatar` → 302 (middleware protegiendo API antes del handler). Container healthy en ~90s.
- **Aprendizajes registrados**: (a) los "fixes" del bug de upload en sesiones previas (Dockerfile ARG, guard typeof process) NUNCA fueron el fix real — solo eran precondiciones necesarias. El bug real requiere abandonar la idea de que el `browserClient` pueda hablar directo con Storage cuando las cookies son httpOnly. Diagnóstico definitivo llegó por leer logs de `supabase-storage` container donde el error 42501/anon estaba explícito; no era necesario reproducir en browser. (b) Contratos de agentes aislados por CARPETA (no por archivo) escalan mucho mejor — 3 agentes editaron 3 conjuntos disjuntos de rutas/componentes/endpoints sin overhead de coordinación. (c) El uso de `preventDefault` como señal semántica funciona bien: cualquier form que llama `preventDefault()` en su `onSubmit` React no dispara el overlay global, sin necesidad de opt-out explícito por form. (d) `partial unique index` `where unbanned_at is null` es el patrón limpio para "máximo un baneo activo por profile" — evita constraint compleja y da el error 23505 traducible.
- **Deliberadamente NO tocado**: (i) super admin (mencionado por usuario como consideración futura); (ii) baneo automático por rate limiting; (iii) UI para expires_at en baneos (columna existe, se puede exponer si se pide); (iv) compresión de imágenes cliente-side (si las fotos que sube el admin regularmente pasan de 2MB se agrega); (v) el bug preexistente de "Quitar foto" en MaterialForm que no propagaba null a la BD.
- **Deploy completado**: 2 commits (migración + código), push con token efímero de Gitea, `set -a && . .env.production && set +a && docker compose up -d --build` en buglabs (patrón del build args). Container healthy. **v1.7 en producción en https://prestamos.buglabs.dev**. El usuario puede empezar a: (a) crear/actualizar fotos de materiales y perfil (bug arreglado); (b) banear cuentas problemáticas desde `/admin/usuarios`.
- **2026-08-26 — Primera ronda de QA manual con agent-browser (post v1.6)**. Se siguió el proceso de `CLAUDE.md`/`AGENTS.md`: bypass temporal `?preview=alumno|admin|docente` en el middleware (solo `import.meta.env.DEV`), revertido al terminar (`git diff src/middleware.ts` queda limpio).
- **Bug crítico encontrado y arreglado — `process is not defined` rompía la hidratación de 3 formularios en producción**: `src/lib/supabase.ts` leía `process.env.X ?? import.meta.env.X` (orden fijado en la sesión del 24-ago para el bug de upload en Docker). En el browser `process` no existe como global — evaluar `process.env` revienta con `ReferenceError` **antes** de que el `??` pueda caer al fallback. Como el módulo se evalúa completo al importarse (aunque solo se use `browserClient`), esto tumbaba la hidratación de **`PerfilForm.tsx`** (usado en `/perfil` y `/onboarding`) y de **`MaterialForm.tsx`** (admin, alta/edición de material) — los tres quedaban sin JS: los botones "Guardar" hacían un submit nativo del `<form>` (sin `action`, sin querystring) que caía en `/login` en vez de llamar al endpoint. Confirmado con red real: antes del fix, `PATCH /api/profile` nunca se disparaba (submit nativo); con el fix, sí, y persiste correctamente. Fix: guard `typeof process !== 'undefined'` antes de leer `process.env` en las 3 constantes de `supabase.ts` — mantiene la prioridad process→import.meta.env para el server (necesaria por el fix de Docker de esa sesión) sin tocar `process` en el bundle de browser. `avatar.ts` y `materialImg.ts` ya tenían el orden inverso (`import.meta.env` primero) por eso nunca mostraron el bug. **Pendiente: este fix vive solo en el working tree, no se ha commiteado ni desplegado** — el bug sigue viivo en producción hasta que se despliegue.
- **Hallazgo operativo, no bug de código — no había ningún perfil `rol='admin'`** en la base de datos de producción al momento de probar (los 10 perfiles reales eran todos `alumno`/`docente`). Bloqueaba por completo `/admin/*`. Con confirmación del usuario, se promovió `amado.garcia.ramirez@uabc.edu.mx` a `admin` vía `PATCH` directo a PostgREST con `service_role`. No se investigó la causa de cómo se quedó sin admin (posble que nunca se re-promovió tras alguna migración, o que el admin real usaba otro correo ya no presente) — si vuelve a pasar, vale la pena revisar.
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { toastAfterReload } from '@/lib/toast';
type Props = {
@@ -8,6 +8,9 @@ type Props = {
export default function DesbanearButton({ baneoId, nombre }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const errorDialogRef = useRef<HTMLDialogElement | null>(null);
const titleId = `desbanear-err-${baneoId}`;
const submit = async () => {
if (loading) return;
@@ -24,12 +27,14 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
});
location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : 'Error inesperado');
setError(err instanceof Error ? err.message : 'Error inesperado');
errorDialogRef.current?.showModal();
setLoading(false);
}
};
return (
<>
<button
type="button"
className="btn btn-primary"
@@ -39,5 +44,26 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
>
{loading ? 'Desbaneando…' : 'Desbanear'}
</button>
<dialog
ref={errorDialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
No se pudo desbanear
</h2>
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
{error}
</p>
<div className="flex justify-end pt-2">
<button type="button" className="btn btn-primary" onClick={() => errorDialogRef.current?.close()}>
Entendido
</button>
</div>
</div>
</dialog>
</>
);
}
+40 -17
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast';
import { imgUrl } from '@/lib/materialImg';
import AgregarMaterial from './AgregarMaterial';
type Material = {
@@ -9,9 +10,18 @@ type Material = {
cantidad_disponible: number;
cantidad_total: number;
numero_inventario: string | null;
imagen_path: string | null;
categoria: { id: number; nombre: string } | null;
};
// El catálogo sólo lista materiales con estado 'disponible' (ver catalogo.astro),
// así que el chip siempre coincide con el mismo estilo que usa el inventario del admin.
const DISPONIBLE_BADGE_STYLE = {
background: 'color-mix(in oklab, var(--color-positive) 18%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-positive)',
} as const;
type CartItem = {
material_id: number;
nombre: string;
@@ -229,30 +239,41 @@ export default function CartProvider({
return (
<CartContext.Provider value={value}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{materiales.map((m) => (
{materiales.map((m) => {
const src = imgUrl(m.imagen_path);
return (
<article
key={m.id}
className="card flex flex-col gap-3"
className="card p-0 flex flex-col overflow-hidden"
data-cat={m.categoria?.id ?? 'sin'}
data-nombre={m.nombre}
data-numero-inventario={m.numero_inventario ?? ''}
>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && (
<span
className="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap uppercase tracking-wide"
style={{
background: 'color-mix(in oklab, var(--color-primary) 25%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-primary)',
}}
<div
className="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style={{ borderBottom: '2px solid var(--color-ink)' }}
>
{m.categoria.nombre}
</span>
{src ? (
<img src={src} alt={m.nombre} className="w-full h-full object-cover" loading="lazy" />
) : (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth={1.5} aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
</svg>
)}
<span
className="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]"
style={DISPONIBLE_BADGE_STYLE}
>
Disponible
</span>
</div>
<div className="p-4 flex flex-col gap-3 flex-1">
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && <p className="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>}
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
@@ -267,8 +288,10 @@ export default function CartProvider({
<div className="mt-auto">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
</div>
</div>
</article>
))}
);
})}
</div>
{items.length > 0 && (
@@ -394,7 +417,7 @@ export default function CartProvider({
id="notas-cart"
className="input"
rows={3}
maxLength={250}
maxLength={100}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="¿Para qué clase o proyecto lo necesitas?"
@@ -402,7 +425,7 @@ export default function CartProvider({
aria-describedby="notas-cart-hint"
/>
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3. (Opcional, máx. 250 caracteres)
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez. (Opcional, máx. 100 caracteres)
</p>
</div>
+1
View File
@@ -0,0 +1 @@
export const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
+4 -6
View File
@@ -136,7 +136,7 @@ const estadoBadge = (e: Material['estado']) => {
return (
<article class="card p-0 flex flex-col overflow-hidden">
<div
class="aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
class="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style="border-bottom: 2px solid var(--color-ink);"
>
{src ? (
@@ -148,14 +148,12 @@ const estadoBadge = (e: Material['estado']) => {
<circle cx="8" cy="9" r="1.5" />
</svg>
)}
</div>
<div class="p-3 flex flex-col gap-2 flex-1">
<div class="flex items-start justify-between gap-2">
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shrink-0" style={badge.style}>
<span class="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]" style={badge.style}>
{badge.label}
</span>
</div>
<div class="p-3 flex flex-col gap-2 flex-1">
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
{m.categoria && (
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
)}
+3 -2
View File
@@ -2,6 +2,7 @@
import AppLayout from '@/layouts/AppLayout.astro';
import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx';
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
import { truncate } from '@/lib/text';
const supabase = Astro.locals.supabase;
const { data, error } = await supabase
@@ -107,7 +108,7 @@ const tabs = [
</div>
{r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span>
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div>
)}
</td>
@@ -162,7 +163,7 @@ const tabs = [
<div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
{r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span>
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p>
)}
<div class="mt-3 flex gap-2 flex-wrap">
+3 -2
View File
@@ -1,6 +1,7 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
import { truncate } from '@/lib/text';
const supabase = Astro.locals.supabase;
const { data, error } = await supabase
@@ -99,7 +100,7 @@ const tabs = [
</div>
{r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span>
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div>
)}
</td>
@@ -135,7 +136,7 @@ const tabs = [
</div>
{r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span>
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p>
)}
<div class="mt-3">
+1 -1
View File
@@ -56,7 +56,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
? rawMaestro
: null;
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim().slice(0, 250) : null;
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim().slice(0, 100) : null;
if (!Array.isArray(body.items) || body.items.length === 0) {
return Response.json({ error: 'Agrega al menos un material' }, { status: 400 });