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
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' }
|
|
});
|
|
}
|