diff --git a/api/.env.example b/api/.env.example index 9a1e8da..7d402f4 100644 --- a/api/.env.example +++ b/api/.env.example @@ -21,7 +21,13 @@ 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 # Glitchtip GLITCHTIP_DSN= diff --git a/api/package-lock.json b/api/package-lock.json index 25088d0..2152930 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@sentry/node": "^10.27.0", "@types/express-session": "^1.18.2", - "chalk": "^5.6.2", "connect-sqlite3": "^0.9.16", "cors": "^2.8.5", "dotenv": "16.6.1", @@ -1323,18 +1322,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3286,9 +3273,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/api/package.json b/api/package.json index 64392f2..2d9b053 100644 --- a/api/package.json +++ b/api/package.json @@ -22,7 +22,6 @@ "dependencies": { "@sentry/node": "^10.27.0", "@types/express-session": "^1.18.2", - "chalk": "^5.6.2", "connect-sqlite3": "^0.9.16", "cors": "^2.8.5", "dotenv": "16.6.1", diff --git a/api/src/db.ts b/api/src/db.ts index 1539dbd..480dcf4 100644 --- a/api/src/db.ts +++ b/api/src/db.ts @@ -1,14 +1,5 @@ // const mariadb = require('mariadb') import * as mariadb from 'mariadb'; -// const dotenv = require('dotenv') -// import path = require('path'); -// console.log('NODE_ENV =', process.env.NODE_ENV); - -// const envFile = process.env.NODE_ENV === 'development' ? '.env.development' : '.env'; -// dotenv.config({ path: path.resolve(process.cwd(), envFile) }); -// console.log(`Loaded environment from ${envFile}`); - -// console.log(process.env.DB_HOST) const pool = mariadb.createPool({ host: process.env.DB_HOST, diff --git a/api/src/index.ts b/api/src/index.ts index 118162e..cfdc719 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,31 +1,40 @@ import dotenv = require('dotenv'); -dotenv.config(); +dotenv.config({ quiet: true }); import express = require('express'); import cors = require('cors'); import morgan = require('morgan'); +import { logger, LogHeader, LogPayload } from './services/logging/logger'; + const app = express() -import chalk from 'chalk'; + app.use(morgan((tokens: morgan.TokenIndexer, req: express.Request, res: express.Response) => { - const status = Number(tokens.status(req, res)); - // Colorize status code - const statusColor = status >= 500 ? chalk.red - : status >= 400 ? chalk.yellow - : status >= 300 ? chalk.cyan - : chalk.green; + const head: LogHeader = { + type: 'http', + level: 'info', + depth: 'normal', + timestamp: new Date().toISOString(), + } - return [ - chalk.gray(`[${new Date().toISOString()}]`), - chalk.blue.bold(tokens.method(req, res)), - tokens.url(req, res), - statusColor(status), - chalk.magenta(tokens['response-time'](req, res) + ' ms'), - chalk.yellow(`- User: ${req.user?.name ? `${req.user.name} (${req.user.id})` : 'Unauthenticated'}`), - ].join(' '); + const payload: LogPayload = { + message: 'HTTP request completed', + data: { + method: tokens.method(req, res), + path: tokens.url(req, res), + status: Number(tokens.status(req, res)), + response_time_ms: Number(tokens['response-time'](req, res)), + user_id: req.user?.id, + user_name: req.user?.name, + user_agent: req.headers['user-agent'], + }, + } + + logger.log(head.level, head.type, payload.message, payload.data, head.depth) + return ''; }, { skip: (req: express.Request) => { - return req.originalUrl === '/members/me'; + return req.originalUrl === '/members/me' || req.originalUrl === '/ping'; } })) @@ -43,14 +52,13 @@ const port = process.env.SERVER_PORT; //glitchtip setup import sentry = require('@sentry/node'); if (process.env.DISABLE_GLITCHTIP === "true") { - console.log("Glitchtip disabled") + logger.info('app', 'Glitchtip disabled', null, 'normal') } else { let dsn = process.env.GLITCHTIP_DSN; let release = process.env.APPLICATION_VERSION; let environment = process.env.APPLICATION_ENVIRONMENT; - console.log(release, environment) sentry.init({ dsn: dsn, release: release, environment: environment, integrations: [sentry.captureConsoleIntegration({ levels: ['error'] })] }); - console.log("Glitchtip initialized"); + logger.info('app', 'Glitchtip initialized', null, 'normal') } //session setup @@ -75,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')); @@ -110,5 +123,5 @@ app.get('/ping', (req, res) => { }); app.listen(port, () => { - console.log(`Example app listening on port ${port} `) + logger.info('app', `Example app listening on port ${port} `) }) diff --git a/api/src/routes/applications.ts b/api/src/routes/applications.ts index 5a98f07..92820f5 100644 --- a/api/src/routes/applications.ts +++ b/api/src/routes/applications.ts @@ -2,59 +2,97 @@ const express = require('express'); const router = express.Router(); import pool from '../db'; -import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; -import { setUserState } from '../services/memberService'; +import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/db/applicationService'; +import { setUserState } from '../services/db/memberService'; import { MemberState } from '@app/shared/types/member'; -import { getRankByName, insertMemberRank } from '../services/rankService'; +import { getRankByName, insertMemberRank } from '../services/db/rankService'; import { ApplicationFull, CommentRow } from "@app/shared/types/application" -import { assignUserToStatus } from '../services/statusService'; +import { assignUserToStatus } from '../services/db/statusService'; import { Request, response, Response } from 'express'; -import { getUserRoles } from '../services/rolesService'; +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) => { - const output = await fetch(`${process.env.DOC_HOST}/api/pages/714`, { - headers: { - Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, - } - }) + try { + const response = await fetch(`${process.env.DOC_HOST}/api/pages/714`, { + headers: { + Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, + }, + }); - if (output.ok) { - const out = await output.json(); + if (!response.ok) { + const text = await response.text(); + logger.error('app', 'Failed to fetch LOA policy from Bookstack', { + status: response.status, + statusText: response.statusText, + body: text, + }); + return res.sendStatus(500); + } + + const out = await response.json(); res.status(200).json(out.html); - } else { - console.error("Failed to fetch LOA policy from bookstack"); + + } catch (error) { + logger.error('app', 'Error fetching LOA policy from Bookstack', { + error: error instanceof Error ? error.message : String(error), + }); res.sendStatus(500); } -}) +}); // 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 { - const App = req.body?.App || {}; - const memberID = req.user.id; + let appID = await createApplication(memberID, appVersion, JSON.stringify(App)); - const appVersion = 1; - - await createApplication(memberID, appVersion, JSON.stringify(App)) await setUserState(memberID, MemberState.Applicant); res.sendStatus(201); + + 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) { - console.error('Failed to create application: \n', err); + logger.error( + 'app', + 'Failed to create application', + { + memberID, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + } + ); res.status(500).json({ error: 'Failed to create application' }); } }); + // GET /application/all router.get('/all', [requireLogin, requireRole("Recruiter")], async (req, res) => { try { const rows = await getApplicationList(); res.status(200).json(rows); } catch (err) { - console.error(err); + logger.error( + 'app', + 'Failed to get applications', + { + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + } + ); res.status(500); } }); @@ -68,8 +106,16 @@ router.get('/meList', async (req, res) => { return res.status(200).json(application); } catch (error) { - console.error('Failed to load applications: \n', error); - return res.status(500).json(error); + logger.error( + 'app', + 'Failed to get applications for user', + { + user: userID, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + return res.status(500); } }) @@ -80,8 +126,10 @@ router.get('/me', [requireLogin], async (req, res) => { try { let application = await getMemberApplication(userID); - if (application === undefined) + if (application === undefined) { res.sendStatus(204); + return; + } const comments: CommentRow[] = await getApplicationComments(application.id); @@ -92,12 +140,20 @@ router.get('/me', [requireLogin], async (req, res) => { return res.status(200).json(output); } catch (error) { - console.error('Failed to load application:', error); + logger.error( + 'app', + 'Failed to load application', + { + user: userID, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); return res.status(500).json(error); } }) -// GET /application/:id +// GET /me/:id router.get('/me/:id', [requireLogin], async (req: Request, res: Response) => { let appID = Number(req.params.id); let member = req.user.id; @@ -117,9 +173,18 @@ router.get('/me/:id', [requireLogin], async (req: Request, res: Response) => { } return res.status(200).json(output); } - catch (err) { - console.error('Query failed:', err); - return res.status(500).json({ error: 'Failed to load application' }); + catch (error) { + logger.error( + 'app', + 'Failed to load application', + { + application: appID, + user: member, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + return res.status(500); } }); @@ -141,9 +206,17 @@ router.get('/:id', [requireLogin, requireRole("Recruiter")], async (req: Request } return res.status(200).json(output); } - catch (err) { - console.error('Query failed:', err); - return res.status(500).json({ error: 'Failed to load application' }); + catch (error) { + logger.error( + 'app', + 'Failed to load application', + { + application: appID, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + return res.status(500); } }); @@ -161,9 +234,22 @@ router.post('/approve/:id', [requireLogin, requireRole("Recruiter")], async (req await pool.query('CALL sp_accept_new_recruit_validation(?, ?, ?, ?)', [Number(process.env.CONFIG_ID), app.member_id, approved_by, approved_by]) + logger.info('app', "Member application approved", { + application: app.id, + applicant: app.member_id, + approver: approved_by + }) res.sendStatus(200); - } catch (err) { - console.error('Approve failed:', err); + } catch (error) { + logger.error( + 'app', + 'Failed to approve application', + { + application: appID, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Failed to approve application' }); } }); @@ -177,9 +263,23 @@ router.post('/deny/:id', [requireLogin, requireRole("Recruiter")], async (req: R const app = await getApplicationByID(appID); await denyApplication(appID, approver); await setUserState(app.member_id, MemberState.Denied); + + logger.info('app', "Member application approved", { + application: app.id, + applicant: app.member_id, + approver: approver + }) res.sendStatus(200); - } catch (err) { - console.error('Approve failed:', err); + } catch (error) { + logger.error( + 'app', + 'Failed to deny application', + { + application: appID, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Failed to deny application' }); } }); @@ -217,10 +317,25 @@ VALUES(?, ?, ?);` INNER JOIN members AS member ON member.id = app.poster_id WHERE app.id = ?; `; const comment = await conn.query(getSQL, [result.insertId]) + + logger.info('app', "Application comment posted", { + application: appID, + poster: user.id, + comment: result.insertId, + }) + res.status(201).json(comment[0]); - } catch (err) { - console.error('Comment failed:', err); + } catch (error) { + logger.error( + 'app', + 'Failed to post comment', + { + application: appID, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Could not post comment' }); } finally { conn.release(); @@ -261,10 +376,24 @@ VALUES(?, ?, ?, 1);` INNER JOIN members AS member ON member.id = app.poster_id WHERE app.id = ?; `; const comment = await conn.query(getSQL, [result.insertId]) - res.status(201).json(comment[0]); - } catch (err) { - console.error('Comment failed:', err); + logger.info('app', "Admin application comment posted", { + application: appID, + poster: user.id, + comment: result.insertId, + }) + + res.status(201).json(comment[0]); + } catch (error) { + logger.error( + 'app', + 'Failed to post comment', + { + application: appID, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Could not post comment' }); } finally { conn.release(); @@ -275,9 +404,22 @@ router.post('/restart', async (req: Request, res: Response) => { const user = req.user.id; try { await setUserState(user, MemberState.Guest); + + logger.info('app', "Member restarted application", { + user: user + }) + res.sendStatus(200); } catch (error) { - console.error('Comment failed:', error); + logger.error( + 'app', + 'Failed to restart application', + { + user: user, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Could not rester application' }); } }) diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 4925da1..001a200 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -1,7 +1,5 @@ const passport = require('passport'); const OpenIDConnectStrategy = require('passport-openidconnect'); -const dotenv = require('dotenv'); -dotenv.config(); const express = require('express'); const { param } = require('./applications'); @@ -9,11 +7,14 @@ const router = express.Router(); import { Role } from '@app/shared/types/roles'; import pool from '../db'; import { requireLogin } from '../middleware/auth'; -import { getUserRoles } from '../services/rolesService'; -import { getUserState, mapDiscordtoID } from '../services/memberService'; +import { getUserRoles } from '../services/db/rolesService'; +import { getUserState, mapDiscordtoID } from '../services/db/memberService'; import { MemberState } from '@app/shared/types/member'; import { toDateTime } from '@app/shared/utils/time'; +import { logger } from '../services/logging/logger'; const querystring = require('querystring'); +import { performance } from 'perf_hooks'; + function parseJwt(token) { return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); @@ -37,10 +38,10 @@ passport.use(new OpenIDConnectStrategy({ // console.log('profile:', profile); // console.log('jwt: ', parseJwt(jwtClaims)); // console.log('params:', params); - + let con; try { - var con = await pool.getConnection(); + con = await pool.getConnection(); await con.beginTransaction(); @@ -49,7 +50,13 @@ passport.use(new OpenIDConnectStrategy({ let memberId: number | null = null; //if member exists if (existing.length > 0) { + //login memberId = existing[0].id; + logger.info('auth', `Existing member login`, { + memberId, + issuer, + }); + } else { //otherwise: create account mode const jwt = parseJwt(jwtClaims); @@ -61,13 +68,17 @@ passport.use(new OpenIDConnectStrategy({ if (discordID && memberId) { // claim account - console.log("Claiming account"); const result = await con.query( `UPDATE members SET authentik_sub = ?, authentik_issuer = ? WHERE id = ?;`, [sub, issuer, memberId] ) + logger.info('auth', `Existing member claimed via Discord`, { + memberId, + discordID, + issuer, + }); + } else { - console.log("New Account"); // new account const username = sub.username; const result = await con.query( @@ -75,6 +86,13 @@ passport.use(new OpenIDConnectStrategy({ [username, sub, issuer] ) memberId = Number(result.insertId); + + logger.info('auth', `New member account created`, { + memberId, + username, + issuer, + }); + } } @@ -83,10 +101,26 @@ passport.use(new OpenIDConnectStrategy({ await con.commit(); return cb(null, { memberId }); } catch (error) { - await con.rollback(); + logger.error('auth', `Authentication transaction failed`, { + issuer, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + + if (con) { + try { + await con.rollback(); + } catch (rollbackError) { + logger.error('auth', `Rollback failed`, { + error: rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError), + }); + } + } return cb(error); } finally { - con.release(); + if (con) con.release(); } })); @@ -117,6 +151,7 @@ router.get('/callback', (req, res, next) => { router.get('/logout', [requireLogin], function (req, res, next) { req.logout(function (err) { + if (err) { return next(err); } req.session.destroy((err) => { @@ -133,8 +168,8 @@ router.get('/logout', [requireLogin], function (req, res, next) { client_id: process.env.AUTH_CLIENT_ID, returnTo: process.env.CLIENT_URL }; - res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params)); + res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params)); }) }); }); @@ -146,34 +181,83 @@ passport.serializeUser(function (user, cb) { }); passport.deserializeUser(function (user, cb) { + const start = performance.now(); + const timings: Record = {}; + process.nextTick(async function () { - const memberID = user.memberId as number; + let con; - - var userData: { id: number, name: string, roles: Role[], state: MemberState }; try { - var con = await pool.getConnection(); - let userResults = await con.query(`SELECT id, name FROM members WHERE id = ?;`, [memberID]) - userData = userResults[0]; - let userRoles = await getUserRoles(memberID); + let t; + + t = performance.now(); + con = await pool.getConnection(); + timings.getConnection = performance.now() - t; + + t = performance.now(); + const userResults = await con.query( + `SELECT id, name, discord_id FROM members WHERE id = ?;`, + [memberID] + ); + timings.memberQuery = performance.now() - t; + + const userData: { + id: number; + name: string; + roles: Role[]; + state: MemberState; + discord_id?: string; + } = userResults[0]; + + t = performance.now(); + const userRoles = await getUserRoles(memberID); + timings.roles = performance.now() - t; userData.roles = userRoles || []; + + t = performance.now(); userData.state = await getUserState(memberID); + timings.state = performance.now() - t; + + // 📊 PROFILING LOG + logger.info( + 'profiling', + 'passport.deserializeUser completed', + { + memberId: memberID, + total_ms: performance.now() - start, + breakdown_ms: timings, + }, + 'profiling' + ); + + return cb(null, userData); + } catch (error) { - console.error(error) + logger.error( + 'profiling', + 'passport.deserializeUser failed', + { + memberId: memberID, + error: error instanceof Error ? error.message : String(error), + } + ); + return cb(error); + } finally { - con.release(); + if (con) con.release(); } - return cb(null, userData); }); }); + declare global { namespace Express { interface Request { user: { id: number; name: string; + discord_id: string; roles: Role[]; state: MemberState; }; diff --git a/api/src/routes/calendar.ts b/api/src/routes/calendar.ts index a5c96be..f5279f7 100644 --- a/api/src/routes/calendar.ts +++ b/api/src/routes/calendar.ts @@ -1,8 +1,9 @@ import { Request, Response } from "express"; -import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/calendarService"; +import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/db/calendarService"; import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar"; import { requireLogin, requireMemberState, requireRole } from "../middleware/auth"; import { MemberState } from "@app/shared/types/member"; +import { logger } from "../services/logging/logger"; const express = require('express'); const r = express.Router(); @@ -28,7 +29,14 @@ r.get('/', async (req, res) => { res.status(200).json(events); } catch (error) { - console.error('Error fetching calendar events:', error); + logger.error( + 'app', + 'Failed to get calendar events', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).send('Error fetching calendar events'); } }); @@ -41,9 +49,21 @@ r.post('/:id/cancel', [requireLogin, requireMemberState(MemberState.Member)], as try { const eventID = Number(req.params.id); setEventCancelled(eventID, true); + + logger.info('app', 'Calendar event cancelled', { + event: eventID, + user: req.user.id + }) res.sendStatus(200); } catch (error) { - console.error('Error setting cancel status:', error); + logger.error( + 'app', + 'Failed to get cancel calendar event', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).send('Error setting cancel status'); } }) @@ -51,9 +71,21 @@ r.post('/:id/uncancel', [requireLogin, requireMemberState(MemberState.Member)], try { const eventID = Number(req.params.id); setEventCancelled(eventID, false); + + logger.info('app', 'Calendar event un-cancelled', { + event: eventID, + user: req.user.id + }) res.sendStatus(200); } catch (error) { - console.error('Error setting cancel status:', error); + logger.error( + 'app', + 'Failed to uncancel calendar event', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).send('Error setting cancel status'); } }) @@ -65,12 +97,27 @@ r.post('/:id/attendance', [requireLogin, requireMemberState(MemberState.Member)] let event = Number(req.params.id); let state = req.query.state as CalendarAttendance; setAttendanceStatus(member, event, state); + + logger.info('app', 'Member set calendar event attendance', { + event: event, + user: req.user.id, + state: state + }) + res.sendStatus(200); } catch (error) { - console.error('Failed to set attendance:', error); + logger.error( + 'app', + 'Failed to set attendance', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) + //get event details r.get('/:id', async (req: Request, res: Response) => { try { @@ -79,9 +126,16 @@ r.get('/:id', async (req: Request, res: Response) => { let details: CalendarEvent = await getEventDetails(eventID); details.eventSignups = await getEventAttendance(eventID); res.status(200).json(details); - } catch (err) { - console.error('Insert failed:', err); - res.status(500).json(err); + } catch (error) { + logger.error( + 'app', + 'Failed to get calendar event details', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.status(500); } }) @@ -95,9 +149,22 @@ r.post('/', [requireLogin, requireMemberState(MemberState.Member)], async (req: event.start = new Date(event.start); event.end = new Date(event.end); createEvent(event); + + logger.info('app', 'Calendar event posted', { + event: event.id, + user: req.user.id + }) + res.sendStatus(200); } catch (error) { - console.error('Failed to create event:', error); + logger.error( + 'app', + 'Failed to create calendar event', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) @@ -108,9 +175,22 @@ r.put('/', [requireLogin, requireMemberState(MemberState.Member)], async (req: R event.start = new Date(event.start); event.end = new Date(event.end); updateEvent(event); + + logger.info('app', 'Calendar event updated', { + event: event.id, + user: req.user.id + }) + res.sendStatus(200); } catch (error) { - console.error('Failed to update event:', error); + logger.error( + 'app', + 'Failed to update calendar event', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) diff --git a/api/src/routes/course.ts b/api/src/routes/course.ts index 91a2057..007d593 100644 --- a/api/src/routes/course.ts +++ b/api/src/routes/course.ts @@ -1,8 +1,9 @@ import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course"; -import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce"; +import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/db/CourseSerivce"; import { Request, Response, Router } from "express"; import { requireLogin, requireMemberState } from "../middleware/auth"; import { MemberState } from "@app/shared/types/member"; +import { logger } from "../services/logging/logger"; const cr = Router(); const er = Router(); @@ -16,9 +17,16 @@ cr.get('/', async (req, res) => { try { const courses = await getAllCourses(); res.status(200).json(courses); - } catch (err) { - console.error('failed to fetch courses', err); - res.status(500).json('failed to fetch courses\n' + err); + } catch (error) { + logger.error( + 'app', + 'Failed to fetch courses', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.status(500).json('failed to fetch courses'); } }) @@ -26,12 +34,20 @@ cr.get('/roles', async (req, res) => { try { const roles = await getCourseEventRoles(); res.status(200).json(roles); - } catch (err) { - console.error('failed to fetch course roles', err); - res.status(500).json('failed to fetch course roles\n' + err); + } catch (error) { + logger.error( + 'app', + 'Failed to fetch course roles', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.status(500).json('failed to fetch course roles'); } }) +//get event list er.get('/', async (req: Request, res: Response) => { try { const allowedSorts = new Map([ @@ -55,7 +71,14 @@ er.get('/', async (req: Request, res: Response) => { let events = await getCourseEvents(sortDir, search, page, pageSize); res.status(200).json(events); } catch (error) { - console.error('failed to fetch reports', error); + logger.error( + 'app', + 'Failed to fetch course events', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }); @@ -65,7 +88,14 @@ er.get('/:id', async (req: Request, res: Response) => { let out = await getCourseEventDetails(Number(req.params.id)); res.status(200).json(out); } catch (error) { - console.error('failed to fetch report', error); + logger.error( + 'app', + 'Failed to fetch course event', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }); @@ -74,9 +104,16 @@ er.get('/attendees/:id', async (req: Request, res: Response) => { try { const attendees: CourseAttendee[] = await getCourseEventAttendees(Number(req.params.id)); res.status(200).json(attendees); - } catch (err) { - console.error('failed to fetch attendees', err); - res.status(500).json("failed to fetch attendees\n" + err); + } catch (error) { + logger.error( + 'app', + 'Failed to fetch course event attendees', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.status(500).json("failed to fetch attendees"); } }) @@ -87,9 +124,19 @@ er.post('/', async (req: Request, res: Response) => { data.created_by = posterID; data.event_date = new Date(data.event_date); const id = await insertCourseEvent(data); + + logger.info('app', 'Training report posted', { user: posterID, report: id }) res.status(201).json(id); } catch (error) { - console.error('failed to post training', error); + logger.error( + 'app', + 'Failed to post training report', + { + user: posterID, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json("failed to post training\n" + error) } }) diff --git a/api/src/routes/docs.ts b/api/src/routes/docs.ts index 13ada87..471891a 100644 --- a/api/src/routes/docs.ts +++ b/api/src/routes/docs.ts @@ -3,22 +3,55 @@ const router = express.Router(); import { Request, Response } from 'express'; import { requireLogin } from '../middleware/auth'; +import { logger } from '../services/logging/logger'; +// GET /welcome router.get('/welcome', [requireLogin], async (req: Request, res: Response) => { - const output = await fetch(`${process.env.DOC_HOST}/api/pages/717`, { - headers: { - Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, - } - }) + const t0 = performance.now(); // optional profiling start - if (output.ok) { - const out = await output.json(); + try { + const response = await fetch(`${process.env.DOC_HOST}/api/pages/717`, { + headers: { + Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, + }, + }); + + if (!response.ok) { + const text = await response.text(); + logger.error('app', 'Failed to fetch welcome page from Bookstack', { + status: response.status, + statusText: response.statusText, + body: text, + userId: req.user?.id, + }); + return res.sendStatus(500); + } + + const out = await response.json(); res.status(200).json(out.html); - } else { - console.error("Failed to fetch LOA policy from bookstack"); + + // optional profiling log + const duration = performance.now() - t0; + logger.info( + 'profiling', + 'GET /welcome completed', + { + userId: req.user?.id, + total_ms: duration, + }, + 'profiling' + ); + + } catch (error) { + logger.error('app', 'Error fetching welcome page from Bookstack', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userId: req.user?.id, + }); res.sendStatus(500); } -}) +}); + export const docsRouter = router; \ No newline at end of file diff --git a/api/src/routes/loa.ts b/api/src/routes/loa.ts index 65aa115..8555fb7 100644 --- a/api/src/routes/loa.ts +++ b/api/src/routes/loa.ts @@ -3,9 +3,10 @@ const router = express.Router(); import { Request, Response } from 'express'; import pool from '../db'; -import { closeLOA, createNewLOA, getAllLOA, getLOAbyID, getLoaTypes, getUserLOA, setLOAExtension } from '../services/loaService'; +import { closeLOA, createNewLOA, getAllLOA, getLOAbyID, getLoaTypes, getUserLOA, setLOAExtension } from '../services/db/loaService'; import { LOARequest } from '@app/shared/types/loa'; import { requireLogin, requireRole } from '../middleware/auth'; +import { logger } from '../services/logging/logger'; router.use(requireLogin); @@ -18,9 +19,17 @@ router.post("/", async (req: Request, res: Response) => { try { await createNewLOA(LOARequest); + logger.info('app', 'LOA Posted', { poster: req.user.id, user: LOARequest.member_id }) res.sendStatus(201); } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to post LOA', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).send(error); } }); @@ -32,10 +41,17 @@ router.post("/admin", [requireRole(['17th Administrator', '17th HQ', '17th Comma LOARequest.filed_date = new Date(); try { await createNewLOA(LOARequest); + logger.info('app', 'LOA Posted', { poster: req.user.id, user: LOARequest.member_id }) res.sendStatus(201); } catch (error) { - console.error(error); - res.status(500).send(error); + logger.error( + 'app', + 'Failed to post LOA', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).send(error); } }); @@ -46,7 +62,14 @@ router.get("/me", async (req: Request, res: Response) => { const result = await getUserLOA(user); res.status(200).json(result) } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to get user current LOA', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).send(error); } }) @@ -62,7 +85,14 @@ router.get("/history", async (req: Request, res: Response) => { const result = await getUserLOA(user, page, pageSize); res.status(200).json(result) } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to get user LOA history', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).send(error); } }) @@ -74,7 +104,14 @@ router.get('/all', [requireRole(['17th Administrator', '17th HQ', '17th Command' const result = await getAllLOA(page, pageSize); res.status(200).json(result) } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to get full LOA history', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).send(error); } }) @@ -84,8 +121,15 @@ router.get('/types', async (req: Request, res: Response) => { let out = await getLoaTypes(); res.status(200).json(out); } catch (error) { + logger.error( + 'app', + 'Failed to get LOA types', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); - console.error(error); } }) @@ -99,9 +143,19 @@ router.post('/cancel/:id', async (req: Request, res: Response) => { } await closeLOA(Number(req.params.id), closer); + + logger.info('app', 'LOA Closed', { closed_by: closer, LOA: id }) + res.sendStatus(200); } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to cancel LOA', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) @@ -111,14 +165,24 @@ router.post('/adminCancel/:id', [requireRole(['17th Administrator', '17th HQ', ' let closer = req.user.id; try { await closeLOA(Number(req.params.id), closer); + + logger.info('app', 'LOA Closed', { closed_by: closer, LOA: req.params.id }) + res.sendStatus(200); } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to cancel LOA', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) -// TODO: Enforce admin only +// extend LOA router.post('/extend/:id', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => { const to: Date = req.body.to; @@ -128,27 +192,71 @@ router.post('/extend/:id', [requireRole(['17th Administrator', '17th HQ', '17th try { await setLOAExtension(Number(req.params.id), to); + logger.info('app', 'LOA Extended', { extended_by: req.user.id, LOA: req.params.id }) + res.sendStatus(200); } catch (error) { - console.error(error) + logger.error( + 'app', + 'Failed to extend LOA', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) +// GET /policy router.get('/policy', async (req: Request, res: Response) => { - const output = await fetch(`${process.env.DOC_HOST}/api/pages/42`, { - headers: { - Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, - } - }) + const t0 = performance.now(); - if (output.ok) { - const out = await output.json(); + try { + const response = await fetch(`${process.env.DOC_HOST}/api/pages/42`, { + headers: { + Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, + }, + }); + + if (!response.ok) { + const text = await response.text(); + + logger.error('app', 'Failed to fetch policy page from Bookstack', { + pageId: 42, + status: response.status, + statusText: response.statusText, + body: text, + userId: req.user?.id, + }); + + return res.sendStatus(500); + } + + const out = await response.json(); res.status(200).json(out.html); - } else { - console.error("Failed to fetch LOA policy from bookstack"); + + logger.info( + 'profiling', + 'GET /policy completed', + { + pageId: 42, + total_ms: performance.now() - t0, + }, + 'profiling' + ); + + } catch (error) { + logger.error('app', 'Error fetching policy page from Bookstack', { + pageId: 42, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userId: req.user?.id, + }); + res.sendStatus(500); } -}) +}); + export const loaRouter = router; \ No newline at end of file diff --git a/api/src/routes/members.ts b/api/src/routes/members.ts index c73b8f2..f7bdd15 100644 --- a/api/src/routes/members.ts +++ b/api/src/routes/members.ts @@ -4,11 +4,14 @@ const router = express.Router(); import { Request, Response } from 'express'; import pool from '../db'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; -import { getUserActiveLOA } from '../services/loaService'; -import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, getUserState, setUserSettings } from '../services/memberService'; -import { getUserRoles } from '../services/rolesService'; +import { getUserActiveLOA } from '../services/db/loaService'; +import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, getUserState, setUserSettings } from '../services/db/memberService'; +import { getUserRoles } from '../services/db/rolesService'; import { memberSettings, MemberState, myData } from '@app/shared/types/member'; +import { Performance } from 'perf_hooks'; +import { logger } from '../services/logging/logger'; + //get all users router.get('/', [requireLogin, requireMemberState(MemberState.Member)], async (req, res) => { try { @@ -26,29 +29,69 @@ router.get('/', [requireLogin, requireMemberState(MemberState.Member)], async (r END AS on_loa FROM view_member_rank_unit_status_latest v;`); return res.status(200).json(result); - } catch (err) { - console.error('Error fetching users:', err); + } catch (error) { + logger.error( + 'app', + 'Failed to get all users', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); return res.status(500).json({ error: 'Failed to fetch users' }); } }); -router.get('/me', [requireLogin], async (req, res) => { - if (req.user === undefined) - return res.sendStatus(401) +router.get('/me', [requireLogin], async (req: Request, res) => { + if (!req.user) return res.sendStatus(401); + + const routeStart = performance.now(); + const timings: Record = {}; try { - const memData = await getUserData(req.user.id); - const LOAData = await getUserActiveLOA(req.user.id); - const memState = await getUserState(req.user.id); - const roleData = await getUserRoles(req.user.id); + let t; + + t = performance.now(); + const memData = await getUserData(req.user.id); + timings.member = performance.now() - t; + + t = performance.now(); + const LOAData = await getUserActiveLOA(req.user.id); + timings.loa = performance.now() - t; + + t = performance.now(); + const memState = await getUserState(req.user.id); + timings.state = performance.now() - t; + + t = performance.now(); + const roleData = await getUserRoles(req.user.id); + timings.roles = performance.now() - t; + + const userDataFull: myData = { + member: memData, + LOAs: LOAData, + roles: roleData, + state: memState, + }; - const userDataFull: myData = { member: memData, LOAs: LOAData, roles: roleData, state: memState }; res.status(200).json(userDataFull); + + logger.info('profiling', 'GET /members/me completed', { + userId: req.user.id, + total_ms: performance.now() - routeStart, + breakdown_ms: timings, + }, 'profiling'); + } catch (error) { - console.error('Error fetching user data:', error); + logger.error('profiling', 'GET /members/me failed', { + userId: req.user?.id, + error: error instanceof Error ? error.message : String(error), + }); + return res.status(500).json({ error: 'Failed to fetch user data' }); } -}) +}); + router.get('/settings', [requireLogin], async (req: Request, res: Response) => { try { @@ -56,7 +99,14 @@ router.get('/settings', [requireLogin], async (req: Request, res: Response) => { let output = await getMemberSettings(user); res.status(200).json(output); } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to get member settings', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) @@ -66,10 +116,17 @@ router.put('/settings', [requireLogin], async (req: Request, res: Response) => { let user = req.user.id; let settings: memberSettings = req.body; await setUserSettings(user, settings); + logger.info('app', 'User updated profile settings', { user: user }) res.sendStatus(200); } catch (error) { - console.error(error); - res.status(500).json(error); + logger.error( + 'app', + 'Failed to update user settings', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) @@ -78,7 +135,14 @@ router.get('/lite', [requireLogin], async (req: Request, res: Response) => { let out = await getAllMembersLite(); res.status(200).json(out); } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to get lite users', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) @@ -89,7 +153,14 @@ router.post('/lite/bulk', async (req: Request, res: Response) => { let out = await getMembersLite(ids); res.status(200).json(out); } catch (error) { - console.error(error); + logger.error( + 'app', + 'Failed to get batch lite users', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) @@ -101,22 +172,36 @@ router.post('/full/bulk', async (req: Request, res: Response) => { let out = await getMembersFull(ids); res.status(200).json(out); } catch (error) { - console.error(error); - res.status(500).json(error); + logger.error( + 'app', + 'Failed to get batch full users', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json(error); } }) router.get('/:id', [requireLogin], async (req, res) => { + const userId = req.params.id; + try { - const userId = req.params.id; const result = await pool.query('SELECT * FROM view_member_rank_unit_status_latest WHERE id = $1;', [userId]); if (result.rows.length === 0) { return res.status(404).json({ error: 'User not found' }); } return res.status(200).json(result.rows[0]); - } catch (err) { - console.error('Error fetching user:', err); - return res.status(500).json({ error: 'Failed to fetch user' }); + } catch (error) { + logger.error( + 'app', + 'Failed to get user', + { + user: userId, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); return res.status(500).json({ error: 'Failed to fetch user' }); } }); diff --git a/api/src/routes/ranks.ts b/api/src/routes/ranks.ts index 5eff733..e1cce00 100644 --- a/api/src/routes/ranks.ts +++ b/api/src/routes/ranks.ts @@ -1,8 +1,10 @@ import { MemberState } from "@app/shared/types/member"; import { requireLogin, requireMemberState, requireRole } from "../middleware/auth"; -import { getAllRanks, insertMemberRank } from "../services/rankService"; +import { batchInsertMemberRank, getAllRanks, getPromotionHistorySummary, getPromotionsOnDay, insertMemberRank } from "../services/db/rankService"; +import { BatchPromotion, BatchPromotionMember } from '@app/shared/schemas/promotionSchema' import express = require('express'); +import { logger } from "../services/logging/logger"; const r = express.Router(); const ur = express.Router(); @@ -11,26 +13,81 @@ r.use(requireLogin) ur.use(requireLogin) //insert a new latest rank for a user -ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req, res) => { - 3 +ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req: express.Request, res: express.Response) => { try { - const change = req.body?.change; - await insertMemberRank(change.member_id, change.rank_id, change.date); + const change = req.body.promotions as BatchPromotionMember[]; + const approver = req.body.approver as number; + const author = req.user.id; + if (!change) res.sendStatus(400); + await batchInsertMemberRank(change, author, approver); + logger.info('app', 'Promotion batch submitted', { author: author }) res.sendStatus(201); - } catch (err) { - console.error('Insert failed:', err); + } catch (error) { + logger.error( + 'app', + 'Failed to post rank change', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Failed to update ranks' }); } }); +ur.get('/', async (req: express.Request, res: express.Response) => { + try { + const promos = await getPromotionHistorySummary(); + res.status(200).json(promos); + } catch (error) { + logger.error( + 'app', + 'Failed to get rank change history', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.sendStatus(500); + } +}); + +ur.get('/:day', async (req: express.Request, res: express.Response) => { + try { + if (!req.params.day) res.sendStatus(400); + + var day = new Date(req.params.day) + const promos = await getPromotionsOnDay(day); + res.status(200).json(promos); + } catch (error) { + logger.error( + 'app', + 'Failed to get rank change history on day', + { + day: day, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.sendStatus(500); + } +}) + //get all ranks r.get('/', async (req, res) => { try { const ranks = await getAllRanks(); res.json(ranks); - } catch (err) { - console.error(err); + } catch (error) { + logger.error( + 'app', + 'Failed to get all ranks', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/api/src/routes/roles.ts b/api/src/routes/roles.ts index d0f0e68..c6a088e 100644 --- a/api/src/routes/roles.ts +++ b/api/src/routes/roles.ts @@ -5,84 +5,134 @@ const ur = express.Router(); import { MemberState } from '@app/shared/types/member'; import pool from '../db'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; -import { assignUserGroup, createGroup } from '../services/rolesService'; +import { assignUserGroup, createGroup, getAllRoles, getRole, getUsersWithRole } from '../services/db/rolesService'; +import { Request, Response } from 'express'; +import { logger } from '../services/logging/logger'; r.use(requireLogin) ur.use(requireLogin) //manually assign a member to a group -ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { +ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res) => { + const body = req.body; + try { - const body = req.body; - assignUserGroup(body.member_id, body.role_id); + await assignUserGroup(body.member_id, body.role_id); + logger.info('app', 'User assigned role', { user: body.member_id, role: body.role_id, assigner: req.user.id }) res.sendStatus(201); - } catch (err) { - console.error('Insert failed:', err); + } catch (error) { + if (error?.code === 'ER_DUP_ENTRY') { + return res.status(400).json({ + error: 'Member already has this role', + }); + } + + logger.error( + 'app', + 'Failed to assign role', + { + user: body.member_id, + role: body.role_id, + assigner: req.user.id, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Failed to add to group' }); } }); //manually remove member from group -ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { +ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res: Response) => { + const body = req.body; + try { - const body = req.body; const sql = 'DELETE FROM members_roles WHERE member_id = ? AND role_id = ?' await pool.query(sql, [body.member_id, body.role_id]) + logger.info('app', 'User removed role', { user: body.member_id, role: body.role_id, assigner: req.user.id }) + res.sendStatus(200); } - catch (err) { - console.error("delete failed: ", err) + catch (error) { + logger.error( + 'app', + 'Failed to remove role', + { + user: body.member_id, + role: body.role_id, + assigner: req.user.id, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); res.status(500).json({ error: 'Failed to remove from group' }); - } }) //get all roles r.get('/', [requireMemberState(MemberState.Member)], async (req, res) => { try { - var con = await pool.getConnection(); - - // Get all roles - const roles = await con.query('SELECT * FROM roles;'); - - // Get all members for each role - const membersRoles = await con.query(` - SELECT mr.role_id, v.* - FROM members_roles mr - JOIN view_member_rank_unit_status_latest v ON mr.member_id = v.member_id - `); - - - // Group members by role_id - const roleIdToMembers = {}; - for (const row of membersRoles) { - if (!roleIdToMembers[row.role_id]) roleIdToMembers[row.role_id] = []; - // Remove role_id from member object - const { role_id, ...member } = row; - roleIdToMembers[role_id].push(member); - } - - // Attach members to each role - const result = roles.map(role => ({ - ...role, - members: roleIdToMembers[role.id] || [] - })); - - res.json(result); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Internal server error' }); - } finally { - con.release(); + const roles = await getAllRoles(); + + res.status(200).json(roles); + } catch (error) { + logger.error( + 'app', + 'Failed to get all roles', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.sendStatus(500); } }); +r.get('/:id/members', [requireMemberState(MemberState.Member)], async (req: Request, res: Response) => { + try { + const members = await getUsersWithRole(Number(req.params.id)); + res.status(200).json(members); + } catch (error) { + logger.error( + 'app', + 'Failed to get role members', + { + role: req.params.id, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.sendStatus(500); + } +}) + + +r.get('/:id', [requireMemberState(MemberState.Member)], async (req: Request, res: Response) => { + try { + const role = await getRole(Number(req.params.id)); + res.status(200).json(role); + } catch (error) { + logger.error( + 'app', + 'Failed to get role members', + { + role: req.params.id, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.sendStatus(500); + } +}) + + + //create a new role -r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { +r.post('/', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => { try { const { name, color, description } = req.body; if (!name || !color) { @@ -103,7 +153,7 @@ r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administr } }) -r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { +r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => { try { const id = req.params.id; diff --git a/api/src/routes/statuses.ts b/api/src/routes/statuses.ts index 8457800..3724579 100644 --- a/api/src/routes/statuses.ts +++ b/api/src/routes/statuses.ts @@ -4,6 +4,7 @@ const memberStatusR = express.Router(); import pool from '../db'; import { requireLogin } from '../middleware/auth'; +import { logger } from '../services/logging/logger'; statusR.use(requireLogin); memberStatusR.use(requireLogin); @@ -38,9 +39,16 @@ statusR.get('/', async (req, res) => { try { const result = await pool.query('SELECT * FROM statuses;'); res.json(result); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Internal server error' }); + } catch (error) { + logger.error( + 'app', + 'Failed to get all statuses', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.sendStatus(500); } }); diff --git a/api/src/services/CourseSerivce.ts b/api/src/services/db/CourseSerivce.ts similarity index 99% rename from api/src/services/CourseSerivce.ts rename to api/src/services/db/CourseSerivce.ts index d85ffd8..1fdd8bf 100644 --- a/api/src/services/CourseSerivce.ts +++ b/api/src/services/db/CourseSerivce.ts @@ -1,4 +1,4 @@ -import pool from "../db" +import pool from "../../db" import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course" import { PagedData } from "@app/shared/types/pagination"; import { toDateTime } from "@app/shared/utils/time"; diff --git a/api/src/services/applicationService.ts b/api/src/services/db/applicationService.ts similarity index 92% rename from api/src/services/applicationService.ts rename to api/src/services/db/applicationService.ts index 159dbe3..985ad5b 100644 --- a/api/src/services/applicationService.ts +++ b/api/src/services/db/applicationService.ts @@ -1,11 +1,20 @@ import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application"; -import pool from "../db"; +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/calendarService.ts b/api/src/services/db/calendarService.ts similarity index 99% rename from api/src/services/calendarService.ts rename to api/src/services/db/calendarService.ts index 4862650..3c677b5 100644 --- a/api/src/services/calendarService.ts +++ b/api/src/services/db/calendarService.ts @@ -1,4 +1,4 @@ -import pool from '../db'; +import pool from '../../db'; import { CalendarEventShort, CalendarSignup, CalendarEvent, CalendarAttendance } from "@app/shared/types/calendar" import { toDateTime } from "@app/shared/utils/time" diff --git a/api/src/services/loaService.ts b/api/src/services/db/loaService.ts similarity index 99% rename from api/src/services/loaService.ts rename to api/src/services/db/loaService.ts index c2378b9..d6a9c40 100644 --- a/api/src/services/loaService.ts +++ b/api/src/services/db/loaService.ts @@ -1,5 +1,5 @@ import { toDateTime } from "@app/shared/utils/time"; -import pool from "../db"; +import pool from "../../db"; import { LOARequest, LOAType } from '@app/shared/types/loa' import { PagedData } from '@app/shared/types/pagination' diff --git a/api/src/services/memberService.ts b/api/src/services/db/memberService.ts similarity index 62% rename from api/src/services/memberService.ts rename to api/src/services/db/memberService.ts index 844ef33..d1a2db5 100644 --- a/api/src/services/memberService.ts +++ b/api/src/services/db/memberService.ts @@ -1,5 +1,6 @@ -import pool from "../db"; -import { Member, MemberLight, memberSettings, MemberState } from '@app/shared/types/member' +import { Role } from "@app/shared/types/roles"; +import pool from "../../db"; +import { Member, MemberCardDetails, MemberLight, memberSettings, MemberState } from '@app/shared/types/member' export async function getUserData(userID: number): Promise { const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`; @@ -60,10 +61,50 @@ export async function getAllMembersLite(): Promise { return res; } -export async function getMembersFull(ids: number[]): Promise { - const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id IN (?);`; - const res: Member[] = await pool.query(sql, [ids]); - return res; +export async function getMembersFull(ids: number[]): Promise { + const sql = ` + SELECT m.*, + COALESCE( + JSON_ARRAYAGG( + CASE + WHEN r.id IS NOT NULL THEN JSON_OBJECT( + 'id', r.id, + 'name', r.name, + 'color', r.color, + 'description', r.description + ) + END + ), + JSON_ARRAY() + ) AS roles + FROM view_member_rank_unit_status_latest m + LEFT JOIN members_roles mr ON m.member_id = mr.member_id + LEFT JOIN roles r ON mr.role_id = r.id + WHERE m.member_id IN (?) + GROUP BY m.member_id; + `; + + const rows: any[] = await pool.query(sql, [ids]); + + return rows.map(row => { + const member: Member = { + member_id: row.member_id, + member_name: row.member_name, + displayName: row.displayName, + rank: row.rank, + rank_date: row.rank_date, + unit: row.unit, + unit_date: row.unit_date, + status: row.status, + status_date: row.status_date, + loa_until: row.loa_until ? new Date(row.loa_until) : undefined, + }; + + // roles comes as array of strings; parse each one + const roles: Role[] = JSON.parse(row.roles).map((r: string) => JSON.parse(r)); + + return { member, roles }; + }); } export async function mapDiscordtoID(id: number): Promise { diff --git a/api/src/services/db/rankService.ts b/api/src/services/db/rankService.ts new file mode 100644 index 0000000..4bffdde --- /dev/null +++ b/api/src/services/db/rankService.ts @@ -0,0 +1,108 @@ +import { BatchPromotion, BatchPromotionMember } from "@app/shared/schemas/promotionSchema"; +import { PromotionDetails, PromotionSummary } from "@app/shared/types/rank" +import pool from "../../db"; +import { PagedData } from "@app/shared/types/pagination"; +import { toDate, toDateIgnoreZone, toDateTime } from "@app/shared/utils/time"; + +export async function getAllRanks() { + const rows = await pool.query( + 'SELECT id, name, short_name, sort_id FROM ranks;' + ); + return rows; +} + +export async function getRankByName(name: string) { + const rows = await pool.query(`SELECT id, name, short_name, sort_id FROM ranks WHERE name = ?`, [name]); + + if (rows.length === 0) + throw new Error("Could not find rank: " + name); + + return rows[0]; +} + +export async function insertMemberRank(member_id: number, rank_id: number, date: Date): Promise; +export async function insertMemberRank(member_id: number, rank_id: number): Promise; + +export async function insertMemberRank(member_id: number, rank_id: number, date?: Date): Promise { + const sql = date + ? `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, ?);` + : `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, NOW());`; + + const params = date + ? [member_id, rank_id, date] + : [member_id, rank_id]; + + await pool.query(sql, params); +} + + +export async function batchInsertMemberRank(promos: BatchPromotionMember[], author: number, approver: number) { + try { + var con = await pool.getConnection(); + promos.forEach(p => { + con.query(`CALL sp_update_member_rank(?, ?, ?, ?, ?, ?)`, [p.member_id, p.rank_id, approver, author, "Rank Change", toDateIgnoreZone(new Date(p.start_date))]) + }); + + con.commit(); + return + } catch (error) { + throw error; //pass it up + } finally { + con.release(); + } +} + +export async function getPromotionHistorySummary(page: number = 1, pageSize: number = 15): Promise> { + + const offset = (page - 1) * pageSize; + + let sql = `SELECT + DATE(start_date) AS entry_day + FROM + members_ranks + WHERE reason = 'Rank Change' + GROUP BY + entry_day + ORDER BY + entry_day DESC + LIMIT ? OFFSET ?;` + + let promoList: PromotionSummary[] = await pool.query(sql, [pageSize, offset]) as PromotionSummary[]; + + let rowCount = Number((await pool.query(`SELECT + COUNT(*) AS total_grouped_days_count + FROM + ( + SELECT DISTINCT DATE(start_date) + FROM members_ranks + WHERE reason = 'Rank Change' + ) AS grouped_days;`))[0]); + + let pageCount = rowCount / pageSize; + + let output: PagedData = { data: promoList, pagination: { page: page, pageSize: pageSize, total: rowCount, totalPages: pageCount } } + return output; +} + +export async function getPromotionsOnDay(day: Date): Promise { + + const dayString = toDateTime(day); + + // SQL query to fetch all records from members_unit for the specified day + let sql = ` + SELECT + mr.id AS promo_id, + mr.member_id, + mr.created_by_id, + mr.authorized_by_id, + r.short_name + FROM members_ranks AS mr + LEFT JOIN ranks AS r ON r.id = mr.rank_id + WHERE DATE(mr.start_date) = ? && mr.reason = 'Rank Change' + ORDER BY mr.start_date ASC; + `; + + let batchPromotion = await pool.query(sql, [dayString]) as PromotionDetails[]; + + return batchPromotion; +} \ No newline at end of file diff --git a/api/src/services/db/rolesService.ts b/api/src/services/db/rolesService.ts new file mode 100644 index 0000000..7a857ec --- /dev/null +++ b/api/src/services/db/rolesService.ts @@ -0,0 +1,57 @@ +import { MemberLight } from '@app/shared/types/member'; +import pool from '../../db'; +import { Role, RoleSummary } from '@app/shared/types/roles' + +export async function assignUserGroup(userID: number, roleID: number) { + const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`; + const params = [userID, roleID]; + + return await pool.query(sql, params); +} + +export async function createGroup(name: string, color: string, description: string) { + const sql = `INSERT INTO roles (name, color, description) VALUES (?, ?, ?)`; + const params = [name, color, description]; + + const result = await pool.query(sql, params); + return { id: result.insertId, name, color, description }; +} + +export async function getUserRoles(userID: number): Promise { + const sql = `SELECT r.id, r.name + FROM members_roles mr + INNER JOIN roles r ON mr.role_id = r.id + WHERE mr.member_id = ?;`; + + return await pool.query(sql, [userID]); +} + +export async function getRole(id: number): Promise { + let res = await pool.query(`SELECT * FROM roles WHERE id = ?`, [id]) + return res[0] as Role; +} + +export async function getAllRoles(): Promise { + return await pool.query(`SELECT id, name, color FROM roles`); +} + +export async function getUsersWithRole(roleId: number): Promise { + const out = await pool.query( + ` + SELECT + m.member_id AS id, + m.member_name AS username, + m.displayName, + u.color + FROM members_roles mr + JOIN view_member_rank_unit_status_latest m + ON m.member_id = mr.member_id + LEFT JOIN units u + ON u.name = m.unit + WHERE mr.role_id = ? + `, + [roleId] + ) + + return out as MemberLight[] +} \ No newline at end of file diff --git a/api/src/services/statusService.ts b/api/src/services/db/statusService.ts similarity index 89% rename from api/src/services/statusService.ts rename to api/src/services/db/statusService.ts index 8b8fb28..c02f9e4 100644 --- a/api/src/services/statusService.ts +++ b/api/src/services/db/statusService.ts @@ -1,4 +1,4 @@ -import pool from "../db" +import pool from "../../db" export async function assignUserToStatus(userID: number, statusID: number) { const sql = `INSERT INTO members_statuses (member_id, status_id, start_date) VALUES (?, ?, NOW())` 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..e47d59b --- /dev/null +++ b/api/src/services/integrations/discord.ts @@ -0,0 +1,39 @@ +import { bus } from "../events/eventBus"; +import { logger } from "../logging/logger"; + +export function initializeDiscordIntegrations() { + bus.on('application.create', async (event) => { + + if (!process.env.DISCORD_APPLICATIONS_WEBHOOK) { + logger.error("app", 'Discord Applications Webhook is not defined') + return; + } + + 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] }), + }); + }); +} diff --git a/api/src/services/logging/logger.ts b/api/src/services/logging/logger.ts new file mode 100644 index 0000000..cc4c39e --- /dev/null +++ b/api/src/services/logging/logger.ts @@ -0,0 +1,72 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +export type LogDepth = 'normal' | 'verbose' | 'profiling'; +export type LogType = 'http' | 'app' | 'auth' | 'profiling'; + +export interface LogHeader { + timestamp: string; + level: LogLevel; + depth: LogDepth; + type: LogType; // 'http', 'app', 'db', etc. + user_id?: number; +} + +export interface LogPayload { + message?: string; // short human-friendly description + data?: Record; // type-specific rich data +} + +// Environment defaults +const CURRENT_DEPTH: LogDepth = (process.env.LOG_DEPTH as LogDepth) || 'normal'; + +const DEPTH_ORDER: Record = { normal: 0, verbose: 1, profiling: 2 }; + +function shouldLog(depth: LogDepth) { + let should = DEPTH_ORDER[depth] <= DEPTH_ORDER[CURRENT_DEPTH] + return should; +} + +function emitLog(header: LogHeader, payload: LogPayload = {}) { + if (!shouldLog(header.depth)) return; + + const logLine = { ...header, ...payload }; + + if (header.level === 'error') + console.error(JSON.stringify(logLine)) + else + console.log(JSON.stringify(logLine)); +} + +export const logger = { + log(level: LogLevel, type: LogType, message: string, data?: Record, depth: LogDepth = 'normal', context?: Partial) { + const header: LogHeader = { + timestamp: new Date().toISOString(), + level, + depth, + type, + ...context, + }; + + const payload: LogPayload = { + message, + data, + }; + + emitLog(header, payload); + }, + + info(type: LogType, message: string, data?: Record, depth: LogDepth = 'normal', context?: Partial) { + this.log('info', type, message, data, depth, context); + }, + + debug(type: LogType, message: string, data?: Record, depth: LogDepth = 'normal', context?: Partial) { + this.log('debug', type, message, data, depth, context); + }, + + warn(type: LogType, message: string, data?: Record, depth: LogDepth = 'normal', context?: Partial) { + this.log('warn', type, message, data, depth, context); + }, + + error(type: LogType, message: string, data?: Record, depth: LogDepth = 'normal', context?: Partial) { + this.log('error', type, message, data, depth, context); + }, +} \ No newline at end of file diff --git a/api/src/services/rankService.ts b/api/src/services/rankService.ts deleted file mode 100644 index ada4ea0..0000000 --- a/api/src/services/rankService.ts +++ /dev/null @@ -1,32 +0,0 @@ -import pool from "../db"; - -export async function getAllRanks() { - const rows = await pool.query( - 'SELECT id, name, short_name, sort_id FROM ranks;' - ); - return rows; -} - -export async function getRankByName(name: string) { - const rows = await pool.query(`SELECT id, name, short_name, sort_id FROM ranks WHERE name = ?`, [name]); - - if (rows.length === 0) - throw new Error("Could not find rank: " + name); - - return rows[0]; -} - -export async function insertMemberRank(member_id: number, rank_id: number, date: Date): Promise; -export async function insertMemberRank(member_id: number, rank_id: number): Promise; - -export async function insertMemberRank(member_id: number, rank_id: number, date?: Date): Promise { - const sql = date - ? `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, ?);` - : `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, NOW());`; - - const params = date - ? [member_id, rank_id, date] - : [member_id, rank_id]; - - await pool.query(sql, params); -} diff --git a/api/src/services/rolesService.ts b/api/src/services/rolesService.ts deleted file mode 100644 index f9e724c..0000000 --- a/api/src/services/rolesService.ts +++ /dev/null @@ -1,27 +0,0 @@ -import pool from '../db'; -import { Role } from '@app/shared/types/roles' - -export async function assignUserGroup(userID: number, roleID: number) { - - const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`; - const params = [userID, roleID]; - - return await pool.query(sql, params); -} - -export async function createGroup(name: string, color: string, description: string) { - const sql = `INSERT INTO roles (name, color, description) VALUES (?, ?, ?)`; - const params = [name, color, description]; - - const result = await pool.query(sql, params); - return { id: result.insertId, name, color, description }; -} - -export async function getUserRoles(userID: number): Promise { - const sql = `SELECT r.id, r.name - FROM members_roles mr - INNER JOIN roles r ON mr.role_id = r.id - WHERE mr.member_id = ?;`; - - return await pool.query(sql, [userID]); -} \ No newline at end of file diff --git a/api/tsconfig.json b/api/tsconfig.json index 8893066..d4ab451 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -7,6 +7,7 @@ "node", "express" ], + "sourceMap": true, "paths": { "@app/shared/*": ["../shared/*"] } diff --git a/shared/schemas/promotionSchema.ts b/shared/schemas/promotionSchema.ts new file mode 100644 index 0000000..988f2f8 --- /dev/null +++ b/shared/schemas/promotionSchema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export const batchPromotionMemberSchema = z.object({ + member_id: z.number({ invalid_type_error: "Must select a member" }).int().positive(), + rank_id: z.number({ invalid_type_error: "Must select a rank" }).int().positive(), + start_date: z.string().refine((val) => !isNaN(Date.parse(val)), { + message: "Must be a valid date", + }), +}); + +export const batchPromotionSchema = z.object({ + promotions: z.array(batchPromotionMemberSchema, { message: "At least one promotion is required" }).nonempty({ message: "At least one promotion is required" }), + approver: z.number({ invalid_type_error: "Must select a member" }).int().positive() +}) + .superRefine((data, ctx) => { + // optional: check for duplicate member_ids + const memberCounts = new Map(); + data.promotions.forEach((p, index) => { + memberCounts.set(p.member_id, (memberCounts.get(p.member_id) ?? 0) + 1); + if (memberCounts.get(p.member_id)! > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["promotions", index, "member_id"], + message: "Duplicate member in batch is not allowed", + }); + } + }); + }); + + +export type BatchPromotion = z.infer; +export type BatchPromotionMember = z.infer; \ No newline at end of file diff --git a/shared/types/member.ts b/shared/types/member.ts index 7caa9f0..eea7f85 100644 --- a/shared/types/member.ts +++ b/shared/types/member.ts @@ -34,6 +34,11 @@ export interface MemberLight { color: string } +export interface MemberCardDetails { + member: Member; + roles: Role[]; +} + export interface myData { member: Member; LOAs: LOARequest[]; diff --git a/shared/types/rank.ts b/shared/types/rank.ts new file mode 100644 index 0000000..91899b1 --- /dev/null +++ b/shared/types/rank.ts @@ -0,0 +1,19 @@ +export type Rank = { + id: number + name: string + short_name: string + category: string + sortOrder: number +} + +export interface PromotionSummary { + entry_day: Date; +} + +export interface PromotionDetails { + promo_id: number; + member_id: number; + short_name: string; + created_by_id: number; + authorized_by_id: number; +} \ No newline at end of file diff --git a/shared/types/roles.ts b/shared/types/roles.ts index a232c52..08ab762 100644 --- a/shared/types/roles.ts +++ b/shared/types/roles.ts @@ -1,6 +1,14 @@ +import { MemberLight } from "./member"; + export interface Role { id: number; name: string; color?: string; description?: string; +} + +export interface RoleSummary { + id: number; + name: string; + color?: string; } \ No newline at end of file diff --git a/shared/utils/time.ts b/shared/utils/time.ts index e1b5ea8..58ab991 100644 --- a/shared/utils/time.ts +++ b/shared/utils/time.ts @@ -3,12 +3,34 @@ export function toDateTime(date: Date): string { date = new Date(date); } // This produces a CST-local time because server runs in CST - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, "0"); - const day = date.getDate().toString().padStart(2, "0"); - const hour = date.getHours().toString().padStart(2, "0"); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + const hour = date.getHours().toString().padStart(2, "0"); const minute = date.getMinutes().toString().padStart(2, "0"); const second = date.getSeconds().toString().padStart(2, "0"); return `${year}-${month}-${day} ${hour}:${minute}:${second}`; } + +export function toDateIgnoreZone(date: Date): string { + if (typeof date === 'string') { + date = new Date(date); + } + return date.toISOString().split('T')[0]; +} + +export function toDate(date: Date): string { + if (typeof date === 'string') { + date = new Date(date); + } + console.log(date); + // This produces a CST-local date because server runs in CST + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + let out = `${year}-${month}-${day}`; + + console.log(out); + return out; +} \ No newline at end of file diff --git a/ui/src/App.vue b/ui/src/App.vue index d27efcd..7e208d0 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -19,6 +19,7 @@ function formatDate(dateStr) { } const environment = import.meta.env.VITE_ENVIRONMENT; +const version = import.meta.env.VITE_APPLICATION_VERSION; \ No newline at end of file diff --git a/ui/src/pages/Calendar.vue b/ui/src/pages/Calendar.vue index 954e316..efef877 100644 --- a/ui/src/pages/Calendar.vue +++ b/ui/src/pages/Calendar.vue @@ -155,7 +155,7 @@ onMounted(() => {
-
+
@@ -208,10 +208,10 @@ onMounted(() => {
diff --git a/ui/src/pages/ManageApplications.vue b/ui/src/pages/ManageApplications.vue index 907a023..9e77b36 100644 --- a/ui/src/pages/ManageApplications.vue +++ b/ui/src/pages/ManageApplications.vue @@ -13,9 +13,10 @@ import { import Button from '@/components/ui/button/Button.vue'; import { computed, onMounted, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; -import { CheckIcon, XIcon } from 'lucide-vue-next'; +import { CheckIcon, Link, XIcon } from 'lucide-vue-next'; import Application from './Application.vue'; import MemberCard from '@/components/members/MemberCard.vue'; +import { CopyLink } from '@/lib/copyLink'; const appList = ref([]); const now = Date.now(); @@ -127,9 +128,11 @@ onMounted(async () => {

Application

- +
+ +
diff --git a/ui/src/pages/ManageRoles.vue b/ui/src/pages/ManageRoles.vue index cf437ff..7b76b30 100644 --- a/ui/src/pages/ManageRoles.vue +++ b/ui/src/pages/ManageRoles.vue @@ -9,7 +9,7 @@ import { CardTitle, } from '@/components/ui/card' import { onMounted, ref, computed, reactive, watch } from 'vue'; -import { addMemberToRole, createRole, deleteRole, getRoles, removeMemberFromRole, Role } from '@/api/roles'; +import { addMemberToRole, createRole, deleteRole, getRoles, removeMemberFromRole } from '@/api/roles'; import Badge from '@/components/ui/badge/Badge.vue'; import { Dialog, @@ -34,8 +34,11 @@ import { Plus, X } from 'lucide-vue-next'; import Separator from '@/components/ui/separator/Separator.vue'; import Input from '@/components/ui/input/Input.vue'; import Label from '@/components/ui/label/Label.vue'; -import { getMembers } from '@/api/member'; -import { Member } from '@shared/types/member'; +import { getAllLightMembers, getMembers } from '@/api/member'; +import { Member, MemberLight } from '@shared/types/member'; +import { Role } from '@shared/types/roles'; +import RoleView from '@/components/roles/roleView.vue'; +import { useRoute } from 'vue-router'; const roles = ref([]) const activeRole = ref(null) @@ -43,16 +46,9 @@ const showDialog = ref(false); const showCreateGroupDialog = ref(false); const addingMember = ref(false); const memberToAdd = ref(null); +const route = useRoute(); -const allMembers = ref([]) -const availableMembers = computed(() => { - if (!activeRole.value) return []; - return allMembers.value.filter( - member => !activeRole.value!.members.some( - roleMember => roleMember.member_id === member.member_id - ) - ); -}) +const allMembers = ref([]) type RoleDraft = { name: string @@ -117,141 +113,48 @@ async function handleCreateGroup() { } } -async function handleAddMember() { - //guard - if (memberToAdd.value == null) - return; - - await addMemberToRole(memberToAdd.value.member_id, activeRole.value.id); - roles.value = await getRoles(); -} - -async function handleRemoveMember(memberId: number) { - removeMemberFromRole(memberId, activeRole.value.id); - roles.value = await getRoles(); -} - -async function handleDeleteRole() { - await deleteRole(activeRole.value.id); -} +const viewingGroup = computed(() => { + return route.path.startsWith('/administration/roles/') + && route.params.id !== undefined +}) onMounted(async () => { roles.value = await getRoles(); - allMembers.value = await getMembers(); + allMembers.value = await getAllLightMembers(); }) + + \ No newline at end of file diff --git a/ui/src/pages/TrainingReport.vue b/ui/src/pages/TrainingReport.vue index d321ef3..bad58a5 100644 --- a/ui/src/pages/TrainingReport.vue +++ b/ui/src/pages/TrainingReport.vue @@ -10,7 +10,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { ArrowUpDown, Funnel, Plus, Search, X } from 'lucide-vue-next'; +import { ArrowUpDown, ChevronDown, ChevronUp, Funnel, Link, Plus, Search, X } from 'lucide-vue-next'; import Button from '@/components/ui/button/Button.vue'; import TrainingReportForm from '@/components/trainingReport/trainingReportForm.vue'; import Checkbox from '@/components/ui/checkbox/Checkbox.vue'; @@ -32,6 +32,8 @@ import { PaginationNext, PaginationPrevious, } from '@/components/ui/pagination' +import Tooltip from '@/components/tooltip/Tooltip.vue'; +import { CopyLink } from '@/lib/copyLink'; enum sidePanelState { view, create, closed }; @@ -53,6 +55,7 @@ watch(() => route.params.id, async (newID) => { return; } TRLoaded.value = false; + expanded.value = null; viewTrainingReport(Number(route.params.id)); }) @@ -126,6 +129,8 @@ function setPage(pagenum: number) { pageNum.value = pagenum; loadTrainingReports(); } + +const expanded = ref(null); - - \ No newline at end of file diff --git a/ui/src/pages/Transfer.vue b/ui/src/pages/Transfer.vue deleted file mode 100644 index 57ef20b..0000000 --- a/ui/src/pages/Transfer.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - \ No newline at end of file diff --git a/ui/src/router/index.js b/ui/src/router/index.js index 9801bd4..4e7050a 100644 --- a/ui/src/router/index.js +++ b/ui/src/router/index.js @@ -15,7 +15,6 @@ const router = createRouter({ // MEMBER ROUTES { path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } }, - { path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/profile', component: () => import('@/pages/MyProfile.vue'), meta: { requiresAuth: true } }, @@ -38,9 +37,9 @@ const router = createRouter({ { path: 'applications/:id', component: () => import('@/pages/ManageApplications.vue') }, { path: 'rankChange', component: () => import('@/pages/RankChange.vue') }, { path: 'applications/:id', component: () => import('@/pages/Application.vue') }, - { path: 'transfer', component: () => import('@/pages/ManageTransfers.vue') }, { path: 'loa', component: () => import('@/pages/ManageLOA.vue') }, - { path: 'roles', component: () => import('@/pages/ManageRoles.vue') } + { path: 'roles', component: () => import('@/pages/ManageRoles.vue') }, + { path: 'roles/:id', component: () => import('@/pages/ManageRoles.vue') } ] }, diff --git a/ui/src/stores/memberDirectory.ts b/ui/src/stores/memberDirectory.ts index 98634ab..e01410f 100644 --- a/ui/src/stores/memberDirectory.ts +++ b/ui/src/stores/memberDirectory.ts @@ -1,5 +1,5 @@ import { defineStore } from "pinia" -import type { MemberLight, Member } from "@shared/types/member" +import type { MemberLight, Member, MemberCardDetails } from "@shared/types/member" import { getLightMembers, getFullMembers } from "@/api/member" import { reactive, ref } from "vue" import { resolve } from "path" @@ -7,7 +7,7 @@ import { rejects } from "assert" export const useMemberDirectory = defineStore('memberDirectory', () => { const light = reactive>({}); - const full = reactive>({}) + const full = reactive>({}) function getLight(id: number): Promise { if (light[id]) return Promise.resolve(light[id]); @@ -24,7 +24,7 @@ export const useMemberDirectory = defineStore('memberDirectory', () => { }) } - function getFull(id: number): Promise { + function getFull(id: number): Promise { if (full[id]) return Promise.resolve(full[id]) if (!fullWaiters.has(id)) { @@ -34,7 +34,7 @@ export const useMemberDirectory = defineStore('memberDirectory', () => { scheduleBatch() - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { fullWaiters.get(id)!.push({ resolve, reject }) }) } @@ -50,7 +50,7 @@ export const useMemberDirectory = defineStore('memberDirectory', () => { // promises const lightWaiters = new Map void; reject: (e: any) => void }>>() - const fullWaiters = new Map void; reject: (e: any) => void }>>() + const fullWaiters = new Map void; reject: (e: any) => void }>>() let batchTimer: ReturnType | null = null; @@ -105,12 +105,12 @@ export const useMemberDirectory = defineStore('memberDirectory', () => { try { const res = await getFullMembers(ids); for (const m of res) { - full[m.member_id] = m; + full[m.member.member_id] = m; - const waiters = fullWaiters.get(m.member_id); + const waiters = fullWaiters.get(m.member.member_id); if (waiters) { for (const w of waiters) w.resolve(m) - fullWaiters.delete(m.member_id); + fullWaiters.delete(m.member.member_id); } }