Compare commits

..
Author SHA1 Message Date
PaddyandClaude Opus 5.5 bfb8f2ff9c Add a read-only role per app for calendar, feedback and tickets
A grant can now be `read` instead of `access`: requireAppAccess lets it
through for GET/HEAD/OPTIONS and answers 403 to every other method, so
new write routes are covered without being marked. `read` and `access`
on the same app are rejected together; user management has no read role.
/tickets/admin/me and /feedback/admin/me report `readOnly`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:24:28 +01:00
Paddy bc865813cb Merge pull request 'Fix issue where email would still be required for personalized vouchers in the backend' (#19) from feature/optional-personalized-voucher-email into master
Jenkins Production Deployment
Reviewed-on: #19
2026-09-21 21:01:40 +00:00
Paddy e8f971b54e Fix issue where email would still be required for personalized vouchers in the backend 2026-09-21 23:01:22 +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
PaddyandClaude Sonnet 5 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
PaddyandClaude Sonnet 5 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
20 changed files with 514 additions and 36 deletions

No files matched your search

+16 -2
View File
@@ -47,8 +47,9 @@ and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
A permission is **(app, role)** in `user_app_permissions`, keyed on
`(user_id, app, role)` so one user can hold several roles per app. `access` is the only role
today and means "may use this app at all"; `APP_ROLES` in `admin.schema.ts` is the contract,
`(user_id, app, role)` so one user can hold several roles per app. Two roles exist: `access`
(full access) and `read` (read-only, not for `admin`) - see `docs/admin-roles.md`, including
how `requireAppAccess` enforces `read` by HTTP method; `APP_ROLES` in `admin.schema.ts` is the contract,
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to
narrow to one role, and queries the database on every request (no cookie cache) so disabling
@@ -89,6 +90,19 @@ error. Only the integration tests catch this.
`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.
**`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.
**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;
SOURCE /migrations/tickets/001_init.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;
+73
View File
@@ -0,0 +1,73 @@
# Admin roles: full access and read-only
Added 2026-10-29. A user's permission is a list of **(app, role)** grants in
`user_app_permissions` (see `src/models/admin/admin.schema.ts`). Each app can now be granted at
one of two levels:
| Role | UI label (admin app) | Apps | Meaning |
|----------|----------------------|-------------------------------|--------------------------------------------|
| `access` | Vollzugriff | calendar, feedback, tickets, admin | Everything in that app, as before. |
| `read` | Nur lesen | calendar, feedback, tickets | See everything, change nothing. |
Rules:
- **`access` and `read` on the same app are mutually exclusive.** `toPermissions` returns `null`
for a list holding both, so `PUT /admin/users/:id/permissions` and `POST /admin/invitations`
answer 400. There is no "higher one wins".
- **`admin` (user management) has no `read` role.** Viewing it means viewing other people's
email addresses, IPs and sessions; that is a different trust level from viewing concerts.
- Existing grants were not migrated: every pre-existing row is `access` and still means full
access.
## Enforcement
`requireAppAccess(app)` (no role argument - which is how every app router uses it) does, after
the existing session/disabled/has-any-grant checks:
- `GET`, `HEAD`, `OPTIONS` - allowed for any grant on the app.
- anything else - allowed only with `access` (`canWrite`). A `read`-only user gets
`403 {status: 'FORBIDDEN', message: 'Du hast für diesen Bereich nur Lesezugriff.'}`.
This is by method on purpose: a write route added later is covered without being marked. It
relies on one convention that **must stay true**: admin routes read with GET and change with
POST/PUT/PATCH/DELETE. When this was built, every GET handler in tickets, feedback and calendar
was checked to call only read functions (no SQL writes, no mail). A GET with a side effect, or a
POST that only reads, breaks the read-only role in one direction or the other.
Consequences worth knowing:
- "Resend confirmation email" (tickets) is a POST and therefore not available read-only - by
decision: it has an effect outside the system.
- CSV exports and reports are GETs and therefore available read-only - also by decision.
Read-only means all data, including personal data. A restricted variant would be a new role.
- `requireAppAccess(app, role)` with an explicit role does **not** apply the method rule: the
caller has already named exactly the grant it needs.
## Telling the frontend
- `GET /tickets/admin/me` and `GET /feedback/admin/me` return `readOnly: boolean` alongside
`email` and `fullName`. Frontends hide write controls when it is `true`; the API refuses the
writes regardless.
- `GET /admin/me` returns the full `permissions` list; `isReadOnly(permissions, app)` in
`admin.schema.ts` is the same rule.
## Calendar
The API side is done: calendar writes sit behind `requireAppAccess('calendar')` and so refuse a
`read`-only user, and the optional-cookie reads (`/:calendar/json`, `/ical`) check
`access.apps.includes('calendar')`, which a `read` grant satisfies - read-only users see the
restricted calendars.
The current Angular frontend (`Calendar_Frontend`) was deliberately **not** changed, because it
is being rewritten in Next.js. Until then a read-only calendar user sees the edit buttons and
gets the 403 above when using them. Contract for the rewrite: there is no `/calendar/.../me`
with `readOnly`; read `GET /admin/me` and compute `isReadOnly(permissions, 'calendar')` - or add
a calendar `/me` mirroring tickets/feedback.
## Adding a finer role later
`canWrite` checks for `access` specifically, so a new role (e.g. `tickets:redeem`) grants no
writes through the method rule. Watch out: tickets and feedback apply the role-less
`requireAdminAuth` once at the top of their admin router, so that gate would already refuse the
new role's POST before any route-level `requireAppAccess(app, 'redeem')` runs. A finer role
therefore needs `canWrite` (or the top-level gate) taught about it, not just a route-level check.
+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;
+15 -1
View File
@@ -2,9 +2,14 @@ import express from 'express';
import {fromNodeHeaders} from 'better-auth/node';
import {auth} from './admin.auth.js';
import * as UsersService from './users/users.admin.service.js';
import {AppName, AppPermission, AppRole} from './admin.schema.js';
import {AppName, AppPermission, AppRole, canWrite} from './admin.schema.js';
import {sendServerError} from './admin.errors.js';
// What a read-only grant may do. Every admin route reads with GET and changes
// with POST/PUT/PATCH/DELETE - keep it that way, or a read-only user either
// loses a view or gains a write.
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* The one authenticator for every admin area in this API. It replaces
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
@@ -131,6 +136,15 @@ export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestH
return;
}
// The read-only level, enforced by method rather than per route so a
// write route added later is covered without being marked. Only the
// role-less form applies it: a caller asking for a specific role has
// already said exactly what is required.
if (role === undefined && !READ_METHODS.has(req.method) && !canWrite(access.permissions, app)) {
forbidden(res, 'Du hast für diesen Bereich nur Lesezugriff.');
return;
}
res.locals.admin = access;
next();
} catch (e: any) {
+36 -4
View File
@@ -35,13 +35,26 @@ export const isAppName = (value: unknown): value is AppName => {
*/
export const ACCESS_ROLE = 'access';
/**
* Read-only access to an app: everything `access` may see, nothing it may
* change. requireAppAccess enforces it by HTTP method - a user holding only
* this role gets GET/HEAD/OPTIONS and a 403 for anything else - so a new write
* route is covered without anyone remembering to mark it.
*
* It and `access` are mutually exclusive on one app (toPermissions rejects a
* list holding both), because "full access and also read-only" has no sensible
* reading. The `admin` app deliberately has no read role: viewing user
* management means viewing other people's addresses, IPs and sessions.
*/
export const READ_ROLE = 'read';
export type AppRole = string;
/** Every role that exists, per app, in display order. Extend to add one. */
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
calendar: [ACCESS_ROLE],
feedback: [ACCESS_ROLE],
tickets: [ACCESS_ROLE],
calendar: [ACCESS_ROLE, READ_ROLE],
feedback: [ACCESS_ROLE, READ_ROLE],
tickets: [ACCESS_ROLE, READ_ROLE],
admin: [ACCESS_ROLE]
};
@@ -85,7 +98,7 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
}
const seen = new Set<string>();
return permissions.filter(permission => {
const unique = permissions.filter(permission => {
const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) {
return false;
@@ -93,6 +106,11 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
seen.add(key);
return true;
});
// Rejected rather than resolved to either level: whichever one we picked,
// the caller asked for something else.
const contradictory = APP_NAMES.some(app => seen.has(`${app}:${ACCESS_ROLE}`) && seen.has(`${app}:${READ_ROLE}`));
return contradictory ? null : unique;
};
/** The distinct apps a permission list grants any access to. */
@@ -100,6 +118,20 @@ export const appsOf = (permissions: AppPermission[]): AppName[] => {
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
};
/**
* Whether these grants allow changing anything in `app`. Only `access` does:
* `read` is the read-only level, and a finer role added later grants exactly
* what its own check says, not writes in general.
*/
export const canWrite = (permissions: AppPermission[], app: AppName): boolean => {
return permissions.some(permission => permission.app === app && permission.role === ACCESS_ROLE);
};
/** Holds some grant on `app`, but none that allows changes. */
export const isReadOnly = (permissions: AppPermission[], app: AppName): boolean => {
return appsOf(permissions).includes(app) && !canWrite(permissions, app);
};
export interface UserTable {
id: string;
name: string;
+10 -1
View File
@@ -3,6 +3,7 @@
*/
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../feedback.auth.js';
import {isReadOnly} from '../../admin/admin.schema.js';
import {sendServerError} from '../feedback.errors.js';
import {eventsAdminRouter} from './events.admin.router.js';
import {songsAdminRouter} from './songs.admin.router.js';
@@ -40,13 +41,21 @@ adminRouter.use(requireAdminAuth);
* type: string
* fullName:
* type: string
* readOnly:
* type: boolean
* description: Holds only the read role for feedback - every write answers 403.
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
res.status(200).send({
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// Lets the frontend hide what the API would refuse anyway.
readOnly: isReadOnly(res.locals.admin.permissions, 'feedback')
});
});
/**
+19 -1
View File
@@ -1,5 +1,6 @@
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../tickets.auth.js';
import {isReadOnly} from '../../admin/admin.schema.js';
import {vouchersAdminRouter} from './vouchers.admin.router.js';
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
import {eventsAdminRouter} from './events.admin.router.js';
@@ -21,13 +22,30 @@ adminRouter.use(requireAdminAuth);
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* fullName:
* type: string
* readOnly:
* type: boolean
* description: Holds only the read role for tickets - every write answers 403.
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
res.status(200).send({
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// Lets the frontend hide what the API would refuse anyway.
readOnly: isReadOnly(res.locals.admin.permissions, 'tickets')
});
});
adminRouter.use('/vouchers', vouchersAdminRouter);
@@ -93,7 +93,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* /tickets/admin/events/{eventId}/settings:
* put:
* 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]
* security:
* - AdminSessionCookie: []
@@ -122,6 +122,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* requireAddress:
* type: boolean
* 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:
* 200:
* description: Saved
@@ -132,7 +135,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
*/
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
try {
const {capacity, redemptionDeadline, collectAddress, requireAddress} = req.body || {};
const {capacity, redemptionDeadline, collectAddress, requireAddress, ticketsMailed} = req.body || {};
await EventsAdminService.setEventSettings(Number(req.params.eventId), {
capacity: capacity ?? null,
// 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".
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
collectAddress: !!collectAddress,
requireAddress: !!collectAddress && !!requireAddress
requireAddress: !!collectAddress && !!requireAddress,
ticketsMailed: !!ticketsMailed
});
res.status(200).send({status: 'OK'});
} catch (e: any) {
@@ -14,6 +14,7 @@ export interface EventPickerEntry {
startDateTime: Date;
location: string;
status: string | undefined;
redemptionDeadline: Date | null;
}
/**
@@ -26,19 +27,26 @@ export interface EventPickerEntry {
*/
export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => {
let conn = await NachklangTicketsDB.getConnection();
let enabledEventIds: number[];
let deadlineByEventId: Map<number, Date | null>;
try {
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
enabledEventIds = rows.map((r: any) => r.event_id);
const rows = await conn.query('SELECT event_id, redemption_deadline FROM event_ticket_settings');
deadlineByEventId = new Map(rows.map((r: any) => [r.event_id, r.redemption_deadline]));
} finally {
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
.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());
};
@@ -59,7 +67,9 @@ export const listAvailableEventsToAdd = async (): Promise<EventPickerEntry[]> =>
const events = await CalendarEventsService.getAllEventsAdmin(PUBLIC_CALENDAR_ID);
return events
.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());
};
@@ -89,6 +99,7 @@ export const getEventStats = async (eventId: number): Promise<EventStats> => {
redemptionDeadline: ticketState.redemptionDeadline,
collectAddress: ticketState.collectAddress,
requireAddress: ticketState.requireAddress,
ticketsMailed: ticketState.ticketsMailed,
guestsUsed: ticketState.guestsUsed,
spotsRemaining: ticketState.spotsRemaining,
unusedCodes,
@@ -109,10 +120,10 @@ export const setEventSettings = async (eventId: number, settings: Omit<EventTick
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address)`,
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0]
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address, tickets_mailed)
VALUES (?,?,?,?,?,?)
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, settings.ticketsMailed ? 1 : 0]
);
await conn.commit();
} catch (err) {
@@ -129,12 +129,13 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* type: array
* items:
* type: object
* required: [name, email, eventIds]
* required: [name, eventIds]
* properties:
* name:
* type: string
* email:
* type: string
* description: Optional - a guest can supply their own email when redeeming.
* eventIds:
* type: array
* items:
@@ -123,10 +123,10 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
throw new Error('at least one row is required');
}
for (const row of rows) {
if (!row.name || !row.email || row.eventIds.length === 0) {
throw new Error('each row requires a name, email, and at least one eligible event');
if (!row.name || row.eventIds.length === 0) {
throw new Error('each row requires a name and at least one eligible event');
}
if (!isValidEmail(row.email)) {
if (row.email && !isValidEmail(row.email)) {
throw new Error(`"${row.email}" does not look like a valid email address`);
}
}
@@ -147,7 +147,7 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
codes.push(code);
await conn.query(
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
[code, 'UNUSED', row.maxGuests, row.name, row.email, batchId, createdByEmail]
[code, 'UNUSED', row.maxGuests, row.name, row.email || null, batchId, createdByEmail]
);
for (const eventId of row.eventIds) {
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
@@ -39,6 +39,7 @@ export const validateVoucher = async (code: string): Promise<VoucherValidation |
name: event.name,
startDateTime: event.startDateTime,
location: event.location,
redemptionDeadline: ticketState.redemptionDeadline,
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
spotsRemaining: ticketState.spotsRemaining,
+4 -2
View File
@@ -4,6 +4,7 @@ export interface EventTicketState {
redemptionDeadline: Date | null;
collectAddress: boolean;
requireAddress: boolean;
ticketsMailed: boolean;
guestsUsed: number;
spotsRemaining: number | null;
}
@@ -21,7 +22,7 @@ export interface EventTicketState {
* (uncapped) don't need this - there's no cap to race against.
*/
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 capacity = settingsRows.length > 0 ? settingsRows[0].capacity : 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
// shown/collected at all otherwise, so "required" is moot.
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(
"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 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[];
}
/**
* 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
* 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;
}
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 body =
`Hallo ${recipient.contactName},\n\n` +
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
`Ort: ${event.location}\n\n` +
`Angemeldete Gäste:\n${guestList}\n\n` +
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`;
`Angemeldete Gäste:\n${guestList}` +
pickupNotice +
`\n\nWir freuen uns auf dich!\n\nDein Nachklang-Team`;
let icsAttachment;
try {
+15 -3
View File
@@ -10,7 +10,7 @@
* enum: [ACTIVE, UNDONE]
* EligibleEvent:
* type: object
* required: [eventId, name, startDateTime, location, deadlinePassed, isFull, collectAddress, requireAddress]
* required: [eventId, name, startDateTime, location, redemptionDeadline, deadlinePassed, isFull, collectAddress, requireAddress]
* properties:
* eventId:
* type: integer
@@ -23,6 +23,11 @@
* format: date-time
* location:
* type: string
* redemptionDeadline:
* type: string
* format: date-time
* nullable: true
* description: null when the event has no redemption deadline set
* deadlinePassed:
* type: boolean
* isFull:
@@ -144,7 +149,7 @@
* type: integer
* EventTicketSettings:
* type: object
* required: [eventId, collectAddress, requireAddress]
* required: [eventId, collectAddress, requireAddress, ticketsMailed]
* properties:
* eventId:
* type: integer
@@ -160,9 +165,12 @@
* requireAddress:
* type: boolean
* 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:
* type: object
* required: [eventId, collectAddress, requireAddress, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
* required: [eventId, collectAddress, requireAddress, ticketsMailed, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
* properties:
* eventId:
* type: integer
@@ -177,6 +185,8 @@
* type: boolean
* requireAddress:
* type: boolean
* ticketsMailed:
* type: boolean
* guestsUsed:
* type: integer
* spotsRemaining:
@@ -224,6 +234,7 @@ export interface EligibleEvent {
name: string;
startDateTime: Date;
location: string;
redemptionDeadline: Date | null;
deadlinePassed: boolean;
isFull: boolean;
spotsRemaining: number | null;
@@ -285,6 +296,7 @@ export interface EventTicketSettings {
redemptionDeadline: Date | null;
collectAddress: boolean;
requireAddress: boolean;
ticketsMailed: boolean;
}
export interface EventStats extends EventTicketSettings {
+59 -1
View File
@@ -15,7 +15,7 @@ import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models
const mockGetSession = auth.api.getSession as unknown as Mock;
const mockLoadAccess = UsersService.loadAccess as Mock;
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeReq = (method = 'GET'): Request => ({method, headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => {
const res: any = {};
@@ -219,4 +219,62 @@ describe('requireAppAccess', () => {
expect(next).toHaveBeenCalled();
});
describe('read-only grant', () => {
const readOnlyUser = {
...activeUser,
permissions: [{app: 'tickets', role: 'read'}],
apps: ['tickets']
};
it.each(['GET', 'HEAD', 'OPTIONS'])('lets %s through', async method => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(readOnlyUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(method), res, next);
expect(next).toHaveBeenCalled();
});
it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('403s %s', async method => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(readOnlyUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(method), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({message: 'Du hast für diesen Bereich nur Lesezugriff.'}));
expect(next).not.toHaveBeenCalled();
});
it('does not let full access to another app write here', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'tickets', role: 'read'}, {app: 'feedback', role: 'access'}],
apps: ['feedback', 'tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq('POST'), res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('lets full access write', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq('DELETE'), res, next);
expect(next).toHaveBeenCalled();
});
});
});
+41
View File
@@ -1,9 +1,12 @@
import {describe, expect, it} from 'vitest';
import {
ACCESS_ROLE,
READ_ROLE,
appsOf,
canWrite,
isAppPermission,
isAppRole,
isReadOnly,
toPermissions
} from '../../src/models/admin/admin.schema.js';
@@ -59,6 +62,44 @@ describe('toPermissions', () => {
it('reads an empty list as "no permissions", not as invalid', () => {
expect(toPermissions([])).toEqual([]);
});
it('reads the read-only role', () => {
expect(toPermissions([{app: 'tickets', role: READ_ROLE}])).toEqual([
{app: 'tickets', role: READ_ROLE}
]);
});
it('rejects full and read-only access to the same app together', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: READ_ROLE}])).toBeNull();
});
it('allows full access to one app next to read-only access to another', () => {
expect(toPermissions(['tickets', {app: 'feedback', role: READ_ROLE}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'feedback', role: READ_ROLE}
]);
});
it('rejects read-only access to user management', () => {
expect(toPermissions([{app: 'admin', role: READ_ROLE}])).toBeNull();
});
});
describe('canWrite / isReadOnly', () => {
it('lets only the access role write', () => {
const permissions = [
{app: 'tickets' as const, role: ACCESS_ROLE},
{app: 'feedback' as const, role: READ_ROLE}
];
expect(canWrite(permissions, 'tickets')).toBe(true);
expect(canWrite(permissions, 'feedback')).toBe(false);
expect(isReadOnly(permissions, 'tickets')).toBe(false);
expect(isReadOnly(permissions, 'feedback')).toBe(true);
});
it('is neither for an app without any grant', () => {
expect(canWrite([], 'calendar')).toBe(false);
expect(isReadOnly([], 'calendar')).toBe(false);
});
});
describe('isAppRole', () => {
+121
View File
@@ -0,0 +1,121 @@
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
import type {Application} from 'express';
import {createApp} from '../../src/app.factory.js';
import {READ_ROLE} from '../../src/models/admin/admin.schema.js';
import {closeDatabase, createAndAcceptInvitation, resetDatabase} from './helpers.js';
/**
* The read-only role end to end: a real session cookie through the real
* middleware into each app's routers. The writes are rejected before any
* handler runs, which is why this suite needs no tickets/feedback/calendar
* database - a 403 here proves the gate held, not that a query failed.
*/
let app: Application;
beforeAll(() => {
app = createApp();
});
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
const readOnlyEverywhere = async () => {
return createAndAcceptInvitation(app, 'reader@nachklang.art', 'Reader', [
{app: 'tickets', role: READ_ROLE},
{app: 'feedback', role: READ_ROLE},
{app: 'calendar', role: READ_ROLE}
]);
};
describe('read-only access', () => {
it('reports readOnly on each app\'s /me', async () => {
const {agent} = await readOnlyEverywhere();
const tickets = await agent.get('/tickets/admin/me');
const feedback = await agent.get('/feedback/admin/me');
expect(tickets.status).toBe(200);
expect(tickets.body.readOnly).toBe(true);
expect(feedback.status).toBe(200);
expect(feedback.body.readOnly).toBe(true);
});
it('reports readOnly false for full access', async () => {
const {agent} = await createAndAcceptInvitation(app, 'full@nachklang.art', 'Full', ['tickets']);
const res = await agent.get('/tickets/admin/me');
expect(res.status).toBe(200);
expect(res.body.readOnly).toBe(false);
});
it.each([
['post', '/tickets/admin/vouchers/wildcard'],
['post', '/tickets/admin/redemptions/1/resend-confirmation'],
['patch', '/tickets/admin/redemptions/1'],
['put', '/tickets/admin/events/1/settings'],
['delete', '/feedback/admin/submissions/1'],
['post', '/feedback/admin/events'],
['post', '/calendar/events'],
['delete', '/calendar/events/1']
] as const)('refuses %s %s', async (method, path) => {
const {agent} = await readOnlyEverywhere();
const res = await agent[method](path).send({});
expect(res.status).toBe(403);
expect(res.body.message).toBe('Du hast für diesen Bereich nur Lesezugriff.');
});
it('does not open user management', async () => {
const {agent} = await readOnlyEverywhere();
expect((await agent.get('/admin/users')).status).toBe(403);
});
});
describe('granting read-only access', () => {
const signedInAdmin = () => createAndAcceptInvitation(app, 'admin@nachklang.art', 'Admin', ['admin']);
it('stores the read role per app', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.put(`/admin/users/${other.userId}/permissions`)
.send({permissions: [{app: 'tickets', role: READ_ROLE}, {app: 'feedback', role: 'access'}]});
expect(res.status).toBe(200);
expect(res.body.permissions).toEqual(expect.arrayContaining([
{app: 'tickets', role: READ_ROLE},
{app: 'feedback', role: 'access'}
]));
});
it('rejects full and read-only access to the same app', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.put(`/admin/users/${other.userId}/permissions`)
.send({permissions: [{app: 'tickets', role: READ_ROLE}, {app: 'tickets', role: 'access'}]});
expect(res.status).toBe(400);
});
it('rejects inviting someone with read-only user management', async () => {
const {agent} = await signedInAdmin();
const res = await agent
.post('/admin/invitations')
.send({email: 'new@nachklang.art', name: 'New', permissions: [{app: 'admin', role: READ_ROLE}]});
expect(res.status).toBe(400);
});
});
+34
View File
@@ -47,11 +47,19 @@ const RECIPIENT = {
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(() => {
vi.clearAllMocks();
mockGetEvent.mockResolvedValue(EVENT);
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
mockSendMail.mockResolvedValue(true);
mockGetConnection.mockResolvedValue(makeSettingsConn());
});
describe('sendRedemptionConfirmation', () => {
@@ -95,6 +103,32 @@ describe('sendRedemptionConfirmation', () => {
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', () => {