Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a574e93359 | |||
| d522f35663 | |||
| e7d76f40de | |||
| 5b3e10be94 | |||
| c53d38a0e9 | |||
| 3fc6894070 | |||
| 69c1e4926c | |||
| a49b939fdc |
@@ -89,6 +89,19 @@ error. Only the integration tests catch this.
|
|||||||
`better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not
|
`better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not
|
||||||
use the published `@better-auth/cli`; it lags the library.
|
use the published `@better-auth/cli`; it lags the library.
|
||||||
|
|
||||||
|
**`docker-compose.dev.yml` builds a fresh local dev database from `docker/init/`, not
|
||||||
|
from `sql/<domain>/` directly** - the two domains use different mechanisms and both need
|
||||||
|
to be kept in sync by hand whenever a migration is added: `docker/init/03-tickets-schema.sql`
|
||||||
|
and `02-feedback-schema.sql` are thin files that `SOURCE` every `sql/<domain>/NNN_*.sql`
|
||||||
|
in order (add the new migration's `SOURCE` line there too); `01-calendar-schema-dev.sql`
|
||||||
|
and `04-admin-schema.sql` instead fold each migration's effect directly into one
|
||||||
|
reconstructed CREATE-TABLE schema (own header comment: "keep the two in step") - no
|
||||||
|
`SOURCE` list to extend, edit the reconstructed schema itself. Found
|
||||||
|
`03-tickets-schema.sql` missing the `SOURCE` line for `003_add_confirmation_email_status.sql`
|
||||||
|
while adding `004` - every tickets dev DB spun up since that migration was added has been
|
||||||
|
silently missing the column (mail sends still succeed, `recordConfirmationEmailResult`'s
|
||||||
|
`UPDATE` just fails and logs). Fixed.
|
||||||
|
|
||||||
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
|
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
|
||||||
|
|
||||||
**Calendar types and IDs:** `public` (1), `members` (2), `management` (3), `choir` (4), `birthdays` (5). `credentials.service.ts` enforces which session/credential can read each calendar.
|
**Calendar types and IDs:** `public` (1), `members` (2), `management` (3), `choir` (4), `birthdays` (5). `credentials.service.ts` enforces which session/credential can read each calendar.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
USE nachklang_tickets;
|
USE nachklang_tickets;
|
||||||
SOURCE /migrations/tickets/001_init.sql;
|
SOURCE /migrations/tickets/001_init.sql;
|
||||||
SOURCE /migrations/tickets/002_add_require_address.sql;
|
SOURCE /migrations/tickets/002_add_require_address.sql;
|
||||||
|
SOURCE /migrations/tickets/003_add_confirmation_email_status.sql;
|
||||||
|
SOURCE /migrations/tickets/004_add_tickets_mailed.sql;
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
# Migrating the Calendar domain onto the admin identity module
|
# Migrating the Calendar domain onto the admin identity module
|
||||||
|
|
||||||
Status: **complete.** Steps 1, 3 and 4 were deployed and verified in production on
|
Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by
|
||||||
2026-09-06; step 2 was dropped by decision and part of step 5 brought forward. Step 5 is
|
decision and part of step 5 brought forward; the rest went out as written.
|
||||||
implemented and awaiting deploy - see its own checklist below, whose ordering is the
|
|
||||||
**opposite** of step 4's.
|
|
||||||
|
|
||||||
Verified live after step 4: the public calendar still answers anonymously, all 23 public
|
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
|
||||||
events kept a resolvable author, restricted calendars still refuse without a credential,
|
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
|
||||||
legacy query credentials answer 401, and `calendar.nachklang.art` is trusted for sign-out.
|
cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and
|
||||||
|
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
|
||||||
@@ -157,8 +166,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.** **Implemented 2026-09-06** on `feature/calendar-drop-legacy-path`;
|
5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it
|
||||||
not yet deployed. Gated on step 4 being live, which it now is.
|
was.
|
||||||
|
|
||||||
What went:
|
What went:
|
||||||
|
|
||||||
@@ -178,7 +187,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 — note the order is REVERSED from step 4
|
### Deploy checklist — kept for the record; the order was 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
|
||||||
@@ -209,6 +218,15 @@ 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:
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Calendar iCal export and subscriptions
|
||||||
|
|
||||||
|
How `GET /calendar/events/{calendar}/ical` builds its file, what changed on 2026-09-28, and the
|
||||||
|
endpoint the calendar app uses to hand editors ready-made subscription URLs.
|
||||||
|
|
||||||
|
## The export
|
||||||
|
|
||||||
|
`icalgenerator.service.ts` turns a calendar's `PUBLIC` events into one `VCALENDAR`:
|
||||||
|
|
||||||
|
- Timed events: `DTSTART;TZID=Europe/Berlin:` / `DTEND;TZID=Europe/Berlin:` formatted with the
|
||||||
|
**API process's local** `getHours()` etc. The times are therefore only right if the process runs
|
||||||
|
in Berlin time (`TZ=Europe/Berlin`, or a server in that zone). Nothing enforces this. Inferred
|
||||||
|
from the code; not checked against the production server.
|
||||||
|
- Whole-day events: `VALUE=DATE`, and the stored end date is treated as the *inclusive* last day -
|
||||||
|
the generator adds one day for `DTEND`. The calendar app stores whole-day events as 00:00 to
|
||||||
|
23:59 Berlin time for this reason.
|
||||||
|
- `repeatFrequency` is written verbatim as `RRULE:FREQ=<value>` (no interval, count or end).
|
||||||
|
- `public` needs no credential; the other calendars take `?password=` because a calendar client
|
||||||
|
cannot send the session cookie (see `calendar-auth-migration.md`).
|
||||||
|
|
||||||
|
## RFC 5545 conformance (2026-09-28)
|
||||||
|
|
||||||
|
Before this date the generator wrote text values raw. A line break in a description ended the
|
||||||
|
`DESCRIPTION` property early and the rest was read as a garbage property; unescaped `,` and `;`
|
||||||
|
are invalid in text values. The new calendar frontend has a multi-line description field, so this
|
||||||
|
went from theoretical to certain. Now:
|
||||||
|
|
||||||
|
- `SUMMARY`, `DESCRIPTION` and `LOCATION` go through `escapeText` (`\` `;` `,` and line breaks,
|
||||||
|
backslash first).
|
||||||
|
- `URL` is a URI value and is not escaped; line breaks are stripped from it.
|
||||||
|
- Every content line is folded at 75 octets (UTF-8 aware - a multi-byte character is never split)
|
||||||
|
and the file uses CRLF line endings throughout (`toContentLines`).
|
||||||
|
- `formatDate` no longer shifts the caller's `Date` when it adds the whole-day end's extra day.
|
||||||
|
- The response is `Content-Type: text/calendar; charset=utf-8`. Before, Express inferred
|
||||||
|
`text/html` from the string body; calendar apps coped, but it was wrong.
|
||||||
|
|
||||||
|
Pinned by `test/calendar/icalgenerator.service.test.ts`.
|
||||||
|
|
||||||
|
Not changed, worth knowing: `ORGANIZER` is written as a bare display name (`ORGANIZER:Anna`),
|
||||||
|
while RFC 5545 expects a cal-address (`ORGANIZER;CN=Anna:mailto:…`). Calendar apps have tolerated
|
||||||
|
it so far.
|
||||||
|
|
||||||
|
## `GET /calendar/events/subscriptions`
|
||||||
|
|
||||||
|
For the calendar app's "Abonnieren" dialog. Behind `requireAppAccess('calendar')`; the shared
|
||||||
|
password is **not** accepted here (one calendar's password must not reveal the others).
|
||||||
|
|
||||||
|
Returns `[{calendar, icalUrl}]` in `calendarNames` order, built from `API_BASE_URL`:
|
||||||
|
|
||||||
|
- `public` without a password;
|
||||||
|
- every other calendar with `?password=<its shared password>`, URL-encoded;
|
||||||
|
- a calendar whose credential env var is unset is left out rather than listed with a URL that
|
||||||
|
cannot work.
|
||||||
|
|
||||||
|
`choir` and `birthdays` share `CHOIR_CREDENTIAL`, so rotating it changes both URLs - and breaks
|
||||||
|
every existing subscription to either. The response is sent with `Cache-Control: no-store`
|
||||||
|
because it carries the passwords.
|
||||||
|
|
||||||
|
Signed-in editors can read every event of every calendar anyway, so handing them the passwords
|
||||||
|
exposes nothing they could not already see.
|
||||||
|
|
||||||
|
## Found while doing this
|
||||||
|
|
||||||
|
- Only the admin pool reads `DB_PORT`; `Calendar.db.ts` (and the feedback/tickets pools) always
|
||||||
|
connect to 3306. A local test database for the calendar has to listen there.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Nachklang e.V. Tickets module — adds a per-event "tickets are mailed" flag.
|
||||||
|
-- Defaults to 0 (not mailed) because no event mails physical tickets today -
|
||||||
|
-- the redemption confirmation email uses this to decide whether to tell the
|
||||||
|
-- guest their tickets await pickup at the Abendkasse instead. Apply manually
|
||||||
|
-- against TICKETS_DB, after 003_add_confirmation_email_status.sql:
|
||||||
|
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 004_add_tickets_mailed.sql
|
||||||
|
ALTER TABLE event_ticket_settings
|
||||||
|
ADD COLUMN tickets_mailed TINYINT(1) NOT NULL DEFAULT 0 AFTER require_address;
|
||||||
@@ -21,7 +21,13 @@ dotenv.config();
|
|||||||
* test/calendar/credentials.service.test.ts.
|
* test/calendar/credentials.service.test.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const credentialFor = (calendarName: string): string | undefined => {
|
/**
|
||||||
|
* The shared password for a calendar, or undefined for `public` (which needs
|
||||||
|
* none), an unknown calendar, or one whose credential is not configured.
|
||||||
|
* Exported for the subscriptions endpoint, which hands signed-in editors the
|
||||||
|
* ready-made iCal URLs - see GET /calendar/events/subscriptions.
|
||||||
|
*/
|
||||||
|
export const credentialFor = (calendarName: string): string | undefined => {
|
||||||
switch (calendarName) {
|
switch (calendarName) {
|
||||||
case 'members':
|
case 'members':
|
||||||
return process.env.MEMBER_CREDENTIAL;
|
return process.env.MEMBER_CREDENTIAL;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as EventService from './events.service.js';
|
|||||||
import * as iCalService from './icalgenerator.service.js';
|
import * as iCalService from './icalgenerator.service.js';
|
||||||
import * as CredentialService from './credentials.service.js';
|
import * as CredentialService from './credentials.service.js';
|
||||||
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
|
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
|
||||||
|
import {API_BASE_URL} from '../../admin/admin.config.js';
|
||||||
import {Guid} from 'guid-typescript';
|
import {Guid} from 'guid-typescript';
|
||||||
import logger from '../../../middleware/logger.js';
|
import logger from '../../../middleware/logger.js';
|
||||||
|
|
||||||
@@ -423,7 +424,12 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
|||||||
let file = await iCalService.convertToIcal(events);
|
let file = await iCalService.convertToIcal(events);
|
||||||
|
|
||||||
// Send the ical file back
|
// Send the ical file back
|
||||||
res.set({'Content-Disposition': 'attachment; filename=' + fileName + '.ics'});
|
// text/calendar, not the text/html that send() infers from a string - calendar apps
|
||||||
|
// coped with the wrong type, but it was wrong (RFC 5545 §8.1).
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'text/calendar; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename=' + fileName + '.ics'
|
||||||
|
});
|
||||||
res.status(200).send(file);
|
res.status(200).send(file);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
let errorGuid = Guid.create().toString();
|
let errorGuid = Guid.create().toString();
|
||||||
@@ -436,6 +442,59 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /calendar/events/subscriptions:
|
||||||
|
* get:
|
||||||
|
* summary: The iCal subscription URL of every calendar
|
||||||
|
* description: >
|
||||||
|
* Ready-to-use iCal URLs for each calendar, with the calendar's shared password
|
||||||
|
* already in the query string where it needs one - for the calendar app's
|
||||||
|
* "Abonnieren" dialog. A calendar whose password is not configured is left out
|
||||||
|
* rather than listed with a URL that cannot work. Requires a signed-in account
|
||||||
|
* with the calendar permission: those editors can read every event anyway, so the
|
||||||
|
* passwords tell them nothing new. Never cached.
|
||||||
|
* tags:
|
||||||
|
* - calendar
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* calendar:
|
||||||
|
* type: string
|
||||||
|
* example: members
|
||||||
|
* icalUrl:
|
||||||
|
* type: string
|
||||||
|
* example: https://api.nachklang.art/calendar/events/members/ical?password=…
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized - not signed in
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden - the account lacks the calendar permission
|
||||||
|
*/
|
||||||
|
eventsRouter.get('/subscriptions', requireCalendarAccess, (req: Request, res: Response) => {
|
||||||
|
const base = API_BASE_URL.replace(/\/+$/, '');
|
||||||
|
const subscriptions = Array.from(calendarNames.keys()).flatMap((calendar) => {
|
||||||
|
const url = `${base}/calendar/events/${calendar}/ical`;
|
||||||
|
if (calendar === 'public') {
|
||||||
|
return [{calendar, icalUrl: url}];
|
||||||
|
}
|
||||||
|
const password = CredentialService.credentialFor(calendar);
|
||||||
|
return password ? [{calendar, icalUrl: `${url}?password=${encodeURIComponent(password)}`}] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// These URLs carry the shared passwords; keep them out of every cache on the way.
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.status(200).send(subscriptions);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /calendar/events:
|
* /calendar/events:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const convertToIcal = async (events: Event[]): Promise<string> => {
|
|||||||
addEventToFile(ical, event);
|
addEventToFile(ical, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeIcalFile(ical);
|
return toContentLines(serializeIcalFile(ical));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -116,9 +116,11 @@ const addEventToFile = (ical: iCalFile, event: Event) => {
|
|||||||
* @param event
|
* @param event
|
||||||
*/
|
*/
|
||||||
const createIcalEvent = (event: Event): iCalEvent => {
|
const createIcalEvent = (event: Event): iCalEvent => {
|
||||||
let description = event.description ? event.description + '\n' : '';
|
// Free text is escaped (see escapeText); the URL is a URI value, which has
|
||||||
let location = event.location ? event.location + '\n' : '';
|
// no escapes - it only must not break the line.
|
||||||
let url = event.url ? event.url + '\n' : '';
|
let description = event.description ? escapeText(event.description) + '\n' : '';
|
||||||
|
let location = event.location ? escapeText(event.location) + '\n' : '';
|
||||||
|
let url = event.url ? event.url.replace(/[\r\n]+/g, '') + '\n' : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
header: 'BEGIN:VEVENT\n',
|
header: 'BEGIN:VEVENT\n',
|
||||||
@@ -128,7 +130,7 @@ const createIcalEvent = (event: Event): iCalEvent => {
|
|||||||
start: formatDate(event.startDateTime, event.wholeDay) + '\n',
|
start: formatDate(event.startDateTime, event.wholeDay) + '\n',
|
||||||
end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
|
end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
|
||||||
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
|
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
|
||||||
summary: event.name + '\n',
|
summary: escapeText(event.name) + '\n',
|
||||||
description: description,
|
description: description,
|
||||||
location: location,
|
location: location,
|
||||||
url: url,
|
url: url,
|
||||||
@@ -143,8 +145,11 @@ const createIcalEvent = (event: Event): iCalEvent => {
|
|||||||
* @param wholeDayFormat
|
* @param wholeDayFormat
|
||||||
* @param isEndDate
|
* @param isEndDate
|
||||||
*/
|
*/
|
||||||
const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
|
const formatDate = (input: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
|
||||||
let returnString = '';
|
let returnString = '';
|
||||||
|
// A copy: this used to shift the caller's Date in place, so the event object
|
||||||
|
// came out of the export with its end a day later than it went in.
|
||||||
|
const date = new Date(input);
|
||||||
|
|
||||||
// We need to do this for whole day events as otherwise the event ends one day too early
|
// We need to do this for whole day events as otherwise the event ends one day too early
|
||||||
if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1)
|
if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1)
|
||||||
@@ -184,6 +189,58 @@ export interface iCalEvent {
|
|||||||
footer: string;
|
footer: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 5545 §3.3.11 TEXT escaping. Without it a description with a line break
|
||||||
|
* ended the DESCRIPTION property early and the rest of the text was read as a
|
||||||
|
* new (garbage) property, and an unescaped `,` or `;` is invalid in the value.
|
||||||
|
* The backslash goes first, or the escapes added after it would be doubled.
|
||||||
|
*/
|
||||||
|
export const escapeText = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/;/g, '\\;')
|
||||||
|
.replace(/,/g, '\\,')
|
||||||
|
.replace(/\r\n|\r|\n/g, '\\n');
|
||||||
|
|
||||||
|
const MAX_LINE_OCTETS = 75;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 5545 §3.1 line folding: no content line longer than 75 octets, a longer
|
||||||
|
* one continues on the next line after a single leading space. Counts UTF-8
|
||||||
|
* octets, not characters, and never splits inside a character - "ü" is two
|
||||||
|
* octets and a fold between them corrupts it.
|
||||||
|
*/
|
||||||
|
export const foldLine = (line: string): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let octets = 0;
|
||||||
|
for (const char of line) {
|
||||||
|
const size = Buffer.byteLength(char, 'utf8');
|
||||||
|
// A continuation line's leading space counts towards its 75.
|
||||||
|
const limit = parts.length === 0 ? MAX_LINE_OCTETS : MAX_LINE_OCTETS - 1;
|
||||||
|
if (octets + size > limit) {
|
||||||
|
parts.push(current);
|
||||||
|
current = '';
|
||||||
|
octets = 0;
|
||||||
|
}
|
||||||
|
current += char;
|
||||||
|
octets += size;
|
||||||
|
}
|
||||||
|
parts.push(current);
|
||||||
|
return parts.join('\r\n ');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generator builds the file with bare `\n` line ends; the format wants
|
||||||
|
* every content line folded and terminated by CRLF (RFC 5545 §3.1).
|
||||||
|
*/
|
||||||
|
const toContentLines = (ical: string): string =>
|
||||||
|
ical
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line !== '')
|
||||||
|
.map(foldLine)
|
||||||
|
.join('\r\n') + '\r\n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a given string is null, undefined or blank
|
* Checks if a given string is null, undefined or blank
|
||||||
* @param str The string to check
|
* @param str The string to check
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
|||||||
* /tickets/admin/events/{eventId}/settings:
|
* /tickets/admin/events/{eventId}/settings:
|
||||||
* put:
|
* put:
|
||||||
* summary: Set a concert's voucher settings
|
* summary: Set a concert's voucher settings
|
||||||
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
|
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), whether to collect a mailing address, and whether tickets are mailed to guests.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
* security:
|
* security:
|
||||||
* - AdminSessionCookie: []
|
* - AdminSessionCookie: []
|
||||||
@@ -122,6 +122,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
|||||||
* requireAddress:
|
* requireAddress:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: Only meaningful when collectAddress is true.
|
* description: Only meaningful when collectAddress is true.
|
||||||
|
* ticketsMailed:
|
||||||
|
* type: boolean
|
||||||
|
* description: When false, the redemption confirmation email tells the guest their tickets await pickup at the Abendkasse instead.
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Saved
|
* description: Saved
|
||||||
@@ -132,7 +135,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
|||||||
*/
|
*/
|
||||||
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
|
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const {capacity, redemptionDeadline, collectAddress, requireAddress} = req.body || {};
|
const {capacity, redemptionDeadline, collectAddress, requireAddress, ticketsMailed} = req.body || {};
|
||||||
await EventsAdminService.setEventSettings(Number(req.params.eventId), {
|
await EventsAdminService.setEventSettings(Number(req.params.eventId), {
|
||||||
capacity: capacity ?? null,
|
capacity: capacity ?? null,
|
||||||
// The mariadb driver needs an actual Date to serialize a DATETIME
|
// The mariadb driver needs an actual Date to serialize a DATETIME
|
||||||
@@ -140,7 +143,8 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
|
|||||||
// rejected with "Incorrect datetime value".
|
// rejected with "Incorrect datetime value".
|
||||||
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
|
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
|
||||||
collectAddress: !!collectAddress,
|
collectAddress: !!collectAddress,
|
||||||
requireAddress: !!collectAddress && !!requireAddress
|
requireAddress: !!collectAddress && !!requireAddress,
|
||||||
|
ticketsMailed: !!ticketsMailed
|
||||||
});
|
});
|
||||||
res.status(200).send({status: 'OK'});
|
res.status(200).send({status: 'OK'});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -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());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,6 +99,7 @@ export const getEventStats = async (eventId: number): Promise<EventStats> => {
|
|||||||
redemptionDeadline: ticketState.redemptionDeadline,
|
redemptionDeadline: ticketState.redemptionDeadline,
|
||||||
collectAddress: ticketState.collectAddress,
|
collectAddress: ticketState.collectAddress,
|
||||||
requireAddress: ticketState.requireAddress,
|
requireAddress: ticketState.requireAddress,
|
||||||
|
ticketsMailed: ticketState.ticketsMailed,
|
||||||
guestsUsed: ticketState.guestsUsed,
|
guestsUsed: ticketState.guestsUsed,
|
||||||
spotsRemaining: ticketState.spotsRemaining,
|
spotsRemaining: ticketState.spotsRemaining,
|
||||||
unusedCodes,
|
unusedCodes,
|
||||||
@@ -109,10 +120,10 @@ export const setEventSettings = async (eventId: number, settings: Omit<EventTick
|
|||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address)
|
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address, tickets_mailed)
|
||||||
VALUES (?,?,?,?,?)
|
VALUES (?,?,?,?,?,?)
|
||||||
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address)`,
|
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address), tickets_mailed = VALUES(tickets_mailed)`,
|
||||||
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0]
|
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0, settings.ticketsMailed ? 1 : 0]
|
||||||
);
|
);
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface EventTicketState {
|
|||||||
redemptionDeadline: Date | null;
|
redemptionDeadline: Date | null;
|
||||||
collectAddress: boolean;
|
collectAddress: boolean;
|
||||||
requireAddress: boolean;
|
requireAddress: boolean;
|
||||||
|
ticketsMailed: boolean;
|
||||||
guestsUsed: number;
|
guestsUsed: number;
|
||||||
spotsRemaining: number | null;
|
spotsRemaining: number | null;
|
||||||
}
|
}
|
||||||
@@ -21,7 +22,7 @@ export interface EventTicketState {
|
|||||||
* (uncapped) don't need this - there's no cap to race against.
|
* (uncapped) don't need this - there's no cap to race against.
|
||||||
*/
|
*/
|
||||||
export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise<EventTicketState> => {
|
export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise<EventTicketState> => {
|
||||||
const settingsQuery = `SELECT capacity, redemption_deadline, collect_address, require_address FROM event_ticket_settings WHERE event_id = ?${forUpdate ? ' FOR UPDATE' : ''}`;
|
const settingsQuery = `SELECT capacity, redemption_deadline, collect_address, require_address, tickets_mailed FROM event_ticket_settings WHERE event_id = ?${forUpdate ? ' FOR UPDATE' : ''}`;
|
||||||
const settingsRows = await conn.query(settingsQuery, [eventId]);
|
const settingsRows = await conn.query(settingsQuery, [eventId]);
|
||||||
const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null;
|
const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null;
|
||||||
const redemptionDeadline = settingsRows.length > 0 ? settingsRows[0].redemption_deadline : null;
|
const redemptionDeadline = settingsRows.length > 0 ? settingsRows[0].redemption_deadline : null;
|
||||||
@@ -29,6 +30,7 @@ export const getEventTicketState = async (conn: any, eventId: number, forUpdate
|
|||||||
// Only meaningful when collectAddress is also true - the field isn't
|
// Only meaningful when collectAddress is also true - the field isn't
|
||||||
// shown/collected at all otherwise, so "required" is moot.
|
// shown/collected at all otherwise, so "required" is moot.
|
||||||
const requireAddress = collectAddress && settingsRows.length > 0 ? !!settingsRows[0].require_address : false;
|
const requireAddress = collectAddress && settingsRows.length > 0 ? !!settingsRows[0].require_address : false;
|
||||||
|
const ticketsMailed = settingsRows.length > 0 ? !!settingsRows[0].tickets_mailed : false;
|
||||||
|
|
||||||
const usedRows = await conn.query(
|
const usedRows = await conn.query(
|
||||||
"SELECT COALESCE(SUM(guest_count), 0) as used FROM redemptions WHERE event_id = ? AND status = 'ACTIVE'",
|
"SELECT COALESCE(SUM(guest_count), 0) as used FROM redemptions WHERE event_id = ? AND status = 'ACTIVE'",
|
||||||
@@ -37,5 +39,5 @@ export const getEventTicketState = async (conn: any, eventId: number, forUpdate
|
|||||||
const guestsUsed = Number(usedRows[0].used);
|
const guestsUsed = Number(usedRows[0].used);
|
||||||
const spotsRemaining = capacity === null ? null : Math.max(0, capacity - guestsUsed);
|
const spotsRemaining = capacity === null ? null : Math.max(0, capacity - guestsUsed);
|
||||||
|
|
||||||
return {eventId, capacity, redemptionDeadline, collectAddress, requireAddress, guestsUsed, spotsRemaining};
|
return {eventId, capacity, redemptionDeadline, collectAddress, requireAddress, ticketsMailed, guestsUsed, spotsRemaining};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,23 @@ export interface ConfirmationRecipient {
|
|||||||
guestNames: string[];
|
guestNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an event's tickets are mailed to guests - read directly rather than
|
||||||
|
* via getEventTicketState (tickets.capacity.ts) since that also computes a
|
||||||
|
* live guest count this function doesn't need. Absent settings row (no
|
||||||
|
* ticket-shop config yet) defaults to false, same "absence over sentinels"
|
||||||
|
* convention as the rest of event_ticket_settings.
|
||||||
|
*/
|
||||||
|
const ticketsAreMailed = async (eventId: number): Promise<boolean> => {
|
||||||
|
let conn = await NachklangTicketsDB.getConnection();
|
||||||
|
try {
|
||||||
|
const rows = await conn.query('SELECT tickets_mailed FROM event_ticket_settings WHERE event_id = ?', [eventId]);
|
||||||
|
return rows.length > 0 ? !!rows[0].tickets_mailed : false;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends the redemption confirmation email for one redemption. Returns whether
|
* Sends the redemption confirmation email for one redemption. Returns whether
|
||||||
* the mail was accepted by the relay. Never throws: a missing event is treated
|
* the mail was accepted by the relay. Never throws: a missing event is treated
|
||||||
@@ -36,14 +53,20 @@ export const sendRedemptionConfirmation = async (recipient: ConfirmationRecipien
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ticketsMailed = await ticketsAreMailed(recipient.eventId);
|
||||||
|
const pickupNotice = ticketsMailed
|
||||||
|
? ''
|
||||||
|
: `\n\nDeine Tickets werden nicht postalisch versendet: Sie liegen am Konzertabend unter dem Namen ${recipient.contactName} für dich an der Abendkasse bereit.`;
|
||||||
|
|
||||||
const guestList = recipient.guestNames.map(name => `- ${name}`).join('\n');
|
const guestList = recipient.guestNames.map(name => `- ${name}`).join('\n');
|
||||||
const body =
|
const body =
|
||||||
`Hallo ${recipient.contactName},\n\n` +
|
`Hallo ${recipient.contactName},\n\n` +
|
||||||
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
|
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
|
||||||
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
|
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
|
||||||
`Ort: ${event.location}\n\n` +
|
`Ort: ${event.location}\n\n` +
|
||||||
`Angemeldete Gäste:\n${guestList}\n\n` +
|
`Angemeldete Gäste:\n${guestList}` +
|
||||||
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`;
|
pickupNotice +
|
||||||
|
`\n\nWir freuen uns auf dich!\n\nDein Nachklang-Team`;
|
||||||
|
|
||||||
let icsAttachment;
|
let icsAttachment;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -144,7 +149,7 @@
|
|||||||
* type: integer
|
* type: integer
|
||||||
* EventTicketSettings:
|
* EventTicketSettings:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [eventId, collectAddress, requireAddress]
|
* required: [eventId, collectAddress, requireAddress, ticketsMailed]
|
||||||
* properties:
|
* properties:
|
||||||
* eventId:
|
* eventId:
|
||||||
* type: integer
|
* type: integer
|
||||||
@@ -160,9 +165,12 @@
|
|||||||
* requireAddress:
|
* requireAddress:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: Only meaningful when collectAddress is true.
|
* description: Only meaningful when collectAddress is true.
|
||||||
|
* ticketsMailed:
|
||||||
|
* type: boolean
|
||||||
|
* description: When false, the redemption confirmation email tells the guest their tickets await pickup at the Abendkasse instead.
|
||||||
* EventStats:
|
* EventStats:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [eventId, collectAddress, requireAddress, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
|
* required: [eventId, collectAddress, requireAddress, ticketsMailed, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
|
||||||
* properties:
|
* properties:
|
||||||
* eventId:
|
* eventId:
|
||||||
* type: integer
|
* type: integer
|
||||||
@@ -177,6 +185,8 @@
|
|||||||
* type: boolean
|
* type: boolean
|
||||||
* requireAddress:
|
* requireAddress:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
|
* ticketsMailed:
|
||||||
|
* type: boolean
|
||||||
* guestsUsed:
|
* guestsUsed:
|
||||||
* type: integer
|
* type: integer
|
||||||
* spotsRemaining:
|
* spotsRemaining:
|
||||||
@@ -224,6 +234,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;
|
||||||
@@ -285,6 +296,7 @@ export interface EventTicketSettings {
|
|||||||
redemptionDeadline: Date | null;
|
redemptionDeadline: Date | null;
|
||||||
collectAddress: boolean;
|
collectAddress: boolean;
|
||||||
requireAddress: boolean;
|
requireAddress: boolean;
|
||||||
|
ticketsMailed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventStats extends EventTicketSettings {
|
export interface EventStats extends EventTicketSettings {
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ const validEvent = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
process.env.MEMBER_CREDENTIAL = 'member-secret';
|
process.env.MEMBER_CREDENTIAL = 'member-secret';
|
||||||
|
process.env.CHOIR_CREDENTIAL = 'choir & more';
|
||||||
|
process.env.MANAGEMENT_CREDENTIAL = '';
|
||||||
(EventService.getAllEvents as any).mockResolvedValue([]);
|
(EventService.getAllEvents as any).mockResolvedValue([]);
|
||||||
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
|
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
|
||||||
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
|
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
|
||||||
@@ -164,6 +166,13 @@ describe('reading', () => {
|
|||||||
expect(auth.api.getSession).not.toHaveBeenCalled();
|
expect(auth.api.getSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serves the iCal export as text/calendar', async () => {
|
||||||
|
const res = await request(app).get('/calendar/events/public/ical').expect(200);
|
||||||
|
|
||||||
|
expect(res.headers['content-type']).toBe('text/calendar; charset=utf-8');
|
||||||
|
expect(res.headers['content-disposition']).toBe('attachment; filename=Nachklang_calendar.ics');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the shared password working on the iCal export', async () => {
|
it('keeps the shared password working on the iCal export', async () => {
|
||||||
(EventService.getAllEvents as any).mockResolvedValue([]);
|
(EventService.getAllEvents as any).mockResolvedValue([]);
|
||||||
|
|
||||||
@@ -222,3 +231,43 @@ describe('writing', () => {
|
|||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('subscriptions', () => {
|
||||||
|
it('is not for anyone signed out, or signed in without the calendar permission', async () => {
|
||||||
|
await request(app).get('/calendar/events/subscriptions').expect(401);
|
||||||
|
|
||||||
|
signedInAs(['tickets']);
|
||||||
|
await request(app).get('/calendar/events/subscriptions').expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the shared password as a way in', async () => {
|
||||||
|
// Otherwise one calendar's password would unlock all the others.
|
||||||
|
await request(app).get('/calendar/events/subscriptions').query({password: 'member-secret'}).expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists a ready-made iCal URL per configured calendar, never cached', async () => {
|
||||||
|
signedInAs(['calendar']);
|
||||||
|
|
||||||
|
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
|
||||||
|
|
||||||
|
expect(res.headers['cache-control']).toBe('no-store');
|
||||||
|
expect(res.body).toEqual([
|
||||||
|
{calendar: 'public', icalUrl: 'http://localhost:3000/calendar/events/public/ical'},
|
||||||
|
{calendar: 'members', icalUrl: 'http://localhost:3000/calendar/events/members/ical?password=member-secret'},
|
||||||
|
// URL-encoded, so a password with & or spaces survives.
|
||||||
|
{calendar: 'choir', icalUrl: 'http://localhost:3000/calendar/events/choir/ical?password=choir%20%26%20more'},
|
||||||
|
// management is missing: its credential is unset, and a URL without one could never work.
|
||||||
|
{calendar: 'birthdays', icalUrl: 'http://localhost:3000/calendar/events/birthdays/ical?password=choir%20%26%20more'}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands out URLs that actually open the calendar', async () => {
|
||||||
|
signedInAs(['calendar']);
|
||||||
|
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
|
||||||
|
const members = res.body.find((s: {calendar: string}) => s.calendar === 'members');
|
||||||
|
|
||||||
|
signedOut();
|
||||||
|
const path = new URL(members.icalUrl);
|
||||||
|
await request(app).get(path.pathname + path.search).expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
import {convertToIcal, escapeText, foldLine} from '../../src/models/calendar/events/icalgenerator.service.js';
|
||||||
|
import {Event} from '../../src/models/calendar/events/event.interface.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The iCal export is read by every subscribed calendar app, so a malformed
|
||||||
|
* file is an outage nobody on our side sees. These pin RFC 5545's text
|
||||||
|
* escaping and line folding, which the generator did not do before: a line
|
||||||
|
* break in a description ended the DESCRIPTION property early, and the new
|
||||||
|
* calendar frontend's multi-line description field would have produced
|
||||||
|
* exactly that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const event = (overrides: Partial<Event> = {}): Event => ({
|
||||||
|
eventId: 1,
|
||||||
|
calendarId: 1,
|
||||||
|
uuid: 'uuid-1',
|
||||||
|
name: 'Konzert',
|
||||||
|
description: '',
|
||||||
|
startDateTime: new Date('2026-10-03T17:00:00Z'),
|
||||||
|
endDateTime: new Date('2026-10-03T19:00:00Z'),
|
||||||
|
createdDate: new Date('2026-01-01T12:00:00Z'),
|
||||||
|
location: '',
|
||||||
|
createdBy: 'Anna',
|
||||||
|
url: '',
|
||||||
|
wholeDay: false,
|
||||||
|
repeatFrequency: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
/** RFC 5545 unfolding: a CRLF followed by one space or tab joins the lines. */
|
||||||
|
const unfold = (ical: string): string[] => ical.replace(/\r\n[ \t]/g, '').split('\r\n').filter(Boolean);
|
||||||
|
|
||||||
|
describe('escapeText', () => {
|
||||||
|
it('escapes backslash, semicolon, comma and line breaks', () => {
|
||||||
|
expect(escapeText('a\\b;c,d')).toBe('a\\\\b\\;c\\,d');
|
||||||
|
expect(escapeText('Zeile 1\nZeile 2\r\nZeile 3\rZeile 4')).toBe('Zeile 1\\nZeile 2\\nZeile 3\\nZeile 4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes the backslash first, so the others are not doubled', () => {
|
||||||
|
expect(escapeText(';')).toBe('\\;');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('foldLine', () => {
|
||||||
|
it('leaves a short line alone', () => {
|
||||||
|
expect(foldLine('SUMMARY:Konzert')).toBe('SUMMARY:Konzert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every physical line within 75 octets and unfolds back to the original', () => {
|
||||||
|
const line = 'DESCRIPTION:' + 'Probe im Gemeindehaus Süd – bitte Noten mitbringen. '.repeat(6);
|
||||||
|
const folded = foldLine(line);
|
||||||
|
for (const physical of folded.split('\r\n')) {
|
||||||
|
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
|
||||||
|
}
|
||||||
|
expect(folded.replace(/\r\n /g, '')).toBe(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never splits a multi-byte character', () => {
|
||||||
|
const line = 'SUMMARY:' + 'ü'.repeat(80);
|
||||||
|
for (const physical of foldLine(line).split('\r\n')) {
|
||||||
|
expect(physical.replace(/^ /, '')).toMatch(/^(SUMMARY:)?ü*$/);
|
||||||
|
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convertToIcal', () => {
|
||||||
|
it('terminates every line with CRLF', async () => {
|
||||||
|
const ical = await convertToIcal([event()]);
|
||||||
|
expect(ical.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||||||
|
expect(ical.replace(/\r\n/g, '')).not.toContain('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a multi-line description inside one DESCRIPTION property', async () => {
|
||||||
|
const ical = await convertToIcal([event({description: 'Einlass 18:30\nBeginn 19:00; Eintritt frei, Spenden willkommen'})]);
|
||||||
|
const lines = unfold(ical);
|
||||||
|
expect(lines).toContain('DESCRIPTION:Einlass 18:30\\nBeginn 19:00\\; Eintritt frei\\, Spenden willkommen');
|
||||||
|
// Nothing leaked out as a line of its own.
|
||||||
|
expect(lines.some((l) => l.startsWith('Beginn'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes the title and the location too', async () => {
|
||||||
|
const lines = unfold(await convertToIcal([event({name: 'Konzert, Teil 2', location: 'Kirche; Karlsruhe'})]));
|
||||||
|
expect(lines).toContain('SUMMARY:Konzert\\, Teil 2');
|
||||||
|
expect(lines).toContain('LOCATION:Kirche\\; Karlsruhe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not escape the URL, but keeps it on one line', async () => {
|
||||||
|
const lines = unfold(await convertToIcal([event({url: 'https://nachklang.art/konzert?a=1,2'})]));
|
||||||
|
expect(lines).toContain('URL:https://nachklang.art/konzert?a=1,2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still writes the yearly rule for birthdays', async () => {
|
||||||
|
const lines = unfold(await convertToIcal([event({repeatFrequency: 'YEARLY', wholeDay: true})]));
|
||||||
|
expect(lines).toContain('RRULE:FREQ=YEARLY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the event\'s own dates untouched', async () => {
|
||||||
|
// formatDate used to add the whole-day end's extra day to the caller's Date in place.
|
||||||
|
const whole = event({
|
||||||
|
wholeDay: true,
|
||||||
|
startDateTime: new Date('2026-10-03T00:00:00'),
|
||||||
|
endDateTime: new Date('2026-10-03T23:59:00')
|
||||||
|
});
|
||||||
|
const endBefore = whole.endDateTime.getTime();
|
||||||
|
await convertToIcal([whole]);
|
||||||
|
expect(whole.endDateTime.getTime()).toBe(endBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,11 +47,19 @@ const RECIPIENT = {
|
|||||||
guestNames: ['Erika Mustermann', 'Hans Mustermann']
|
guestNames: ['Erika Mustermann', 'Hans Mustermann']
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default: no event_ticket_settings row, same "absence over sentinels" case
|
||||||
|
// as everywhere else - ticketsAreMailed reads this as false (not mailed).
|
||||||
|
const makeSettingsConn = (ticketsMailed?: boolean) => ({
|
||||||
|
query: vi.fn().mockResolvedValue(ticketsMailed === undefined ? [] : [{tickets_mailed: ticketsMailed ? 1 : 0}]),
|
||||||
|
end: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetEvent.mockResolvedValue(EVENT);
|
mockGetEvent.mockResolvedValue(EVENT);
|
||||||
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
|
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
|
||||||
mockSendMail.mockResolvedValue(true);
|
mockSendMail.mockResolvedValue(true);
|
||||||
|
mockGetConnection.mockResolvedValue(makeSettingsConn());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sendRedemptionConfirmation', () => {
|
describe('sendRedemptionConfirmation', () => {
|
||||||
@@ -95,6 +103,32 @@ describe('sendRedemptionConfirmation', () => {
|
|||||||
|
|
||||||
expect(await sendRedemptionConfirmation(RECIPIENT)).toBe(false);
|
expect(await sendRedemptionConfirmation(RECIPIENT)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds the Abendkasse pickup notice when the event does not mail tickets', async () => {
|
||||||
|
mockGetConnection.mockResolvedValue(makeSettingsConn(false));
|
||||||
|
|
||||||
|
await sendRedemptionConfirmation(RECIPIENT);
|
||||||
|
|
||||||
|
const body = mockSendMail.mock.calls[0][2];
|
||||||
|
expect(body).toContain('Abendkasse');
|
||||||
|
expect(body).toContain('Erika Mustermann');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the pickup notice when there is no ticket-shop settings row at all', async () => {
|
||||||
|
mockGetConnection.mockResolvedValue(makeSettingsConn());
|
||||||
|
|
||||||
|
await sendRedemptionConfirmation(RECIPIENT);
|
||||||
|
|
||||||
|
expect(mockSendMail.mock.calls[0][2]).toContain('Abendkasse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the pickup notice when the event mails tickets', async () => {
|
||||||
|
mockGetConnection.mockResolvedValue(makeSettingsConn(true));
|
||||||
|
|
||||||
|
await sendRedemptionConfirmation(RECIPIENT);
|
||||||
|
|
||||||
|
expect(mockSendMail.mock.calls[0][2]).not.toContain('Abendkasse');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('recordConfirmationEmailResult', () => {
|
describe('recordConfirmationEmailResult', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user