[Calendar] Introduce permissions for editing events

This commit is contained in:
metacryst
2026-04-30 02:07:35 -05:00
parent 8b083ae7bb
commit 19a810ef78
7 changed files with 91 additions and 63 deletions

View File

@@ -1,9 +1,10 @@
import fs from 'fs'
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`
await context.sql`
UPDATE events.events
SET time_start = ${start}, time_end = ${end}
WHERE id = ${eventId}
@@ -22,7 +23,7 @@ export async function getEventsByNetwork(networkId) {
}
// Returns each event with its linked calendar IDs, file attachments, and recurrence rule
return await this.sql`
return await context.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,
@@ -42,22 +43,23 @@ export async function getCalendarsByNetwork(networkId) {
throw new global.ServerError(400, "Invalid network ID!");
}
return await this.sql`
return await context.sql`
SELECT c.* FROM events.calendars c
WHERE c.network_id = ${networkId}
`;
}
export async function addCalendar(userId, newCalendar, networkId) {
export async function addCalendar(newCalendar, networkId) {
//@ should have scoping here
const { name, description, color } = newCalendar;
try {
let insertedCalendar;
await this.sql.begin(async tx => {
await context.sql.begin(async tx => {
const [calendar] = await tx`
INSERT INTO events.calendars ${tx({
network_id: networkId,
owner_id: userId,
owner_id: context.userId,
name: name,
description: description ?? null,
color: color
@@ -74,10 +76,10 @@ export async function addCalendar(userId, newCalendar, networkId) {
}
}
export async function editCalendar(userId, id, updatedCalendar, networkId) {
export async function editCalendar(id, updatedCalendar, networkId) {
const { name, description, color } = updatedCalendar;
try {
const [calendar] = await this.sql`
const [calendar] = await context.sql`
UPDATE events.calendars
SET
name = ${name},
@@ -87,7 +89,8 @@ export async function editCalendar(userId, id, updatedCalendar, networkId) {
WHERE
id = ${id}
AND network_id = ${networkId}
AND owner_id = ${userId}
--@ Calendar editing is owner-only on the backend.
AND owner_id = ${context.userId}
RETURNING *
`;
@@ -99,11 +102,12 @@ export async function editCalendar(userId, id, updatedCalendar, networkId) {
}
}
export async function deleteCalendar(userId, id, networkId) {
export async function deleteCalendar(id, networkId) {
try {
const [calendar] = await this.sql`
//@ Calendar deletion is owner-only on the backend.
const [calendar] = await context.sql`
DELETE FROM events.calendars
WHERE id = ${id} AND network_id = ${networkId} AND owner_id = ${userId}
WHERE id = ${id} AND network_id = ${networkId} AND owner_id = ${context.userId}
RETURNING *
`;
if (!calendar) return { status: 403, error: "Calendar not found or not authorized" };
@@ -114,12 +118,12 @@ export async function deleteCalendar(userId, id, networkId) {
}
}
export async function addEvent(userId, newEvent, networkId) {
export async function addEvent(newEvent, networkId) {
const { title, description, location, time_start, time_end, all_day, calendars, recurrence } = newEvent;
try {
let insertedEvent;
await this.sql.begin(async tx => {
await context.sql.begin(async tx => {
let recurrenceId = null;
if (recurrence) {
const [rule] = await tx`
@@ -137,7 +141,7 @@ export async function addEvent(userId, newEvent, networkId) {
const [event] = await tx`
INSERT INTO events.events ${tx({
network_id: networkId,
creator_id: userId,
creator_id: context.userId,
title,
description: description ?? null,
location: location ?? null,
@@ -162,18 +166,30 @@ export async function addEvent(userId, newEvent, networkId) {
}
}
export async function networkFromEvent(eventId) {
return await context.sql`
SELECT c.network_id
FROM events.event_calendars ec
JOIN events.calendars c ON c.id = ec.calendar_id
WHERE ec.event_id = ${eventId};
`
}
export async function addAttachments(userId, eventId, files) {
const [event] = await this.sql`
let results = await networkFromEvent(eventId)
if(!(await global.permissions.canDo("events.edit", userId, results[0].network_id))) return { status: 403, error: "Forbidden" };
const [event] = await context.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 => {
await context.sql.begin(async tx => {
insertedFiles = await Promise.all(
files.map(file =>
tx`
@@ -207,13 +223,16 @@ export async function addAttachments(userId, eventId, files) {
}
export async function deleteAttachment(userId, fileId, eventId) {
const [event] = await this.sql`
let results = await networkFromEvent(eventId)
if(!(await global.permissions.canDo("events.edit", userId, results[0].network_id))) return { status: 403, error: "Forbidden" };
const [event] = await context.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`
const [file] = await context.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}
@@ -222,7 +241,7 @@ export async function deleteAttachment(userId, fileId, eventId) {
try {
let deletedFile = null;
await this.sql.begin(async tx => {
await context.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`;
@@ -238,16 +257,17 @@ export async function deleteAttachment(userId, fileId, eventId) {
}
}
export async function editEvent(userId, id, updatedEvent, networkId) {
export async function editEvent(id, updatedEvent, networkId) {
if(!(await global.permissions.canDo("events.edit", context.userId, networkId))) return { status: 403, error: "Forbidden" };
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 => {
await context.sql.begin(async tx => {
const [current] = await tx`
SELECT * FROM events.events
WHERE id = ${id} AND network_id = ${networkId} AND creator_id = ${userId}
WHERE id = ${id} AND network_id = ${networkId}
`;
if (!current) return;
@@ -299,7 +319,7 @@ export async function editEvent(userId, id, updatedEvent, networkId) {
all_day = ${all_day},
recurrence_id = ${newRecurrenceId},
updated_at = NOW()
WHERE id = ${id} AND network_id = ${networkId} AND creator_id = ${userId}
WHERE id = ${id} AND network_id = ${networkId} AND creator_id = ${context.userId}
RETURNING *
`;
if (!event) return;
@@ -321,7 +341,7 @@ export async function editEvent(userId, id, updatedEvent, networkId) {
const [override] = await tx`
INSERT INTO events.events ${tx({
network_id: networkId,
creator_id: userId,
creator_id: context.userId,
title,
description: description ?? null,
location: location ?? null,
@@ -410,7 +430,7 @@ export async function editEvent(userId, id, updatedEvent, networkId) {
const [newEvent] = await tx`
INSERT INTO events.events ${tx({
network_id: networkId,
creator_id: userId,
creator_id: context.userId,
title,
description: description ?? null,
location: location ?? null,
@@ -453,11 +473,12 @@ export async function editEvent(userId, id, updatedEvent, networkId) {
}
}
export async function deleteEvent(userId, eventId, networkId, scope, exception_date) {
export async function deleteEvent(eventId, networkId, scope, exception_date) {
if(!global.permissions.canDo("events.delete", context.userId, networkId)) return { status: 403, error: "Forbidden" };
try {
const [event] = await this.sql`
const [event] = await context.sql`
SELECT * FROM events.events
WHERE id = ${eventId} AND network_id = ${networkId} AND creator_id = ${userId}
WHERE id = ${eventId} AND network_id = ${networkId}
`;
if (!event) return { status: 403, error: "Event not found or not authorized" };
@@ -466,16 +487,16 @@ export async function deleteEvent(userId, eventId, networkId, scope, exception_d
if (isOverride) {
// Already a concrete row — just mark it cancelled
await this.sql`UPDATE events.events SET is_cancelled = true WHERE id = ${eventId}`;
await context.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({
await context.sql`
INSERT INTO events.events ${context.sql({
network_id: networkId,
creator_id: userId,
creator_id: context.userId,
title: event.title,
time_start: exception_date,
time_end: exception_date,
@@ -490,16 +511,16 @@ export async function deleteEvent(userId, eventId, networkId, scope, exception_d
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 [existingRule] = await context.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`
await context.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`
await context.sql`
UPDATE events.events
SET recurrence_parent_id = NULL, recurrence_exception_date = NULL, updated_at = NOW()
WHERE recurrence_parent_id = ${eventId}
@@ -507,24 +528,24 @@ export async function deleteEvent(userId, eventId, networkId, scope, exception_d
AND (is_cancelled IS NULL OR is_cancelled = false)
`;
// Drop future cancelled placeholders — they are meaningless without the series
await this.sql`
await context.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`
const descendants = await context.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``}
${oldEndDate ? context.sql`AND time_start < ${oldEndDate}` : context.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})`;
await context.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})`;
await context.sql`DELETE FROM events.event_recurrence WHERE id = ANY(${descRuleIds})`;
}
}
return { status: 200 };
@@ -533,7 +554,7 @@ export async function deleteEvent(userId, eventId, networkId, scope, exception_d
// 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`
const files = await context.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
@@ -548,7 +569,7 @@ export async function deleteEvent(userId, eventId, networkId, scope, exception_d
`;
const recurrenceId = event.recurrence_id;
await this.sql.begin(async tx => {
await context.sql.begin(async tx => {
// Promote non-cancelled overrides (single-event edits) to standalone events before deleting the template
await tx`
UPDATE events.events