major recurring event structure changes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
@@ -454,7 +454,14 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
||||
setSaving(true);
|
||||
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 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);
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
finally { setSaving(false); }
|
||||
|
||||
@@ -648,7 +648,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
setSaving(true);
|
||||
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 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);
|
||||
}catch(e){toast(e.message,'error');}finally{setSaving(false);}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user