Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c53d38a0e9 |
@@ -1,22 +1,13 @@
|
|||||||
# Migrating the Calendar domain onto the admin identity module
|
# Migrating the Calendar domain onto the admin identity module
|
||||||
|
|
||||||
Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by
|
Status: **complete.** Steps 1, 3 and 4 were deployed and verified in production on
|
||||||
decision and part of step 5 brought forward; the rest went out as written.
|
2026-09-06; step 2 was dropped by decision and part of step 5 brought forward. Step 5 is
|
||||||
|
implemented and awaiting deploy - see its own checklist below, whose ordering is the
|
||||||
|
**opposite** of step 4's.
|
||||||
|
|
||||||
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
|
Verified live after step 4: the public calendar still answers anonymously, all 23 public
|
||||||
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
|
events kept a resolvable author, restricted calendars still refuse without a credential,
|
||||||
cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and
|
legacy query credentials answer 401, and `calendar.nachklang.art` is trusted for sign-out.
|
||||||
referenced by nothing. `DEFERRED_SECURITY.md` items 1, 3 and 4 closed with it.
|
|
||||||
|
|
||||||
Verified in production after the final deploy: the public calendar answers anonymously on all
|
|
||||||
three endpoints (23 events, 23 VEVENTs in the iCal feed, the next-event teaser intact),
|
|
||||||
restricted calendars still refuse without a credential, unauthenticated writes answer 401,
|
|
||||||
all six `/calendar/users/*` routes answer 404, CORS grants only `Content-Type`, sign-out from
|
|
||||||
`calendar.nachklang.art` succeeds, and **every event kept its author** - rendered from the
|
|
||||||
`created_by_name` snapshot, since the table it was copied from no longer exists under that
|
|
||||||
name. That last one is the whole reason part of step 5 was brought forward.
|
|
||||||
|
|
||||||
Remaining, at your leisure: `DROP TABLE sessions_legacy_archive, users_legacy_archive;`
|
|
||||||
|
|
||||||
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
|
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
|
||||||
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
|
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
|
||||||
@@ -166,8 +157,8 @@ Each step is meant to leave production working on its own.
|
|||||||
|
|
||||||
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
|
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
|
||||||
than `?password=` stops working permanently. The shared-password URLs are unaffected.
|
than `?password=` stops working permanently. The shared-password URLs are unaffected.
|
||||||
5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it
|
5. **Drop the legacy path.** **Implemented 2026-09-06** on `feature/calendar-drop-legacy-path`;
|
||||||
was.
|
not yet deployed. Gated on step 4 being live, which it now is.
|
||||||
|
|
||||||
What went:
|
What went:
|
||||||
|
|
||||||
@@ -187,7 +178,7 @@ Each step is meant to leave production working on its own.
|
|||||||
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
|
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
|
||||||
close with it - not by adding expiries but by deleting the code that issued them.
|
close with it - not by adding expiries but by deleting the code that issued them.
|
||||||
|
|
||||||
### Deploy checklist — kept for the record; the order was REVERSED from step 4
|
### Deploy checklist — note the order is REVERSED from step 4
|
||||||
|
|
||||||
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
|
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
|
||||||
table that the currently running build still selects and joins, so running it first fails
|
table that the currently running build still selects and joins, so running it first fails
|
||||||
@@ -218,15 +209,6 @@ Each step is meant to leave production working on its own.
|
|||||||
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
|
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
|
||||||
anything reading `/calendar/events/*/json` that is not the calendar frontend.
|
anything reading `/calendar/events/*/json` that is not the calendar frontend.
|
||||||
|
|
||||||
**Observed during the deploy, worth keeping.** A browser holding a *cached pre-cutover*
|
|
||||||
Angular bundle looked signed in and showed every event's status as "Error". The old bundle
|
|
||||||
called `/calendar/users/checkSessionValid`, which still existed between step 4 and step 5,
|
|
||||||
so it rendered as authenticated - then fetched events with no cookie, got the anonymous
|
|
||||||
listing, which omits `status`, and the UI's status switch fell through to its error label.
|
|
||||||
Signing out and back in fixed it. After this step that route 404s, so a stale bundle now
|
|
||||||
fails honestly instead of faking a session. This is the same "does not look broken" window
|
|
||||||
the step 4 checklist warns about, seen from the other side.
|
|
||||||
|
|
||||||
## What the code actually looks like (surveyed 2026-09-06)
|
## What the code actually looks like (surveyed 2026-09-06)
|
||||||
|
|
||||||
Four things found while doing step 1 that change how the later steps should be built:
|
Four things found while doing step 1 that change how the later steps should be built:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface EventPickerEntry {
|
|||||||
startDateTime: Date;
|
startDateTime: Date;
|
||||||
location: string;
|
location: string;
|
||||||
status: string | undefined;
|
status: string | undefined;
|
||||||
|
redemptionDeadline: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,19 +27,26 @@ export interface EventPickerEntry {
|
|||||||
*/
|
*/
|
||||||
export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => {
|
export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => {
|
||||||
let conn = await NachklangTicketsDB.getConnection();
|
let conn = await NachklangTicketsDB.getConnection();
|
||||||
let enabledEventIds: number[];
|
let deadlineByEventId: Map<number, Date | null>;
|
||||||
try {
|
try {
|
||||||
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
|
const rows = await conn.query('SELECT event_id, redemption_deadline FROM event_ticket_settings');
|
||||||
enabledEventIds = rows.map((r: any) => r.event_id);
|
deadlineByEventId = new Map(rows.map((r: any) => [r.event_id, r.redemption_deadline]));
|
||||||
} finally {
|
} finally {
|
||||||
await conn.end();
|
await conn.end();
|
||||||
}
|
}
|
||||||
if (enabledEventIds.length === 0) return [];
|
if (deadlineByEventId.size === 0) return [];
|
||||||
|
|
||||||
const events = await Promise.all(enabledEventIds.map(id => CalendarEventsService.getEventById(id)));
|
const events = await Promise.all([...deadlineByEventId.keys()].map(id => CalendarEventsService.getEventById(id)));
|
||||||
return events
|
return events
|
||||||
.filter((e): e is NonNullable<typeof e> => e !== null && e.status !== 'DELETED')
|
.filter((e): e is NonNullable<typeof e> => e !== null && e.status !== 'DELETED')
|
||||||
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status}))
|
.map(e => ({
|
||||||
|
eventId: e.eventId,
|
||||||
|
name: e.name,
|
||||||
|
startDateTime: e.startDateTime,
|
||||||
|
location: e.location,
|
||||||
|
status: e.status,
|
||||||
|
redemptionDeadline: deadlineByEventId.get(e.eventId) ?? null
|
||||||
|
}))
|
||||||
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
|
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,7 +67,9 @@ export const listAvailableEventsToAdd = async (): Promise<EventPickerEntry[]> =>
|
|||||||
const events = await CalendarEventsService.getAllEventsAdmin(PUBLIC_CALENDAR_ID);
|
const events = await CalendarEventsService.getAllEventsAdmin(PUBLIC_CALENDAR_ID);
|
||||||
return events
|
return events
|
||||||
.filter(e => e.status !== 'DELETED' && !enabledEventIds.has(e.eventId))
|
.filter(e => e.status !== 'DELETED' && !enabledEventIds.has(e.eventId))
|
||||||
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status}))
|
// Not yet added to the ticket shop, so there's no event_ticket_settings
|
||||||
|
// row and therefore no deadline to report.
|
||||||
|
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status, redemptionDeadline: null}))
|
||||||
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
|
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const validateVoucher = async (code: string): Promise<VoucherValidation |
|
|||||||
name: event.name,
|
name: event.name,
|
||||||
startDateTime: event.startDateTime,
|
startDateTime: event.startDateTime,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
|
redemptionDeadline: ticketState.redemptionDeadline,
|
||||||
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
|
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
|
||||||
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
|
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
|
||||||
spotsRemaining: ticketState.spotsRemaining,
|
spotsRemaining: ticketState.spotsRemaining,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* enum: [ACTIVE, UNDONE]
|
* enum: [ACTIVE, UNDONE]
|
||||||
* EligibleEvent:
|
* EligibleEvent:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [eventId, name, startDateTime, location, deadlinePassed, isFull, collectAddress, requireAddress]
|
* required: [eventId, name, startDateTime, location, redemptionDeadline, deadlinePassed, isFull, collectAddress, requireAddress]
|
||||||
* properties:
|
* properties:
|
||||||
* eventId:
|
* eventId:
|
||||||
* type: integer
|
* type: integer
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
* format: date-time
|
* format: date-time
|
||||||
* location:
|
* location:
|
||||||
* type: string
|
* type: string
|
||||||
|
* redemptionDeadline:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* nullable: true
|
||||||
|
* description: null when the event has no redemption deadline set
|
||||||
* deadlinePassed:
|
* deadlinePassed:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* isFull:
|
* isFull:
|
||||||
@@ -224,6 +229,7 @@ export interface EligibleEvent {
|
|||||||
name: string;
|
name: string;
|
||||||
startDateTime: Date;
|
startDateTime: Date;
|
||||||
location: string;
|
location: string;
|
||||||
|
redemptionDeadline: Date | null;
|
||||||
deadlinePassed: boolean;
|
deadlinePassed: boolean;
|
||||||
isFull: boolean;
|
isFull: boolean;
|
||||||
spotsRemaining: number | null;
|
spotsRemaining: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user