diff --git a/calendar/CalendarForm.js b/calendar/CalendarForm.js index 982e4d8..cb2bd97 100644 --- a/calendar/CalendarForm.js +++ b/calendar/CalendarForm.js @@ -1,4 +1,4 @@ -import server from "/@server/server.js" +import server from "/calendar/@server/calendar.js" css(` calendarform- { @@ -434,4 +434,4 @@ class CalendarForm extends Shadow { } } -register(CalendarForm) \ No newline at end of file +register(CalendarForm) diff --git a/calendar/Events/EventForm.js b/calendar/Events/EventForm.js index 59a498c..3040bdf 100644 --- a/calendar/Events/EventForm.js +++ b/calendar/Events/EventForm.js @@ -1,4 +1,4 @@ -import server from "/@server/server.js" +import server from "/calendar/@server/calendar.js" import calendarUtil from "../calendarUtil.js" import "../EventFileList.js" import "../../components/Avatar.js" diff --git a/calendar/desktop/DesktopCalendarForm.js b/calendar/desktop/DesktopCalendarForm.js index 08da376..95a28b5 100644 --- a/calendar/desktop/DesktopCalendarForm.js +++ b/calendar/desktop/DesktopCalendarForm.js @@ -1,4 +1,4 @@ -import server from "/@server/server.js" +import server from "/calendar/@server/calendar.js" class DesktopCalendarForm extends Shadow { diff --git a/calendar/desktop/Events/DesktopEventForm.js b/calendar/desktop/Events/DesktopEventForm.js index cad8b40..39fe2a7 100644 --- a/calendar/desktop/Events/DesktopEventForm.js +++ b/calendar/desktop/Events/DesktopEventForm.js @@ -1,4 +1,4 @@ -import server from "/@server/server.js" +import server from "/calendar/@server/calendar.js" import calendarUtil from "../../calendarUtil.js" import "../../EventFileList.js" import "../../../components/Avatar.js" diff --git a/calendar/server/functions.js b/calendar/server/functions.js new file mode 100644 index 0000000..a11bbba --- /dev/null +++ b/calendar/server/functions.js @@ -0,0 +1,579 @@ +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" }; + } +} \ No newline at end of file