import { getDb } from '$lib/server/db'; /** * Validate session and parse JSON body for POST endpoints. * Returns { userId, payload } on success, or a Response on failure. * Callers should check `instanceof Response` before destructuring. */ export async function requireAuthAndJson(event: { locals: { userId?: string | null }; request: { json(): Promise }; }): Promise<{ userId: string; payload: any } | Response> { const userId = event.locals.userId ?? null; if (!userId) return new Response('Unauthorized', { status: 401 }); let payload: any = null; try { payload = await event.request.json(); } catch { return new Response('Bad Request', { status: 400 }); } return { userId, payload }; } /** * Shared auth + task loading logic used by task detail, claim, and unassign routes. * * Validates the user, parses the task ID from params, opens the DB, loads the task, * and checks that it exists and is not completed. Returns the context on success * or a Response on failure — callers should check `instanceof Response` first. */ export async function loadAndCheckTask(event: { locals: { userId?: string | null }; params: { id?: string }; }): Promise<{ db: any; task: any; userId: string } | Response> { const ctx = await _loadTask(event); if (ctx instanceof Response) return ctx; // Additional check: reject completed tasks const { task } = ctx; if (Number(task.completed) !== 0 || task.completed_at) { return new Response(JSON.stringify({ status: 'completed' }), { status: 400, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } }); } return ctx; } /** * Shared group gating check: verifies the group is allowed and the user * is an active member. Returns a 403 Response on failure, or true to * continue. Callers should `if (res instanceof Response) return res;`. */ export function checkGroupAccess( db: any, groupId: string | null, userId: string ): Response | true { if (!groupId) return true; const allowed = db .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`) .get(groupId); const active = db .prepare( `SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1` ) .get(groupId, userId); if (!allowed || !active) { return new Response('Forbidden', { status: 403 }); } return true; } /** * Auth + task load + full gating (group + personal assignment). * Returns context or a Response on failure. Does NOT check completed status — * callers must handle that themselves (complete vs uncomplete have opposite * semantics). */ /** * Load a task, check auth, and verify group access. * Returns { db, task, userId } or a Response on failure. * Does NOT check personal assignment (suitable for claim/unassign routes). */ export async function loadTaskAndCheckGroup(event: { locals: { userId?: string | null }; params: { id?: string }; }): Promise<{ db: any; task: any; userId: string } | Response> { const ctx = await loadAndCheckTask(event); if (ctx instanceof Response) return ctx; const { db, task, userId } = ctx; // Gating: grupo permitido + usuario miembro activo const groupId: string | null = task.group_id ? String(task.group_id) : null; const gating = checkGroupAccess(db, groupId, userId); if (gating instanceof Response) return gating; return { db, task, userId }; } /** * Fetch allowed groups for a user where the user is an active member. * * @param excludeCommunityArchived - when true, also filters out * community groups (is_community=0) and archived groups (archived=0). * Defaults to false (includes all active allowed groups). */ export function fetchAllowedUserGroups( db: any, userId: string, opts?: { excludeCommunityArchived?: boolean } ): Array<{ id: string; name: string | null }> { const extraWhere = opts?.excludeCommunityArchived ? ' AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0' : ''; return db .prepare( `SELECT g.id, g.name FROM groups g INNER JOIN group_members gm ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1 INNER JOIN allowed_groups ag ON ag.group_id = g.id AND ag.status = 'allowed' WHERE COALESCE(g.active, 1) = 1${extraWhere} ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC` ) .all(userId) as Array<{ id: string; name: string | null }>; } /** * Low-level: auth + taskId parsing + DB + task load + not-found check. * Does NOT reject completed tasks — that's up to the caller. */ async function _loadTask(event: { locals: { userId?: string | null }; params: { id?: string }; }): Promise<{ db: any; task: any; userId: string } | Response> { // Auth const userId = event.locals.userId ?? null; if (!userId) return new Response('Unauthorized', { status: 401 }); // Parse task ID const idStr = event.params.id || ''; const taskId = parseInt(idStr, 10); if (!Number.isFinite(taskId) || taskId <= 0) return new Response('Bad Request', { status: 400 }); // DB const db = await getDb(); // Load const task = db .prepare( `SELECT id, description, due_date, group_id, created_by, COALESCE(completed, 0) AS completed, completed_at, display_code FROM tasks WHERE id = ?` ) .get(taskId) as any; if (!task) { return new Response(JSON.stringify({ status: 'not_found' }), { status: 404, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } }); } return { db, task, userId }; } export async function loadTaskAndGating(event: { locals: { userId?: string | null }; params: { id?: string }; }): Promise<{ db: any; task: any; userId: string; groupId: string | null } | Response> { const ctx = await _loadTask(event); if (ctx instanceof Response) return ctx; const { db, task, userId } = ctx; // Gating: grupo allowed + miembro activo; si no tiene grupo, debe estar asignado const groupId: string | null = task.group_id ? String(task.group_id) : null; const gating = checkGroupAccess(db, groupId, userId); if (gating instanceof Response) return gating; if (!groupId) { const isAssigned = db .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`) .get(task.id, userId); if (!isAssigned) { return new Response('Forbidden', { status: 403 }); } } return { db, task, userId, groupId }; } /** Convert a DB row to the standard API task shape. */ export function formatTask(row: any): Record { return { id: Number(row.id), description: String(row.description || ''), due_date: row.due_date ? String(row.due_date) : null, display_code: row.display_code != null ? Number(row.display_code) : null, completed: 'completed' in (row || {}) ? Number(row.completed || 0) : undefined, completed_at: 'completed_at' in (row || {}) ? (row.completed_at ? String(row.completed_at) : null) : undefined }; } /** Map a DB row to a task list item (id, desc, date, group, code, assignees). */ export function mapTaskRow(r: any): Record { return { id: Number(r.id), description: String(r.description || ''), due_date: r.due_date ? String(r.due_date) : null, group_id: r.group_id ? String(r.group_id) : null, display_code: r.display_code != null ? Number(r.display_code) : null, assignees: [] as string[] }; } /** * Populate item.assignees by batch-loading task_assignments. * Optionally computes can_unassign for the given userId (pass null to skip). */ export function loadAssignees(db: any, items: any[], userId: string | null): void { if (items.length === 0) return; const ids = items.map((it) => it.id); const placeholders = ids.map(() => '?').join(','); const assignRows = db .prepare( `SELECT task_id, user_id FROM task_assignments WHERE task_id IN (${placeholders}) ORDER BY assigned_at ASC` ) .all(...ids) as any[]; const map = new Map(); for (const row of assignRows) { const tid = Number(row.task_id); const uid = String(row.user_id); if (!map.has(tid)) map.set(tid, []); map.get(tid)!.push(uid); } for (const it of items) { it.assignees = map.get(it.id) || []; if (userId != null) { const personal = it.group_id == null; const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0; const mine = (it.assignees || []).some((uid: string) => uid === userId); (it as any).can_unassign = !(personal && cnt === 1 && mine); } } } /** Build a 200 JSON response { status, task }. */ export function respondTask(status: string, task: Record): Response { return new Response(JSON.stringify({ status, task }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } }); }