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>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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 = `<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;
|
||||
}
|
||||
Reference in New Issue
Block a user