feat: añadir ICS por token (grupo/personal/aggregate) horizon 12m
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>webui
							parent
							
								
									ffae527543
								
							
						
					
					
						commit
						b738d8008d
					
				| @ -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}"` }; | ||||||
|  | } | ||||||
| @ -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 | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | }; | ||||||
| @ -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 | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | }; | ||||||
| @ -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 | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | }; | ||||||
					Loading…
					
					
				
		Reference in New Issue