2405625f99
feedback.auth.ts and tickets.auth.ts each become one binding to requireAppAccess. Everything downstream was already written against requireAdminAuth and res.locals.admin, and both still mean what they meant, so no router or service changed. What changed is the policy: an activated @nachklang.art account is no longer sufficient, an explicit per-app permission is. Three things followed from that and are not obvious from the diff: - APP_ORIGINS gets a production default. It feeds better-auth's trustedOrigins, and this is the first time the tickets and feedback origins matter there - before, the only browser origin that ever reached /admin/auth was the admin app itself. An origin missing from that list fails in a way that is easy to misread: sign-in works, the app works, and only sign-out returns an origin error. - Nothing reads X-Session-* any more; these two files were the last readers, and the calendar module passes its session in query parameters. The headers stay in the CORS allowedHeaders only so a browser still running a pre-cutover bundle gets a clean 401 rather than a preflight failure, and can come out once both frontends are deployed. - 40 admin operations documented a required X-Session-Id/X-Session-Key in swagger. They now declare the AdminSessionCookie scheme the admin module already defined, and each documents a 403 next to its 401. The integration assertions flip as their own comment predicted: one admin cookie opens both /feedback/admin/me and /tickets/admin/me, a user holding only feedback gets 200 and 403 respectively, and a legacy header session gets 401. The unit test that covered the old header authenticator is replaced by one asserting each module is bound to its own app and that neither consults the calendar users service. 163 unit tests and 43 integration tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
7.8 KiB
TypeScript
233 lines
7.8 KiB
TypeScript
/**
|
|
* Required External Modules and Interfaces
|
|
*/
|
|
import express, {Request, Response} from 'express';
|
|
import * as ReportsAdminService from './reports.admin.service.js';
|
|
import * as CsvService from './csv.service.js';
|
|
import * as EventsAdminService from './events.admin.service.js';
|
|
import {sendServerError} from '../feedback.errors.js';
|
|
|
|
/**
|
|
* Router Definition
|
|
*/
|
|
export const reportsAdminRouter = express.Router();
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}/report:
|
|
* get:
|
|
* summary: Aggregated feedback report for one event
|
|
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
|
|
* tags: [feedback-admin]
|
|
* security:
|
|
* - AdminSessionCookie: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 404:
|
|
* description: Unknown event
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Signed in without the permission for this app, or account disabled
|
|
*/
|
|
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
|
|
try {
|
|
const report = await ReportsAdminService.getReport(Number(req.params.eventId));
|
|
if (!report) {
|
|
res.status(404).send({status: 'NOT_FOUND'});
|
|
return;
|
|
}
|
|
res.status(200).send(report);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}/guestbook:
|
|
* get:
|
|
* summary: Guest Book entries for one event
|
|
* description: Newest first, paginated.
|
|
* tags: [feedback-admin]
|
|
* security:
|
|
* - AdminSessionCookie: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: page
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: pageSize
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: search
|
|
* description: Filters entries whose name or message contains this text (case-insensitive).
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Signed in without the permission for this app, or account disabled
|
|
*/
|
|
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
|
|
try {
|
|
const page = Math.max(1, Number(req.query.page) || 1);
|
|
const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50));
|
|
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
|
|
const result = await ReportsAdminService.getGuestBookEntries(Number(req.params.eventId), page, pageSize, search);
|
|
res.status(200).send(result);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}/newsletter:
|
|
* get:
|
|
* summary: Newsletter signups for one event
|
|
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
|
|
* tags: [feedback-admin]
|
|
* security:
|
|
* - AdminSessionCookie: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: page
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: pageSize
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: search
|
|
* description: Filters entries whose first name, last name, or email contains this text (case-insensitive).
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Signed in without the permission for this app, or account disabled
|
|
*/
|
|
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
|
|
try {
|
|
const page = Math.max(1, Number(req.query.page) || 1);
|
|
const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50));
|
|
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
|
|
const result = await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId), page, pageSize, search);
|
|
res.status(200).send(result);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}/export/responses.csv:
|
|
* get:
|
|
* summary: CSV export of all answers for one event
|
|
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
|
|
* tags: [feedback-admin]
|
|
* security:
|
|
* - AdminSessionCookie: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: CSV file
|
|
* content:
|
|
* text/csv: {}
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Signed in without the permission for this app, or account disabled
|
|
*/
|
|
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
|
|
try {
|
|
const eventId = Number(req.params.eventId);
|
|
const event = await EventsAdminService.getEventAdmin(eventId);
|
|
if (!event) {
|
|
res.status(404).send({status: 'NOT_FOUND'});
|
|
return;
|
|
}
|
|
const csv = await CsvService.buildResponsesCsv(eventId);
|
|
res.status(200)
|
|
.set('Content-Type', 'text/csv; charset=utf-8')
|
|
.set('Content-Disposition', `attachment; filename="nachklang-feedback-${event.slug}.csv"`)
|
|
.send(csv);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}/export/guestbook.csv:
|
|
* get:
|
|
* summary: CSV export of Guest Book entries for one event
|
|
* tags: [feedback-admin]
|
|
* security:
|
|
* - AdminSessionCookie: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: CSV file
|
|
* content:
|
|
* text/csv: {}
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Signed in without the permission for this app, or account disabled
|
|
*/
|
|
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
|
|
try {
|
|
const eventId = Number(req.params.eventId);
|
|
const event = await EventsAdminService.getEventAdmin(eventId);
|
|
if (!event) {
|
|
res.status(404).send({status: 'NOT_FOUND'});
|
|
return;
|
|
}
|
|
const csv = await CsvService.buildGuestBookCsv(eventId);
|
|
res.status(200)
|
|
.set('Content-Type', 'text/csv; charset=utf-8')
|
|
.set('Content-Disposition', `attachment; filename="nachklang-feedback-guestbook-${event.slug}.csv"`)
|
|
.send(csv);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|