diff --git a/db/seeds/06-story-admin.ts b/db/seeds/06-story-admin.ts index 721144a..59cebb5 100644 --- a/db/seeds/06-story-admin.ts +++ b/db/seeds/06-story-admin.ts @@ -6,7 +6,7 @@ import { parsedBoolean, parseCommaSeparatedString, trimmedStringOrNull, parsedIn import { StoryPlot, StoryPlotEventLink, StoryPlotArtifactLink, StoryPlotPersonLink, StoryPlotMessagesLink } from "../../src/models/story-plots"; import { StoryEvent, StoryEventArtifactLink, StoryEventMessagesLink, StoryEventPersonLink } from "../../src/models/story-events"; import { StoryMessage, StoryMessagePersonLink } from "../../src/models/story-messages"; -import { StoryPersonRelation } from "../../src/models/story-person-relations"; +import { StoryPersonRelation } from "../../src/models/story-person"; const OptionalString = z.preprocess(trimmedStringOrNull, z.string().nullable()); const OptionalInt = z.preprocess(parsedIntOrNull, z.number().nullable()); diff --git a/package-lock.json b/package-lock.json index 9c83b8e..86c8c5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@types/body-parser": "^1.19.2", - "@types/express": "^4.17.17", + "@types/express": "^4.17.21", "@types/http-errors": "^2.0.3", "@types/lodash": "^4.14.200", "@types/node": "^18.14.6", @@ -542,9 +542,9 @@ } }, "node_modules/@types/express": { - "version": "4.17.17", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", - "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "dev": true, "dependencies": { "@types/body-parser": "*", @@ -11507,9 +11507,9 @@ } }, "@types/express": { - "version": "4.17.17", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", - "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "dev": true, "requires": { "@types/body-parser": "*", diff --git a/package.json b/package.json index 4aacc46..8075267 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "@types/body-parser": "^1.19.2", - "@types/express": "^4.17.17", + "@types/express": "^4.17.21", "@types/http-errors": "^2.0.3", "@types/lodash": "^4.14.200", "@types/node": "^18.14.6", diff --git a/src/dmx.js b/src/dmx.ts similarity index 86% rename from src/dmx.js rename to src/dmx.ts index 58fab02..1ca98a3 100644 --- a/src/dmx.js +++ b/src/dmx.ts @@ -2,9 +2,8 @@ import { logger } from './logger'; import DMX from 'dmx'; import { isNumber } from 'lodash'; - const UNIVERSE_NAME = 'backend'; -const EVENT_DURATION = 1000; // ms +const EVENT_DURATION = 1000; // ms export const CHANNELS = { JumpFixed: 100, @@ -88,28 +87,41 @@ export const CHANNELS = { HangarBayDoorMalfunction: 197, HangarBayPressurize: 198, HangarBayDepressurize: 199, +} as const; + +type Channel = typeof CHANNELS[keyof typeof CHANNELS]; + +interface Dmx { + update: (universe: string, value: Record) => void; }; const dmx = init(); -function init() { +function init(): Dmx { if (process.env.DMX_DRIVER) { const dmx = new DMX(); dmx.addUniverse(UNIVERSE_NAME, process.env.DMX_DRIVER, process.env.DMX_DEVICE_PATH); return dmx; } else { return { - update() {} + update: (universe: string, value: Record) => { + logger.debug(`DMX update on universe ${universe}: ${JSON.stringify(value)}`); + }, }; } } -export function setValue(channel, value) { - logger.debug(`Setting DMX channel ${channel} (${findChannelName(channel)}) to ${value}`); - dmx.update(UNIVERSE_NAME, { [channel]: value }); +function findChannelName(channel: Channel) { + for (const name of Object.keys(CHANNELS)) { + if (CHANNELS[name] === channel) { + return name; + } + } + logger.error(`Unknown DMX channel ${channel} used`); + return 'UNKNOWN'; } -export function fireEvent(channel, value = 255) { +export function fireEvent(channel: Channel, value = 255) { if (!isNumber(channel) || !isNumber(value) || channel < 0 || channel > 255 || value < 0 || value > 255) { logger.error(`Attempted DMX fireEvent with invalid channel=${channel} or value=${value}`); return; @@ -118,13 +130,3 @@ export function fireEvent(channel, value = 255) { dmx.update(UNIVERSE_NAME, { [channel]: value }); setTimeout(() => dmx.update(UNIVERSE_NAME, { [channel]: 0 }), EVENT_DURATION); } - -function findChannelName(channel) { - for (const name of Object.keys(CHANNELS)) { - if (CHANNELS[name] === channel) { - return name; - } - } - logger.error(`Unknown DMX channel ${channel} used`); - return 'UNKNOWN'; -} diff --git a/src/docs.ts b/src/docs.ts index 01b52c4..c1b7468 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -16,7 +16,7 @@ const options = { securityDefinitions: {} }, basedir: __dirname, - files: ['./routes/**/*.js', './models/**/*.js', './models/**/*.ts', './index.js', './messaging.js', './emptyepsilon.js'] + files: ['./routes/**/*.{js,ts}', './models/**/*.{js,ts}', './index.ts', './messaging.ts', './emptyepsilon.js'] }; export function loadSwagger(app: Express) { diff --git a/src/index.ts b/src/index.ts index 7f9b6e1..c5eccb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,17 @@ import 'dotenv/config'; import { Server } from 'http'; -import { HttpError } from 'http-errors'; -import express, { NextFunction, Request, Response } from 'express'; +import express, { Request, Response } from 'express'; import bodyParser from 'body-parser'; -import socketIo from 'socket.io'; import { logger, loggerMiddleware } from './logger'; import { loadSwagger } from './docs'; import { getEmptyEpsilonClient, setStateRouteHandler } from './emptyepsilon'; import { loadEvents } from './eventhandler'; import { loadMessaging, router as messaging } from './messaging'; import { Store } from './models/store'; -import { handleAsyncErrors } from './routes/helpers'; +import { errorHandlingMiddleware, handleAsyncErrors } from './routes/helpers'; import { get, isEqual, omit, isEmpty } from 'lodash'; import cors from 'cors'; - +import { initSocketIoClient } from './websocket'; import prometheusIoMetrics from 'socket.io-prometheus'; import prometheusMiddleware from 'express-prometheus-middleware'; @@ -31,15 +29,15 @@ import science from './routes/science'; import tag from './routes/tag'; import operation from './routes/operation'; import sip from './routes/sip'; +import storyAdminRoutes from './routes/story-admin'; import { setData, getData, router as data } from './routes/data'; import infoboard from './routes/infoboard'; import dmxRoutes from './routes/dmx'; - import { loadRules } from './rules/rules'; const app = express(); const http = new Server(app); -const io = socketIo(http); +const io = initSocketIoClient(http); // Setup logging middleware and body parsing app.use(bodyParser.json()); @@ -84,6 +82,7 @@ app.use('/messaging', messaging); app.use('/tag', tag); app.use('/operation', operation); app.use('/sip', sip); +app.use('/story', storyAdminRoutes); // Empty Epsilon routes app.put('/state', setStateRouteHandler); @@ -117,22 +116,9 @@ app.post('/emit/:eventName', (req, res) => { }); // Error handling middleware -app.use(async (err: HttpError, req: Request, res: Response, _next: NextFunction) => { - let status = 500; - if ('statusCode' in err) { - status = err.statusCode; - } - logger.error(err.message); - return res.status(status).json({ error: err.message }); -}); +app.use(errorHandlingMiddleware); -// Setup Socket.IO -io.on('connection', socket => { - logger.info('Socket.IO Client connected'); - socket.on('disconnect', () => { - logger.info('Socket.IO Client disconnected'); - }); -}); +// Setup EOS Datahub messaging loadMessaging(io); // Get latest Empty Epsilon game state and save it to store @@ -186,15 +172,10 @@ initStoreSocket(io); function startServer() { const { APP_PORT } = process.env; - http.listen(APP_PORT, () => logger.start(`Odysseus backend listening to port ${APP_PORT}`)); + http.listen(APP_PORT, () => logger.start(`Odysseus backend listening on http://localhost:${APP_PORT}`)); } // Health check route app.get('/ping', (req, res) => { res.send('pong'); }); - -// For emitting Socket.IO events from rule files -export function getSocketIoClient() { - return io; -} diff --git a/src/messaging.js b/src/messaging.ts similarity index 61% rename from src/messaging.js rename to src/messaging.ts index 8f00cb1..65c2a06 100644 --- a/src/messaging.js +++ b/src/messaging.ts @@ -1,10 +1,33 @@ import { logger } from './logger'; import { Person, Group } from './models/person'; import { ComMessage } from './models/communications'; -import { isEmpty, uniqBy, pick } from 'lodash'; +import { isEmpty, uniqBy } from 'lodash'; import { Router } from 'express'; +import socketIo from 'socket.io'; +import { handleAsyncErrors } from './routes/helpers'; +import { z } from 'zod'; -export const router = new Router(); +export const router = Router(); + +interface UserDetails { + user: any; + userId: string; +} + +let messaging: socketIo.Namespace; +const connectedUsers = new Map>(); +const socketUserDetails = new WeakMap(); + +const createAdminMockSocket = (senderUserId: string) => { + const mockSocket = { + emit: () => {}, + userId: senderUserId + } as any; + + socketUserDetails.set(mockSocket, { user: { id: senderUserId }, userId: senderUserId }); + + return mockSocket; +} /** * @typedef AdminMessageDetails @@ -20,9 +43,15 @@ export const router = new Router(); * @group Messaging - Messaging related admin operations * @returns {object} 200 - List of all unread messages */ -router.get('/unread', async (req, res) => { +router.get('/unread', handleAsyncErrors(async (req, res) => { const messages = await new ComMessage().where('seen', false).fetchAllWithRelated(); res.json(messages); +})); + +const SendMessageRequest = z.object({ + sender: z.string(), + target: z.string(), + message: z.string() }); /** @@ -33,40 +62,24 @@ router.get('/unread', async (req, res) => { * @param {AdminMessageDetails.model} messageDetails.body.required - Message details * @returns {object} 204 - OK */ -router.post('/send', async (req, res) => { - const params = pick(req.body, ['sender', 'target', 'message']); +router.post('/send', handleAsyncErrors(async (req, res) => { const messageDetails = { - target: params.target, + ...SendMessageRequest.parse(req.body), type: 'private' }; - const mockSocket = { - emit: () => { - // Empty on purpose - }, - userId: params.sender - }; - await onSendMessage(mockSocket, messageDetails); + await onSendMessage(createAdminMockSocket(messageDetails.sender), messageDetails); res.sendStatus(204); -}); +})); -export function adminSendMessage(userId, messageDetails) { - const mockSocket = { - emit: () => { - // Empty on purpose - }, - userId - }; - return onSendMessage(mockSocket, messageDetails); +export function adminSendMessage(userId: string, messageDetails: any) { + return onSendMessage(createAdminMockSocket(userId), messageDetails); } -let messaging; -const connectedUsers = new Map(); - /** * Initializes messaging * @param {object} io - Socket IO instance attached to Express */ -export function loadMessaging(io) { +export function loadMessaging(io: socketIo.Server) { // Create custom Socket.IO namespace for messaging messaging = io.of('/messaging'); @@ -75,8 +88,7 @@ export function loadMessaging(io) { const { id } = socket.handshake.query; const user = await Person.forge({ id }).fetch(); if (!user) return next(new Error('Invalid user')); - socket.user = user; - socket.userId = user.get('id'); + socketUserDetails.set(socket, { user, userId: user.get('id') }); return next(); }); @@ -88,11 +100,18 @@ export function loadMessaging(io) { * Handler for new Socket connections * @param {object} socket - Socket */ -async function onConnection(socket) { +async function onConnection(socket: socketIo.Socket) { + const userDetails = socketUserDetails.get(socket); + if (!userDetails) { + logger.warn('User details not found for socket'); + return; + } + const { user, userId } = userDetails; + // Add user to list of active sockets for private messaging - const userSet = connectedUsers.get(socket.userId) || new Set([]); + const userSet = connectedUsers.get(userId) || new Set([]); userSet.add(socket); - connectedUsers.set(socket.userId, userSet); + connectedUsers.set(userId, userSet); socket.on('disconnect', () => onUserDisconnect(socket)); socket.on('message', messageDetails => onSendMessage(socket, messageDetails)); @@ -101,25 +120,35 @@ async function onConnection(socket) { socket.on('fetchUnseenMessages', () => onFetchUnseenMessages(socket)); socket.on('searchUsers', name => onSearchUsers(socket, name)); socket.on('getUserList', async () => socket.emit( - 'userList', await getInitialUserList(socket.userId))); + 'userList', await getInitialUserList(userId))); // Emit list of all persons to new client - socket.emit('userList', await getInitialUserList(socket.userId)); + socket.emit('userList', await getInitialUserList(userId)); // Emit all unseen private mesages to new client onFetchUnseenMessages(socket); // Let all active clients know that user has come online - messaging.emit('status', { state: 'connected', user: socket.user }); + messaging.emit('status', { state: 'connected', user }); - logger.info(`User ${socket.userId} connected to messaging`); + logger.info(`User ${userId} connected to messaging`); } -async function onSearchUsers(socket, name) { - if (!name) return socket.emit('userList', await getInitialUserList(socket.userId)); +async function onSearchUsers(socket: socketIo.Socket, name: string) { + const userDetails = socketUserDetails.get(socket); + if (!userDetails) { + logger.warn('User details not found for socket'); + return; + } + const { userId } = userDetails; + + if (!name) { + return socket.emit('userList', await getInitialUserList(userId)); + } + const [foundUsers, usersWithMessageHistory] = await Promise.all([ new Person().search(name), - getInitialUserList(socket.userId) + getInitialUserList(userId) ]); const users = uniqBy([ ...foundUsers.toArray(), @@ -135,14 +164,21 @@ async function onSearchUsers(socket, name) { * @param {object} socket - Socket * @param {object} messageDetails - Message payload */ -async function onSendMessage(socket, messageDetails) { +async function onSendMessage(socket: socketIo.Socket, messageDetails: any) { + const userDetails = socketUserDetails.get(socket); + if (!userDetails) { + logger.warn('User details not found for socket'); + return; + } + const { userId } = userDetails; + const { target, message, type } = messageDetails; - const messageData = { - person_id: socket.userId, + const messageData: Record = { + person_id: userId, message, seen: false }; - if (target === socket.userId) { + if (target === userId) { return logger.warn(`${target} tried to message themself, returning`); } if (type === 'private') { @@ -160,7 +196,7 @@ async function onSendMessage(socket, messageDetails) { const msgWithRelated = await msg.fetchWithRelated(); const receiverSocketSet = connectedUsers.get(target); if (receiverSocketSet) receiverSocketSet.forEach(s => s.emit('message', msgWithRelated)); - const senderSocketSet = connectedUsers.get(socket.userId); + const senderSocketSet = connectedUsers.get(userId); if (senderSocketSet) senderSocketSet.forEach(s => s.emit('message', msgWithRelated)); } else { // Send to general channel for now @@ -173,13 +209,19 @@ async function onSendMessage(socket, messageDetails) { * @param {object} socket - Socket * @param {Array.} messageIds - Message ID */ -async function onMessagesSeen(socket, messageIds) { +async function onMessagesSeen(socket: socketIo.Socket, messageIds: number[]) { if (isEmpty(messageIds)) return; + const userDetails = socketUserDetails.get(socket); + if (!userDetails) { + logger.warn('User details not found for socket'); + return; + } + await ComMessage.forge() .where('id', 'in', messageIds) .save({ seen: true }, { method: 'update', patch: true }); const messages = await ComMessage.forge().where('id', 'in', messageIds).fetchPageWithRelated(); - const socketSet = connectedUsers.get(socket.userId); + const socketSet = connectedUsers.get(userDetails.userId); if (socketSet) socketSet.forEach(s => s.emit('messagesSeen', messages)); } @@ -187,9 +229,15 @@ async function onMessagesSeen(socket, messageIds) { * Handler for Socket onFetchUnseenMessages events * @param {object} socket - Socket */ -async function onFetchUnseenMessages(socket) { +async function onFetchUnseenMessages(socket: socketIo.Socket) { + const userDetails = socketUserDetails.get(socket); + if (!userDetails) { + logger.warn('User details not found for socket'); + return; + } + const messages = await ComMessage.forge().where({ - target_person: socket.userId, seen: false + target_person: userDetails.userId, seen: false }).fetchPageWithRelated(); socket.emit('unseenMessages', messages); } @@ -199,12 +247,18 @@ async function onFetchUnseenMessages(socket) { * @param {object} socket - Socket * @param {object} payload - Request payload */ -async function onFetchHistory(socket, payload) { +async function onFetchHistory(socket: socketIo.Socket, payload: any) { const { type, target } = payload; + const userDetails = socketUserDetails.get(socket); + if (!userDetails) { + logger.warn('User details not found for socket'); + return; + } + let messages; // TODO: Add support for pagination instead of only 50 latest if (type === 'private') { - messages = await ComMessage.forge().getPrivateHistory(socket.userId, target); + messages = await ComMessage.forge().getPrivateHistory(userDetails.userId, target); } else { messages = await ComMessage.forge().getChannelHistory(target); } @@ -215,19 +269,26 @@ async function onFetchHistory(socket, payload) { * Handler for Socket disconnect event * @param {object} socket - Socket */ -function onUserDisconnect(socket) { - messaging.emit('status', { state: 'disconnected', user: socket.user }); - const userSet = connectedUsers.get(socket.userId); +function onUserDisconnect(socket: socketIo.Socket) { + const userDetails = socketUserDetails.get(socket); + if (!userDetails) { + logger.warn('User details not found for socket'); + return; + } + const { user, userId } = userDetails; + + messaging.emit('status', { state: 'disconnected', user }); + const userSet = connectedUsers.get(userId); if (userSet) userSet.delete(socket); - if (userSet && !userSet.size) connectedUsers.delete(socket.userId); - logger.info(`User ${socket.userId} disconnected from messaging`); + if (userSet && !userSet.size) connectedUsers.delete(userId); + logger.info(`User ${userId} disconnected from messaging`); } /** * Gets a list of all Persons. Adds 'is_online' attribute to models. * @returns {Array.} List of persons */ -async function getInitialUserList(personId) { +async function getInitialUserList(personId: string) { const [users, adminUsers] = await Promise.all([ Person.query(qb => { // eslint-disable-next-line max-len diff --git a/src/models/artifact.js b/src/models/artifact.js index ee989b6..291472d 100644 --- a/src/models/artifact.js +++ b/src/models/artifact.js @@ -38,6 +38,8 @@ const artifactWithRelated = [ * @property {string} discovered_from - Discovered from * @property {string} type - Artifact type * @property {string} text - Text description of the artifact + * @property {string} gm_notes - GM notes + * @property {boolean} is_visible - Is the artifact visible to players * @property {string} created_at - ISO 8601 String Date-time when object was created * @property {string} updated_at - ISO 8601 String Date-time when object was last updated */ @@ -47,7 +49,10 @@ export const Artifact = Bookshelf.Model.extend({ entries: function () { return this.hasMany(ArtifactEntry); }, - fetchAllWithRelated: function () { + fetchAllWithRelated: function (isVisible) { + if (typeof isVisible === 'boolean') { + return this.where({ is_visible: isVisible }).fetchAll({ withRelated: artifactWithRelated }); + } return this.orderBy('-created_at').fetchAll({ withRelated: artifactWithRelated }); }, fetchWithRelated: function () { diff --git a/src/models/log.js b/src/models/log.js index cf78584..874bef4 100644 --- a/src/models/log.js +++ b/src/models/log.js @@ -1,5 +1,5 @@ import Bookshelf from '../../db'; -import { getSocketIoClient } from '../index'; +import { getSocketIoClient } from '../websocket'; import { logger } from '../logger'; import { Person } from './person'; diff --git a/src/models/person.js b/src/models/person.js index 76915b5..5f433ba 100644 --- a/src/models/person.js +++ b/src/models/person.js @@ -114,6 +114,14 @@ const withRelated = [ * @property {string} medical_current_medication - Any current medication * @property {string} created_year - When the person was inserted into the system * @property {string} is_visible - Is the person visible or not (have they been discovered) + * @property {string} link_to_character - Link to the character's Google doc + * @property {string} summary - Summary of the character + * @property {string} gm_notes - GM notes + * @property {string} shift - Character's shift (Lunar, Solar, Twilight) + * @property {string} role - Role (e.g. Chief Engineer) + * @property {string} role_additional - Additional role + * @property {string} special_group - Special group + * @property {string} character_group - Character group * @property {string} created_at - Date-time when object was created * @property {string} updated_at - Date-time when object was last updated */ @@ -163,7 +171,8 @@ export const Person = Bookshelf.Model.extend({ 'status', 'home_planet', 'is_visible', - 'card_id' + 'character_group', + 'is_character', ], withRelated: [{ ship: qb => qb.column('id', 'name') diff --git a/src/models/post.js b/src/models/post.js index 5803960..7f3e67a 100644 --- a/src/models/post.js +++ b/src/models/post.js @@ -1,5 +1,5 @@ import Bookshelf from '../../db'; -import { getSocketIoClient } from '../index'; +import { getSocketIoClient } from '../websocket'; import { Person } from './person'; import { logger } from '../logger'; diff --git a/src/models/ship.js b/src/models/ship.js index 882a801..cae794f 100644 --- a/src/models/ship.js +++ b/src/models/ship.js @@ -2,7 +2,7 @@ import Bookshelf, { knex } from '../../db'; import { addShipLogEntry } from './log'; import { MapObject } from './map-object'; import { get, pick } from 'lodash'; -import { getSocketIoClient } from '../index'; +import { getSocketIoClient } from '../websocket'; import { logger } from '../logger'; import { Person } from './person'; diff --git a/src/models/story-artifact.ts b/src/models/story-artifact.ts new file mode 100644 index 0000000..24cd3a5 --- /dev/null +++ b/src/models/story-artifact.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { knex } from "@db/index"; + +export const StoryArtifactRelations = z.object({ + id: z.number(), + events: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + plots: z.array(z.object({ + id: z.number(), + name: z.string(), + })), +}); +export type StoryArtifactRelations = z.infer; + +export async function getArtifactRelations(id: number): Promise { + const artifact = await knex('artifact').select('*').where({ id }).first(); + if (!artifact) { + return null; + } + + const [events, plots] = await Promise.all([ + knex('story_artifact_events') + .join('story_events', 'story_artifact_events.event_id', 'story_events.id') + .select('story_events.id', 'story_events.name') + .where({ artifact_id: id }), + knex('story_artifact_plots') + .join('story_plots', 'story_artifact_plots.plot_id', 'story_plots.id') + .select('story_plots.id', 'story_plots.name') + .where({ artifact_id: id }), + ]); + + return StoryArtifactRelations.parse({ id: artifact.id, events, plots, }); +} diff --git a/src/models/story-events.ts b/src/models/story-events.ts index 929733b..1f2aad9 100644 --- a/src/models/story-events.ts +++ b/src/models/story-events.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { knex } from "@db/index"; /** * @typedef StoryEvent @@ -56,3 +57,62 @@ export const StoryEventMessagesLink = z.object({ message_id: z.number(), }); export type StoryEventMessagesLink = z.infer; + +export const StoryEventWithRelations = StoryEvent.extend({ + artifacts: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + messages: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + persons: z.array(z.object({ + id: z.string(), + name: z.string(), + })), + plots: z.array(z.object({ + id: z.number(), + name: z.string(), + })), +}); +export type StoryEventWithRelations = z.infer; + +export const listStoryEvents = async (): Promise => { + const events = await knex('story_events').select('*'); + return StoryEvent.array().parse(events); +} + +export const getStoryEvent = async (id: number): Promise => { + const event = await knex('story_events').select('*').where({ id }).first() + if (!event) { + return null; + } + + const [artifacts, persons, messages, plots] = await Promise.all([ + knex('story_artifact_events') + .join('artifact', 'story_artifact_events.artifact_id', 'artifact.id') + .select('artifact.name', 'artifact.id') + .where({ event_id: id }), + knex('story_person_events') + .join('person', 'story_person_events.person_id', 'person.id') + .select('person.id', knex.raw('TRIM(CONCAT(person.first_name, \' \', person.last_name)) as name')) + .where({ event_id: id }), + knex('story_event_messages') + .join('story_messages', 'story_event_messages.message_id', 'story_messages.id') + .select('story_messages.id', 'story_messages.name') + .where({ event_id: id }), + knex('story_event_plots') + .join('story_plots', 'story_event_plots.plot_id', 'story_plots.id') + .select('story_plots.id', 'story_plots.name') + .where({ event_id: id }), + ]); + + return StoryEventWithRelations.parse({ + ...event, + artifacts, + persons, + messages, + plots, + }); +} diff --git a/src/models/story-messages.ts b/src/models/story-messages.ts index 601816a..8aebbc5 100644 --- a/src/models/story-messages.ts +++ b/src/models/story-messages.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { knex } from "@db/index"; /** * @typedef StoryMessage @@ -30,3 +31,53 @@ export const StoryMessagePersonLink = z.object({ message_id: z.number(), }); export type StoryMessagePersonLink = z.infer; + +export const StoryMessageWithRelations = StoryMessage.extend({ + events: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + persons: z.array(z.object({ + id: z.string(), + name: z.string(), + })), + plots: z.array(z.object({ + id: z.number(), + name: z.string(), + })), +}); +export type StoryMessageWithRelations = z.infer; + +export async function listStoryMessages(): Promise { + const messages = await knex('story_messages').select('*'); + return StoryMessage.array().parse(messages); +} + +export async function getStoryMessage(id: number): Promise { + const message = await knex('story_messages').select('*').where({ id }).first(); + if (!message) { + return null; + } + + const [events, persons, plots] = await Promise.all([ + knex('story_event_messages') + .join('story_events', 'story_event_messages.event_id', 'story_events.id') + .select('story_events.id', 'story_events.name') + .where({ message_id: id }), + knex('story_person_messages') + .join('person', 'story_person_messages.person_id', 'person.id') + .select('person.id', knex.raw('TRIM(CONCAT(person.first_name, \' \', person.last_name)) as name')) + .where({ message_id: id }), + knex('story_plot_messages') + .join('story_plots', 'story_plot_messages.plot_id', 'story_plots.id') + .select('story_plots.id', 'story_plots.name') + .where({ message_id: id }), + ]); + + return StoryMessageWithRelations.parse({ + ...message, + events, + persons, + plots, + }); +} diff --git a/src/models/story-person-relations.ts b/src/models/story-person-relations.ts deleted file mode 100644 index c723090..0000000 --- a/src/models/story-person-relations.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; - -/** - * @typedef StoryPersonRelations - * @property {string} first_person_id - First person ID - * @property {string} second_person_id - Second person ID - * @property {string} relation - Relation - */ -export const StoryPersonRelation = z.object({ - first_person_id: z.string().nullable(), - second_person_id: z.string().nullable(), - relation: z.string().nullable(), -}); -export type StoryPersonRelation = z.infer; diff --git a/src/models/story-person.ts b/src/models/story-person.ts new file mode 100644 index 0000000..0fc2739 --- /dev/null +++ b/src/models/story-person.ts @@ -0,0 +1,135 @@ +import { z } from 'zod'; +import { knex } from "@db/index"; + +/** + * @typedef StoryPersonRelations + * @property {string} first_person_id - First person ID + * @property {string} second_person_id - Second person ID + * @property {string} relation - Relation + */ +export const StoryPersonRelation = z.object({ + first_person_id: z.string().nullable(), + second_person_id: z.string().nullable(), + relation: z.string().nullable(), +}); +export type StoryPersonRelation = z.infer; + +export const StoryAdminPersonDetails = z.object({ + id: z.string(), + link_to_character: z.string().nullable(), + summary: z.string().nullable(), + gm_notes: z.string().nullable(), + shift: z.string().nullable(), + role: z.string().nullable(), + role_additional: z.string().nullable(), + special_group: z.string().nullable(), + character_group: z.string().nullable(), + medical_elder_gene: z.boolean(), +}); +export type StoryAdminPersonDetails = z.infer; + +const FormattedRelation = z.object({ + id: z.string(), + name: z.string(), + relation: z.string().nullable(), + status: z.string(), + ship: z.string().nullable(), + is_character: z.boolean(), +}); +type FormattedRelation = z.infer + +export const StoryAdminPersonDetailsWithRelations = StoryAdminPersonDetails.extend({ + events: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + messages: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + plots: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + relations: z.array(FormattedRelation), +}); +export type StoryAdminPersonDetailsWithRelations = z.infer; + +const RelationsRow = z.object({ + first_person_id: z.string(), + second_person_id: z.string(), + relation: z.string().nullable(), + first_person_name: z.string(), + second_person_name: z.string(), + first_person_ship_id: z.string().nullable(), + second_person_ship_id: z.string().nullable(), + first_person_is_character: z.boolean(), + second_person_is_character: z.boolean(), + first_person_status: z.string(), + second_person_status: z.string(), +}); + +function parseRelation (userId: string, rawRow: any): FormattedRelation { + const row = RelationsRow.parse(rawRow); + const isRelationFirstPerson = row.second_person_id === userId; + + const id = isRelationFirstPerson ? row.first_person_id : row.second_person_id; + const name = isRelationFirstPerson ? row.first_person_name : row.second_person_name; + const status = isRelationFirstPerson ? row.first_person_status : row.second_person_status; + const ship = isRelationFirstPerson ? row.first_person_ship_id : row.second_person_ship_id; + const isCharacter = isRelationFirstPerson ? row.first_person_is_character : row.second_person_is_character; + + return FormattedRelation.parse({ + id, + name, + status, + ship, + is_character: isCharacter, + relation: row.relation, + }); +} + +export async function getStoryPersonDetails(id: string): Promise { + const person = await knex('person').select('*').where({ id }).first(); + if (!person) { + return null; + } + + const [events, messages, plots, relations] = await Promise.all([ + knex('story_person_events') + .join('story_events', 'story_person_events.event_id', 'story_events.id') + .select('story_events.id', 'story_events.name') + .where({ person_id: id }), + knex('story_person_messages') + .join('story_messages', 'story_person_messages.message_id', 'story_messages.id') + .select('story_messages.id', 'story_messages.name') + .where({ person_id: id }), + knex('story_person_plots') + .join('story_plots', 'story_person_plots.plot_id', 'story_plots.id') + .select('story_plots.id', 'story_plots.name') + .where({ person_id: id }), + knex('story_person_relations') + .join('person as first_person', 'story_person_relations.first_person_id', 'first_person.id') + .join('person as second_person', 'story_person_relations.second_person_id', 'second_person.id') + .select( + 'story_person_relations.*', + knex.raw('TRIM(CONCAT(first_person.first_name, \' \', first_person.last_name)) as first_person_name'), + knex.raw('TRIM(CONCAT(second_person.first_name, \' \', second_person.last_name)) as second_person_name'), + 'first_person.ship_id as first_person_ship_id', + 'second_person.ship_id as second_person_ship_id', + 'first_person.is_character as first_person_is_character', + 'second_person.is_character as second_person_is_character', + 'first_person.status as first_person_status', + 'second_person.status as second_person_status', + ) + .where({ first_person_id: id }).orWhere({ second_person_id: id }), + ]); + + return StoryAdminPersonDetailsWithRelations.parse({ + ...person, + events, + messages, + plots, + relations: relations.map((row) => parseRelation(id, row)), + }); +} diff --git a/src/models/story-plots.ts b/src/models/story-plots.ts index 0841b1c..ae84c6b 100644 --- a/src/models/story-plots.ts +++ b/src/models/story-plots.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { knex } from "@db/index"; /** * @typedef StoryPlot @@ -56,3 +57,62 @@ export const StoryPlotMessagesLink = z.object({ message_id: z.number(), }); export type StoryPlotMessagesLink = z.infer; + +export const StoryPlotWithRelations = StoryPlot.extend({ + artifacts: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + events: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + messages: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + persons: z.array(z.object({ + id: z.string(), + name: z.string(), + })), +}); +export type StoryPlotWithRelations = z.infer; + +export async function listStoryPlots(): Promise { + const plots = await knex('story_plots').select('*'); + return StoryPlot.array().parse(plots); +} + +export async function getStoryPlot(id: number): Promise { + const plot = await knex('story_plots').select('*').where({ id }).first(); + if (!plot) { + return null; + } + + const [artifacts, events, messages, persons] = await Promise.all([ + knex('story_artifact_plots') + .join('artifact', 'story_artifact_plots.artifact_id', 'artifact.id') + .select('artifact.name', 'artifact.id') + .where({ plot_id: id }), + knex('story_event_plots') + .join('story_events', 'story_event_plots.event_id', 'story_events.id') + .select('story_events.id', 'story_events.name') + .where({ plot_id: id }), + knex('story_plot_messages') + .join('story_messages', 'story_plot_messages.message_id', 'story_messages.id') + .select('story_messages.id', 'story_messages.name') + .where({ plot_id: id }), + knex('story_person_plots') + .join('person', 'story_person_plots.person_id', 'person.id') + .select('person.id', knex.raw('TRIM(CONCAT(person.first_name, \' \', person.last_name)) as name')) + .where({ plot_id: id }), + ]); + + return StoryPlotWithRelations.parse({ + ...plot, + artifacts, + events, + messages, + persons, + }); +} diff --git a/src/models/vote.js b/src/models/vote.js index 2778459..c9929d1 100644 --- a/src/models/vote.js +++ b/src/models/vote.js @@ -1,6 +1,6 @@ import Bookshelf from '../../db'; import { Person } from './person'; -import { getSocketIoClient } from '../index'; +import { getSocketIoClient } from '../websocket'; import { logger } from '../logger'; /* eslint-disable object-shorthand */ diff --git a/src/routes/helpers.ts b/src/routes/helpers.ts index 5939576..9c745f8 100644 --- a/src/routes/helpers.ts +++ b/src/routes/helpers.ts @@ -1,4 +1,7 @@ import { Request, Response, NextFunction } from 'express'; +import { HttpError } from 'http-errors'; +import { ZodError } from 'zod'; +import { logger } from '../logger'; // Helper middleware for handling errors in async routes export const handleAsyncErrors = @@ -6,3 +9,17 @@ export const handleAsyncErrors = (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch(next); }; + +export const errorHandlingMiddleware = async (err: HttpError, req: Request, res: Response, _next: NextFunction) => { + let status = 500; + let error: any = err.message; + if ('statusCode' in err) { + status = err.statusCode; + } + if (err instanceof ZodError) { + status = 400; + error = err.errors; + } + logger.error(err.message); + return res.status(status).json({ error }); +} diff --git a/src/routes/operation.ts b/src/routes/operation.ts index a90ae67..209a5bb 100644 --- a/src/routes/operation.ts +++ b/src/routes/operation.ts @@ -121,8 +121,8 @@ router.get('/:id', handleAsyncErrors(async (req: Request, res: Response) => { * @route POST /operation * @consumes application/json * @group Operation - HANSCA Operation related operations - * @param {OperationResult} operationresult.body.required - OperationResult model - * @returns {OperationResult} 200 - Inserted OperationResult model + * @param {OperationResult.model} operationresult.body.required - OperationResult model + * @returns {OperationResult.model} 200 - Inserted OperationResult model */ router.post('/', handleAsyncErrors(async (req: Request, res: Response) => { const operationResult = await OperationResult.forge().save({ diff --git a/src/routes/science.js b/src/routes/science.js index fe43bfa..dd9ccc2 100644 --- a/src/routes/science.js +++ b/src/routes/science.js @@ -11,13 +11,15 @@ const router = new Router(); * Get a list of all science artifacts * @route GET /science/artifact * @group Artifact - Science artifact related operations + * @param {boolean} is_visible.query - Filter by visibility. If undefined, returns all artifacts. * @returns {Array.} 200 - List of all science artifacts */ router.get('/artifact', handleAsyncErrors(async (req, res) => { // TODO: add pagination // TODO: allow request parameters to define if results should only // contain artifacts that have at least 1 research completed - res.json(await Artifact.forge().fetchAllWithRelated()); + const showVisibleOnly = req.query.is_visible ? req.query.is_visible === 'true' : undefined; + res.json(await Artifact.forge().fetchAllWithRelated(showVisibleOnly)); })); /** diff --git a/src/routes/story-admin.ts b/src/routes/story-admin.ts new file mode 100644 index 0000000..112b20a --- /dev/null +++ b/src/routes/story-admin.ts @@ -0,0 +1,131 @@ +import { Router, Request, Response } from 'express'; +import { handleAsyncErrors } from './helpers'; +import { getStoryPlot, listStoryPlots } from '@/models/story-plots'; +import httpErrors from 'http-errors'; +import { z } from 'zod'; +import { getStoryEvent, listStoryEvents } from '@/models/story-events'; +import { getStoryMessage, listStoryMessages } from '@/models/story-messages'; +import { getStoryPersonDetails } from '@/models/story-person'; +import { getArtifactRelations } from '@/models/story-artifact'; + +const router = Router(); + +const NumericIdSchema = z.object({ + id: z.string().transform((val) => parseInt(val, 10)).pipe(z.number().positive().int()), +}); +const StringIdSchema = z.object({ + id: z.string(), +}); + +/** + * Get artifact relations by ID + * @route GET /story/artifact/{id} + * @group Story admin - Story admin related operations + * @param {integer} id.path.required - ID of the artifact to get + */ +router.get('/artifact/:id', handleAsyncErrors(async (req: Request, res: Response) => { + const { id } = NumericIdSchema.parse(req.params); + const artifactRelations = await getArtifactRelations(id); + if (!artifactRelations) { + throw new httpErrors.NotFound(`Artifact with ID ${id} not found`); + } + res.json(artifactRelations); +})); + +/** + * Get a list of all events + * @route GET /story/events + * @group Story admin - Story admin related operations + * @returns {Array.} 200 - List of all StoryEvent models + */ +router.get('/events', handleAsyncErrors(async (req: Request, res: Response) => { + const events = await listStoryEvents(); + res.json(events); +})); + +/** + * Get an event by ID + * @route GET /story/events/{id} + * @group Story admin - Story admin related operations + * @param {integer} id.path.required - ID of the event to get + */ +router.get('/events/:id', handleAsyncErrors(async (req: Request, res: Response) => { + const { id } = NumericIdSchema.parse(req.params); + const event = await getStoryEvent(id); + if (!event) { + throw new httpErrors.NotFound(`Event with ID ${id} not found`); + } + res.json(event); +})); + +/** + * Get a list of all messages + * @route GET /story/messages + * @group Story admin - Story admin related operations + * @returns {Array.} 200 - List of all StoryMessage models + */ +router.get('/messages', handleAsyncErrors(async (req: Request, res: Response) => { + const messages = await listStoryMessages(); + res.json(messages); +})); + +/** + * Get a message by ID + * @route GET /story/messages/{id} + * @group Story admin - Story admin related operations + * @param {integer} id.path.required - ID of the message to get + * @returns {StoryMessage.model} 200 - StoryMessage model + */ +router.get('/messages/:id', handleAsyncErrors(async (req: Request, res: Response) => { + const { id } = NumericIdSchema.parse(req.params); + const message = await getStoryMessage(id); + if (!message) { + throw new httpErrors.NotFound(`Message with ID ${id} not found`); + } + res.json(message); +})); + +/** + * Get a person by ID + * @route GET /story/person/{id} + * @group Story admin - Story admin related operations + * @param {string} id.path.required - ID of the person to get + * @returns {object} 200 - StoryAdminPersonDetailsWithRelations model + */ +router.get('/person/:id', handleAsyncErrors(async (req: Request, res: Response) => { + const { id } = StringIdSchema.parse(req.params); + const person = await getStoryPersonDetails(id); + if (!person) { + throw new httpErrors.NotFound(`Person with ID ${id} not found`); + } + res.json(person); +})); + +/** + * Get a list of all plots + * @route GET /story/plots + * @group Story admin - Story admin related operations + * @returns {Array.} 200 - List of all StoryPlot models + */ +router.get('/plots', async (req: Request, res: Response) => { + const plots = await listStoryPlots(); + res.json(plots); +}); + +/** + * Get a plot by ID + * @route GET /story/plots/{id} + * @group Story admin - Story admin related operations + * @param {integer} id.path.required - ID of the plot to get + * @returns {StoryPlot.model} 200 - StoryPlot model + */ +router.get('/plots/:id', handleAsyncErrors(async (req: Request, res: Response) => { + const { id } = NumericIdSchema.parse(req.params); + const plot = await getStoryPlot(id); + if (!plot) { + throw new httpErrors.NotFound(`Plot with ID ${id} not found`); + } + res.json(plot); +})); + +export default router; diff --git a/src/websocket.ts b/src/websocket.ts new file mode 100644 index 0000000..19974b8 --- /dev/null +++ b/src/websocket.ts @@ -0,0 +1,23 @@ +import socketIo from 'socket.io'; +import { logger } from './logger'; +import { Server } from 'http'; + +let io: socketIo.Server | undefined; + +export function initSocketIoClient(http: Server) { + io = socketIo(http); + io.on('connection', (socket) => { + logger.info('Socket.IO Client connected'); + socket.on('disconnect', () => { + logger.info('Socket.IO Client disconnected'); + }); + }); + return io; +} + +export function getSocketIoClient() { + if (!io) { + throw new Error('Socket.IO client not initialized'); + } + return io; +}