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