major recurring event structure changes

This commit is contained in:
2026-03-29 10:40:06 -04:00
parent 4b4ddf0825
commit 2dffeb1fde
11 changed files with 183 additions and 1097 deletions

View File

@@ -0,0 +1,5 @@
-- Exception instances for recurring events (Google Calendar Series-Instance model)
-- recurring_master_id: links a standalone exception instance back to its series master
-- original_start_at: the virtual occurrence date/time this instance replaced
ALTER TABLE events ADD COLUMN IF NOT EXISTS recurring_master_id INTEGER REFERENCES events(id) ON DELETE CASCADE;
ALTER TABLE events ADD COLUMN IF NOT EXISTS original_start_at TIMESTAMPTZ;

View File

@@ -1,5 +1,5 @@
const express = require('express');
const { query, queryOne, queryResult, exec } = require('../models/db');
const { query, queryOne, queryResult, exec, withTransaction } = require('../models/db');
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
const multer = require('multer');
const { parse: csvParse } = require('csv-parse/sync');
@@ -298,7 +298,7 @@ router.patch('/:id', authMiddleware, async (req, res) => {
if (!event) return res.status(404).json({ error: 'Not found' });
const itm = await isToolManagerFn(req.schema, req.user);
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
let { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
let { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope, occurrenceStart } = req.body;
if (!itm) {
// Regular users editing their own event: force private, validate group membership
isPublic = false;
@@ -319,9 +319,17 @@ router.patch('/:id', authMiddleware, async (req, res) => {
}
}
}
const pad = n => String(n).padStart(2, '0');
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
// Capture group/DM mapping before applyEventUpdate modifies event_user_groups
// Resolve group list for new-event paths (exception instance / future split)
// Pre-fetched before any transaction so it uses the regular pool connection
const resolvedGroupIds = Array.isArray(userGroupIds)
? userGroupIds
: (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
// ── Capture prev group/DM mapping before any mutations ────────────────────
const prevGroupRows = await query(req.schema, `
SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug
JOIN user_groups ug ON ug.id=eug.user_group_id
@@ -329,86 +337,171 @@ router.patch('/:id', authMiddleware, async (req, res) => {
`, [req.params.id]);
const prevGroupIdSet = new Set(prevGroupRows.map(r => r.user_group_id));
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
let targetId = Number(req.params.id); // ID of the event to return in the response
// Recurring future scope — update this and all future occurrences
if (recurringScope === 'future' && event.recurrence_rule) {
const futureEvents = await query(req.schema, `
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
AND start_at >= $3 AND title=$4
`, [req.params.id, event.created_by, event.start_at, event.title]);
for (const fe of futureEvents)
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
}
// Recurring all scope — update every occurrence
if (recurringScope === 'all' && event.recurrence_rule) {
const allEvents = await query(req.schema, `
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL AND title=$3
`, [req.params.id, event.created_by, event.title]);
for (const ae of allEvents)
await applyEventUpdate(req.schema, ae.id, fields, userGroupIds);
}
if (event.recurrence_rule && recurringScope === 'this') {
// ── EXCEPTION INSTANCE ────────────────────────────────────────────────
// 1. Add occurrence date to master's exceptions (hides the virtual occurrence)
// 2. INSERT a new standalone event row for this modified occurrence
const occDate = new Date(occurrenceStart || event.start_at);
const occDateStr = `${occDate.getFullYear()}-${pad(occDate.getMonth()+1)}-${pad(occDate.getDate())}`;
await withTransaction(req.schema, async (client) => {
const rule = { ...event.recurrence_rule };
const existing = Array.isArray(rule.exceptions) ? rule.exceptions : [];
rule.exceptions = [...existing.filter(d => d !== occDateStr), occDateStr];
await client.query('UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), event.id]);
// Clean up availability for users removed from groups
if (Array.isArray(userGroupIds)) {
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
const newGroupSet = new Set(userGroupIds.map(Number));
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
for (const removedGid of removedGroupIds) {
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
for (const uid of removedUids) {
if (newGroupSet.size > 0) {
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
if (stillAssigned) continue;
}
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, uid]);
const r2 = await client.query(`
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,created_by,recurring_master_id,original_start_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id
`, [
title?.trim() || event.title,
eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
startAt || occurrenceStart || event.start_at,
endAt || event.end_at,
allDay !== undefined ? allDay : event.all_day,
location !== undefined ? (location || null) : event.location,
description !== undefined ? (description || null) : event.description,
isPublic !== undefined ? isPublic : event.is_public,
trackAvailability !== undefined ? trackAvailability : event.track_availability,
event.created_by,
event.id,
occurrenceStart || event.start_at,
]);
targetId = r2.rows[0].id;
for (const ugId of resolvedGroupIds)
await client.query('INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [targetId, ugId]);
});
// Notify: "Event updated" for the occurrence date
try {
const exceptionGroupRows = await query(req.schema, `
SELECT ug.dm_group_id FROM event_user_groups eug
JOIN user_groups ug ON ug.id=eug.user_group_id
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
`, [targetId]);
const dateStr = new Date(startAt || occurrenceStart || event.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
const timeChanged = startAt && new Date(startAt).getTime() !== occDate.getTime();
const locationChanged = location !== undefined && (location || null) !== (event.location || null);
if (timeChanged) {
for (const { dm_group_id } of exceptionGroupRows)
await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event updated: "${title?.trim() || event.title}" on ${dateStr}`);
}
if (locationChanged) {
const locMsg = location ? `📍 Location updated to "${location}": "${title?.trim() || event.title}" on ${dateStr}` : `📍 Location removed: "${title?.trim() || event.title}" on ${dateStr}`;
for (const { dm_group_id } of exceptionGroupRows)
await sendEventMessage(req.schema, dm_group_id, req.user.id, locMsg);
}
} catch (e) { console.error('[Schedule] exception notification error:', e.message); }
} else if (event.recurrence_rule && recurringScope === 'future') {
// ── SERIES SPLIT ──────────────────────────────────────────────────────
// Truncate old master to end before this occurrence; INSERT new master starting here
const occDate = new Date(occurrenceStart || event.start_at);
if (occDate <= new Date(event.start_at)) {
// Splitting at/before the first occurrence = effectively "edit all"
await applyEventUpdate(req.schema, event.id, fields, userGroupIds);
targetId = event.id;
} else {
await withTransaction(req.schema, async (client) => {
// 1. Truncate old master
const endBefore = new Date(occDate);
endBefore.setDate(endBefore.getDate() - 1);
const rule = { ...event.recurrence_rule };
rule.ends = 'on';
rule.endDate = `${endBefore.getFullYear()}-${pad(endBefore.getMonth()+1)}-${pad(endBefore.getDate())}`;
delete rule.endCount;
await client.query('UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), event.id]);
// 2. INSERT new master with submitted fields
const newRecRule = recurrenceRule !== undefined ? recurrenceRule : event.recurrence_rule;
const r2 = await client.query(`
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
`, [
title?.trim() || event.title,
eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
startAt || (occurrenceStart || event.start_at),
endAt || event.end_at,
allDay !== undefined ? allDay : event.all_day,
location !== undefined ? (location || null) : event.location,
description !== undefined ? (description || null) : event.description,
isPublic !== undefined ? isPublic : event.is_public,
trackAvailability !== undefined ? trackAvailability : event.track_availability,
newRecRule ? JSON.stringify(newRecRule) : null,
event.created_by,
]);
targetId = r2.rows[0].id;
for (const ugId of resolvedGroupIds)
await client.query('INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [targetId, ugId]);
});
await postEventNotification(req.schema, targetId, req.user.id);
}
}
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
} else {
// ── EDIT ALL (or non-recurring direct edit) ───────────────────────────
await applyEventUpdate(req.schema, event.id, fields, userGroupIds);
targetId = event.id;
// Targeted notifications — only for meaningful changes, only to relevant groups
try {
const finalGroupRows = await query(req.schema, `
SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug
JOIN user_groups ug ON ug.id=eug.user_group_id
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
`, [req.params.id]);
const allDmIds = finalGroupRows.map(r => r.dm_group_id);
const dateStr = new Date(updated.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
// Newly added groups → "Event added" only to those groups
// Clean up availability for users removed from groups
if (Array.isArray(userGroupIds)) {
for (const { user_group_id, dm_group_id } of finalGroupRows) {
if (!prevGroupIdSet.has(user_group_id))
await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event added: "${updated.title}" on ${dateStr}`);
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [event.id])).map(r => r.user_group_id);
const newGroupSet = new Set(userGroupIds.map(Number));
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
for (const removedGid of removedGroupIds) {
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
for (const uid of removedUids) {
if (newGroupSet.size > 0) {
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
if (stillAssigned) continue;
}
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [event.id, uid]);
}
}
}
// Date/time changed → "Event updated" to all groups
const timeChanged = (startAt && new Date(startAt).getTime() !== new Date(event.start_at).getTime())
|| (endAt && new Date(endAt).getTime() !== new Date(event.end_at).getTime())
|| (allDay !== undefined && !!allDay !== !!event.all_day);
if (timeChanged) {
for (const dmId of allDmIds)
await sendEventMessage(req.schema, dmId, req.user.id, `📅 Event updated: "${updated.title}" on ${dateStr}`);
}
// Targeted notifications — only for meaningful changes, only to relevant groups
try {
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [event.id]);
const finalGroupRows = await query(req.schema, `
SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug
JOIN user_groups ug ON ug.id=eug.user_group_id
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
`, [event.id]);
const allDmIds = finalGroupRows.map(r => r.dm_group_id);
const dateStr = new Date(updated.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
// Location changed → "Location updated" to all groups
const locationChanged = location !== undefined && (location || null) !== (event.location || null);
if (locationChanged) {
const locContent = updated.location
? `📍 Location updated to "${updated.location}": "${updated.title}" on ${dateStr}`
: `📍 Location removed: "${updated.title}" on ${dateStr}`;
for (const dmId of allDmIds)
await sendEventMessage(req.schema, dmId, req.user.id, locContent);
// Newly added groups → "Event added" only to those groups
if (Array.isArray(userGroupIds)) {
for (const { user_group_id, dm_group_id } of finalGroupRows) {
if (!prevGroupIdSet.has(user_group_id))
await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event added: "${updated.title}" on ${dateStr}`);
}
}
// Date/time changed → "Event updated" to all groups
const timeChanged = (startAt && new Date(startAt).getTime() !== new Date(event.start_at).getTime())
|| (endAt && new Date(endAt).getTime() !== new Date(event.end_at).getTime())
|| (allDay !== undefined && !!allDay !== !!event.all_day);
if (timeChanged) {
for (const dmId of allDmIds)
await sendEventMessage(req.schema, dmId, req.user.id, `📅 Event updated: "${updated.title}" on ${dateStr}`);
}
// Location changed → "Location updated" to all groups
const locationChanged = location !== undefined && (location || null) !== (event.location || null);
if (locationChanged) {
const locContent = updated.location
? `📍 Location updated to "${updated.location}": "${updated.title}" on ${dateStr}`
: `📍 Location removed: "${updated.title}" on ${dateStr}`;
for (const dmId of allDmIds)
await sendEventMessage(req.schema, dmId, req.user.id, locContent);
}
} catch (e) {
console.error('[Schedule] event update notification error:', e.message);
}
} catch (e) {
console.error('[Schedule] event update notification error:', e.message);
}
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [targetId]);
res.json({ event: await enrichEvent(req.schema, updated) });
} catch (e) { res.status(500).json({ error: e.message }); }
});