Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb8f2ff9c | ||
|
|
bc865813cb | ||
|
|
e8f971b54e | ||
|
|
d522f35663 | ||
|
|
e7d76f40de | ||
|
|
5b3e10be94 | ||
|
|
c53d38a0e9 | ||
|
|
3fc6894070 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user