You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

265 lines
8.8 KiB
TypeScript

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<any> };
}): 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<string, any> {
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<string, any> {
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<number, string[]>();
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<string, any>): Response {
return new Response(JSON.stringify({ status, task }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}