diff --git a/API.md b/API.md index 5b4ddd2..3359bf8 100644 --- a/API.md +++ b/API.md @@ -31,6 +31,29 @@ catálogos, notas, tareas recurrentes, equipos y SMTP exigen `editVersion` (ente obtenido de la lectura correspondiente como `edit_version`. El `PUT /api/company` exige el mismo campo en multipart. La API responde `428` si falta o es inválido y `409` si otro guardado cambió el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. +Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`). + +## Calendario de reuniones (v4.4.0) + +Todas las rutas requieren JWT y módulo `calendar`. Las fechas son `YYYY-MM-DD HH:mm:ss` en hora +civil `America/Guayaquil` (UTC-5). `GET /api/meetings?from=&to=&page=&pageSize=` devuelve +`{items,total,page,pageSize}` con ocurrencias expandidas (`occurrence_start`, `occurrence_end`); +rango máximo 93 días y hasta 1500 ocurrencias por página (predeterminado: 1500). +`GET /api/meetings/activities?from=&to=` devuelve hasta 1000 vencimientos legibles. + +- `GET /api/meetings/:id`: detalle, participantes con RSVP y excepciones. +- `POST /api/meetings`: `{title,description,startsAt,endsAt,allDay,location,videoUrl,projectId,reminderMinutes,participantUserIds,externalEmails,recurrence}`. +- `PUT /api/meetings/:id`: mismo cuerpo con `editVersion` (aplica a toda la serie). +- `DELETE /api/meetings/:id`: **cancelación lógica** de toda la serie, con `METHOD:CANCEL`. +- `POST /api/meetings/:id/occurrences/:date/cancel`: excluye una ocurrencia; `date` URL encoded. +- `PUT /api/meetings/:id/rsvp`: `{status:"accepted"|"declined"|"tentative"}`; solo participante propio. + +`recurrence` es `null` o `{frequency:"daily"|"weekly"|"monthly",interval,weekdays,until,count}`; +`weekdays` usa 0=lunes a 6=domingo. Se exige exactamente uno de `until` o `count` y máximo +365 ocurrencias. Cuando hay choque de participantes, POST/PUT devuelve +`{requiresConfirmation:true,warnings:[nombres]}` sin guardar; reenviar con +`confirmConflicts:true` confirma. Correo y avisos se despachan en segundo plano; sin SMTP el correo +se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar la migración 034. ## Endpoints principales @@ -66,7 +89,7 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. - `POST /api/users/:id/approve` — `{ roleId?, departmentId?, editVersion }`, solo sobre usuarios `pending_approval`; también rechaza cambios concurrentes con `409`. - `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes) - `GET /api/users/:id/modules` · `PUT /api/users/:id/modules` `{ modules: [...] }` — solo Superadministrador. - Módulos: `projects`, `recurring_tasks`, `notes`, `vault`, `devices`, `organization`. No aplica a + Módulos: `projects`, `calendar`, `recurring_tasks`, `notes`, `vault`, `devices`, `organization`. No aplica a un superadmin (`400`). `GET /api/auth/me` y el login devuelven `modules` (efectivos del usuario). Las rutas de un módulo deshabilitado responden `403`; la búsqueda global devuelve vacío. diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 9b2b7af..7273b54 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -10,6 +10,11 @@ - Charset: `utf8mb4` - Collation: `utf8mb4_unicode_ci` +Migración `034_meetings.sql` (v4.4.0): pendiente de aplicar tanto en desarrollo como en +producción. Crea tablas e índices de reuniones y amplía el tipo de entidad de notificaciones. +`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`. +La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia. + ## Fuente de verdad - La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`). @@ -34,6 +39,10 @@ | `activity_dependencies` | dependencias simples entre actividades ("depende de") | | `tasks` | checklist liviano dentro de una actividad ("Tareas" en la UI; renombrada desde `subtasks` en v1.8.0) | | `recurring_tasks` | recordatorios periódicos (ej. renovación de licencias), menú propio "Tareas recurrentes" | +| `meetings` | serie de reunión en hora civil America/Guayaquil; UID iCalendar, secuencia, versión y cancelación lógica | +| `meeting_participants` / `meeting_external_participants` | usuarios con RSVP y correos externos sin respuesta | +| `meeting_exceptions` | ocurrencias canceladas, sin materializar el resto de la serie | +| `meeting_reminders` | clave `(meeting_id, occurrence_start)` para deduplicar avisos | | `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) | | `attachments` | archivos adjuntos a proyecto o actividad | | `notifications` | notificaciones in-app por usuario | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbcaec..0267bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog - project.linkedcorp.net +## [4.4.0] - 2026-10-27 +### Added +- Menú Calendario con vistas Mes, Semana, Día y Agenda; reuniones con participantes internos/externos, + RSVP interno (avisa al organizador), vínculo opcional a proyecto y vencimientos de actividades diferenciados. +- Invitaciones iCalendar por correo, notificaciones in-app, recurrencia con excepciones, recordatorios + por minuto y advertencia de choques. Requiere migración `034_meetings.sql`. +- Acceso al módulo `calendar` configurable por usuario y edición con versión optimista. + + ## [4.3.0] - 2026-10-26 ### Added - Sesiones activas: cada inicio de sesión queda registrado (`user_sessions`, migración 033) con diff --git a/ESTADO.md b/ESTADO.md index 874001c..9b6eaeb 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,9 +1,22 @@ # ESTADO — project.linkedcorp.net -Ultima actualizacion: 2026-10-23 (v3.0.1 en desarrollo, sin desplegar) +Ultima actualizacion: 2026-10-27 (v4.4.0 en desarrollo, sin desplegar) Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo + autorregistro/aprobacion/SMTP. +## Calendario de reuniones (v4.4.0, sin desplegar) + +- Menú y API de reuniones, recurrencia, RSVP, vencimientos de actividades, correo iCalendar y + recordatorios in-app implementados en código. `DELETE` marca cancelada toda la serie. +- Migración `034_meetings.sql` **aplicada en dev** (2026-10-27), **no en producción**. Aplicarla + antes de arrancar el backend 4.4.0 en prod. El backend de dev en :4019 sigue con el código + anterior hasta reiniciarlo. +- Probado en dev con instancia temporal (4099/5197, datos de prueba ya borrados): alta, choque con + confirmación, validaciones, expansión semanal, rango máx. 93 días, edición con versión (409), + cancelar ocurrencia (EXDATE/RECURRENCE-ID), cancelar serie (METHOD:CANCEL), RSVP solo + participante (aviso in-app al organizador), recordatorio in-app a tiempo, UI escritorio/móvil sin + errores de consola. Pendiente: probar envío real con SMTP (dev no tiene SMTP → log). + ## Auditoría de seguridad y calidad (v3.0.0, sin desplegar) - Se corrigieron controles de roles, alcance de búsqueda y tareas recurrentes, bootstrap diff --git a/SPEC.md b/SPEC.md index 18341ff..8a65805 100644 --- a/SPEC.md +++ b/SPEC.md @@ -66,7 +66,17 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa promedio. Lista de actividades vencidas y de hoy del usuario. ### 7. Calendario -- Vista mensual de actividades por fecha límite (incluye hitos marcados). +- Menú propio de reuniones (v4.4.0): Mes/Semana/Día/Agenda, creación desde hueco o botón, + detalle de lectura y edición de toda la serie. Título, descripción, inicio/fin, todo el día, + ubicación, enlace de videollamada, proyecto opcional, recordatorio y recurrencia. +- Participantes internos (RSVP aceptar/rechazar/quizás) y hasta 50 correos externos sin RSVP. + Solo organizador, admin y superadmin editan/cancelan. Lectura: organizador, participante o + acceso al proyecto asociado. El módulo `calendar` se puede deshabilitar por usuario. +- Recurrencia diaria, semanal por días o mensual por día del mes; hasta 365 ocurrencias y fin + obligatorio por fecha o cantidad. Cancelación puntual con EXDATE; DELETE cancela lógicamente + toda la serie. Invitaciones/cancelaciones iCalendar y recordatorios in-app. +- Los vencimientos de actividades se muestran en solo lectura y se pueden ocultar. La pestaña + Calendario del proyecto muestra sus actividades y reuniones vinculadas. ### 8. Gantt - Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa diff --git a/VERSION b/VERSION index 8089590..fdc6698 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.3.0 +4.4.0 diff --git a/backend/database/migrations/034_meetings.sql b/backend/database/migrations/034_meetings.sql new file mode 100644 index 0000000..b37dce5 --- /dev/null +++ b/backend/database/migrations/034_meetings.sql @@ -0,0 +1,65 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS meetings ( + id INT AUTO_INCREMENT PRIMARY KEY, + uid CHAR(36) NOT NULL UNIQUE, + title VARCHAR(160) NOT NULL, + description TEXT NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME NOT NULL, + all_day TINYINT(1) NOT NULL DEFAULT 0, + location VARCHAR(255) NULL, + video_url VARCHAR(2048) NULL, + project_id INT NULL, + organizer_id INT NOT NULL, + reminder_minutes SMALLINT UNSIGNED NULL, + recurrence_json TEXT NULL, + sequence_number INT UNSIGNED NOT NULL DEFAULT 0, + edit_version INT UNSIGNED NOT NULL DEFAULT 1, + cancelled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_meetings_range (starts_at, ends_at, cancelled_at), + INDEX idx_meetings_project (project_id, starts_at), + INDEX idx_meetings_organizer (organizer_id, starts_at), + CONSTRAINT fk_meetings_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL, + CONSTRAINT fk_meetings_organizer FOREIGN KEY (organizer_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS meeting_participants ( + meeting_id INT NOT NULL, + user_id INT NOT NULL, + rsvp ENUM('pending','accepted','declined','tentative') NOT NULL DEFAULT 'pending', + responded_at DATETIME NULL, + PRIMARY KEY (meeting_id, user_id), + INDEX idx_meeting_participants_user (user_id, meeting_id), + CONSTRAINT fk_mp_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE, + CONSTRAINT fk_mp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS meeting_external_participants ( + meeting_id INT NOT NULL, + email VARCHAR(160) NOT NULL, + PRIMARY KEY (meeting_id, email), + CONSTRAINT fk_mep_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS meeting_exceptions ( + meeting_id INT NOT NULL, + occurrence_start DATETIME NOT NULL, + created_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (meeting_id, occurrence_start), + CONSTRAINT fk_mex_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE, + CONSTRAINT fk_mex_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS meeting_reminders ( + meeting_id INT NOT NULL, + occurrence_start DATETIME NOT NULL, + sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (meeting_id, occurrence_start), + CONSTRAINT fk_mr_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE notifications MODIFY COLUMN entity_type ENUM('project','activity','meeting') NULL; diff --git a/backend/package.json b/backend/package.json index 68c8cc2..b2ae758 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.3.0", + "version": "4.4.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 4315bbc..66d70c6 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -27,6 +27,7 @@ import vaultRoutes from './modules/vault/routes.js'; import devicesRoutes from './modules/devices/routes.js'; import inventoryIngestRoutes from './modules/devices/ingest.js'; import aiRoutes from './modules/ai/routes.js'; +import meetingsRoutes from './modules/meetings/routes.js'; export const app = express(); @@ -68,6 +69,7 @@ app.use('/api/notes', notesRoutes); app.use('/api/vault', vaultRoutes); app.use('/api/devices', devicesRoutes); app.use('/api/ai', aiRoutes); +app.use('/api/meetings', meetingsRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/jobs/meetingReminders.js b/backend/src/jobs/meetingReminders.js new file mode 100644 index 0000000..d9f2945 --- /dev/null +++ b/backend/src/jobs/meetingReminders.js @@ -0,0 +1,12 @@ +import { sendDueReminders } from '../modules/meetings/service.js'; +export function startMeetingReminderJob() { + let running = false; + const run = async () => { + if (running) return; + running = true; + try { await sendDueReminders(); } catch (err) { console.error('[meetings] recordatorios:', err.message); } + finally { running = false; } + }; + setInterval(run, 60_000); + void run(); +} diff --git a/backend/src/middleware/modules.js b/backend/src/middleware/modules.js index bca3b19..55689e1 100644 --- a/backend/src/middleware/modules.js +++ b/backend/src/middleware/modules.js @@ -3,7 +3,7 @@ import { fail } from '../utils/http.js'; // Modulos que el superadmin puede habilitar/deshabilitar por usuario. Panel y Configuracion no // estan aqui: el Panel lo ve todo usuario y Configuracion sigue siendo solo de superadmin. -export const MODULES = ['projects', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization']; +export const MODULES = ['projects', 'calendar', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization']; // Sin fila en user_modules = habilitado. El superadmin siempre tiene todo. export async function loadModules(userId, roleCode) { diff --git a/backend/src/modules/meetings/controller.js b/backend/src/modules/meetings/controller.js new file mode 100644 index 0000000..4a4732a --- /dev/null +++ b/backend/src/modules/meetings/controller.js @@ -0,0 +1,10 @@ +import { asyncHandler, ok } from '../../utils/http.js'; +import * as service from './service.js'; +export const getMeetings = asyncHandler(async (req,res) => ok(res, await service.list(req.query, req.currentUser))); +export const getActivities = asyncHandler(async (req,res) => ok(res, await service.activities(req.query, req.currentUser))); +export const getMeeting = asyncHandler(async (req,res) => ok(res, await service.detail(Number(req.params.id), req.currentUser))); +export const postMeeting = asyncHandler(async (req,res) => { const result = await service.create(req.body || {}, req.currentUser); ok(res, result, result.requiresConfirmation ? 200 : 201); }); +export const putMeeting = asyncHandler(async (req,res) => ok(res, await service.update(Number(req.params.id), req.body || {}, req.currentUser))); +export const deleteMeeting = asyncHandler(async (req,res) => ok(res, await service.cancel(Number(req.params.id), req.currentUser))); +export const postCancelOccurrence = asyncHandler(async (req,res) => ok(res, await service.cancelOccurrence(Number(req.params.id), req.params.date, req.currentUser))); +export const putRsvp = asyncHandler(async (req,res) => ok(res, await service.rsvp(Number(req.params.id), req.body?.status, req.currentUser))); diff --git a/backend/src/modules/meetings/ical.js b/backend/src/modules/meetings/ical.js new file mode 100644 index 0000000..15fc518 --- /dev/null +++ b/backend/src/modules/meetings/ical.js @@ -0,0 +1,40 @@ +const escapeText = (value = '') => String(value).replace(/\\/g, '\\\\').replace(/\r\n|\r|\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;'); +const safeEmail = (value) => String(value || '').replace(/[^A-Za-z0-9._%+@-]/g, ''); +const stamp = (s) => String(s).replace(/[-:]/g, '').replace(' ', 'T') + 'Z'; +// Hora civil de Guayaquil -> UTC para iCalendar. Ecuador continental no usa DST. +const utc = (s) => stamp(new Date(`${s.replace(' ', 'T')}-05:00`).toISOString().slice(0, 19).replace('T', ' ')); +function fold(line) { + const chunks = []; let part = ''; + for (const char of line) { + if (Buffer.byteLength(part + char, 'utf8') > 75) { chunks.push(part); part = ' ' + char; } else part += char; + } + chunks.push(part); + return chunks.join('\r\n'); +} +export function buildIcs(meeting, method = 'REQUEST', occurrenceStart = null) { + const duration = new Date(`${meeting.ends_at.replace(' ', 'T')}-05:00`) - new Date(`${meeting.starts_at.replace(' ', 'T')}-05:00`); + const start = occurrenceStart || meeting.starts_at; + const end = occurrenceStart ? new Date(new Date(`${start.replace(' ', 'T')}-05:00`).getTime() + duration - 5 * 3600000).toISOString().slice(0, 19).replace('T', ' ') : meeting.ends_at; + const allDay = !!meeting.all_day; + const dateValue = (s) => s.slice(0, 10).replace(/-/g, ''); + const exclusiveEnd = (s) => new Date(new Date(`${s.slice(0,10)}T00:00:00Z`).getTime() + 86400000).toISOString().slice(0,10).replace(/-/g,''); + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//LinkedCorp//Project Calendar//ES', 'CALSCALE:GREGORIAN', 'X-WR-TIMEZONE:America/Guayaquil', `METHOD:${method}`, 'BEGIN:VEVENT', `UID:${meeting.uid}@project.linkedcorp.net`, `SEQUENCE:${meeting.sequence_number}`, `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')}`, + allDay ? `DTSTART;VALUE=DATE:${dateValue(start)}` : `DTSTART:${utc(start)}`, + allDay ? `DTEND;VALUE=DATE:${exclusiveEnd(end)}` : `DTEND:${utc(end)}`, + `SUMMARY:${escapeText(meeting.title)}`, `DESCRIPTION:${escapeText(meeting.description)}`, `LOCATION:${escapeText(meeting.location)}`, `ORGANIZER:mailto:${safeEmail(meeting.organizer_email)}`, `STATUS:${method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`]; + for (const participant of meeting.participants || []) lines.push(`ATTENDEE;RSVP=TRUE;PARTSTAT=${({accepted:'ACCEPTED',declined:'DECLINED',tentative:'TENTATIVE'})[participant.rsvp] || 'NEEDS-ACTION'}:mailto:${safeEmail(participant.email)}`); + for (const email of meeting.external_emails || []) lines.push(`ATTENDEE;RSVP=FALSE:mailto:${safeEmail(email)}`); + if (occurrenceStart) lines.push(allDay ? `RECURRENCE-ID;VALUE=DATE:${dateValue(start)}` : `RECURRENCE-ID:${utc(start)}`); + if (meeting.video_url) lines.push(`URL:${meeting.video_url}`); + const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null; + if (rule && !occurrenceStart) { + const parts = [`FREQ=${rule.frequency.toUpperCase()}`, `INTERVAL=${rule.interval}`]; + if (rule.frequency === 'weekly') parts.push(`BYDAY=${rule.weekdays.map((i) => ['MO','TU','WE','TH','FR','SA','SU'][i]).join(',')}`); + if (rule.count) parts.push(`COUNT=${rule.count}`); + if (rule.until) parts.push(`UNTIL=${allDay ? dateValue(rule.until) : utc(`${rule.until} 23:59:59`)}`); + lines.push(`RRULE:${parts.join(';')}`); + } + if (meeting.exceptions?.length && !occurrenceStart) lines.push(allDay ? `EXDATE;VALUE=DATE:${meeting.exceptions.map(dateValue).join(',')}` : `EXDATE:${meeting.exceptions.map(utc).join(',')}`); + lines.push('END:VEVENT', 'END:VCALENDAR'); + return lines.map(fold).join('\r\n') + '\r\n'; +} diff --git a/backend/src/modules/meetings/recurrence.js b/backend/src/modules/meetings/recurrence.js new file mode 100644 index 0000000..310bae9 --- /dev/null +++ b/backend/src/modules/meetings/recurrence.js @@ -0,0 +1,77 @@ +import { HttpError } from '../../utils/http.js'; + +// DATETIME representa hora civil de America/Guayaquil (UTC-5 sin DST). Date en UTC se usa +// solo para aritmética de calendario; no convierte estos valores a la zona del servidor. +const parse = (s) => new Date(`${s.replace(' ', 'T')}Z`); +const format = (d) => d.toISOString().slice(0, 19).replace('T', ' '); +export const addMinutes = (s, n) => format(new Date(parse(s).getTime() + n * 60000)); +export const durationMinutes = (a, b) => (parse(b) - parse(a)) / 60000; +export const guayaquilNow = () => format(new Date(Date.now() - 5 * 3600000)); + +function* occurrenceStarts(start, rule, max = 365) { + const first = parse(start); + if (!rule) { yield start; return; } + let emitted = 0; + const accept = (d) => d >= first && d.getUTCFullYear() <= 9999 && (!rule.until || format(d).slice(0, 10) <= rule.until); + if (rule.frequency === 'daily') { + for (let n = 0; emitted < max && (!rule.count || emitted < rule.count); n++) { + const d = new Date(first.getTime() + n * rule.interval * 86400000); + if (!accept(d)) break; + emitted++; yield format(d); + } + } else if (rule.frequency === 'weekly') { + const monday = new Date(first.getTime() - ((first.getUTCDay() + 6) % 7) * 86400000); + for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) { + const week = new Date(monday.getTime() + block * rule.interval * 7 * 86400000); + if (week.getUTCFullYear() > 9999 || (rule.until && format(week).slice(0, 10) > rule.until)) break; + for (const weekday of rule.weekdays) { + const d = new Date(week.getTime() + weekday * 86400000); + if (d < first) continue; + if (!accept(d)) return; + emitted++; yield format(d); + if (emitted >= max || (rule.count && emitted >= rule.count)) return; + } + } + } else if (rule.frequency === 'monthly') { + const time = [first.getUTCHours(), first.getUTCMinutes(), first.getUTCSeconds()]; + for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) { + const totalMonths = first.getUTCFullYear() * 12 + first.getUTCMonth() + block * rule.interval; + const year = Math.floor(totalMonths / 12), month = totalMonths % 12; + if (year > 9999 || (rule.until && `${year}-${String(month+1).padStart(2,'0')}-01` > rule.until)) break; + const d = new Date(Date.UTC(year, month, first.getUTCDate(), ...time)); + if (d.getUTCMonth() !== month) continue; // p. ej. no hay 31 de febrero. + if (!accept(d)) break; + emitted++; yield format(d); + } + } +} + +export function validateRecurrence(raw, start) { + if (!raw) return null; + const { frequency, interval = 1, weekdays, until, count } = raw; + if (!['daily', 'weekly', 'monthly'].includes(frequency) || !Number.isInteger(interval) || interval < 1 || interval > 365) throw new HttpError(400, 'Recurrencia invalida'); + if ((!!until) === (!!count)) throw new HttpError(400, 'Indica fecha final o numero de ocurrencias'); + if (count && (!Number.isInteger(count) || count < 1 || count > 365)) throw new HttpError(400, 'Maximo 365 ocurrencias'); + if (until && (typeof until !== 'string' || !/^\d{4}-\d\d-\d\d$/.test(until) || Number.isNaN(Date.parse(`${until}T00:00:00Z`)) || new Date(`${until}T00:00:00Z`).toISOString().slice(0,10) !== until || until < start.slice(0, 10))) throw new HttpError(400, 'Fecha final de recurrencia invalida'); + if (frequency === 'weekly' && (!Array.isArray(weekdays) || !weekdays.length || weekdays.some((v) => !Number.isInteger(v) || v < 0 || v > 6))) throw new HttpError(400, 'Dias semanales invalidos'); + if (frequency === 'weekly' && !weekdays.includes((parse(start).getUTCDay() + 6) % 7)) throw new HttpError(400, 'El primer dia debe estar incluido'); + const rule = { frequency, interval, weekdays: frequency === 'weekly' ? [...new Set(weekdays)].sort() : [], until: until || null, count: count || null }; + const occurrences = [...occurrenceStarts(start, rule, 366)]; + if (occurrences.length > 365) throw new HttpError(400, 'Maximo 365 ocurrencias'); + if (count && occurrences.length < count) throw new HttpError(400, 'La serie supera el rango de fechas admitido'); + return rule; +} + +export function expand(meeting, from, to) { + if (meeting.cancelled_at) return []; + const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null; + const duration = durationMinutes(meeting.starts_at, meeting.ends_at); + const exclusions = new Set(meeting.exceptions || []); + const out = []; + for (const start of occurrenceStarts(meeting.starts_at, rule)) { + if (start >= to) break; + const end = addMinutes(start, duration); + if (end > from && !exclusions.has(start)) out.push({ ...meeting, occurrence_start: start, occurrence_end: end }); + } + return out; +} diff --git a/backend/src/modules/meetings/repository.js b/backend/src/modules/meetings/repository.js new file mode 100644 index 0000000..5464d2b --- /dev/null +++ b/backend/src/modules/meetings/repository.js @@ -0,0 +1,133 @@ +import { pool } from '../../config/db.js'; +import * as notificationsRepo from '../notifications/repository.js'; + +const base = `SELECT m.*, u.name AS organizer_name, u.email AS organizer_email, p.name AS project_name FROM meetings m + JOIN users u ON u.id=m.organizer_id LEFT JOIN projects p ON p.id=m.project_id`; + +export async function findById(id) { + const [[row]] = await pool.query(`${base} WHERE m.id=?`, [id]); + if (!row) return null; + const [users] = await pool.query(`SELECT mp.user_id, mp.rsvp, u.name, u.email FROM meeting_participants mp JOIN users u ON u.id=mp.user_id WHERE mp.meeting_id=?`, [id]); + const [externals] = await pool.query('SELECT email FROM meeting_external_participants WHERE meeting_id=?', [id]); + const [exceptions] = await pool.query('SELECT occurrence_start FROM meeting_exceptions WHERE meeting_id=?', [id]); + return { ...row, participants: users, external_emails: externals.map((r) => r.email), exceptions: exceptions.map((r) => r.occurrence_start) }; +} + +export async function findCandidates(from, to) { + const [rows] = await pool.query(`${base} WHERE m.cancelled_at IS NULL AND m.starts_at < ? AND (m.recurrence_json IS NOT NULL OR m.ends_at > ?) ORDER BY m.starts_at`, [to, from]); + return rows; +} + +export async function findParticipantConflicts(from, to, ids, excludeId = null) { + const [rows] = await pool.query(`SELECT m.*, mp.user_id AS conflict_user_id, u.name AS conflict_name + FROM meeting_participants mp JOIN meetings m ON m.id=mp.meeting_id JOIN users u ON u.id=mp.user_id + WHERE mp.user_id IN (${ids.map(() => '?').join(',')}) AND m.cancelled_at IS NULL AND m.starts_at + AND (m.recurrence_json IS NOT NULL OR m.ends_at>?) AND (? IS NULL OR m.id<>?) + ORDER BY m.starts_at`, [...ids, to, from, excludeId, excludeId]); + const grouped = new Map(); + for (const row of rows) { + if (!grouped.has(row.id)) grouped.set(row.id, { ...row, conflict_names: [] }); + grouped.get(row.id).conflict_names.push(row.conflict_name); + } + return [...grouped.values()]; +} + +export async function findVisibleCandidates(from, to, user) { + const roles = ['superadmin', 'admin', 'it_manager']; + const access = roles.includes(user.roleCode) ? '' : `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?) OR (m.project_id IS NOT NULL AND p.deleted_at IS NULL AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=m.project_id AND pm.user_id=?)))`; + // Roles globales ven proyectos; las reuniones sin proyecto siguen privadas. + const globalAccess = !user.modules.includes('projects') ? `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?))` : roles.includes(user.roleCode) ? `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?) OR (m.project_id IS NOT NULL AND p.deleted_at IS NULL))` : access; + const [rows] = await pool.query(`${base} WHERE m.cancelled_at IS NULL AND m.starts_at < ? AND (m.recurrence_json IS NOT NULL OR m.ends_at > ?) ${globalAccess} ORDER BY m.starts_at`, [to, from, user.id, user.id, ...(roles.includes(user.roleCode) || !user.modules.includes('projects') ? [] : [user.id])]); + return rows; +} + +export async function exceptionsFor(ids) { + if (!ids.length) return new Map(); + const [rows] = await pool.query(`SELECT meeting_id, occurrence_start FROM meeting_exceptions WHERE meeting_id IN (${ids.map(() => '?').join(',')})`, ids); + const map = new Map(); + for (const row of rows) { if (!map.has(row.meeting_id)) map.set(row.meeting_id, []); map.get(row.meeting_id).push(row.occurrence_start); } + return map; +} + +export async function activeUsers(ids) { + if (!ids.length) return []; + const [rows] = await pool.query(`SELECT id,name,email FROM users WHERE id IN (${ids.map(() => '?').join(',')}) AND is_active=1 AND status='active'`, ids); + return rows; +} + +async function replaceParticipants(conn, id, data) { + if (data.participantUserIds.length) await conn.query(`DELETE FROM meeting_participants WHERE meeting_id=? AND user_id NOT IN (${data.participantUserIds.map(() => '?').join(',')})`, [id, ...data.participantUserIds]); + else await conn.query('DELETE FROM meeting_participants WHERE meeting_id=?', [id]); + await conn.query('DELETE FROM meeting_external_participants WHERE meeting_id=?', [id]); + for (const userId of data.participantUserIds) await conn.query('INSERT IGNORE INTO meeting_participants (meeting_id,user_id) VALUES (?,?)', [id, userId]); + for (const email of data.externalEmails) await conn.query('INSERT INTO meeting_external_participants (meeting_id,email) VALUES (?,?)', [id, email]); +} + +export async function create(data, organizerId, uid) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query(`INSERT INTO meetings (uid,title,description,starts_at,ends_at,all_day,location,video_url,project_id,organizer_id,reminder_minutes,recurrence_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, [uid, data.title, data.description, data.startsAt, data.endsAt, data.allDay, data.location, data.videoUrl, data.projectId, organizerId, data.reminderMinutes, data.recurrence ? JSON.stringify(data.recurrence) : null]); + await replaceParticipants(conn, result.insertId, data); + await conn.commit(); + return findById(result.insertId); + } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } +} + +export async function update(id, data, version, resetExceptions) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query(`UPDATE meetings SET title=?,description=?,starts_at=?,ends_at=?,all_day=?,location=?,video_url=?,project_id=?,reminder_minutes=?,recurrence_json=?,sequence_number=sequence_number+1,edit_version=edit_version+1 + WHERE id=? AND edit_version=? AND cancelled_at IS NULL`, [data.title, data.description, data.startsAt, data.endsAt, data.allDay, data.location, data.videoUrl, data.projectId, data.reminderMinutes, data.recurrence ? JSON.stringify(data.recurrence) : null, id, version]); + if (!result.affectedRows) { await conn.rollback(); return null; } + await replaceParticipants(conn, id, data); + if (resetExceptions) await conn.query('DELETE FROM meeting_exceptions WHERE meeting_id=?', [id]); + await conn.commit(); + return findById(id); + } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } +} + +export async function cancel(id) { + const [result] = await pool.query('UPDATE meetings SET cancelled_at=NOW(),sequence_number=sequence_number+1,edit_version=edit_version+1 WHERE id=? AND cancelled_at IS NULL', [id]); + return result.affectedRows ? findById(id) : null; +} +export async function cancelOccurrence(id, date, userId) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [[row]] = await conn.query('SELECT cancelled_at FROM meetings WHERE id=? FOR UPDATE', [id]); + if (!row || row.cancelled_at) { await conn.rollback(); return null; } + const [insert] = await conn.query('INSERT IGNORE INTO meeting_exceptions (meeting_id,occurrence_start,created_by) VALUES (?,?,?)', [id, date, userId]); + if (!insert.affectedRows) { await conn.rollback(); return null; } + await conn.query('UPDATE meetings SET sequence_number=sequence_number+1,edit_version=edit_version+1 WHERE id=?', [id]); + await conn.commit(); + return findById(id); + } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } +} +export async function rsvp(id, userId, status) { + const [result] = await pool.query('UPDATE meeting_participants SET rsvp=?,responded_at=NOW() WHERE meeting_id=? AND user_id=?', [status, id, userId]); + return !!result.affectedRows; +} +export async function activityDue(from, to, user) { + const admin = ['superadmin', 'admin', 'it_manager'].includes(user.roleCode); + const [rows] = await pool.query(`SELECT a.id,a.name,a.due_date,a.project_id,p.name AS project_name FROM activities a JOIN projects p ON p.id=a.project_id + WHERE a.due_date>=? AND a.due_date AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND (a.responsible_user_id=? ${admin ? 'OR 1=1' : 'OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=p.id AND pm.user_id=?)'}) + ORDER BY a.due_date,a.id LIMIT 1000`, admin ? [from, to, user.id] : [from, to, user.id, user.id]); + return rows; +} +export async function sendReminderOnce(id, start, recipientIds, title) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [[meeting]] = await conn.query('SELECT cancelled_at FROM meetings WHERE id=? FOR UPDATE', [id]); + const [[exception]] = await conn.query('SELECT 1 AS excluded FROM meeting_exceptions WHERE meeting_id=? AND occurrence_start=?', [id, start]); + if (!meeting || meeting.cancelled_at || exception) { await conn.rollback(); return false; } + const [r] = await conn.query('INSERT IGNORE INTO meeting_reminders (meeting_id,occurrence_start) VALUES (?,?)', [id, start]); + if (!r.affectedRows) { await conn.rollback(); return false; } + for (const userId of recipientIds) await notificationsRepo.create({ userId, type: 'meeting_reminder', message: title.slice(0,255), entityType: 'meeting', entityId: id }, conn); + await conn.commit(); + return true; + } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } +} diff --git a/backend/src/modules/meetings/routes.js b/backend/src/modules/meetings/routes.js new file mode 100644 index 0000000..8549ec0 --- /dev/null +++ b/backend/src/modules/meetings/routes.js @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { requireModule } from '../../middleware/modules.js'; +import { getMeetings, getActivities, getMeeting, postMeeting, putMeeting, deleteMeeting, postCancelOccurrence, putRsvp } from './controller.js'; +const router = Router(); +router.use(requireAuth, requireModule('calendar')); +router.get('/', getMeetings); +router.get('/activities', getActivities); +router.post('/', postMeeting); +router.get('/:id', getMeeting); +router.put('/:id', putMeeting); +router.delete('/:id', deleteMeeting); +router.post('/:id/occurrences/:date/cancel', postCancelOccurrence); +router.put('/:id/rsvp', putRsvp); +export default router; diff --git a/backend/src/modules/meetings/service.js b/backend/src/modules/meetings/service.js new file mode 100644 index 0000000..5f180dc --- /dev/null +++ b/backend/src/modules/meetings/service.js @@ -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 = `
${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; +} diff --git a/backend/src/modules/notifications/repository.js b/backend/src/modules/notifications/repository.js index 9850dcf..b63177c 100644 --- a/backend/src/modules/notifications/repository.js +++ b/backend/src/modules/notifications/repository.js @@ -14,8 +14,8 @@ export async function countUnread(userId) { return rows[0].c; } -export async function create({ userId, type, message, entityType, entityId }) { - const [result] = await pool.query( +export async function create({ userId, type, message, entityType, entityId }, connection = pool) { + const [result] = await connection.query( 'INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, ?, ?)', [userId, type, message, entityType || null, entityId || null] ); diff --git a/backend/src/server.js b/backend/src/server.js index 2bdb355..12d5b91 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,9 +1,11 @@ import { app } from './app.js'; import { env } from './config/env.js'; import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js'; +import { startMeetingReminderJob } from './jobs/meetingReminders.js'; app.listen(env.port, () => { console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`); }); startRecurringTaskReminderJob(); +startMeetingReminderJob(); diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js index 1c8c211..7445930 100644 --- a/backend/src/utils/mailer.js +++ b/backend/src/utils/mailer.js @@ -3,12 +3,12 @@ import { getSmtpConfigInternal } from '../modules/settings/service.js'; // Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de // fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando. -export async function sendMail({ to, subject, html }) { +export async function sendMail({ to, subject, html, icalEvent }) { try { const config = await getSmtpConfigInternal(); if (!config) { - console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`); + console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}${icalEvent?.content ? `\n[icalEvent]\n${icalEvent.content}` : ''}`); return { sent: false, dev: true }; } @@ -22,7 +22,7 @@ export async function sendMail({ to, subject, html }) { // Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama — // critico para forgot-password, que siempre debe responder generico sin importar el resultado. - await transporter.sendMail({ from: config.from, to, subject, html }); + await transporter.sendMail({ from: config.from, to, subject, html, ...(icalEvent ? { icalEvent } : {}) }); return { sent: true, dev: false }; } catch (err) { console.error('[mail:error]', err.message); diff --git a/frontend/package.json b/frontend/package.json index bc22067..55b3221 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.3.0", + "version": "4.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 5f04315..0919201 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -24,6 +24,7 @@ import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx'; import EquiposPage from '../pages/Equipos/EquiposPage.jsx'; import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx'; import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx'; +import CalendarioPage from '../pages/Calendario/CalendarioPage.jsx'; // Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una // seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes. @@ -40,6 +41,7 @@ const router = createBrowserRouter(createRoutesFromElements({t('calendar_empty')}
}{t('calendar_start')}: {readable(selected.starts_at, lang, !!selected.all_day)}
{t('calendar_end')}: {readable(selected.ends_at, lang, !!selected.all_day)}
{t('calendar_all_day')}
} + {selected.description &&{selected.description}
}{selected.location &&{t('calendar_location')}: {selected.location}
} + {selected.video_url && } + {selected.project_name &&{t('calendar_project')}: {selected.project_name}
} + {!!selected.reminder_minutes &&{t('calendar_reminder')}: {selected.reminder_minutes === 1440 ? t('calendar_one_day') : `${selected.reminder_minutes} ${t('calendar_minutes')}`}
} + {selected.recurrence_json &&{t('calendar_recurrence')}: {t(`calendar_${JSON.parse(selected.recurrence_json).frequency}`)}
} +{t('calendar_organizer')}: {selected.organizer_name}
+