diff --git a/api/.env.example b/api/.env.example index 710756b..7d402f4 100644 --- a/api/.env.example +++ b/api/.env.example @@ -21,7 +21,10 @@ CLIENT_URL= # This is whatever URL the client web app is served on CLIENT_DOMAIN= #whatever.com APPLICATION_VERSION= # Should match release tag APPLICATION_ENVIRONMENT= # dev / prod -CONFIG_ID= # configures +CONFIG_ID= # config version + +# webhooks/integrations +DISCORD_APPLICATIONS_WEBHOOK= # Logger LOG_DEPTH= # normal / verbose / profiling diff --git a/api/src/index.ts b/api/src/index.ts index 33f17f1..cfdc719 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -83,6 +83,11 @@ const sessionOptions: session.SessionOptions = { cookie: cookieOptions } +import { initializeDiscordIntegrations } from './services/integrations/discord'; + +//event bus setup +initializeDiscordIntegrations(); + app.use(session(sessionOptions)); app.use(passport.authenticate('session')); diff --git a/api/src/routes/applications.ts b/api/src/routes/applications.ts index 24b0fb2..92820f5 100644 --- a/api/src/routes/applications.ts +++ b/api/src/routes/applications.ts @@ -12,6 +12,7 @@ import { Request, response, Response } from 'express'; import { getUserRoles } from '../services/db/rolesService'; import { requireLogin, requireRole } from '../middleware/auth'; import { logger } from '../services/logging/logger'; +import { bus } from '../services/events/eventBus'; //get CoC router.get('/coc', async (req: Request, res: Response) => { @@ -45,36 +46,24 @@ router.get('/coc', async (req: Request, res: Response) => { // POST /application -router.post('/', [requireLogin], async (req, res) => { +router.post('/', [requireLogin], async (req: Request, res: Response) => { const memberID = req.user.id; const App = req.body?.App || {}; const appVersion = 1; try { - req.profiler?.start('createApplication'); - await createApplication(memberID, appVersion, JSON.stringify(App)); - req.profiler?.end('createApplication'); + let appID = await createApplication(memberID, appVersion, JSON.stringify(App)); - req.profiler?.start('setUserState'); await setUserState(memberID, MemberState.Applicant); - req.profiler?.end('setUserState'); res.sendStatus(201); - // Log full route profiling - const summary = req.profiler?.summary(); - if (summary) { - logger.info( - 'profiling', - 'POST /application completed', - { - memberID, - appVersion, - ...summary, - }, - 'profiling' - ); - } + bus.emit("application.create", { application: appID, member_name: req.user.name, member_discord_id: req.user.discord_id || null }) + + logger.info('app', 'Application Posted', { + user: memberID, + app: appID + }) } catch (err) { logger.error( 'app', diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index b292811..e2ae46b 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -200,7 +200,7 @@ passport.deserializeUser(function (user, cb) { t = performance.now(); const userResults = await con.query( - `SELECT id, name FROM members WHERE id = ?;`, + `SELECT id, name, discord_id FROM members WHERE id = ?;`, [memberID] ); timings.memberQuery = performance.now() - t; @@ -210,6 +210,7 @@ passport.deserializeUser(function (user, cb) { name: string; roles: Role[]; state: MemberState; + discord_id?: string; } = userResults[0]; t = performance.now(); @@ -259,6 +260,7 @@ declare global { user: { id: number; name: string; + discord_id: string; roles: Role[]; state: MemberState; }; diff --git a/api/src/services/db/applicationService.ts b/api/src/services/db/applicationService.ts index 451d01c..985ad5b 100644 --- a/api/src/services/db/applicationService.ts +++ b/api/src/services/db/applicationService.ts @@ -2,10 +2,19 @@ import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/type import pool from "../../db"; import { error } from "console"; -export async function createApplication(memberID: number, appVersion: number, app: string) { +/** + * Create an application in the db + * @param memberID + * @param appVersion + * @param app + * @returns ID of the created application + */ +export async function createApplication(memberID: number, appVersion: number, app: string): Promise { const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`; const params = [memberID, appVersion, JSON.stringify(app)] - return await pool.query(sql, params); + + let result = await pool.query(sql, params); + return Number(result.insertId); } export async function getMemberApplication(memberID: number): Promise { diff --git a/api/src/services/events/eventBus.ts b/api/src/services/events/eventBus.ts new file mode 100644 index 0000000..35f228e --- /dev/null +++ b/api/src/services/events/eventBus.ts @@ -0,0 +1,56 @@ +import { randomUUID } from "crypto"; +import { logger } from "../logging/logger"; + +interface Event { + id: string + type: string + occurredAt: string + payload?: Record +} + +type EventHandler = (event: Event) => void | Promise; + +class EventBus { + private handlers: Map = new Map(); + + /** + * Register event listener + * @param type + * @param handler + */ + on(type: string, handler: EventHandler) { + const handlers = this.handlers.get(type) ?? []; + handlers.push(handler); + this.handlers.set(type, handlers); + } + + /** + * Emit event of given type + * @param type + * @param payload + */ + async emit(type: string, payload?: Record) { + const event: Event = { + id: randomUUID(), + type, + occurredAt: new Date().toISOString(), + payload + } + + const handlers = this.handlers.get(type) ?? [] + + for (const h of handlers) { + try { + await h(event) + } catch (error) { + logger.error('app', 'Event handler failed', { + type: event.type, + id: event.id, + error: error instanceof Error ? error.message : String(error), + }) + } + } + } +} + +export const bus = new EventBus(); \ No newline at end of file diff --git a/api/src/services/integrations/discord.ts b/api/src/services/integrations/discord.ts new file mode 100644 index 0000000..74308bc --- /dev/null +++ b/api/src/services/integrations/discord.ts @@ -0,0 +1,32 @@ +import { bus } from "../events/eventBus"; + +export function initializeDiscordIntegrations() { + bus.on('application.create', async (event) => { + let applicantName = event.payload.member_discord_id || event.payload.member_name; + if (event.payload.member_discord_id) { + applicantName = `<@${event.payload.member_discord_id}>`; + } + const link = `${process.env.CLIENT_URL}/administration/applications/${event.payload.application}`; + + const embed = { + title: "Application Posted", + description: `[View Application](${link})`, + color: 0x00ff00, // optional: green color + timestamp: new Date().toISOString(), // <-- Discord expects ISO8601 + fields: [ + { + name: "Submitted By", + value: applicantName, + inline: false, + }, + ], + }; + + // send to Discord webhook + await fetch(process.env.DISCORD_APPLICATIONS_WEBHOOK!, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ embeds: [embed] }), + }); + }); +}