export async function setEventTime(eventId, startHour, startMin, endHour, endMin, month, day, year = 2026) { const start = new Date(`${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}T${String(startHour).padStart(2,'0')}:${String(startMin).padStart(2,'0')}:00-05:00`) const end = new Date(`${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}T${String(endHour).padStart(2,'0')}:${String(endMin).padStart(2,'0')}:00-05:00`) console.log(start, end) await this.sql` UPDATE events.events SET time_start = ${start}, time_end = ${end} WHERE id = ${eventId} ` } export async function changeTimes(_userId) { await this.setEventTime(1, 18, 30, 19, 30, 4, 27) await this.setEventTime(2, 19, 0, 20, 0, 4, 21) } export async function getEventsByNetwork(networkId) { if (!Number.isInteger(networkId) || networkId <= 0) { throw new global.ServerError(400, "Invalid network ID!"); } // Returns each event with its linked calendar IDs, file attachments, and recurrence rule return await this.sql` SELECT e.*, COALESCE(jsonb_agg(DISTINCT ec.calendar_id) FILTER (WHERE ec.calendar_id IS NOT NULL), '[]') AS calendars, COALESCE(jsonb_agg(DISTINCT jsonb_build_object('id', f.id, 'name', f.name, 'type', f.type, 'original_name', f.original_name, 'size_bytes', f.size_bytes)) FILTER (WHERE f.id IS NOT NULL), '[]') AS attachments, row_to_json(er.*) AS recurrence FROM events.events e LEFT JOIN events.event_calendars ec ON ec.event_id = e.id LEFT JOIN public.file_to_event fe ON fe.event_id = e.id LEFT JOIN public.files f ON f.id = fe.file_id LEFT JOIN events.event_recurrence er ON er.id = e.recurrence_id WHERE e.network_id = ${networkId} GROUP BY e.id, er.id `; } export async function getCalendarsByNetwork(networkId) { if (!Number.isInteger(networkId) || networkId <= 0) { throw new global.ServerError(400, "Invalid network ID!"); } return await this.sql` SELECT c.* FROM events.calendars c WHERE c.network_id = ${networkId} `; } export async function addCalendar(userId, newCalendar, networkId) { const { name, description, color } = newCalendar; try { let insertedCalendar; await this.sql.begin(async tx => { const [calendar] = await tx` INSERT INTO events.calendars ${tx({ network_id: networkId, owner_id: userId, name: name, description: description ?? null, color: color })} RETURNING * `; insertedCalendar = calendar; }); return { status: 200, calendar: insertedCalendar }; } catch (e) { console.error(e); return { status: 400, error: "Failed to add calendar" }; } } export async function editCalendar(userId, id, updatedCalendar, networkId) { const { name, description, color } = updatedCalendar; try { const [calendar] = await this.sql` UPDATE events.calendars SET name = ${name}, description = ${description ?? null}, color = ${color}, updated_at = NOW() WHERE id = ${id} AND network_id = ${networkId} AND owner_id = ${userId} RETURNING * `; if (!calendar) return { status: 403, error: "Calendar not found or not authorized" }; return { status: 200, calendar: calendar }; } catch (e) { console.error(e); return { status: 400, error: "Failed to edit calendar" }; } } export async function deleteCalendar(userId, id, networkId) { try { const [calendar] = await this.sql` DELETE FROM events.calendars WHERE id = ${id} AND network_id = ${networkId} AND owner_id = ${userId} RETURNING * `; if (!calendar) return { status: 403, error: "Calendar not found or not authorized" }; return { status: 200 }; } catch (e) { console.error(e); return { status: 400, error: "Failed to delete calendar" }; } } export async function addEvent(userId, newEvent, networkId) { const { title, description, location, time_start, time_end, all_day, calendars, recurrence } = newEvent; try { let insertedEvent; await this.sql.begin(async tx => { let recurrenceId = null; if (recurrence) { const [rule] = await tx` INSERT INTO events.event_recurrence ${tx({ frequency: recurrence.frequency, interval: recurrence.interval ?? 1, days_of_week: recurrence.days_of_week ?? null, end_date: recurrence.end_date ?? null, count: recurrence.count ?? null })} RETURNING id `; recurrenceId = rule.id; } const [event] = await tx` INSERT INTO events.events ${tx({ network_id: networkId, creator_id: userId, title, description: description ?? null, location: location ?? null, time_start, time_end, all_day, recurrence_id: recurrenceId })} RETURNING * `; insertedEvent = event; await Promise.all(calendars.map(calId => tx` INSERT INTO events.event_calendars ${tx({ event_id: event.id, calendar_id: calId })} `)); }); return { status: 200, event: { ...insertedEvent, calendars, recurrence: recurrence ?? null } }; } catch (e) { console.error(e); return { status: e.status, error: e.message }; } } export async function addAttachments(userId, eventId, files) { const [event] = await this.sql` SELECT id FROM events.events WHERE id = ${eventId} AND creator_id = ${userId} `; if (!event) throw new global.ServerError(403, "Not authorized to add attachments to this event"); try { let insertedFiles = []; // Will rollback changes if it encounters an error await this.sql.begin(async tx => { insertedFiles = await Promise.all( files.map(file => tx` INSERT INTO public.files ${tx({ name: file.filename, type: file.mimetype, original_name: file.originalname, size_bytes: file.size })} RETURNING * `.then(([f]) => f) ) ); await Promise.all( insertedFiles.map(file => tx` INSERT INTO public.file_to_event ${tx({ file_id: file.id, event_id: eventId })} `) ); }) console.log("addAttachments:", insertedFiles) return { status: 200, files: insertedFiles }; } catch (e) { console.error(e); throw new global.ServerError(400, "Failed to add attachments!"); } } export async function deleteAttachment(userId, fileId, eventId) { const [event] = await this.sql` SELECT id FROM events.events WHERE id = ${eventId} AND creator_id = ${userId} `; if (!event) throw new global.ServerError(403, "Not authorized to modify this event"); const [file] = await this.sql` SELECT f.* FROM public.files f JOIN public.file_to_event fte ON fte.file_id = f.id WHERE f.id = ${fileId} AND fte.event_id = ${eventId} `; if (!file) throw new global.ServerError(404, "Attachment not found on this event"); try { let deletedFile = null; await this.sql.begin(async tx => { await tx`DELETE FROM public.file_to_event WHERE file_id = ${fileId} AND event_id = ${eventId}`; // Only remove the file record (and trigger disk unlink) if no other event still references it const [stillReferenced] = await tx`SELECT 1 FROM public.file_to_event WHERE file_id = ${fileId} LIMIT 1`; if (!stillReferenced) { await tx`DELETE FROM public.files WHERE id = ${fileId}`; deletedFile = file; } }); return { status: 200, file: deletedFile }; } catch (e) { console.error(e); throw new global.ServerError(400, "Failed to delete attachment"); } } export async function editEvent(userId, id, updatedEvent, networkId) { const { title, description, location, time_start, time_end, all_day, calendars, recurrence, scope, exception_date } = updatedEvent; try { let editedEvent; await this.sql.begin(async tx => { const [current] = await tx` SELECT * FROM events.events WHERE id = ${id} AND network_id = ${networkId} AND creator_id = ${userId} `; if (!current) return; const isRecurring = !!current.recurrence_id; const isOverride = !!current.recurrence_parent_id; if (!isRecurring || isOverride || !scope || scope === 'all') { // Standard update: non-recurring, override row, or editing the whole series let newRecurrenceId = current.recurrence_id; if (isRecurring && recurrence) { // Preserve end_date if not explicitly provided — it may have been set by a prior 'this and future' split const [existingRule] = await tx`SELECT end_date FROM events.event_recurrence WHERE id = ${current.recurrence_id}`; const preservedEndDate = recurrence.end_date ?? existingRule?.end_date ?? null; await tx` UPDATE events.event_recurrence SET frequency = ${recurrence.frequency}, interval = ${recurrence.interval ?? 1}, days_of_week = ${recurrence.days_of_week ?? null}, end_date = ${preservedEndDate}, count = ${recurrence.count ?? null}, updated_at = NOW() WHERE id = ${current.recurrence_id} `; } else if (!isRecurring && recurrence) { // Adding recurrence to a previously non-recurring event const [newRule] = await tx` INSERT INTO events.event_recurrence ${tx({ frequency: recurrence.frequency, interval: recurrence.interval ?? 1, days_of_week: recurrence.days_of_week ?? null, end_date: recurrence.end_date ?? null, count: recurrence.count ?? null })} RETURNING * `; newRecurrenceId = newRule.id; } else if (isRecurring && !recurrence) { // Removing recurrence from a recurring event newRecurrenceId = null; } const [event] = await tx` UPDATE events.events SET title = ${title}, description = ${description ?? null}, location = ${location ?? null}, time_start = ${time_start}, time_end = ${time_end}, all_day = ${all_day}, recurrence_id = ${newRecurrenceId}, updated_at = NOW() WHERE id = ${id} AND network_id = ${networkId} AND creator_id = ${userId} RETURNING * `; if (!event) return; editedEvent = event; // Delete the orphaned rule after the FK is cleared if (isRecurring && !recurrence) { await tx`DELETE FROM events.event_recurrence WHERE id = ${current.recurrence_id}`; } await tx`DELETE FROM events.event_calendars WHERE event_id = ${id}`; if (calendars.length > 0) { await Promise.all(calendars.map(calId => tx` INSERT INTO events.event_calendars ${tx({ event_id: id, calendar_id: calId })} `)); } } else if (scope === 'single') { // Insert an override row for just this one occurrence const [override] = await tx` INSERT INTO events.events ${tx({ network_id: networkId, creator_id: userId, title, description: description ?? null, location: location ?? null, time_start, time_end, all_day, recurrence_parent_id: id, recurrence_exception_date: exception_date })} RETURNING * `; editedEvent = override; // Inherit the parent template's attachments so the override starts with the same files await tx` INSERT INTO public.file_to_event (file_id, event_id) SELECT file_id, ${override.id} FROM public.file_to_event WHERE event_id = ${id} ON CONFLICT DO NOTHING `; if (calendars.length > 0) { await Promise.all(calendars.map(calId => tx` INSERT INTO events.event_calendars ${tx({ event_id: override.id, calendar_id: calId })} `)); } } else if (scope === 'future') { // Capture current end_date before modifying — marks the boundary of A's existing series const [existingRule] = current.recurrence_id ? await tx`SELECT * FROM events.event_recurrence WHERE id = ${current.recurrence_id}` : [null]; const oldEndDate = existingRule?.end_date ?? null; // Cap the current series at exception_date, then create a new series from there await tx` UPDATE events.event_recurrence SET end_date = ${exception_date}, updated_at = NOW() WHERE id = ${current.recurrence_id} `; // Remove intermediate descendants in [exception_date, oldEndDate); independent splits at/beyond oldEndDate are preserved const descendants = await tx` SELECT id, recurrence_id FROM events.events WHERE parent_template_id = ${id} AND time_start >= ${exception_date} ${oldEndDate ? tx`AND time_start < ${oldEndDate}` : tx``} `; if (descendants.length > 0) { const descIds = descendants.map(d => d.id); const descRuleIds = descendants.map(d => d.recurrence_id).filter(Boolean); await tx`DELETE FROM events.events WHERE id = ANY(${descIds})`; if (descRuleIds.length > 0) { await tx`DELETE FROM events.event_recurrence WHERE id = ANY(${descRuleIds})`; } } let newRecurrenceId = null; if (recurrence) { // If A was already capped, the new series must not extend past that boundary const newEndDate = oldEndDate ? (!recurrence.end_date || new Date(recurrence.end_date) >= new Date(oldEndDate) ? oldEndDate : recurrence.end_date) : (recurrence.end_date ?? null); const [rule] = await tx` INSERT INTO events.event_recurrence ${tx({ frequency: recurrence.frequency, interval: recurrence.interval ?? 1, days_of_week: recurrence.days_of_week ?? null, end_date: newEndDate, count: recurrence.count ?? null })} RETURNING id `; newRecurrenceId = rule.id; } else if (current.recurrence_id) { // No new recurrence rule — copy A's rule with oldEndDate so the new series isn't infinite if (existingRule) { const [rule] = await tx` INSERT INTO events.event_recurrence ${tx({ frequency: existingRule.frequency, interval: existingRule.interval, days_of_week: existingRule.days_of_week, end_date: oldEndDate, count: existingRule.count })} RETURNING id `; newRecurrenceId = rule.id; } } const [newEvent] = await tx` INSERT INTO events.events ${tx({ network_id: networkId, creator_id: userId, title, description: description ?? null, location: location ?? null, time_start, time_end, all_day, recurrence_id: newRecurrenceId, parent_template_id: id })} RETURNING * `; editedEvent = newEvent; // Inherit the original template's attachments so the new series starts with the same files await tx` INSERT INTO public.file_to_event (file_id, event_id) SELECT file_id, ${newEvent.id} FROM public.file_to_event WHERE event_id = ${id} ON CONFLICT DO NOTHING `; if (calendars.length > 0) { await Promise.all(calendars.map(calId => tx` INSERT INTO events.event_calendars ${tx({ event_id: newEvent.id, calendar_id: calId })} `)); } // Migrate A's overrides in [exception_date, oldEndDate) to the new template to preserve their customizations await tx` UPDATE events.events SET recurrence_parent_id = ${newEvent.id} WHERE recurrence_parent_id = ${id} AND recurrence_exception_date >= ${exception_date} ${oldEndDate ? tx`AND recurrence_exception_date < ${oldEndDate}` : tx``} `; } }); if (!editedEvent) return { status: 403, error: "Event not found or not authorized" }; return { status: 200, event: { ...editedEvent, calendars, recurrence: recurrence ?? null } }; } catch (e) { console.error(e); return { status: 400, error: "Failed to edit event" }; } } export async function deleteEvent(userId, eventId, networkId, scope, exception_date) { try { const [event] = await this.sql` SELECT * FROM events.events WHERE id = ${eventId} AND network_id = ${networkId} AND creator_id = ${userId} `; if (!event) return { status: 403, error: "Event not found or not authorized" }; const isRecurring = !!event.recurrence_id; const isOverride = !!event.recurrence_parent_id; if (isOverride) { // Already a concrete row — just mark it cancelled await this.sql`UPDATE events.events SET is_cancelled = true WHERE id = ${eventId}`; return { status: 200 }; } if (isRecurring && scope === 'single') { // Virtual occurrence — insert a cancelled placeholder so it gets skipped await this.sql` INSERT INTO events.events ${this.sql({ network_id: networkId, creator_id: userId, title: event.title, time_start: exception_date, time_end: exception_date, all_day: event.all_day, recurrence_parent_id: eventId, recurrence_exception_date: exception_date, is_cancelled: true })} `; return { status: 200 }; } if (isRecurring && scope === 'future' && new Date(exception_date) > new Date(event.time_start)) { // Capture current end_date before modifying — marks the boundary of independent later splits const [existingRule] = await this.sql`SELECT end_date FROM events.event_recurrence WHERE id = ${event.recurrence_id}`; const oldEndDate = existingRule?.end_date ?? null; // Cap the series at this date await this.sql` UPDATE events.event_recurrence SET end_date = ${exception_date}, updated_at = NOW() WHERE id = ${event.recurrence_id} `; // Promote future non-cancelled overrides (single-event edits) to standalone events await this.sql` UPDATE events.events SET recurrence_parent_id = NULL, recurrence_exception_date = NULL, updated_at = NOW() WHERE recurrence_parent_id = ${eventId} AND recurrence_exception_date >= ${exception_date} AND (is_cancelled IS NULL OR is_cancelled = false) `; // Drop future cancelled placeholders — they are meaningless without the series await this.sql` DELETE FROM events.events WHERE recurrence_parent_id = ${eventId} AND recurrence_exception_date >= ${exception_date} AND is_cancelled = true `; // Remove intermediate descendant templates in [exception_date, oldEndDate); independent splits at/beyond oldEndDate are preserved const descendants = await this.sql` SELECT id, recurrence_id FROM events.events WHERE parent_template_id = ${eventId} AND time_start >= ${exception_date} ${oldEndDate ? this.sql`AND time_start < ${oldEndDate}` : this.sql``} `; if (descendants.length > 0) { const descIds = descendants.map(d => d.id); const descRuleIds = descendants.map(d => d.recurrence_id).filter(Boolean); await this.sql`DELETE FROM events.events WHERE id = ANY(${descIds})`; if (descRuleIds.length > 0) { await this.sql`DELETE FROM events.event_recurrence WHERE id = ANY(${descRuleIds})`; } } return { status: 200 }; } // Delete the whole event/series — collect files from the template AND all overrides, // but only those not shared with surviving events (e.g. a 'this and future' split // inherits the same file_ids; deleting the physical file would orphan that series) const files = await this.sql` SELECT DISTINCT f.* FROM public.files f JOIN public.file_to_event fte ON fte.file_id = f.id JOIN events.events e ON e.id = fte.event_id WHERE (e.id = ${eventId} OR e.recurrence_parent_id = ${eventId}) AND NOT EXISTS ( SELECT 1 FROM public.file_to_event fte2 WHERE fte2.file_id = f.id AND fte2.event_id NOT IN ( SELECT id FROM events.events WHERE id = ${eventId} OR recurrence_parent_id = ${eventId} ) ) `; const recurrenceId = event.recurrence_id; await this.sql.begin(async tx => { // Promote non-cancelled overrides (single-event edits) to standalone events before deleting the template await tx` UPDATE events.events SET recurrence_parent_id = NULL, recurrence_exception_date = NULL, updated_at = NOW() WHERE recurrence_parent_id = ${eventId} AND (is_cancelled IS NULL OR is_cancelled = false) `; await tx`DELETE FROM public.file_to_event WHERE event_id = ${eventId}`; if (files.length > 0) { await tx`DELETE FROM public.files WHERE id IN ${tx(files.map(f => f.id))}`; } await tx`DELETE FROM events.event_calendars WHERE event_id = ${eventId}`; await tx`DELETE FROM events.events WHERE id = ${eventId}`; // CASCADE removes remaining cancelled overrides if (recurrenceId) { await tx`DELETE FROM events.event_recurrence WHERE id = ${recurrenceId}`; } }); await Promise.all(files.map(async file => { await fs.unlink(`${global.DB_PATH}/images/events/${file.name}`).catch(() => {}); })); return { status: 200 }; } catch (e) { console.error(e); return { status: 400, error: "Failed to delete event" }; } }