diff --git a/apps/web/src/lib/server/ics.ts b/apps/web/src/lib/server/ics.ts new file mode 100644 index 0000000..dcff319 --- /dev/null +++ b/apps/web/src/lib/server/ics.ts @@ -0,0 +1,91 @@ +import { sha256Hex } from './crypto'; + +function escapeIcsText(s: string): string { + return String(s) + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/\r/g, '') + .replace(/,/g, '\\,') + .replace(/;/g, '\\;'); +} + +function foldIcsLine(line: string): string { + // 75 octetos; para simplicidad contamos caracteres (UTF-8 simple en nuestro caso) + const max = 75; + if (line.length <= max) return line; + const parts: string[] = []; + let i = 0; + while (i < line.length) { + const chunk = line.slice(i, i + max); + parts.push(i === 0 ? chunk : ' ' + chunk); + i += max; + } + return parts.join('\r\n'); +} + +function padTaskId(id: number, width: number = 4): string { + const s = String(Math.max(0, Math.floor(id))); + if (s.length >= width) return s; + return '0'.repeat(width - s.length) + s; +} + +function ymdToBasic(ymd: string): string { + // Espera YYYY-MM-DD + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); + if (!m) return ''; + return `${m[1]}${m[2]}${m[3]}`; +} + +function addDays(ymd: string, days: number): string { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); + if (!m) return ymd; + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + d.setUTCDate(d.getUTCDate() + days); + const yyyy = String(d.getUTCFullYear()).padStart(4, '0'); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +export type IcsEvent = { + id: number; + description: string; + due_date: string; // YYYY-MM-DD + group_name?: string | null; + prefix?: string; // ej: "T" para [T0123] +}; + +export async function buildIcsCalendar(title: string, events: IcsEvent[]): Promise<{ body: string; etag: string }> { + const lines: string[] = []; + lines.push('BEGIN:VCALENDAR'); + lines.push('VERSION:2.0'); + lines.push('PRODID:-//TaskWhatsApp//Calendar//ES'); + lines.push('CALSCALE:GREGORIAN'); + lines.push('METHOD:PUBLISH'); + lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`); + lines.push('X-WR-TIMEZONE:UTC'); + + for (const ev of events) { + const idPad = padTaskId(ev.id); + const summary = `[${ev.prefix || 'T'}${idPad}] ${ev.description}`; + const dtStart = ymdToBasic(ev.due_date); + const dtEnd = ymdToBasic(addDays(ev.due_date, 1)); + const uid = `task-${ev.id}@tw`; + + lines.push('BEGIN:VEVENT'); + lines.push(foldIcsLine(`UID:${uid}`)); + lines.push(foldIcsLine(`SUMMARY:${escapeIcsText(summary)}`)); + lines.push(`DTSTART;VALUE=DATE:${dtStart}`); + lines.push(`DTEND;VALUE=DATE:${dtEnd}`); + if (ev.group_name) { + lines.push(foldIcsLine(`CATEGORIES:${escapeIcsText(ev.group_name || '')}`)); + } + lines.push('END:VEVENT'); + } + + lines.push('END:VCALENDAR'); + + const body = lines.join('\r\n') + '\r\n'; + const etag = await sha256Hex(body); + return { body, etag: `W/"${etag}"` }; +} diff --git a/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts new file mode 100644 index 0000000..41b1bf9 --- /dev/null +++ b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts @@ -0,0 +1,90 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; +import { sha256Hex } from '$lib/server/crypto'; +import { icsHorizonMonths } from '$lib/server/env'; +import { buildIcsCalendar } from '$lib/server/ics'; + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +function ymdUTC(date: Date): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, '0'); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function addMonthsUTC(date: Date, months: number): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCMonth(d.getUTCMonth() + months); + return d; +} + +export const GET: RequestHandler = async ({ params, request }) => { + const db = await getDb(); + const token = params.token || ''; + if (!token) return new Response('Not Found', { status: 404 }); + + const tokenHash = await sha256Hex(token); + const row = db + .prepare( + `SELECT id, type, user_id, group_id, revoked_at + FROM calendar_tokens + WHERE token_hash = ? + LIMIT 1` + ) + .get(tokenHash) as any; + + if (!row) return new Response('Not Found', { status: 404 }); + if (row.revoked_at) return new Response('Gone', { status: 410 }); + if (String(row.type) !== 'aggregate') return new Response('Not Found', { status: 404 }); + + const today = new Date(); + const startYmd = ymdUTC(today); + const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); + + // Sin responsable en todos los grupos allowed donde el usuario esté activo + const tasks = db + .prepare( + `SELECT t.id, t.description, t.due_date, g.name AS group_name + FROM tasks t + INNER JOIN groups g ON g.id = t.group_id AND COALESCE(g.active,1)=1 + INNER JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1 + INNER JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed' + WHERE COALESCE(t.completed, 0) = 0 + AND t.due_date IS NOT NULL + AND t.due_date >= ? AND t.due_date <= ? + AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id) + ORDER BY t.due_date ASC, t.id ASC` + ) + .all(row.user_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>; + + const events = tasks.map((t) => ({ + id: t.id, + description: t.description, + due_date: t.due_date, + group_name: t.group_name || null, + prefix: 'T' + })); + + const { body, etag } = await buildIcsCalendar('Tareas sin responsable (mis grupos)', events); + + // 304 si ETag coincide + const inm = request.headers.get('if-none-match'); + if (inm && inm === etag) { + db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); + return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); + } + + db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); + + return new Response(body, { + status: 200, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + 'cache-control': 'public, max-age=300', + ETag: etag + } + }); +}; diff --git a/apps/web/src/routes/ics/group/[token].ics/+server.ts b/apps/web/src/routes/ics/group/[token].ics/+server.ts new file mode 100644 index 0000000..3588439 --- /dev/null +++ b/apps/web/src/routes/ics/group/[token].ics/+server.ts @@ -0,0 +1,89 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; +import { sha256Hex } from '$lib/server/crypto'; +import { icsHorizonMonths } from '$lib/server/env'; +import { buildIcsCalendar } from '$lib/server/ics'; + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +function ymdUTC(date: Date): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, '0'); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function addMonthsUTC(date: Date, months: number): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCMonth(d.getUTCMonth() + months); + return d; +} + +export const GET: RequestHandler = async ({ params, request }) => { + const db = await getDb(); + const token = params.token || ''; + if (!token) return new Response('Not Found', { status: 404 }); + + const tokenHash = await sha256Hex(token); + const row = db + .prepare( + `SELECT id, type, user_id, group_id, revoked_at + FROM calendar_tokens + WHERE token_hash = ? + LIMIT 1` + ) + .get(tokenHash) as any; + + if (!row) return new Response('Not Found', { status: 404 }); + if (row.revoked_at) return new Response('Gone', { status: 410 }); + if (String(row.type) !== 'group' || !row.group_id) return new Response('Not Found', { status: 404 }); + + const today = new Date(); + const startYmd = ymdUTC(today); + const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); + + const tasks = db + .prepare( + `SELECT t.id, t.description, t.due_date, g.name AS group_name + FROM tasks t + LEFT JOIN groups g ON g.id = t.group_id + WHERE t.group_id = ? + AND COALESCE(t.completed, 0) = 0 + AND t.due_date IS NOT NULL + AND t.due_date >= ? AND t.due_date <= ? + AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id) + ORDER BY t.due_date ASC, t.id ASC` + ) + .all(row.group_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>; + + const events = tasks.map((t) => ({ + id: t.id, + description: t.description, + due_date: t.due_date, + group_name: t.group_name || null, + prefix: 'T' + })); + + const { body, etag } = await buildIcsCalendar('Tareas sin responsable (grupo)', events); + + // 304 si ETag coincide + const inm = request.headers.get('if-none-match'); + if (inm && inm === etag) { + // Actualizar last_used_at aunque sea 304 + db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); + return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); + } + + db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); + + return new Response(body, { + status: 200, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + 'cache-control': 'public, max-age=300', + ETag: etag + } + }); +}; diff --git a/apps/web/src/routes/ics/personal/[token].ics/+server.ts b/apps/web/src/routes/ics/personal/[token].ics/+server.ts new file mode 100644 index 0000000..0619756 --- /dev/null +++ b/apps/web/src/routes/ics/personal/[token].ics/+server.ts @@ -0,0 +1,91 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; +import { sha256Hex } from '$lib/server/crypto'; +import { icsHorizonMonths } from '$lib/server/env'; +import { buildIcsCalendar } from '$lib/server/ics'; + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +function ymdUTC(date: Date): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, '0'); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function addMonthsUTC(date: Date, months: number): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCMonth(d.getUTCMonth() + months); + return d; +} + +export const GET: RequestHandler = async ({ params, request }) => { + const db = await getDb(); + const token = params.token || ''; + if (!token) return new Response('Not Found', { status: 404 }); + + const tokenHash = await sha256Hex(token); + const row = db + .prepare( + `SELECT id, type, user_id, group_id, revoked_at + FROM calendar_tokens + WHERE token_hash = ? + LIMIT 1` + ) + .get(tokenHash) as any; + + if (!row) return new Response('Not Found', { status: 404 }); + if (row.revoked_at) return new Response('Gone', { status: 410 }); + if (String(row.type) !== 'personal') return new Response('Not Found', { status: 404 }); + + const today = new Date(); + const startYmd = ymdUTC(today); + const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); + + // "Mis tareas": asignadas al usuario; incluir privadas (group_id IS NULL) y de grupos donde esté activo y allowed. + const tasks = db + .prepare( + `SELECT t.id, t.description, t.due_date, g.name AS group_name + FROM tasks t + LEFT JOIN groups g ON g.id = t.group_id + LEFT JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1 + LEFT JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed' + WHERE COALESCE(t.completed, 0) = 0 + AND t.due_date IS NOT NULL + AND t.due_date >= ? AND t.due_date <= ? + AND EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id AND a.user_id = ?) + AND (t.group_id IS NULL OR (gm.user_id IS NOT NULL AND ag.group_id IS NOT NULL AND COALESCE(g.active,1)=1)) + ORDER BY t.due_date ASC, t.id ASC` + ) + .all(row.user_id, startYmd, endYmd, row.user_id) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>; + + const events = tasks.map((t) => ({ + id: t.id, + description: t.description, + due_date: t.due_date, + group_name: t.group_name || null, + prefix: 'T' + })); + + const { body, etag } = await buildIcsCalendar('Mis tareas', events); + + // 304 si ETag coincide + const inm = request.headers.get('if-none-match'); + if (inm && inm === etag) { + db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); + return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); + } + + db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); + + return new Response(body, { + status: 200, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + 'cache-control': 'public, max-age=300', + ETag: etag + } + }); +};