Files
project.linkedcorp.net/backend/src/modules/meetings/service.js
T
Milton Gonzalez 6066f27ba0 feat: version 4.4.0 -- Calendario de reuniones
Menu Calendario (Mes/Semana/Dia/Agenda) para agendar reuniones con participantes
internos y externos, RSVP, vinculo a proyecto, vencimientos de actividades,
invitaciones iCalendar por correo, recurrencia con excepciones, recordatorios
in-app y aviso de choques. Migracion 034_meetings.sql.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:04:41 -05:00

199 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[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 = `<p><strong>${htmlEscape(row.title)}</strong></p><p>${htmlEscape(row.description).replace(/\n/g, '<br>')}</p><p>${htmlEscape(occurrenceStart || row.starts_at)} – ${htmlEscape(row.ends_at)} (America/Guayaquil)</p><p>${htmlEscape(row.location)}</p>${row.video_url ? `<p><a href="${htmlEscape(row.video_url)}">${htmlEscape(row.video_url)}</a></p>` : ''}`;
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;
}