6 Commits

Author SHA1 Message Date
Paddy a574e93359 Escape and fold the iCal export, serve it as text/calendar, add subscription links
- iCal export: RFC 5545 text escaping for SUMMARY, DESCRIPTION and LOCATION,
  line folding at 75 octets (UTF-8 aware), CRLF line endings, and
  Content-Type text/calendar instead of the inferred text/html. A line break
  in a description used to end the property early; the new calendar
  frontend has a multi-line description field.
- formatDate no longer shifts the event's end date in place.
- GET /calendar/events/subscriptions: the iCal URL of every calendar, with
  its shared password where needed, for the calendar app's "Abonnieren"
  dialog. Editors only (requireAppAccess('calendar')), the shared password
  is not accepted there, never cached; calendars without a configured
  credential are left out.
- Tests for both; documented in docs/calendar-ical.md.

Backwards compatible with the current Angular calendar app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:15:59 +02:00
Paddy d522f35663 Merge pull request 'Add a per-event "tickets mailed" flag and mention Abendkasse pickup in the confirmation email' (#18) from feature/abendkasse-pickup-notice into master
Jenkins Production Deployment
Reviewed-on: #18
2026-09-15 20:55:08 +00:00
Paddy e7d76f40de Add a per-event "tickets mailed" flag and mention Abendkasse pickup in the confirmation email
New event_ticket_settings.tickets_mailed column (default false, since
no event mails physical tickets today). When false, the redemption
confirmation email tells the guest their tickets await pickup at the
Abendkasse under their name instead. Read directly by
sendRedemptionConfirmation from event_ticket_settings, so both send
paths (redeem flow, admin resend) pick it up without either caller
changing.

Also fixes docker/init/03-tickets-schema.sql, found missing the
SOURCE line for migration 003 while adding 004 - local tickets dev
databases have been silently missing confirmation_email_status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:46:56 +02:00
Paddy 5b3e10be94 Merge pull request 'Expose each concert's redemption deadline via the voucher/event APIs' (#17) from feature/voucher-redemption-deadline into master
Jenkins Production Deployment
Reviewed-on: #17
2026-09-15 17:32:38 +00:00
Paddy c53d38a0e9 Expose each concert's redemption deadline via the voucher/event APIs
The value already lived in event_ticket_settings but only the derived
deadlinePassed boolean reached callers - guests and the PDF export
couldn't show *when* a deadline is, only whether it already passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:30:42 +02:00
Paddy 3fc6894070 Merge pull request 'Record the calendar migration as complete and deployed' (#16) from docs/calendar-migration-complete into master
Reviewed-on: #16
2026-09-06 21:53:12 +00:00
16 changed files with 486 additions and 29 deletions
+13
View File
@@ -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.
+2
View File
@@ -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;
+65
View File
@@ -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.
+8
View File
@@ -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;
+60 -1
View File
@@ -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 -2
View File
@@ -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 {
+15 -3
View File
@@ -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 {
+49
View File
@@ -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);
});
});
+111
View File
@@ -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);
});
});
+34
View File
@@ -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', () => {