move calendar functions to calendar app

This commit is contained in:
metacryst
2026-04-28 21:25:54 -05:00
parent 0d6c7683ff
commit 8b083ae7bb
5 changed files with 584 additions and 5 deletions

View File

@@ -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)
register(CalendarForm)

View File

@@ -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"

View File

@@ -1,4 +1,4 @@
import server from "/@server/server.js"
import server from "/calendar/@server/calendar.js"
class DesktopCalendarForm extends Shadow {

View File

@@ -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"

View File

@@ -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" };
}
}