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

File diff suppressed because it is too large Load Diff

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 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 { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
const multer = require('multer'); const multer = require('multer');
const { parse: csvParse } = require('csv-parse/sync'); 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' }); if (!event) return res.status(404).json({ error: 'Not found' });
const itm = await isToolManagerFn(req.schema, req.user); const itm = await isToolManagerFn(req.schema, req.user);
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' }); 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) { if (!itm) {
// Regular users editing their own event: force private, validate group membership // Regular users editing their own event: force private, validate group membership
isPublic = false; 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 }; 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, ` const prevGroupRows = await query(req.schema, `
SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug 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 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]); `, [req.params.id]);
const prevGroupIdSet = new Set(prevGroupRows.map(r => r.user_group_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 (event.recurrence_rule && recurringScope === 'this') {
if (recurringScope === 'future' && event.recurrence_rule) { // ── EXCEPTION INSTANCE ────────────────────────────────────────────────
const futureEvents = await query(req.schema, ` // 1. Add occurrence date to master's exceptions (hides the virtual occurrence)
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL // 2. INSERT a new standalone event row for this modified occurrence
AND start_at >= $3 AND title=$4 const occDate = new Date(occurrenceStart || event.start_at);
`, [req.params.id, event.created_by, event.start_at, event.title]); const occDateStr = `${occDate.getFullYear()}-${pad(occDate.getMonth()+1)}-${pad(occDate.getDate())}`;
for (const fe of futureEvents) await withTransaction(req.schema, async (client) => {
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds); const rule = { ...event.recurrence_rule };
} const existing = Array.isArray(rule.exceptions) ? rule.exceptions : [];
// Recurring all scope — update every occurrence rule.exceptions = [...existing.filter(d => d !== occDateStr), occDateStr];
if (recurringScope === 'all' && event.recurrence_rule) { await client.query('UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), event.id]);
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);
}
// Clean up availability for users removed from groups const r2 = await client.query(`
if (Array.isArray(userGroupIds)) { 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)
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); VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id
const newGroupSet = new Set(userGroupIds.map(Number)); `, [
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id)); title?.trim() || event.title,
for (const removedGid of removedGroupIds) { eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id); startAt || occurrenceStart || event.start_at,
for (const uid of removedUids) { endAt || event.end_at,
if (newGroupSet.size > 0) { allDay !== undefined ? allDay : event.all_day,
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(','); location !== undefined ? (location || null) : event.location,
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]); description !== undefined ? (description || null) : event.description,
if (stillAssigned) continue; isPublic !== undefined ? isPublic : event.is_public,
} trackAvailability !== undefined ? trackAvailability : event.track_availability,
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, uid]); 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 // Clean up availability for users removed from 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
if (Array.isArray(userGroupIds)) { if (Array.isArray(userGroupIds)) {
for (const { user_group_id, dm_group_id } of finalGroupRows) { 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);
if (!prevGroupIdSet.has(user_group_id)) const newGroupSet = new Set(userGroupIds.map(Number));
await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event added: "${updated.title}" on ${dateStr}`); 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 // Targeted notifications — only for meaningful changes, only to relevant groups
const timeChanged = (startAt && new Date(startAt).getTime() !== new Date(event.start_at).getTime()) try {
|| (endAt && new Date(endAt).getTime() !== new Date(event.end_at).getTime()) const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [event.id]);
|| (allDay !== undefined && !!allDay !== !!event.all_day); const finalGroupRows = await query(req.schema, `
if (timeChanged) { SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug
for (const dmId of allDmIds) JOIN user_groups ug ON ug.id=eug.user_group_id
await sendEventMessage(req.schema, dmId, req.user.id, `📅 Event updated: "${updated.title}" on ${dateStr}`); 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 // Newly added groups → "Event added" only to those groups
const locationChanged = location !== undefined && (location || null) !== (event.location || null); if (Array.isArray(userGroupIds)) {
if (locationChanged) { for (const { user_group_id, dm_group_id } of finalGroupRows) {
const locContent = updated.location if (!prevGroupIdSet.has(user_group_id))
? `📍 Location updated to "${updated.location}": "${updated.title}" on ${dateStr}` await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event added: "${updated.title}" on ${dateStr}`);
: `📍 Location removed: "${updated.title}" on ${dateStr}`; }
for (const dmId of allDmIds) }
await sendEventMessage(req.schema, dmId, req.user.id, locContent); // 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) }); res.json({ event: await enrichEvent(req.schema, updated) });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });

View File

@@ -454,7 +454,14 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
setSaving(true); setSaving(true);
try { try {
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:isToolManager?!isPrivate:false, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null }; const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:isToolManager?!isPrivate:false, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body); let r;
if (event) {
const updateBody = { ...body, recurringScope: scope };
if (event._virtual) updateBody.occurrenceStart = event.start_at;
r = await api.updateEvent(event.id, updateBody);
} else {
r = await api.createEvent(body);
}
onSave(r.event); onSave(r.event);
} catch(e) { toast(e.message,'error'); } } catch(e) { toast(e.message,'error'); }
finally { setSaving(false); } finally { setSaving(false); }

View File

@@ -648,7 +648,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
setSaving(true); setSaving(true);
try{ try{
const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st),endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et),allDay,location:loc,description:desc,isPublic:isToolManager?pub:false,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null}; const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st),endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et),allDay,location:loc,description:desc,isPublic:isToolManager?pub:false,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):await api.createEvent(body); let r;
if(event){
const updateBody={...body,recurringScope:scope};
if(event._virtual) updateBody.occurrenceStart=event.start_at;
r=await api.updateEvent(event.id,updateBody);
} else {
r=await api.createEvent(body);
}
onSave(r.event); onSave(r.event);
}catch(e){toast(e.message,'error');}finally{setSaving(false);} }catch(e){toast(e.message,'error');}finally{setSaving(false);}
}; };