feat: centralizar formateo UTC con util canónica y adaptar web/ICS

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 1 month ago
parent 21164194c0
commit f4f7d95485

@ -2,10 +2,7 @@ import type { Handle } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
import { toIsoSqlUTC } from '$lib/server/datetime';
export const handle: Handle = async ({ event, resolve }) => {
// Bypass de auth en desarrollo (siempre activo en dev para entorno de pruebas)
@ -47,7 +44,7 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.userId = row.user_id;
// Renovar expiración por inactividad y last_seen_at
const newExpIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
const newExpIso = toIsoSqlUTC(new Date(Date.now() + sessionIdleTtlMs));
try {
db.prepare(
`UPDATE web_sessions

@ -1,13 +1,10 @@
import { getDb } from './db';
import { randomTokenBase64Url, sha256Hex } from './crypto';
import { WEB_BASE_URL } from './env';
import { toIsoSqlUTC } from './datetime';
export type CalendarTokenType = 'personal' | 'group' | 'aggregate';
function toIsoSql(d: Date = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function requireBaseUrl(): string {
const base = (WEB_BASE_URL || '').trim();
if (!base) {
@ -73,7 +70,7 @@ export async function createCalendarTokenUrl(
const token = randomTokenBase64Url(32);
const tokenHash = await sha256Hex(token);
const createdAt = toIsoSql(new Date());
const createdAt = toIsoSqlUTC(new Date());
const insert = db.prepare(`
INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, token_plain, created_at)
@ -94,7 +91,7 @@ export async function rotateCalendarTokenUrl(
groupId?: string | null
): Promise<{ url: string; token: string; id: number; revoked: number | null }> {
const db = await getDb();
const now = toIsoSql(new Date());
const now = toIsoSqlUTC(new Date());
const existing = await findActiveToken(type, userId, groupId ?? null);
let revoked: number | null = null;

@ -0,0 +1,28 @@
import { toIsoSqlUTC as coreToIsoSqlUTC } from '../../../../src/utils/datetime';
/**
* Serializa una fecha en UTC al formato SQL ISO "YYYY-MM-DD HH:MM:SS[.SSS]".
* Mantiene exactamente la semántica previa basada en toISOString().replace().
*/
export function toIsoSqlUTC(d: Date = new Date()): string {
return coreToIsoSqlUTC(d);
}
/**
* Devuelve YYYY-MM-DD en UTC (útil para consultas por rango de fecha).
*/
export 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}`;
}
/**
* Suma meses en UTC preservando día (ajustado por desbordes de fin de mes por el propio motor).
*/
export 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;
}

@ -12,9 +12,7 @@ function toIsoYmd(d: Date): string {
function addDays(base: Date, days: number): Date {
return new Date(base.getTime() + days * 24 * 3600 * 1000);
}
function isoSql(dt: Date): string {
return dt.toISOString().replace('T', ' ').replace('Z', '');
}
import { toIsoSqlUTC } from './datetime';
export async function seedDev(db: any, defaultUser: string): Promise<void> {
try { db.exec(`PRAGMA foreign_keys = ON;`); } catch {}
@ -108,11 +106,11 @@ export async function seedDev(db: any, defaultUser: string): Promise<void> {
`);
// Helpers para completadas
const completedRecent = isoSql(addDays(now, 0)); // ahora
const completed2hAgo = isoSql(new Date(Date.now() - 2 * 3600 * 1000));
const completed12hAgo = isoSql(new Date(Date.now() - 12 * 3600 * 1000));
const completed48hAgo = isoSql(new Date(Date.now() - 48 * 3600 * 1000));
const completed72hAgo = isoSql(new Date(Date.now() - 72 * 3600 * 1000));
const completedRecent = toIsoSqlUTC(addDays(now, 0)); // ahora
const completed2hAgo = toIsoSqlUTC(new Date(Date.now() - 2 * 3600 * 1000));
const completed12hAgo = toIsoSqlUTC(new Date(Date.now() - 12 * 3600 * 1000));
const completed48hAgo = toIsoSqlUTC(new Date(Date.now() - 48 * 3600 * 1000));
const completed72hAgo = toIsoSqlUTC(new Date(Date.now() - 72 * 3600 * 1000));
type Spec = {
desc: string;

@ -1,10 +1,6 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {

@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { REACTIONS_ENABLED, REACTIONS_TTL_DAYS, REACTIONS_SCOPE, GROUP_GATING_MODE } from '$lib/server/env';
import { toIsoSqlUTC } from '$lib/server/datetime';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
@ -142,8 +143,8 @@ export const POST: RequestHandler = async (event) => {
if (withinTtl && allowed) {
// Idempotencia 24h por metadata canónica exacta
const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', '');
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').replace('Z', '');
const nowIso = toIsoSqlUTC(new Date());
const cutoff = toIsoSqlUTC(new Date(Date.now() - 24 * 60 * 60 * 1000));
const meta: any = { kind: 'reaction', emoji: '✅', chatId, messageId: String(origin.message_id) };
if (origin && (origin.from_me === 1 || origin.from_me === true)) meta.fromMe = true;

@ -1,10 +1,7 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
import { toIsoSqlUTC } from '$lib/server/datetime';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
@ -79,7 +76,7 @@ export const POST: RequestHandler = async (event) => {
if (!task.completed_at) {
return new Response('Forbidden', { status: 403 });
}
const cutoff = toIsoSql(new Date(Date.now() - UNCOMPLETE_WINDOW_MIN * 60 * 1000));
const cutoff = toIsoSqlUTC(new Date(Date.now() - UNCOMPLETE_WINDOW_MIN * 60 * 1000));
if (String(task.completed_at) < String(cutoff)) {
return new Response('Forbidden', { status: 403 });
}

@ -3,23 +3,7 @@ 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;
}
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
@ -73,11 +57,11 @@ export const GET: RequestHandler = async ({ params, request }) => {
// 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);
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), 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);
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
return new Response(body, {
status: 200,

@ -3,23 +3,7 @@ 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;
}
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
@ -45,7 +29,7 @@ export const GET: RequestHandler = async ({ params, request }) => {
.prepare(`SELECT COALESCE(active,1) as active, COALESCE(archived,0) as archived, COALESCE(is_community,0) as is_community FROM groups WHERE id = ?`)
.get(row.group_id) as any;
if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1 || Number(gRow.is_community || 0) === 1) {
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
return new Response('Gone', { status: 410 });
}
@ -85,7 +69,7 @@ export const GET: RequestHandler = async ({ params, request }) => {
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);
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
return new Response(body, {
status: 200,

@ -3,23 +3,7 @@ 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;
}
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
@ -74,11 +58,11 @@ export const GET: RequestHandler = async ({ params, request }) => {
// 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);
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), 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);
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
return new Response(body, {
status: 200,

@ -4,9 +4,6 @@ import { getDb } from '$lib/server/db';
import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto';
import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function escapeHtml(s: string): string {
return s
@ -154,7 +151,7 @@ export const POST: RequestHandler = async (event) => {
const sessionToken = randomTokenBase64Url(32);
const sessionHash = await sha256Hex(sessionToken);
const sessionId = randomTokenBase64Url(16);
const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
const expiresAtIso = toIsoSqlUTC(new Date(Date.now() + sessionIdleTtlMs));
// Datos de agente e IP (best-effort)
const userAgent = event.request.headers.get('user-agent') || null;

@ -2,12 +2,13 @@ import type { Database } from 'bun:sqlite';
import { mkdirSync, appendFileSync } from 'fs';
import { join } from 'path';
import { migrations, type Migration } from './migrations';
import { toIsoSqlUTC } from '../utils/datetime';
const MIGRATIONS_LOG_LEVEL = (process.env.MIGRATIONS_LOG_LEVEL || '').toLowerCase();
const MIGRATIONS_QUIET = process.env.NODE_ENV === 'test' || MIGRATIONS_LOG_LEVEL === 'silent';
function nowIso(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
return toIsoSqlUTC(new Date());
}
function logEvent(level: 'info' | 'error', event: string, data: any = {}) {

@ -3,6 +3,7 @@ import { ensureUserExists } from '../../../db';
import { isGroupId } from '../../../utils/whatsapp';
import { randomTokenBase64Url, sha256Hex } from '../../../utils/crypto';
import { Metrics } from '../../metrics';
import { toIsoSqlUTC } from '../../../utils/datetime';
type Ctx = {
sender: string;
@ -38,10 +39,9 @@ export async function handleWeb(context: Ctx, deps: { db: Database }): Promise<M
throw new Error('No se pudo asegurar el usuario');
}
const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '');
const now = new Date();
const nowIso = toIso(now);
const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos
const nowIso = toIsoSqlUTC(now);
const expiresIso = toIsoSqlUTC(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos
// Invalidar tokens vigentes (uso único)
deps.db.prepare(`

@ -5,6 +5,7 @@ import { Metrics } from './metrics';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
import { ResponseQueue } from './response-queue';
import { toIsoSqlUTC } from '../utils/datetime';
// In-memory cache for active groups
// const activeGroupsCache = new Map<string, string>(); // groupId -> groupName
@ -689,7 +690,7 @@ export class GroupSyncService {
*/
static upsertMemberSeen(groupId: string, userId: string, nowIso?: string): void {
if (!groupId || !userId) return;
const now = nowIso || new Date().toISOString().replace('T', ' ').replace('Z', '');
const now = nowIso || toIsoSqlUTC(new Date());
this.dbInstance.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 1, ?, ?)
@ -707,7 +708,7 @@ export class GroupSyncService {
if (!groupId || !Array.isArray(snapshot)) {
throw new Error('Invalid arguments for reconcileGroupMembers');
}
const now = nowIso || new Date().toISOString().replace('T', ' ').replace('Z', '');
const now = nowIso || toIsoSqlUTC(new Date());
let added = 0, updated = 0, deactivated = 0;
// Build quick lookup from snapshot

@ -1,9 +1,6 @@
import type { Database } from 'bun:sqlite';
import { db } from '../db';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
import { toIsoSqlUTC } from '../utils/datetime';
export class MaintenanceService {
private static _timer: any = null;
@ -38,7 +35,7 @@ export class MaintenanceService {
static async cleanupInactiveMembersOnce(instance: Database = db, retentionDays: number = this.retentionDays): Promise<number> {
if (retentionDays <= 0) return 0;
const threshold = toIsoSql(new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000));
const threshold = toIsoSqlUTC(new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000));
const res = instance.prepare(`
DELETE FROM group_members
WHERE is_active = 0
@ -63,7 +60,7 @@ export class MaintenanceService {
const real = String(r.user_id);
instance.transaction(() => {
const nowIso = toIsoSql(new Date());
const nowIso = toIsoSqlUTC(new Date());
// Asegurar existencia del usuario real
try {
instance.prepare(`INSERT OR IGNORE INTO users (id, created_at, updated_at) VALUES (?, ?, ?)`)

@ -3,6 +3,7 @@ import { db } from '../db';
import { IdentityService } from './identity';
import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics';
import { toIsoSqlUTC } from '../utils/datetime';
const MAX_FALLBACK_DIGITS = (() => {
const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
@ -60,11 +61,11 @@ export const ResponseQueue = {
_cleanupRunCount: 0,
nowIso(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
return toIsoSqlUTC(new Date());
},
futureIso(ms: number): string {
return new Date(Date.now() + ms).toISOString().replace('T', ' ').replace('Z', '');
return toIsoSqlUTC(new Date(Date.now() + ms));
},
computeDelayMs(attempt: number): number {

@ -0,0 +1,3 @@
export function toIsoSqlUTC(d: Date = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
Loading…
Cancel
Save