diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index cbab207..57a8db4 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,17 +1,34 @@ import type { Handle } from '@sveltejs/kit'; import { getDb } from '$lib/server/db'; import { sha256Hex } from '$lib/server/crypto'; -import { isProd, sessionIdleTtlMs } from '$lib/server/env'; +import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env'; function toIsoSql(d: Date): string { return d.toISOString().replace('T', ' ').replace('Z', ''); } export const handle: Handle = async ({ event, resolve }) => { + // Bypass de auth en desarrollo (opcional) + const bypass = isDev() && DEV_BYPASS_AUTH; + if (bypass) { + const qp = event.url.searchParams.get('__as')?.trim(); + const current = event.cookies.get('dev_as') || ''; + const user = qp && qp.length ? qp : (current || DEV_DEFAULT_USER); + if (qp && qp.length && qp !== current) { + event.cookies.set('dev_as', user, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: isProd(), + maxAge: 60 * 60 * 24 * 30 // 30 días + }); + } + event.locals.userId = user; + } // Sesión por cookie 'sid' const isLogout = event.url.pathname === '/api/logout' || event.url.pathname.startsWith('/api/logout/'); const sid = event.cookies.get('sid'); - if (sid) { + if (!bypass && sid) { try { const db = await getDb(); const hash = await sha256Hex(sid); @@ -97,5 +114,11 @@ export const handle: Handle = async ({ event, resolve }) => { } catch { // Ignorar si la implementación de Response no permite set() } + // Indicador de bypass en respuestas (útil en dev) + try { + if (bypass) { + response.headers.set('X-Dev-Auth', 'bypass'); + } + } catch {} return response; }; diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts index dc8cfa4..32f9735 100644 --- a/apps/web/src/lib/server/db.ts +++ b/apps/web/src/lib/server/db.ts @@ -1,6 +1,6 @@ -import { mkdirSync } from 'fs'; +import { mkdirSync, existsSync } from 'fs'; import { dirname } from 'path'; -import { resolveDbAbsolutePath } from './env'; +import { resolveDbAbsolutePath, isDev, DEV_AUTOSEED_DB, DEV_DEFAULT_USER } from './env'; function applyDefaultPragmas(instance: any): void { try { @@ -17,11 +17,13 @@ function applyDefaultPragmas(instance: any): void { } /** - * Abre la BD compartida sin ejecutar migraciones (las realiza el proceso del bot). + * Abre la BD compartida. En desarrollo, si el archivo no existe y DEV_AUTOSEED_DB=true, + * inicializa el esquema (migraciones) y siembra datos de demo. * Nota: uso de import dinámico de 'bun:sqlite' para que el build con Node no falle. */ async function openDb(filename: string = 'tasks.db'): Promise { const absolutePath = resolveDbAbsolutePath(filename); + const firstCreate = !existsSync(absolutePath); // Crear directorio padre si no existe try { @@ -33,6 +35,27 @@ async function openDb(filename: string = 'tasks.db'): Promise { const { Database } = await import('bun:sqlite'); const instance = new Database(absolutePath); applyDefaultPragmas(instance); + + // Auto-inicialización y seed sólo en desarrollo y primer arranque + if (firstCreate && isDev() && DEV_AUTOSEED_DB) { + try { + const dbModule = await import('../../../../../src/db'); + if (typeof (dbModule as any).initializeDatabase === 'function') { + (dbModule as any).initializeDatabase(instance); + } + } catch (e) { + console.warn('[web/db] No se pudo ejecutar initializeDatabase en dev:', e); + } + try { + const seed = await import('./dev-seed'); + if (typeof (seed as any).seedDev === 'function') { + await (seed as any).seedDev(instance, DEV_DEFAULT_USER); + } + } catch (e) { + console.warn('[web/db] No se pudo realizar el seed de datos de demo:', e); + } + } + return instance; } diff --git a/apps/web/src/lib/server/dev-seed.ts b/apps/web/src/lib/server/dev-seed.ts new file mode 100644 index 0000000..5290059 --- /dev/null +++ b/apps/web/src/lib/server/dev-seed.ts @@ -0,0 +1,109 @@ +/** + * Seed mínimo de datos de demo para desarrollo. + * Inserta usuarios, grupos permitidos, membresías, preferencias y tareas variadas. + */ +function toIsoYmd(d: Date): string { + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} +function addDays(base: Date, days: number): Date { + return new Date(base.getTime() + days * 24 * 3600 * 1000); +} + +export async function seedDev(db: any, defaultUser: string): Promise { + try { db.exec(`PRAGMA foreign_keys = ON;`); } catch {} + // Si ya hay tareas, asumimos BD poblada + try { + const row = db.query(`SELECT COUNT(*) AS c FROM tasks`).get() as any; + if (row && Number(row.c || 0) > 0) return; + } catch {} + + const now = new Date(); + const today = toIsoYmd(now); + const tomorrow = toIsoYmd(addDays(now, 1)); + const nextWeek = toIsoYmd(addDays(now, 7)); + const overdue = toIsoYmd(addDays(now, -1)); + const inTwoDays = toIsoYmd(addDays(now, 2)); + + const otherUser = '5550001111'; + // Usuarios + db.prepare(` + INSERT OR IGNORE INTO users (id, first_seen, last_seen) + VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) + `).run(defaultUser); + db.prepare(` + INSERT OR IGNORE INTO users (id, first_seen, last_seen) + VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) + `).run(otherUser); + + // Grupos + db.prepare(` + INSERT OR IGNORE INTO groups (id, community_id, name, active, last_verified) + VALUES (?, 'comm-1', ?, 1, strftime('%Y-%m-%d %H:%M:%f','now')) + `).run('g-1@g.us', 'Grupo Alpha'); + db.prepare(` + INSERT OR IGNORE INTO groups (id, community_id, name, active, last_verified) + VALUES (?, 'comm-1', ?, 1, strftime('%Y-%m-%d %H:%M:%f','now')) + `).run('g-2@g.us', 'Grupo Beta'); + + // Allowed groups + try { + db.prepare(` + INSERT OR IGNORE INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by) + VALUES (?, 'Alpha', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), NULL) + `).run('g-1@g.us'); + db.prepare(` + INSERT OR IGNORE INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by) + VALUES (?, 'Beta', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), NULL) + `).run('g-2@g.us'); + } catch {} + + // Membresías (usuario por defecto activo en ambos grupos) + db.prepare(` + INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES (?, ?, 0, 1, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) + `).run('g-1@g.us', defaultUser); + db.prepare(` + INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES (?, ?, 0, 1, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) + `).run('g-2@g.us', defaultUser); + + // Preferencias del usuario + try { + db.prepare(` + INSERT OR REPLACE INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + VALUES (?, 'daily', '08:30', NULL, strftime('%Y-%m-%d %H:%M:%f','now')) + `).run(defaultUser); + } catch {} + + // Crear tareas diversas + const insertTask = db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES (?, ?, ?, ?, COALESCE(?, 0), ?) + `); + const assignStmt = db.prepare(` + INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) + VALUES (?, ?, ?) + `); + + const tasksToCreate = [ + { desc: 'Tarea sin responsable (hoy) - Grupo Alpha', due: today, group: 'g-1@g.us', assignTo: null, completed: 0, completedAt: null }, + { desc: 'Tarea sin responsable (mañana) - Grupo Alpha', due: tomorrow, group: 'g-1@g.us', assignTo: null, completed: 0, completedAt: null }, + { desc: 'Tarea sin responsable (próx. semana) - Grupo Beta', due: nextWeek, group: 'g-2@g.us', assignTo: null, completed: 0, completedAt: null }, + { desc: 'Mi tarea personal (en 2 días)', due: inTwoDays, group: null, assignTo: defaultUser, completed: 0, completedAt: null }, + { desc: 'Mi tarea (vencida ayer) - Grupo Alpha', due: overdue, group: 'g-1@g.us', assignTo: defaultUser, completed: 0, completedAt: null }, + { desc: 'Tarea completada reciente', due: today, group: null, assignTo: defaultUser, completed: 1, completedAt: new Date().toISOString().replace('T',' ').replace('Z','') } + ]; + + db.transaction(() => { + for (const t of tasksToCreate) { + const res = insertTask.run(t.desc, t.due, t.group, defaultUser, t.completed, t.completedAt); + const id = Number((res as any)?.lastInsertRowid ?? 0); + if (t.assignTo && id > 0) { + assignStmt.run(id, t.assignTo, defaultUser); + } + } + })(); +} diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index 4957996..69a15aa 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -6,14 +6,15 @@ const env = process.env; * Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida. * Prioridad: * 1) DB_PATH (ruta completa al archivo) - * 2) DATA_DIR + filename (por defecto ./data/tasks.db) + * 2) DATA_DIR + filename (en prod por defecto /app/data; en dev por defecto ./tmp) */ export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string { const dbPathEnv = (env.DB_PATH || '').trim(); if (dbPathEnv) { return resolve(dbPathEnv); } - const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : '/app/data'; + const isProdEnv = String(env.NODE_ENV || 'development').trim().toLowerCase() === 'production'; + const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : (isProdEnv ? '/app/data' : 'tmp'); return resolve(join(dataDir, filename)); } @@ -25,6 +26,13 @@ export const sessionIdleTtlMs = Math.max(1, Math.floor(SESSION_IDLE_TTL_MIN)) * export const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase(); export const isProd = () => NODE_ENV === 'production'; +export const isDev = () => NODE_ENV === 'development'; + +// Flags de desarrollo (solo en entornos no productivos) +const toBool = (v: string) => ['1', 'true', 'yes', 'on'].includes(String(v || '').trim().toLowerCase()); +export const DEV_BYPASS_AUTH = toBool(env.DEV_BYPASS_AUTH || ''); +export const DEV_DEFAULT_USER = (env.DEV_DEFAULT_USER || 'demo').trim(); +export const DEV_AUTOSEED_DB = toBool(env.DEV_AUTOSEED_DB || ''); // ICS: horizonte en meses y rate limit (por minuto, 0 = desactivado) const ICS_HORIZON_MONTHS = Number(env.ICS_HORIZON_MONTHS || 12); diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 1f56750..5dd0f87 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import '$lib/styles/tokens.css'; import '$lib/styles/base.css'; import favicon from '$lib/assets/favicon.svg'; + const dev = import.meta.env.DEV; @@ -9,4 +10,28 @@ +{#if dev} +
+ Modo desarrollo: auth bypass activo +
+{/if} + diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts index 9b4f397..778e819 100644 --- a/apps/web/src/routes/login/+server.ts +++ b/apps/web/src/routes/login/+server.ts @@ -2,7 +2,7 @@ import type { RequestHandler } from './$types'; import { redirect } from '@sveltejs/kit'; import { getDb } from '$lib/server/db'; import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto'; -import { sessionIdleTtlMs, isProd } from '$lib/server/env'; +import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env'; function toIsoSql(d: Date): string { return d.toISOString().replace('T', ' ').replace('Z', ''); @@ -20,6 +20,9 @@ function escapeHtml(s: string): string { // GET: página intermedia que requiere una interacción mínima y establece una cookie "login_intent" vía JS. // Evita que bots de previsualización canjeen el token antes de que el usuario haga clic. export const GET: RequestHandler = async (event) => { + if (isDev() && DEV_BYPASS_AUTH) { + throw redirect(303, '/app'); + } const token = event.url.searchParams.get('token')?.trim(); if (!token) { console.warn('[web/login] Solicitud sin token'); @@ -80,6 +83,9 @@ export const GET: RequestHandler = async (event) => { // POST: canje real del token (uso único). Crea sesión y redirige a /app. export const POST: RequestHandler = async (event) => { + if (isDev() && DEV_BYPASS_AUTH) { + throw redirect(303, '/app'); + } const form = await event.request.formData(); const token = String(form.get('token') || '').trim(); if (!token) {