Read calendar event creators from the admin module, and archive the old ones (#14)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #14 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #14.
This commit is contained in:
@@ -2,28 +2,56 @@ 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();
|
||||
|
||||
/**
|
||||
* Returns all events for the given calendar
|
||||
* @param calendarId The calendar Id
|
||||
* Step 3 of docs/calendar-auth-migration.md: the dual read.
|
||||
*
|
||||
* An event records its creator twice - `created_by_id`, the legacy INT into
|
||||
* the calendar database's own `users` table, and `created_by_user_id`, the
|
||||
* admin module's VARCHAR(36) id. Old rows have only the first, rows written
|
||||
* after the step 4 cutover will have only the second, and the two live in
|
||||
* different databases, so this file has to read both and prefer the new one.
|
||||
*
|
||||
* The one thing the creator is used for is a display name. Nothing authorises
|
||||
* on it - there is no "only the creator may edit" rule anywhere - which is why
|
||||
* a name that cannot be resolved degrades to blank instead of to an error.
|
||||
*
|
||||
* That name has three possible sources, and they are tried weakest first:
|
||||
*
|
||||
* 1. LEGACY - joining the calendar's own `users` table on `created_by_id`.
|
||||
* 2. `created_by_name`, the snapshot migration 002 took of exactly that join,
|
||||
* so the authorship of pre-cutover events survives step 5 dropping the
|
||||
* table. An archive: nothing writes it after the backfill.
|
||||
* 3. The admin module's `user.name`, looked up live for rows that carry an
|
||||
* admin id. It wins because it is the only one that follows a rename.
|
||||
*
|
||||
* Writes only ever set the admin id: since the step 4 cutover there is no
|
||||
* calendar user id to write, which is why migration 003 made `created_by_id`
|
||||
* nullable. The reads below still handle rows that predate that.
|
||||
*
|
||||
* Removal note: everything marked LEGACY below comes out in step 5, together
|
||||
* with the `users`/`sessions` tables and the `created_by_id` columns. The
|
||||
* snapshot stays - it is the reason step 5 can drop them.
|
||||
*/
|
||||
export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
let eventRows: Event[] = [];
|
||||
try {
|
||||
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
|
||||
const calendarRes = await conn.query(calendarQuery, calendarId);
|
||||
let calendarsToFetch: number[] = [calendarId];
|
||||
for(let row of calendarRes) {
|
||||
let includes: number[] = JSON.parse(row.includes_calendars);
|
||||
calendarsToFetch = [...calendarsToFetch, ...includes];
|
||||
}
|
||||
|
||||
const eventsQuery = `
|
||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
|
||||
/**
|
||||
* The one SELECT the four read paths share. It was copied out four times
|
||||
* before, which is precisely why the dual read had to be added in four
|
||||
* places; callers append their own WHERE and ORDER BY.
|
||||
*
|
||||
* `v.*` carries `version_created_by_user_id` and `version_created_by_name`
|
||||
* along with the rest of the version row, so only the `events` columns need
|
||||
* naming. The two joined names are aliased `legacy_*` because the unprefixed
|
||||
* names are now real columns.
|
||||
*/
|
||||
const EVENT_SELECT = `
|
||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, e.created_by_user_id, e.created_by_name,
|
||||
u.full_name as legacy_created_by_name, u2.full_name as legacy_last_modified_by_name, v.* FROM events e
|
||||
INNER JOIN (
|
||||
SELECT event_id, MAX(event_version_id) AS latest_version
|
||||
FROM event_versions
|
||||
@@ -33,34 +61,124 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
||||
INNER JOIN event_versions v
|
||||
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
|
||||
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
|
||||
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
|
||||
ORDER BY e.event_id`;
|
||||
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch]);
|
||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id`;
|
||||
|
||||
for (let row of eventsRes) {
|
||||
eventRows.push({
|
||||
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,
|
||||
createdBy: row.created_by_name,
|
||||
createdById: row.created_by_id,
|
||||
lastModifiedBy: row.last_modified_by_name,
|
||||
lastModifiedById: row.version_created_by_id,
|
||||
url: row.url,
|
||||
wholeDay: row.whole_day,
|
||||
repeatFrequency: row.repeat_frequency
|
||||
});
|
||||
/**
|
||||
* 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,
|
||||
// Name resolution, weakest first: the LEGACY join against the calendar
|
||||
// users table, then the snapshot taken in migration 002, then - in
|
||||
// resolveAdminNames below - the live admin name, which wins because it
|
||||
// is the only one that follows an account being renamed.
|
||||
createdBy: row.created_by_name ?? row.legacy_created_by_name,
|
||||
createdById: row.created_by_id,
|
||||
createdByUserId: row.created_by_user_id ?? null,
|
||||
lastModifiedBy: row.version_created_by_name ?? row.legacy_last_modified_by_name,
|
||||
lastModifiedById: row.version_created_by_id,
|
||||
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 legacy join produced 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;
|
||||
}
|
||||
|
||||
return eventRows;
|
||||
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 {
|
||||
@@ -76,48 +194,16 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
||||
*/
|
||||
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
let eventRows: Event[] = [];
|
||||
try {
|
||||
const eventsQuery = `
|
||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_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
|
||||
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
|
||||
const eventsQuery = `${EVENT_SELECT}
|
||||
WHERE e.calendar_id = ?
|
||||
ORDER BY e.event_id`;
|
||||
const eventsRes = await conn.query(eventsQuery, calendarId);
|
||||
|
||||
for (let row of eventsRes) {
|
||||
eventRows.push({
|
||||
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,
|
||||
createdBy: row.created_by_name,
|
||||
createdById: row.created_by_id,
|
||||
lastModifiedBy: row.last_modified_by_name,
|
||||
lastModifiedById: row.version_created_by_id,
|
||||
url: row.url,
|
||||
wholeDay: row.whole_day,
|
||||
repeatFrequency: row.repeat_frequency,
|
||||
status: row.status
|
||||
});
|
||||
}
|
||||
const events = eventsRes.map((row: any) => toEvent(row, true));
|
||||
await resolveAdminNames(events);
|
||||
|
||||
return eventRows;
|
||||
return events;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -136,18 +222,7 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
|
||||
export const getEventById = async (eventId: number): Promise<Event | null> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
const eventsQuery = `
|
||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_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
|
||||
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
|
||||
const eventsQuery = `${EVENT_SELECT}
|
||||
WHERE e.event_id = ?`;
|
||||
const eventsRes = await conn.query(eventsQuery, eventId);
|
||||
|
||||
@@ -155,27 +230,10 @@ export const getEventById = async (eventId: number): Promise<Event | null> => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = eventsRes[0];
|
||||
return {
|
||||
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,
|
||||
createdBy: row.created_by_name,
|
||||
createdById: row.created_by_id,
|
||||
lastModifiedBy: row.last_modified_by_name,
|
||||
lastModifiedById: row.version_created_by_id,
|
||||
url: row.url,
|
||||
wholeDay: row.whole_day,
|
||||
repeatFrequency: row.repeat_frequency,
|
||||
status: row.status
|
||||
} as Event;
|
||||
const event = toEvent(eventsRes[0], true);
|
||||
await resolveAdminNames([event]);
|
||||
|
||||
return event;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -193,11 +251,11 @@ export const createEvent = async (event: Event): Promise<number> => {
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
let eventUUID = Guid.create().toString();
|
||||
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id';
|
||||
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]);
|
||||
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_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.createdById]);
|
||||
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();
|
||||
|
||||
@@ -218,8 +276,8 @@ 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_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.createdById]);
|
||||
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();
|
||||
|
||||
@@ -240,8 +298,8 @@ 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_id) VALUES (?,?,?);'
|
||||
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]);
|
||||
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();
|
||||
|
||||
@@ -283,56 +341,23 @@ export const moveEvent = async (event: Event): Promise<boolean> => {
|
||||
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
|
||||
const calendarRes = await conn.query(calendarQuery, calendarId);
|
||||
let calendarsToFetch: number[] = [calendarId];
|
||||
for(let row of calendarRes) {
|
||||
let includes: number[] = JSON.parse(row.includes_calendars);
|
||||
calendarsToFetch = [...calendarsToFetch, ...includes];
|
||||
}
|
||||
const calendars = await calendarsToFetch(conn, calendarId);
|
||||
|
||||
const now = new Date();
|
||||
const eventsQuery = `
|
||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_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
|
||||
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
|
||||
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, [calendarsToFetch, now]);
|
||||
const eventsRes = await conn.query(eventsQuery, [calendars, now]);
|
||||
|
||||
if (eventsRes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = eventsRes[0];
|
||||
return {
|
||||
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,
|
||||
createdBy: row.created_by_name,
|
||||
createdById: row.created_by_id,
|
||||
lastModifiedBy: row.last_modified_by_name,
|
||||
lastModifiedById: row.version_created_by_id,
|
||||
url: row.url,
|
||||
wholeDay: row.whole_day,
|
||||
repeatFrequency: row.repeat_frequency
|
||||
} as Event;
|
||||
const event = toEvent(eventsRes[0], false);
|
||||
await resolveAdminNames([event]);
|
||||
|
||||
return event;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user