aa95ab2745
Step 5, the last one, of docs/calendar-auth-migration.md. Step 4 is deployed and verified, which is what this was waiting on: it removes the fallbacks that step 4 still leaned on. Gone: src/models/calendar/users/ entirely - registration, login, activation, both password-reset routes, and the session checking that the feedback and tickets admin areas used to authenticate against - along with its mount. That was the API's last unauthenticated account-creation and mail-sending endpoint. A survey confirmed nothing outside that directory imported it and nothing else touched its tables. Also gone: the two joins against the calendar users table in events.service.ts and the created_by_id / version_created_by_id columns they read, from the SQL, the row mapper, the Event interface and the swagger schema; and X-Session-Id / X-Session-Key from the CORS allowedHeaders, which nothing has read since the first cutover and nothing has sent since the second. An event's author still renders, because migration 002 snapshotted the names before this could erase them. That was brought forward from this step on purpose, and it is the reason 004 can rename the accounts aside at all. The accounts are renamed rather than dropped - they still hold e-mail addresses and password hashes, and a rename makes them unreachable without destroying anything. InnoDB rewires the sessions foreign key to the new name; verified on MariaDB 11, along with the whole 001-004 chain from the pre-cutover production schema, which lands byte-identical to a fresh dev database. Migration 004 must be applied AFTER deploying, not before - the reverse of step 4, whose migration only added things. Its own header and the runbook both say so, since getting it wrong by analogy is the obvious mistake. DEFERRED_SECURITY.md items 3 and 4 close with it: the activation and reset tokens that never expired are gone along with the code that issued them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
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<void> => {
|
|
const ids = events
|
|
.flatMap(event => [event.createdByUserId, event.lastModifiedByUserId])
|
|
.filter((id): id is string => Boolean(id));
|
|
|
|
if (ids.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let names: Map<string, string>;
|
|
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<number[]> => {
|
|
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<Event[]> => {
|
|
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<Event[]> => {
|
|
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<Event | null> => {
|
|
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<number> => {
|
|
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<number> => {
|
|
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<boolean> => {
|
|
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<boolean> => {
|
|
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<Event | null> => {
|
|
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();
|
|
}
|
|
}
|