import { randomUUID } from 'node:crypto'; import * as repo from './repository.js'; import * as projectsRepo from '../projects/repository.js'; import { canReadProject } from '../projects/service.js'; import { createNotification } from '../notifications/service.js'; import { sendMail } from '../../utils/mailer.js'; import { logAudit } from '../../utils/audit.js'; import { HttpError } from '../../utils/http.js'; import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { expand, validateRecurrence, durationMinutes, guayaquilNow, addMinutes } from './recurrence.js'; import { buildIcs } from './ical.js'; const DATE = /^\d{4}-\d\d-\d\d \d\d:\d\d(?::\d\d)?$/; const EMAIL = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i; const ROLES = ['admin', 'superadmin']; const htmlEscape = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[c]); const normalizeDate = (s) => { if (typeof s !== 'string' || !DATE.test(s) || Number(s.slice(0, 4)) < 1000) return null; const short = `${s.slice(0, 16)}:00`; const date = new Date(`${short.replace(' ', 'T')}Z`); return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 19).replace('T', ' ') === short ? short : null; }; function range(from, to) { const a = normalizeDate(from), b = normalizeDate(to); if (!a || !b || a >= b || durationMinutes(a, b) > 93 * 1440) throw new HttpError(400, 'Rango invalido (maximo 93 dias)'); return [a, b]; } async function normalize(raw, actor) { const title = String(raw.title || '').trim(); const description = String(raw.description || '').trim(); const location = String(raw.location || '').trim(); let videoUrl = String(raw.videoUrl || '').trim(); const startsAt = normalizeDate(raw.startsAt), endsAt = normalizeDate(raw.endsAt); if (!title || title.length > 160 || /[\r\n\u0000]/.test(title) || description.length > 5000 || location.length > 255 || videoUrl.length > 2048) throw new HttpError(400, 'Titulo o texto invalido'); if (!startsAt || !endsAt || endsAt <= startsAt || durationMinutes(startsAt, endsAt) > 30 * 1440) throw new HttpError(400, 'Horario invalido'); if (videoUrl && (!/^https?:\/\//i.test(videoUrl) || /[\u0000-\u001f\u007f]/.test(videoUrl))) throw new HttpError(400, 'Enlace http/https invalido'); try { if (videoUrl) videoUrl = new URL(videoUrl).href; } catch { throw new HttpError(400, 'Enlace invalido'); } const reminderMinutes = raw.reminderMinutes === null || raw.reminderMinutes === '' || raw.reminderMinutes === undefined ? null : Number(raw.reminderMinutes); if (reminderMinutes !== null && ![5,10,15,30,60,1440].includes(reminderMinutes)) throw new HttpError(400, 'Recordatorio invalido'); const projectId = raw.projectId ? Number(raw.projectId) : null; if (projectId !== null && (!Number.isSafeInteger(projectId) || projectId <= 0)) throw new HttpError(400, 'Proyecto invalido'); if (projectId) { if (!actor.modules.includes('projects')) throw new HttpError(403, 'Sin acceso al modulo Proyectos'); const project = await projectsRepo.findById(projectId); if (!project || !(await canReadProject(actor, project))) throw new HttpError(404, 'Proyecto no encontrado'); } if (!Array.isArray(raw.participantUserIds || []) || !Array.isArray(raw.externalEmails || [])) throw new HttpError(400, 'Participantes invalidos'); const ids = [...new Set((raw.participantUserIds || []).map(Number))]; const emails = [...new Set((raw.externalEmails || []).map((e) => String(e).trim().toLowerCase()))]; if (ids.length + emails.length > 50 || ids.some((id) => !Number.isSafeInteger(id) || id <= 0) || emails.some((e) => e.length > 160 || !EMAIL.test(e))) throw new HttpError(400, 'Participantes invalidos (maximo 50)'); const users = await repo.activeUsers(ids); if (users.length !== ids.length) throw new HttpError(400, 'Selecciona solo usuarios activos'); return { title, description, location, videoUrl: videoUrl || null, startsAt, endsAt, allDay: !!raw.allDay, reminderMinutes, projectId, participantUserIds: ids.filter((id) => id !== actor.id), externalEmails: emails, recurrence: validateRecurrence(raw.recurrence, startsAt) }; } async function visible(meeting, user) { if (meeting.organizer_id === user.id || meeting.participants.some((p) => p.user_id === user.id)) return true; if (!meeting.project_id || !user.modules.includes('projects')) return false; const project = await projectsRepo.findById(meeting.project_id); return !!project && canReadProject(user, project); } export async function detail(id, user) { if (!Number.isSafeInteger(id) || id <= 0) throw new HttpError(404, 'Reunion no encontrada'); const row = await repo.findById(id); if (!row || !(await visible(row, user))) throw new HttpError(404, 'Reunion no encontrada'); return row; } function canManage(row, user) { return row.organizer_id === user.id || ROLES.includes(user.roleCode); } async function notify(row, action, extraIds = []) { const recipients = [...new Set([...row.participants.map((p) => p.user_id), ...extraIds])]; await Promise.allSettled(recipients.map((userId) => createNotification({ userId, type: `meeting_${action}`, message: row.title.slice(0, 255), entityType: 'meeting', entityId: row.id }))); } async function mail(row, method, recipientsOverride = null, occurrenceStart = null) { const recipients = recipientsOverride || [...new Set([row.organizer_email, ...row.participants.map((p) => p.email), ...row.external_emails].filter(Boolean))]; const html = `
${htmlEscape(row.title)}
${htmlEscape(row.description).replace(/\n/g, '
')}
${htmlEscape(occurrenceStart || row.starts_at)} – ${htmlEscape(row.ends_at)} (America/Guayaquil)
${htmlEscape(row.location)}
${row.video_url ? `` : ''}`; await Promise.allSettled(recipients.map((to) => sendMail({ to, subject: `${method === 'CANCEL' ? 'Reunión cancelada / Meeting cancelled' : 'Invitación / Invitation'}: ${row.title}`, html, icalEvent: { method, content: buildIcs(row, method, occurrenceStart), filename: 'reunion.ics' } }))); } function dispatch(row, action, method = 'REQUEST', extraIds = []) { setImmediate(() => { void Promise.allSettled([notify(row, action, extraIds), mail(row, method)]); }); } function deferMail(row, method, recipients = null, occurrenceStart = null) { setImmediate(() => { void mail(row, method, recipients, occurrenceStart).catch((err) => console.error('[meetings] correo:', err.message)); }); } function overlaps(a, b) { let i = 0, j = 0; while (i < a.length && j < b.length) { if (a[i].occurrence_start < b[j].occurrence_end && a[i].occurrence_end > b[j].occurrence_start) return true; if (a[i].occurrence_end <= b[j].occurrence_end) i++; else j++; } return false; } async function conflictWarnings(data, excludeId) { const ids = data.participantUserIds; if (!ids.length) return []; const proposed = expand({ starts_at: data.startsAt, ends_at: data.endsAt, recurrence_json: data.recurrence ? JSON.stringify(data.recurrence) : null, exceptions: [] }, data.startsAt, '9999-12-31 23:59:59'); const end = proposed.at(-1)?.occurrence_end || data.endsAt; const rows = await repo.findParticipantConflicts(data.startsAt, end, ids, excludeId || null); const exceptionMap = await repo.exceptionsFor(rows.map((r) => r.id)); const conflicts = new Set(); for (const row of rows) { const existing = expand({ ...row, exceptions: exceptionMap.get(row.id) || [] }, data.startsAt, end); if (overlaps(proposed, existing)) row.conflict_names.forEach((name) => conflicts.add(name)); } return [...conflicts]; } export async function list(query, user) { const [from, to] = range(query.from, query.to); const page = Number(query.page || 1), pageSize = Number(query.pageSize || 1500); if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1500 || !Number.isSafeInteger((page-1)*pageSize)) throw new HttpError(400, 'Paginacion invalida'); const rows = await repo.findVisibleCandidates(from, to, user); const exceptions = await repo.exceptionsFor(rows.map((r) => r.id)); const all = rows.flatMap((r) => expand({ ...r, exceptions: exceptions.get(r.id) || [] }, from, to)) .sort((a, b) => a.occurrence_start.localeCompare(b.occurrence_start) || a.id - b.id); return { items: all.slice((page-1)*pageSize, page*pageSize), total: all.length, page, pageSize }; } export async function activities(query, user) { const [from, to] = range(query.from, query.to); if (!user.modules.includes('projects')) return []; return repo.activityDue(from.slice(0, 10), to.slice(0, 10), user); } export async function create(raw, user) { const data = await normalize(raw, user); const warnings = await conflictWarnings(data); if (warnings.length && raw.confirmConflicts !== true) return { requiresConfirmation: true, warnings }; const row = await repo.create(data, user.id, randomUUID()); await logAudit({ userId: user.id, action: 'create', entityType: 'meeting', entityId: row.id }); dispatch(row, 'created'); return { meeting: row, warnings }; } export async function update(id, raw, user) { const before = await detail(id, user); if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para editar'); if (before.cancelled_at) throw new HttpError(409, 'Reunion cancelada'); requireEditVersion(raw.editVersion); const data = await normalize(raw, user); const warnings = await conflictWarnings(data, id); if (warnings.length && raw.confirmConflicts !== true) return { requiresConfirmation: true, warnings }; const resetExceptions = before.starts_at !== data.startsAt || before.ends_at !== data.endsAt || before.recurrence_json !== (data.recurrence ? JSON.stringify(data.recurrence) : null); const row = requireUpdated(await repo.update(id, data, raw.editVersion, resetExceptions)); await logAudit({ userId: user.id, action: 'update', entityType: 'meeting', entityId: id }); const currentEmails = new Set([...row.participants.map((p) => p.email), ...row.external_emails]); const removedEmails = [...new Set([...before.participants.map((p) => p.email), ...before.external_emails])].filter((email) => !currentEmails.has(email)); const currentIds = new Set(row.participants.map((p) => p.user_id)); const removedIds = before.participants.filter((p) => !currentIds.has(p.user_id)).map((p) => p.user_id); if (removedEmails.length) deferMail(row, 'CANCEL', removedEmails); if (before.starts_at !== row.starts_at || before.ends_at !== row.ends_at || before.location !== row.location || before.video_url !== row.video_url || before.recurrence_json !== row.recurrence_json || removedIds.length || before.participants.length !== row.participants.length) dispatch(row, 'updated', 'REQUEST', removedIds); else deferMail(row, 'REQUEST'); return { meeting: row, warnings }; } export async function cancel(id, user) { const before = await detail(id, user); if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para cancelar'); if (before.cancelled_at) throw new HttpError(409, 'Reunion ya cancelada'); const row = requireUpdated(await repo.cancel(id)); await logAudit({ userId: user.id, action: 'cancel', entityType: 'meeting', entityId: id }); dispatch(row, 'cancelled', 'CANCEL'); return { cancelled: true }; } export async function cancelOccurrence(id, date, user) { const before = await detail(id, user); if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para cancelar'); if (!before.recurrence_json) throw new HttpError(400, 'Esta reunion no es recurrente'); const start = normalizeDate(date); if (!start || !expand(before, start, addMinutes(start, 1)).some((r) => r.occurrence_start === start)) throw new HttpError(400, 'Ocurrencia invalida'); const row = requireUpdated(await repo.cancelOccurrence(id, start, user.id)); await logAudit({ userId: user.id, action: 'cancel_occurrence', entityType: 'meeting', entityId: id, detail: { start } }); dispatch(row, 'occurrence_cancelled'); deferMail(row, 'CANCEL', null, start); return row; } export async function rsvp(id, status, user) { if (!['accepted','declined','tentative'].includes(status)) throw new HttpError(400, 'Respuesta invalida'); const row = await detail(id, user); if (row.cancelled_at) throw new HttpError(409, 'Reunion cancelada'); if (!(await repo.rsvp(id, user.id, status))) throw new HttpError(403, 'Solo el participante puede responder'); await logAudit({ userId: user.id, action: 'rsvp', entityType: 'meeting', entityId: id, detail: { status } }); if (row.organizer_id !== user.id) { setImmediate(() => { void createNotification({ userId: row.organizer_id, type: `meeting_rsvp_${status}`, message: `${user.name || ''} · ${row.title}`.slice(0, 255), entityType: 'meeting', entityId: id }).catch(() => {}); }); } return repo.findById(id); } export async function sendDueReminders() { const now = guayaquilNow(), end = addMinutes(now, 1441); const rows = await repo.findCandidates(now, end); const map = await repo.exceptionsFor(rows.map((r) => r.id)); let count = 0; for (const row of rows) { if (!row.reminder_minutes) continue; const occurrences = expand({ ...row, exceptions: map.get(row.id) || [] }, now, end); for (const occurrence of occurrences) { const due = addMinutes(occurrence.occurrence_start, -row.reminder_minutes); if (due > now || due < addMinutes(now, -2)) continue; const full = await repo.findById(row.id); const recipientIds = [...new Set([row.organizer_id, ...full.participants.map((p) => p.user_id)])]; if (await repo.sendReminderOnce(row.id, occurrence.occurrence_start, recipientIds, row.title)) count++; } } return count; }