From 19a810ef7881534f2349b05cd580490223ed2304 Mon Sep 17 00:00:00 2001 From: metacryst Date: Thu, 30 Apr 2026 02:07:35 -0500 Subject: [PATCH] [Calendar] Introduce permissions for editing events --- calendar/Events/EventDetails.js | 12 +- .../desktop/Events/DesktopEventDetails.js | 11 +- calendar/server/functions.js | 109 +++++++++++------- donations/server/functions.js | 4 +- notes.txt | 4 + people/server/functions.js | 4 +- tasks/server/functions.js | 10 +- 7 files changed, 91 insertions(+), 63 deletions(-) create mode 100644 notes.txt diff --git a/calendar/Events/EventDetails.js b/calendar/Events/EventDetails.js index 6f841a0..2f9084e 100644 --- a/calendar/Events/EventDetails.js +++ b/calendar/Events/EventDetails.js @@ -1,4 +1,4 @@ -import server from "/@server/server.js" +import server from "/calendar/@server/calendar.js" import calendarUtil from "../calendarUtil.js" import "./EventForm.js" import "../../components/BottomSheet.js" @@ -39,10 +39,11 @@ class EventDetails extends Shadow { render() { this.editSheet = BottomSheet(100) // separate sheet for the edit form, layered above this one - const isOwner = this.event?.creator_id === global.profile?.id; + // Editing is currently owner-only: this flag gates the mobile Edit/Delete actions. + const canEdit = global.currentNetwork.permissions.includes("events.edit") VStack(() => { - this.renderHeader(isOwner) + this.renderHeader(canEdit) // ── Error toast ─────────────────────────────────────── VStack(() => { @@ -276,11 +277,12 @@ class EventDetails extends Shadow { .height(100, pct) } - renderHeader(isOwner) { + renderHeader(canEdit) { VStack(() => { HStack(() => { BackButton(false, true, () => $("bottomsheet-").toggle()) - if (isOwner) { + // Non-owners never see the controls that open the edit sheet or delete flow. + if (canEdit) { HStack(() => { // ── Delete button ───────────────────────────────── button("Delete") diff --git a/calendar/desktop/Events/DesktopEventDetails.js b/calendar/desktop/Events/DesktopEventDetails.js index a1d803f..76b5852 100644 --- a/calendar/desktop/Events/DesktopEventDetails.js +++ b/calendar/desktop/Events/DesktopEventDetails.js @@ -1,4 +1,4 @@ -import server from "/@server/server.js" +import server from "/calendar/@server/calendar.js" import calendarUtil from "../../calendarUtil.js" import "../../../components/Avatar.js" @@ -19,10 +19,10 @@ class DesktopEventDetails extends Shadow { if (!this.event) return const eventCals = this.calendars.filter(c => this.event.calendars?.includes(c.id)) - const isOwner = this.event.creator_id === global.profile?.id + const canEdit = global.currentNetwork.permissions.includes("events.edit") VStack(() => { - this.renderHeader(isOwner) + this.renderHeader(canEdit) this.renderBody(eventCals) HStack(() => { const members = global.currentNetwork.data?.members || [] @@ -53,7 +53,7 @@ class DesktopEventDetails extends Shadow { // ── Header ──────────────────────────────────────────────────────────────── - renderHeader(isOwner) { + renderHeader(canEdit) { HStack(() => { VStack(() => { h2(this.event.title || "Untitled") @@ -69,7 +69,8 @@ class DesktopEventDetails extends Shadow { .paddingBottom(0.5, em) .justifyContent("center") - if (isOwner) { + // Non-owners never see the controls that open the edit form or delete flow. + if (canEdit) { button("Delete") .paddingVertical(0.34, em) .paddingHorizontal(0.85, em) diff --git a/calendar/server/functions.js b/calendar/server/functions.js index a11bbba..f87ac2d 100644 --- a/calendar/server/functions.js +++ b/calendar/server/functions.js @@ -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 diff --git a/donations/server/functions.js b/donations/server/functions.js index e63f009..b63db01 100644 --- a/donations/server/functions.js +++ b/donations/server/functions.js @@ -1,11 +1,11 @@ export async function getMoneyData(networkId) { - const purchases = await this.sql` + const purchases = await context.sql` SELECT * FROM purchases WHERE network_id = ${networkId} ORDER BY created DESC `; - const subscriptions = await this.sql` + const subscriptions = await context.sql` SELECT mn.id, mn.created, mn.active, mn.network_plan_id, np.name AS plan_name, np.price AS plan_price, m.first_name, m.last_name, m.email diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..2bd16b3 --- /dev/null +++ b/notes.txt @@ -0,0 +1,4 @@ +reset permissions + +sudo chown -R test:staff /Users/Shared/apps +chmod -R ug+w /Users/Shared/apps \ No newline at end of file diff --git a/people/server/functions.js b/people/server/functions.js index 912315d..78ddddb 100644 --- a/people/server/functions.js +++ b/people/server/functions.js @@ -1,7 +1,7 @@ export async function saveMemberNote(email, text) { console.log("saving note: ", email, text) try { - await this.sql` + await context.sql` UPDATE members SET notes = ${text} WHERE email = ${email} @@ -14,7 +14,7 @@ export async function saveMemberNote(email, text) { export async function getPeople(networkId) { try { - const people = await this.sql` + const people = await context.sql` SELECT m.*, mn.created AS joined_network, diff --git a/tasks/server/functions.js b/tasks/server/functions.js index 49bfe24..f810641 100644 --- a/tasks/server/functions.js +++ b/tasks/server/functions.js @@ -1,5 +1,5 @@ export async function getTasks(networkId) { - const tasks = await this.sql` + const tasks = await context.sql` SELECT * FROM tasks.tasks WHERE network_id = ${networkId} AND is_active = true @@ -9,7 +9,7 @@ export async function getTasks(networkId) { } export async function addTask(networkId, title) { - const [task] = await this.sql` + const [task] = await context.sql` INSERT INTO tasks.tasks (title, network_id) VALUES (${title}, ${networkId}) RETURNING * @@ -18,7 +18,7 @@ export async function addTask(networkId, title) { } export async function updateTaskDone(taskId, done) { - const [task] = await this.sql` + const [task] = await context.sql` UPDATE tasks.tasks SET done = ${done} WHERE id = ${taskId} @@ -28,7 +28,7 @@ export async function updateTaskDone(taskId, done) { } export async function editTaskTitle(taskId, title) { - const [task] = await this.sql` + const [task] = await context.sql` UPDATE tasks.tasks SET title = ${title} WHERE id = ${taskId} @@ -38,7 +38,7 @@ export async function editTaskTitle(taskId, title) { } export async function deleteTask(taskId) { - await this.sql` + await context.sql` UPDATE tasks.tasks SET is_active = false WHERE id = ${taskId}