3ea9e630ed
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:
- "type": "module", module nodenext, target ES2024, .js suffixes on all
relative imports, require('mariadb'|'cors') replaced by imports, and
export= packages (winston, app-root-path, bcrypt) consumed via default
imports. The logger now uses appRoot.path explicitly.
- TypeScript 5.9, @types/node 26, tslint removed. Node 26 pinned via
engines and .nvmrc (Plesk runs 26).
- Jest 28 + ts-jest replaced by vitest 5. Eight test files depend on
hoisted module mocks with static imports and resetModules + require,
which Jest's ESM mode does not support; vitest keeps them nearly
verbatim. Coverage via @vitest/coverage-v8 (lcov), Sonar generic report
via vitest-sonar-reporter, so sonar-project.properties is unchanged.
vitest.config.ts sets FEEDBACK_IP_SALT so the suite passes without a
local .env.
- dotenv 8 -> 16 and axios 0.24 -> 1.x: their old typings are not
resolvable under nodenext.
- autoCommit: false dropped from the pool configs; it is not a mariadb
connector option and was silently ignored.
tsc clean, 96/96 tests green, compiled app boots and serves /, /docs and
CORS under Node ESM.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
/**
|
|
* Required External Modules and Interfaces
|
|
*/
|
|
import express, {Request, Response} from 'express';
|
|
import * as EventsAdminService from './events.admin.service.js';
|
|
import * as SongsAdminService from './songs.admin.service.js';
|
|
import {sendServerError} from '../feedback.errors.js';
|
|
|
|
/**
|
|
* Router Definition
|
|
*/
|
|
export const eventsAdminRouter = express.Router();
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events:
|
|
* get:
|
|
* summary: List all events (admin)
|
|
* description: All events, published or not, past or future, with submission counts.
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/EventAdminSummary'
|
|
* 401:
|
|
* description: Unauthorized
|
|
* post:
|
|
* summary: Create an event
|
|
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [name, eventDate]
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* subtitle:
|
|
* type: string
|
|
* eventDate:
|
|
* type: string
|
|
* format: date
|
|
* feedbackDeadline:
|
|
* type: string
|
|
* format: date-time
|
|
* introText:
|
|
* type: string
|
|
* posterImageUrl:
|
|
* type: string
|
|
* responses:
|
|
* 201:
|
|
* description: Created
|
|
* 400:
|
|
* description: Missing required fields
|
|
* 401:
|
|
* description: Unauthorized
|
|
*/
|
|
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
|
try {
|
|
res.status(200).send(await EventsAdminService.listEventsAdmin());
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
|
try {
|
|
const {name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl} = req.body || {};
|
|
if (!name || !eventDate) {
|
|
res.status(400).send({status: 'BAD_REQUEST', message: 'name and eventDate are required'});
|
|
return;
|
|
}
|
|
const eventId = await EventsAdminService.createEvent(
|
|
{name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl},
|
|
res.locals.admin.email
|
|
);
|
|
res.status(201).send({eventId});
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}:
|
|
* get:
|
|
* summary: Get one event (admin)
|
|
* description: Full event detail including setlist and assigned questions.
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/EventAdminDetail'
|
|
* 404:
|
|
* description: Unknown event
|
|
* 401:
|
|
* description: Unauthorized
|
|
* put:
|
|
* summary: Update an event
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Updated
|
|
* 404:
|
|
* description: Unknown event
|
|
* 401:
|
|
* description: Unauthorized
|
|
* delete:
|
|
* summary: Delete an event
|
|
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: force
|
|
* schema:
|
|
* type: boolean
|
|
* responses:
|
|
* 204:
|
|
* description: Deleted
|
|
* 404:
|
|
* description: Unknown event
|
|
* 409:
|
|
* description: Submissions exist and force was not set
|
|
* 401:
|
|
* description: Unauthorized
|
|
*/
|
|
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
|
|
try {
|
|
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
|
|
if (!event) {
|
|
res.status(404).send({status: 'NOT_FOUND'});
|
|
return;
|
|
}
|
|
res.status(200).send(event);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
eventsAdminRouter.put('/:eventId', async (req: Request, res: Response) => {
|
|
try {
|
|
const updated = await EventsAdminService.updateEvent(Number(req.params.eventId), req.body || {});
|
|
if (!updated) {
|
|
res.status(404).send({status: 'NOT_FOUND'});
|
|
return;
|
|
}
|
|
res.status(200).send({status: 'OK'});
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
|
try {
|
|
const force = req.query.force === 'true';
|
|
const result = await EventsAdminService.deleteEvent(Number(req.params.eventId), force);
|
|
if (result === 'NOT_FOUND') {
|
|
res.status(404).send({status: 'NOT_FOUND'});
|
|
return;
|
|
}
|
|
if (result === 'HAS_SUBMISSIONS') {
|
|
res.status(409).send({status: 'HAS_SUBMISSIONS', message: 'This event has submissions. Pass ?force=true to delete anyway.'});
|
|
return;
|
|
}
|
|
res.status(204).send();
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}/songs:
|
|
* get:
|
|
* summary: Get an event's setlist
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 401:
|
|
* description: Unauthorized
|
|
* post:
|
|
* summary: Add a song to an event's setlist
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [title]
|
|
* properties:
|
|
* title:
|
|
* type: string
|
|
* composer:
|
|
* type: string
|
|
* responses:
|
|
* 201:
|
|
* description: Created
|
|
* 400:
|
|
* description: Missing title
|
|
* 401:
|
|
* description: Unauthorized
|
|
*/
|
|
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
|
|
try {
|
|
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
|
|
if (!event) {
|
|
res.status(404).send({status: 'NOT_FOUND'});
|
|
return;
|
|
}
|
|
res.status(200).send(event.songs);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) => {
|
|
try {
|
|
const {title, composer} = req.body || {};
|
|
if (!title) {
|
|
res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'});
|
|
return;
|
|
}
|
|
const songId = await SongsAdminService.addSong(Number(req.params.eventId), title, composer || null);
|
|
res.status(201).send({songId});
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}/songs/order:
|
|
* put:
|
|
* summary: Bulk reorder an event's setlist
|
|
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [songIds]
|
|
* properties:
|
|
* songIds:
|
|
* type: array
|
|
* items:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Reordered
|
|
* 401:
|
|
* description: Unauthorized
|
|
*/
|
|
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
|
|
try {
|
|
const songIds: number[] = req.body?.songIds || [];
|
|
await EventsAdminService.reorderSongs(Number(req.params.eventId), songIds);
|
|
res.status(200).send({status: 'OK'});
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /feedback/admin/events/{eventId}/questions:
|
|
* get:
|
|
* summary: Get an event's assigned questions
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 401:
|
|
* description: Unauthorized
|
|
* put:
|
|
* summary: Bulk-set an event's assigned questions
|
|
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
|
|
* tags: [feedback-admin]
|
|
* parameters:
|
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
* - in: path
|
|
* name: eventId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [items]
|
|
* properties:
|
|
* items:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* questionId:
|
|
* type: integer
|
|
* position:
|
|
* type: integer
|
|
* isActive:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: Saved
|
|
* 401:
|
|
* description: Unauthorized
|
|
*/
|
|
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
|
|
try {
|
|
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
|
|
if (!event) {
|
|
res.status(404).send({status: 'NOT_FOUND'});
|
|
return;
|
|
}
|
|
res.status(200).send(event.questions);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
eventsAdminRouter.put('/:eventId/questions', async (req: Request, res: Response) => {
|
|
try {
|
|
const items = req.body?.items || [];
|
|
await EventsAdminService.setEventQuestions(Number(req.params.eventId), items);
|
|
res.status(200).send({status: 'OK'});
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|