Add Feedback domain module: public submission flow, admin CRUD, reporting #7
@@ -0,0 +1,9 @@
|
|||||||
|
-- Nachklang e.V. Feedback module — adds a per-event poster image URL.
|
||||||
|
-- Apply manually against the FEEDBACK_DB database, after 001_init.sql:
|
||||||
|
-- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 002_add_poster_image_url.sql
|
||||||
|
--
|
||||||
|
-- Stores a URL only (e.g. an existing nachklang.art poster image) rather
|
||||||
|
-- than an uploaded file — the concert posters already live on the public
|
||||||
|
-- website, so there is no need for the feedback app to host its own copy.
|
||||||
|
ALTER TABLE events
|
||||||
|
ADD COLUMN poster_image_url VARCHAR(500) NULL AFTER intro_text;
|
||||||
@@ -33,6 +33,9 @@
|
|||||||
* feedbackDeadline:
|
* feedbackDeadline:
|
||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
|
* posterImageUrl:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
* isPublished:
|
* isPublished:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* submissionCount:
|
* submissionCount:
|
||||||
@@ -87,6 +90,7 @@ export interface EventAdminSummary {
|
|||||||
subtitle: string | null;
|
subtitle: string | null;
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
feedbackDeadline: string;
|
feedbackDeadline: string;
|
||||||
|
posterImageUrl: string | null;
|
||||||
isPublished: boolean;
|
isPublished: boolean;
|
||||||
submissionCount: number;
|
submissionCount: number;
|
||||||
}
|
}
|
||||||
@@ -110,6 +114,7 @@ export interface CreateEventInput {
|
|||||||
eventDate: string;
|
eventDate: string;
|
||||||
feedbackDeadline?: string;
|
feedbackDeadline?: string;
|
||||||
introText?: string;
|
introText?: string;
|
||||||
|
posterImageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateEventInput {
|
export interface UpdateEventInput {
|
||||||
@@ -119,6 +124,7 @@ export interface UpdateEventInput {
|
|||||||
feedbackDeadline?: string;
|
feedbackDeadline?: string;
|
||||||
isPublished?: boolean;
|
isPublished?: boolean;
|
||||||
introText?: string;
|
introText?: string;
|
||||||
|
posterImageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminQuestion {
|
export interface AdminQuestion {
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export const eventsAdminRouter = express.Router();
|
|||||||
* format: date-time
|
* format: date-time
|
||||||
* introText:
|
* introText:
|
||||||
* type: string
|
* type: string
|
||||||
|
* posterImageUrl:
|
||||||
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 201:
|
* 201:
|
||||||
* description: Created
|
* description: Created
|
||||||
@@ -77,13 +79,13 @@ eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const {name, subtitle, eventDate, feedbackDeadline, introText} = req.body || {};
|
const {name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl} = req.body || {};
|
||||||
if (!name || !eventDate) {
|
if (!name || !eventDate) {
|
||||||
res.status(400).send({status: 'BAD_REQUEST', message: 'name and eventDate are required'});
|
res.status(400).send({status: 'BAD_REQUEST', message: 'name and eventDate are required'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const eventId = await EventsAdminService.createEvent(
|
const eventId = await EventsAdminService.createEvent(
|
||||||
{name, subtitle, eventDate, feedbackDeadline, introText},
|
{name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl},
|
||||||
res.locals.admin.email
|
res.locals.admin.email
|
||||||
);
|
);
|
||||||
res.status(201).send({eventId});
|
res.status(201).send({eventId});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const mapSummaryRow = (row: any): EventAdminSummary => ({
|
|||||||
subtitle: row.subtitle,
|
subtitle: row.subtitle,
|
||||||
eventDate: row.event_date,
|
eventDate: row.event_date,
|
||||||
feedbackDeadline: row.feedback_deadline,
|
feedbackDeadline: row.feedback_deadline,
|
||||||
|
posterImageUrl: row.poster_image_url,
|
||||||
isPublished: !!row.is_published,
|
isPublished: !!row.is_published,
|
||||||
submissionCount: Number(row.submission_count)
|
submissionCount: Number(row.submission_count)
|
||||||
});
|
});
|
||||||
@@ -94,11 +95,11 @@ export const createEvent = async (input: CreateEventInput, createdByEmail: strin
|
|||||||
: computeDefaultDeadline(input.eventDate);
|
: computeDefaultDeadline(input.eventDate);
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, created_by_email)
|
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, created_by_email)
|
||||||
VALUES (?,?,?,?,?,?,?) RETURNING event_id`;
|
VALUES (?,?,?,?,?,?,?,?) RETURNING event_id`;
|
||||||
const res = await conn.query(query, [
|
const res = await conn.query(query, [
|
||||||
slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
|
slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
|
||||||
input.introText || null, createdByEmail
|
input.introText || null, input.posterImageUrl || null, createdByEmail
|
||||||
]);
|
]);
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
return res[0].event_id;
|
return res[0].event_id;
|
||||||
@@ -154,6 +155,7 @@ export const updateEvent = async (eventId: number, input: UpdateEventInput): Pro
|
|||||||
if (input.feedbackDeadline !== undefined) { fields.push('feedback_deadline = ?'); values.push(input.feedbackDeadline); }
|
if (input.feedbackDeadline !== undefined) { fields.push('feedback_deadline = ?'); values.push(input.feedbackDeadline); }
|
||||||
if (input.isPublished !== undefined) { fields.push('is_published = ?'); values.push(input.isPublished ? 1 : 0); }
|
if (input.isPublished !== undefined) { fields.push('is_published = ?'); values.push(input.isPublished ? 1 : 0); }
|
||||||
if (input.introText !== undefined) { fields.push('intro_text = ?'); values.push(input.introText); }
|
if (input.introText !== undefined) { fields.push('intro_text = ?'); values.push(input.introText); }
|
||||||
|
if (input.posterImageUrl !== undefined) { fields.push('poster_image_url = ?'); values.push(input.posterImageUrl || null); }
|
||||||
|
|
||||||
if (fields.length === 0) return true;
|
if (fields.length === 0) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,10 @@
|
|||||||
* feedbackDeadline:
|
* feedbackDeadline:
|
||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
|
* posterImageUrl:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* example: "https://www.nachklang.art/img/nk/image-20260727-214855-851.jpeg"
|
||||||
* EventConfig:
|
* EventConfig:
|
||||||
* allOf:
|
* allOf:
|
||||||
* - $ref: '#/components/schemas/EventSummary'
|
* - $ref: '#/components/schemas/EventSummary'
|
||||||
@@ -116,6 +120,7 @@ export interface EventSummary {
|
|||||||
subtitle: string | null;
|
subtitle: string | null;
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
feedbackDeadline: string;
|
feedbackDeadline: string;
|
||||||
|
posterImageUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventConfig extends EventSummary {
|
export interface EventConfig extends EventSummary {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const getEligibleEvents = async (): Promise<EventSummary[]> => {
|
|||||||
let conn = await NachklangFeedbackDB.getConnection();
|
let conn = await NachklangFeedbackDB.getConnection();
|
||||||
try {
|
try {
|
||||||
const query = `
|
const query = `
|
||||||
SELECT slug, name, subtitle, event_date, feedback_deadline
|
SELECT slug, name, subtitle, event_date, feedback_deadline, poster_image_url
|
||||||
FROM events
|
FROM events
|
||||||
WHERE is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()
|
WHERE is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()
|
||||||
ORDER BY event_date DESC`;
|
ORDER BY event_date DESC`;
|
||||||
@@ -19,7 +19,8 @@ export const getEligibleEvents = async (): Promise<EventSummary[]> => {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
subtitle: row.subtitle,
|
subtitle: row.subtitle,
|
||||||
eventDate: row.event_date,
|
eventDate: row.event_date,
|
||||||
feedbackDeadline: row.feedback_deadline
|
feedbackDeadline: row.feedback_deadline,
|
||||||
|
posterImageUrl: row.poster_image_url
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
await conn.end();
|
await conn.end();
|
||||||
@@ -41,7 +42,7 @@ export const getEventConfigBySlug = async (slug: string): Promise<EventLookupRes
|
|||||||
let conn = await NachklangFeedbackDB.getConnection();
|
let conn = await NachklangFeedbackDB.getConnection();
|
||||||
try {
|
try {
|
||||||
const eventQuery = `
|
const eventQuery = `
|
||||||
SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, is_published
|
SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, is_published
|
||||||
FROM events WHERE slug = ?`;
|
FROM events WHERE slug = ?`;
|
||||||
const eventRows = await conn.query(eventQuery, [slug]);
|
const eventRows = await conn.query(eventQuery, [slug]);
|
||||||
if (eventRows.length === 0) {
|
if (eventRows.length === 0) {
|
||||||
@@ -91,6 +92,7 @@ export const getEventConfigBySlug = async (slug: string): Promise<EventLookupRes
|
|||||||
subtitle: eventRow.subtitle,
|
subtitle: eventRow.subtitle,
|
||||||
eventDate: eventRow.event_date,
|
eventDate: eventRow.event_date,
|
||||||
feedbackDeadline: eventRow.feedback_deadline,
|
feedbackDeadline: eventRow.feedback_deadline,
|
||||||
|
posterImageUrl: eventRow.poster_image_url,
|
||||||
introText: eventRow.intro_text,
|
introText: eventRow.intro_text,
|
||||||
songs,
|
songs,
|
||||||
questions
|
questions
|
||||||
|
|||||||
Reference in New Issue
Block a user