import * as dotenv from 'dotenv'; import {Guid} from 'guid-typescript'; import {Event} from './event.interface.js'; import {NachklangCalendarDB} from '../Calendar.db.js'; import * as AdminUsersService from '../../admin/users/users.admin.service.js'; import logger from '../../../middleware/logger.js'; dotenv.config(); /** * How an event's creator is resolved, after step 5 of * docs/calendar-auth-migration.md removed the legacy path. * * The creator is only ever rendered as a name - nothing authorises on it, there * is no "only the creator may edit" rule anywhere - which is why an unresolvable * one degrades to blank rather than to an error. * * Two sources remain, weaker first: * * 1. `created_by_name`, a snapshot of the name as it stood when the calendar * had its own `users` table. Migration 002 took it, migration 004 dropped * the table it was taken from, and nothing has written it since. It exists * so the authorship of pre-cutover events survived that removal. * 2. The admin module's `user.name`, looked up live for rows carrying an admin * id. It wins, because it is the only one that follows a rename. * * The third source - joining the calendar's own `users` table on * `created_by_id` - is gone with the table and the column. */ /** * The one SELECT the four read paths share; callers append their own WHERE and * ORDER BY. `v.*` carries the version row's own creator columns, so only the * `events` columns need naming. * * There are no joins to a users table any more. There is no users table. */ const EVENT_SELECT = ` SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_user_id, e.created_by_name, v.* FROM events e INNER JOIN ( SELECT event_id, MAX(event_version_id) AS latest_version FROM event_versions GROUP BY event_id ) latest_versions ON e.event_id = latest_versions.event_id INNER JOIN event_versions v ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`; /** * Maps a result row to an Event. `status` is included only where it always * was: the admin views and the by-id lookup return it, the two public listings * do not. */ const toEvent = (row: any, includeStatus: boolean): Event => { const event: Event = { eventId: row.event_id, calendarId: row.calendar_id, uuid: row.uuid, name: row.name, description: row.description, startDateTime: row.start_datetime, endDateTime: row.end_datetime, createdDate: row.created_date, lastModifiedDate: row.version_created_at, location: row.location, // The archived name. resolveAdminNames below overwrites it for rows that // carry an admin id, which is the only source that follows a rename. createdBy: row.created_by_name, createdByUserId: row.created_by_user_id ?? null, lastModifiedBy: row.version_created_by_name, lastModifiedByUserId: row.version_created_by_user_id ?? null, url: row.url, wholeDay: row.whole_day, repeatFrequency: row.repeat_frequency }; if (includeStatus) { event.status = row.status; } return event; }; /** * Fills in creator/editor names for rows that carry an admin user id, by way * of a single lookup against the admin database. The calendar cannot join * against `user` - it is a different schema behind a different pool - and * making it one would tie the two schemas together as tightly as a foreign key * would. * * A failure here is swallowed on purpose. These endpoints include the public * calendar the website reads anonymously, and a name is decoration: if the * admin database is unreachable, an event should still render with whatever * the snapshot holds rather than 500 the whole listing. The alternative * would widen the public calendar's blast radius to include the admin * database, which it has never depended on before. */ const resolveAdminNames = async (events: Event[]): Promise => { const ids = events .flatMap(event => [event.createdByUserId, event.lastModifiedByUserId]) .filter((id): id is string => Boolean(id)); if (ids.length === 0) { return; } let names: Map; try { names = await AdminUsersService.findDisplayNames(ids); } catch (e: any) { logger.warn('Calendar: could not resolve creator names from the admin database: ' + e.message); return; } for (const event of events) { const createdBy = event.createdByUserId ? names.get(event.createdByUserId) : undefined; if (createdBy) { event.createdBy = createdBy; } const lastModifiedBy = event.lastModifiedByUserId ? names.get(event.lastModifiedByUserId) : undefined; if (lastModifiedBy) { event.lastModifiedBy = lastModifiedBy; } } }; /** * The calendars a listing has to cover: the requested one plus whatever it * declares in `includes_calendars`. */ const calendarsToFetch = async (conn: any, calendarId: number): Promise => { const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?'; const calendarRes = await conn.query(calendarQuery, calendarId); let calendars: number[] = [calendarId]; for (let row of calendarRes) { let includes: number[] = JSON.parse(row.includes_calendars); calendars = [...calendars, ...includes]; } return calendars; }; /** * Returns all events for the given calendar * @param calendarId The calendar Id */ export const getAllEvents = async (calendarId: number): Promise => { let conn = await NachklangCalendarDB.getConnection(); try { const calendars = await calendarsToFetch(conn, calendarId); const eventsQuery = `${EVENT_SELECT} WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' ORDER BY e.event_id`; const eventsRes = await conn.query(eventsQuery, [calendars]); const events = eventsRes.map((row: any) => toEvent(row, false)); await resolveAdminNames(events); return events; } catch (err) { throw err; } finally { // Return connection await conn.end(); } }; /** * Returns all events for the given calendar for the admin UI (therefore includes admin relevant information and * ignores the calendar includes * @param calendarId */ export const getAllEventsAdmin = async (calendarId: number): Promise => { let conn = await NachklangCalendarDB.getConnection(); try { const eventsQuery = `${EVENT_SELECT} WHERE e.calendar_id = ? ORDER BY e.event_id`; const eventsRes = await conn.query(eventsQuery, calendarId); const events = eventsRes.map((row: any) => toEvent(row, true)); await resolveAdminNames(events); return events; } catch (err) { throw err; } finally { // Return connection await conn.end(); } }; /** * Returns a single event by id (latest version, any status), or null if it * doesn't exist. Unlike getAllEvents/getAllEventsAdmin this isn't scoped to * a calendar - callers that need to enforce calendar/status visibility * should check the returned event's calendarId/status themselves. * @param eventId The event id */ export const getEventById = async (eventId: number): Promise => { let conn = await NachklangCalendarDB.getConnection(); try { const eventsQuery = `${EVENT_SELECT} WHERE e.event_id = ?`; const eventsRes = await conn.query(eventsQuery, eventId); if (eventsRes.length === 0) { return null; } const event = toEvent(eventsRes[0], true); await resolveAdminNames([event]); return event; } catch (err) { throw err; } finally { // Return connection await conn.end(); } }; /** * Create the given event in the database * @param event The event to create */ export const createEvent = async (event: Event): Promise => { let conn = await NachklangCalendarDB.getConnection(); try { await conn.beginTransaction(); let eventUUID = Guid.create().toString(); const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_user_id) VALUES (?,?,?) RETURNING event_id'; const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdByUserId ?? null]); const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);' await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]); await conn.commit(); return eventsRes[0].event_id; } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } }; /** * Update the given event in the database * @param event The event to update */ export const updateEvent = async (event: Event): Promise => { let conn = await NachklangCalendarDB.getConnection(); try { await conn.beginTransaction(); const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);' const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]); await conn.commit(); return versionRes.affectedRows; } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } }; /** * Deletes the given event from the database * @param event The event to delete */ export const deleteEvent = async (event: Event): Promise => { let conn = await NachklangCalendarDB.getConnection(); try { await conn.beginTransaction(); const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_user_id) VALUES (?,?,?);' const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdByUserId ?? null]); await conn.commit(); return versionRes.affectedRows === 1; } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } }; /** * Moves an event to the specified calendar * @param event The event to move. Has to have the target calendar set already. */ export const moveEvent = async (event: Event): Promise => { let conn = await NachklangCalendarDB.getConnection(); try { await conn.beginTransaction(); const eventQuery = 'UPDATE events SET calendar_id = ? WHERE event_id = ?'; const eventRes = await conn.execute(eventQuery, [event.calendarId, event.eventId]); await conn.commit(); return eventRes.affectedRows === 1; } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } } /** * Returns the next upcoming event for the given calendar * @param calendarId The calendar Id */ export const getNextUpcomingEvent = async (calendarId: number): Promise => { let conn = await NachklangCalendarDB.getConnection(); try { const calendars = await calendarsToFetch(conn, calendarId); const now = new Date(); const eventsQuery = `${EVENT_SELECT} WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ? ORDER BY v.start_datetime ASC LIMIT 1`; const eventsRes = await conn.query(eventsQuery, [calendars, now]); if (eventsRes.length === 0) { return null; } const event = toEvent(eventsRes[0], false); await resolveAdminNames([event]); return event; } catch (err) { throw err; } finally { // Return connection await conn.end(); } }