From 336f7b44fb6ff83eec952a7e76b942bffb13a69d Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 21 Jul 2026 17:02:16 +0000 Subject: [PATCH 1/9] feature: handle streamable doc update policy in student groups --- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + prisma/view-migrations/tsconfig.json | 8 +++----- src/models/StudentGroup.ts | 2 +- src/routes/event-handlers/joinRoom.handler.ts | 9 +++++---- .../event-handlers/streamUpdate.handler.ts | 17 ++++++++++++++--- src/routes/socketEventTypes.ts | 5 +++-- 7 files changed, 29 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20260721161522_add_can_stream_updates_flag_to_groups/migration.sql diff --git a/prisma/migrations/20260721161522_add_can_stream_updates_flag_to_groups/migration.sql b/prisma/migrations/20260721161522_add_can_stream_updates_flag_to_groups/migration.sql new file mode 100644 index 0000000..5b00fa6 --- /dev/null +++ b/prisma/migrations/20260721161522_add_can_stream_updates_flag_to_groups/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "student_groups" ADD COLUMN "can_stream_updates" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9d582e3..ea823c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -139,6 +139,7 @@ model StudentGroup { parentId String? @map("parent_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + canStreamUpdates Boolean @default(false) @map("can_stream_updates") rootGroupPermissions RootGroupPermission[] @relation("root_group_to_student_group_permission") parent StudentGroup? @relation("parent_student_group", fields: [parentId], references: [id], onDelete: Cascade) children StudentGroup[] @relation("parent_student_group") diff --git a/prisma/view-migrations/tsconfig.json b/prisma/view-migrations/tsconfig.json index 7834ebc..b70b2f9 100644 --- a/prisma/view-migrations/tsconfig.json +++ b/prisma/view-migrations/tsconfig.json @@ -4,12 +4,10 @@ "target": "esnext", "module": "nodenext", "outDir": "../../dist/view-migrations", - "rootDir": "./", "esModuleInterop": true, "skipLibCheck": true, - "baseUrl": "../../", - "paths": { - "*": ["node_modules/*"] - } + "types": [ + "node" + ], } } \ No newline at end of file diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index cd57d52..6ed0c89 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -6,7 +6,7 @@ import { hasElevatedAccess, Role } from './User.js'; const getData = createDataExtractor( ['description', 'name'], - ['parentId'] + ['parentId', 'canStreamUpdates'] ); export type ApiStudentGroup = DbStudentGroup & { userIds: string[]; adminIds: string[] }; diff --git a/src/routes/event-handlers/joinRoom.handler.ts b/src/routes/event-handlers/joinRoom.handler.ts index f5aed86..a89775f 100644 --- a/src/routes/event-handlers/joinRoom.handler.ts +++ b/src/routes/event-handlers/joinRoom.handler.ts @@ -7,6 +7,7 @@ import onStreamUpdate from './streamUpdate.handler.js'; import DocumentRoot from '../../models/DocumentRoot.js'; import { highestAccess, RWAccess } from '../../helpers/accessPolicy.js'; import { Role } from '../../models/User.js'; +import Logger from '../../utils/logger.js'; type SocketType = Socket; const isDocumentRoot = (roomId: string) => { @@ -44,9 +45,9 @@ const joinRoom = (socket: SocketType, roomId: string, joinStreamGroup: boolean) const onJoinRoom: (user: User, socket: SocketType) => ClientToServerEvents[IoClientEvent.JOIN_ROOM] = (user, socket) => (roomId: string, callback: (joined: boolean) => void) => { if (user.role === Role.ADMIN) { - return isDocumentRoot(roomId) - .then((docRoot) => { - joinRoom(socket, roomId, !!docRoot); + return Promise.all([isDocumentRoot(roomId), StudentGroup.findModel(user, roomId)]) + .then(([docRoot, group]) => { + joinRoom(socket, roomId, !!docRoot || !!(group && group.canStreamUpdates)); callback(true); }) .catch(() => { @@ -55,7 +56,7 @@ const onJoinRoom: (user: User, socket: SocketType) => ClientToServerEvents[IoCli } StudentGroup.findModel(user, roomId).then((group) => { if (group) { - socket.join(roomId); + joinRoom(socket, roomId, group.canStreamUpdates); callback(true); } else { if (user.role === Role.TEACHER) { diff --git a/src/routes/event-handlers/streamUpdate.handler.ts b/src/routes/event-handlers/streamUpdate.handler.ts index afefcbb..1a0e373 100644 --- a/src/routes/event-handlers/streamUpdate.handler.ts +++ b/src/routes/event-handlers/streamUpdate.handler.ts @@ -1,4 +1,10 @@ -import { ClientToServerEvents, IoClientEvent, IoEvent, ServerToClientEvents } from '../socketEventTypes.js'; +import { + ChangedDocument, + ClientToServerEvents, + IoClientEvent, + IoEvent, + ServerToClientEvents +} from '../socketEventTypes.js'; import type { DefaultEventsMap, Socket } from 'socket.io'; const onStreamUpdate: ( @@ -8,11 +14,16 @@ const onStreamUpdate: ( if (roomId !== payload.roomId) { return; } - socket.to(payload.roomId).emit(IoEvent.CHANGED_DOCUMENT, { + const pkg: ChangedDocument = { data: payload.data, id: payload.id, updatedAt: payload.updatedAt - }); + }; + if (payload.meta) { + pkg.meta = payload.meta; + } + + socket.to(payload.roomId).emit(IoEvent.CHANGED_DOCUMENT, pkg); }; export default onStreamUpdate; diff --git a/src/routes/socketEventTypes.ts b/src/routes/socketEventTypes.ts index 41b568e..7cfbdf2 100644 --- a/src/routes/socketEventTypes.ts +++ b/src/routes/socketEventTypes.ts @@ -47,13 +47,14 @@ export interface ChangedRecord { record: TypeRecordMap[T]; } -export interface ChangedDocument { +export interface ChangedDocument { id: string; data: Prisma.JsonValue; updatedAt: Date; + meta?: T; } -export interface StreamedDynamicDocument extends ChangedDocument { +export interface StreamedDynamicDocument extends ChangedDocument { roomId: string; } From a0d125886372e32ab241957f26f0c18d86354e6d Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 22 Jul 2026 12:28:28 +0000 Subject: [PATCH 2/9] move stream prop to student group --- .../migration.sql | 2 - .../migration.sql | 3 ++ prisma/schema.prisma | 3 +- src/controllers/studentGroups.ts | 11 +++-- src/models/StudentGroup.ts | 46 +++++++++++++++++-- src/routes/event-handlers/joinRoom.handler.ts | 8 ++-- .../event-handlers/streamUpdate.handler.ts | 21 +++++++++ src/routes/socketEvents.ts | 2 + src/server.ts | 2 + 9 files changed, 83 insertions(+), 15 deletions(-) delete mode 100644 prisma/migrations/20260721161522_add_can_stream_updates_flag_to_groups/migration.sql create mode 100644 prisma/migrations/20260722070708_add_presentation_flag_to_student_group/migration.sql diff --git a/prisma/migrations/20260721161522_add_can_stream_updates_flag_to_groups/migration.sql b/prisma/migrations/20260721161522_add_can_stream_updates_flag_to_groups/migration.sql deleted file mode 100644 index 5b00fa6..0000000 --- a/prisma/migrations/20260721161522_add_can_stream_updates_flag_to_groups/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "student_groups" ADD COLUMN "can_stream_updates" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260722070708_add_presentation_flag_to_student_group/migration.sql b/prisma/migrations/20260722070708_add_presentation_flag_to_student_group/migration.sql new file mode 100644 index 0000000..efabb7f --- /dev/null +++ b/prisma/migrations/20260722070708_add_presentation_flag_to_student_group/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "student_groups" ADD COLUMN "can_present" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "presented_document" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ea823c9..a441c63 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -139,7 +139,8 @@ model StudentGroup { parentId String? @map("parent_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - canStreamUpdates Boolean @default(false) @map("can_stream_updates") + canPresent Boolean @default(false) @map("can_present") + presentedDocument Json? @map("presented_document") rootGroupPermissions RootGroupPermission[] @relation("root_group_to_student_group_permission") parent StudentGroup? @relation("parent_student_group", fields: [parentId], references: [id], onDelete: Cascade) children StudentGroup[] @relation("parent_student_group") diff --git a/src/controllers/studentGroups.ts b/src/controllers/studentGroups.ts index ff695a6..6d74037 100644 --- a/src/controllers/studentGroups.ts +++ b/src/controllers/studentGroups.ts @@ -2,6 +2,7 @@ import { StudentGroup as DbStudentGroup } from '../../prisma/generated/client.js import { RequestHandler } from 'express'; import StudentGroup from '../models/StudentGroup.js'; import { IoEvent, RecordType } from '../routes/socketEventTypes.js'; +import { JsonObject } from '@prisma/client/runtime/client'; export const find: RequestHandler<{ id: string }> = async (req, res, next) => { const group = await StudentGroup.findModel((req as any).user!, req.params.id); @@ -14,11 +15,11 @@ export const create: RequestHandler = async (req, res, res.status(200).json(model); }; -export const update: RequestHandler<{ id: string }, any, { data: DbStudentGroup }> = async ( - req, - res, - next -) => { +export const update: RequestHandler< + { id: string }, + any, + { data: Partial & { presentedDocument: JsonObject }> } +> = async (req, res, next) => { const model = await StudentGroup.updateModel((req as any).user!, req.params.id, req.body.data); if (!model) { return res.status(404).json({ message: 'Student group not found' }); diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 6ed0c89..1c3eca3 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -3,14 +3,34 @@ import prisma from '../prisma.js'; import { HTTP403Error, HTTP404Error } from '../utils/errors/Errors.js'; import { createDataExtractor } from '../helpers/dataExtractor.js'; import { hasElevatedAccess, Role } from './User.js'; +import { JsonObject } from '@prisma/client/runtime/client'; +import Logger from '../utils/logger.js'; const getData = createDataExtractor( ['description', 'name'], - ['parentId', 'canStreamUpdates'] + ['parentId', 'canPresent', 'presentedDocument'] ); export type ApiStudentGroup = DbStudentGroup & { userIds: string[]; adminIds: string[] }; +export const StreamableGroupUserCacheStore = new Map>(); + +export const initializeStreamableGroupUserCache = async () => { + const all = await prisma.studentGroup.findMany({ + include: { users: true } + }); + const groupUsers = all + .map((group) => asApiRecord(group)!) + .map((g) => [g.id, [...g.userIds, ...g.adminIds]]) as [string, string[]][]; + StreamableGroupUserCacheStore.clear(); + for (const [groupId, userIds] of groupUsers) { + StreamableGroupUserCacheStore.set(groupId, new Set(userIds)); + } + Logger.info( + `☄️ Initialized StreamableGroupUserCacheStore with ${StreamableGroupUserCacheStore.size} groups` + ); +}; + function asApiRecord( record: DbStudentGroup & { users: { userId: string; isAdmin: boolean }[] } ): ApiStudentGroup; @@ -65,13 +85,18 @@ function StudentGroup(db: PrismaClient['studentGroup']) { return asApiRecord(model); }, - async updateModel(actor: User, id: string, data: Partial): Promise { + async updateModel( + actor: User, + id: string, + data: Partial & { presentedDocument: JsonObject }> + ): Promise { const record = await this.findModel(actor, id); if (!hasAdminAccess(actor, record)) { throw new HTTP403Error('Not authorized'); } /** remove fields not updatable*/ const sanitized = getData(data, false, hasElevatedAccess(actor.role)); + const parentId = typeof sanitized.parentId === 'string' ? sanitized.parentId : sanitized.parentId?.set; if (parentId && actor.role !== Role.ADMIN) { @@ -87,7 +112,18 @@ function StudentGroup(db: PrismaClient['studentGroup']) { data: sanitized, include: { users: { select: { userId: true, isAdmin: true } } } }); - return asApiRecord(result); + const resultApi = asApiRecord(result); + if (resultApi.canPresent !== record.canPresent) { + if (record.canPresent) { + StreamableGroupUserCacheStore.set( + id, + new Set(resultApi.userIds.concat(resultApi.adminIds)) + ); + } else { + StreamableGroupUserCacheStore.delete(id); + } + } + return resultApi; }, async setAdminRole( @@ -143,6 +179,7 @@ function StudentGroup(db: PrismaClient['studentGroup']) { }, include: { users: { select: { userId: true, isAdmin: true } } } }); + StreamableGroupUserCacheStore.get(id)?.add(userId); return asApiRecord(result)!; }, @@ -160,6 +197,7 @@ function StudentGroup(db: PrismaClient['studentGroup']) { data: { users: { delete: { id: { userId: userId, studentGroupId: record.id } } } }, include: { users: { select: { userId: true, isAdmin: true } } } }); + StreamableGroupUserCacheStore.get(id)?.delete(userId); return asApiRecord(result)!; }, @@ -204,6 +242,7 @@ function StudentGroup(db: PrismaClient['studentGroup']) { }, include: { users: { select: { userId: true, isAdmin: true } } } }); + StreamableGroupUserCacheStore.set(model.id, new Set([actor.id])); return asApiRecord(model)!; }, @@ -212,6 +251,7 @@ function StudentGroup(db: PrismaClient['studentGroup']) { if (!hasAdminAccess(actor, record)) { throw new HTTP403Error('Not authorized'); } + StreamableGroupUserCacheStore.delete(id); return db.delete({ where: { id: id } }); } }); diff --git a/src/routes/event-handlers/joinRoom.handler.ts b/src/routes/event-handlers/joinRoom.handler.ts index a89775f..92f9d36 100644 --- a/src/routes/event-handlers/joinRoom.handler.ts +++ b/src/routes/event-handlers/joinRoom.handler.ts @@ -3,7 +3,7 @@ import { ClientToServerEvents, IoClientEvent, ServerToClientEvents } from '../so import type { DefaultEventsMap, Socket } from 'socket.io'; import prisma from '../../prisma.js'; import StudentGroup from '../../models/StudentGroup.js'; -import onStreamUpdate from './streamUpdate.handler.js'; +import onStreamUpdate, { onStreamDynamicRoomUpdate } from './streamUpdate.handler.js'; import DocumentRoot from '../../models/DocumentRoot.js'; import { highestAccess, RWAccess } from '../../helpers/accessPolicy.js'; import { Role } from '../../models/User.js'; @@ -38,7 +38,7 @@ const findStudentGroup = (userId: string, roomId: string) => { const joinRoom = (socket: SocketType, roomId: string, joinStreamGroup: boolean) => { socket.join(roomId); if (joinStreamGroup) { - socket.on(IoClientEvent.STREAM_UPDATE, onStreamUpdate(roomId, socket)); + socket.on(IoClientEvent.STREAM_UPDATE, onStreamDynamicRoomUpdate(roomId, socket)); } }; @@ -47,7 +47,7 @@ const onJoinRoom: (user: User, socket: SocketType) => ClientToServerEvents[IoCli if (user.role === Role.ADMIN) { return Promise.all([isDocumentRoot(roomId), StudentGroup.findModel(user, roomId)]) .then(([docRoot, group]) => { - joinRoom(socket, roomId, !!docRoot || !!(group && group.canStreamUpdates)); + joinRoom(socket, roomId, !!docRoot || !!(group && group.canPresent)); callback(true); }) .catch(() => { @@ -56,7 +56,7 @@ const onJoinRoom: (user: User, socket: SocketType) => ClientToServerEvents[IoCli } StudentGroup.findModel(user, roomId).then((group) => { if (group) { - joinRoom(socket, roomId, group.canStreamUpdates); + joinRoom(socket, roomId, group.canPresent); callback(true); } else { if (user.role === Role.TEACHER) { diff --git a/src/routes/event-handlers/streamUpdate.handler.ts b/src/routes/event-handlers/streamUpdate.handler.ts index 1a0e373..c8bc334 100644 --- a/src/routes/event-handlers/streamUpdate.handler.ts +++ b/src/routes/event-handlers/streamUpdate.handler.ts @@ -5,9 +5,30 @@ import { IoEvent, ServerToClientEvents } from '../socketEventTypes.js'; +import { User } from '../../../prisma/generated/client.js'; import type { DefaultEventsMap, Socket } from 'socket.io'; +import { StreamableGroupUserCacheStore } from '../../models/StudentGroup.js'; const onStreamUpdate: ( + user: User, + socket: Socket +) => ClientToServerEvents[IoClientEvent.STREAM_UPDATE] = (user, socket) => (payload) => { + if (!StreamableGroupUserCacheStore.get(payload.roomId)?.has(user.id)) { + return; + } + const pkg: ChangedDocument = { + data: payload.data, + id: payload.id, + updatedAt: payload.updatedAt + }; + if (payload.meta) { + pkg.meta = payload.meta; + } + + socket.to(payload.roomId).emit(IoEvent.CHANGED_DOCUMENT, pkg); +}; + +export const onStreamDynamicRoomUpdate: ( roomId: string, socket: Socket ) => ClientToServerEvents[IoClientEvent.STREAM_UPDATE] = (roomId, socket) => (payload) => { diff --git a/src/routes/socketEvents.ts b/src/routes/socketEvents.ts index 6e7ab76..ce1c2d1 100644 --- a/src/routes/socketEvents.ts +++ b/src/routes/socketEvents.ts @@ -10,6 +10,7 @@ import onAction from './event-handlers/action.handler.js'; import onJoinRoom from './event-handlers/joinRoom.handler.js'; import onLeaveRoom from './event-handlers/leaveRoom.handler.js'; import { auth } from '../auth.js'; +import onStreamUpdate from './event-handlers/streamUpdate.handler.js'; export enum IoRoom { ADMIN = 'admin', @@ -44,6 +45,7 @@ const EventRouter = (io: Server) => socket.join(IoRoom.ALL); socket.on(IoClientEvent.JOIN_ROOM, onJoinRoom(user, socket)); socket.on(IoClientEvent.LEAVE_ROOM, onLeaveRoom(user, socket)); + socket.on(IoClientEvent.STREAM_UPDATE, onStreamUpdate(user, socket)); const groups = await StudentGroup.all(user); const groupIds = groups.map((group) => group.id); if (groupIds.length > 0) { diff --git a/src/server.ts b/src/server.ts index d959592..6058194 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import http from 'http'; import * as Sentry from '@sentry/node'; import Logger from './utils/logger.js'; import dotenv from 'dotenv'; +import { initializeStreamableGroupUserCache } from './models/StudentGroup.js'; dotenv.config(); const PORT = process.env.PORT || 3002; @@ -12,6 +13,7 @@ const server = http.createServer(app); initializeSocketIo(server); configure(app); +initializeStreamableGroupUserCache(); if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) { Sentry.setupExpressErrorHandler(app); From fdc4c2437b270c88a818060057384dc3921461e3 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 22 Jul 2026 15:58:51 +0000 Subject: [PATCH 3/9] fix cache invariants --- src/models/StudentGroup.ts | 29 ++++++++++++++--------------- src/server.ts | 27 +++++++++++++++++---------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 1c3eca3..a1d1629 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -15,16 +15,21 @@ export type ApiStudentGroup = DbStudentGroup & { userIds: string[]; adminIds: st export const StreamableGroupUserCacheStore = new Map>(); +const setStreamableGroupUsers = (group: ApiStudentGroup) => { + if (!group.canPresent) { + StreamableGroupUserCacheStore.delete(group.id); + return; + } + StreamableGroupUserCacheStore.set(group.id, new Set(group.userIds.concat(group.adminIds))); +}; + export const initializeStreamableGroupUserCache = async () => { const all = await prisma.studentGroup.findMany({ include: { users: true } }); - const groupUsers = all - .map((group) => asApiRecord(group)!) - .map((g) => [g.id, [...g.userIds, ...g.adminIds]]) as [string, string[]][]; StreamableGroupUserCacheStore.clear(); - for (const [groupId, userIds] of groupUsers) { - StreamableGroupUserCacheStore.set(groupId, new Set(userIds)); + for (const group of all.map((record) => asApiRecord(record)!)) { + setStreamableGroupUsers(group); } Logger.info( `☄️ Initialized StreamableGroupUserCacheStore with ${StreamableGroupUserCacheStore.size} groups` @@ -114,14 +119,7 @@ function StudentGroup(db: PrismaClient['studentGroup']) { }); const resultApi = asApiRecord(result); if (resultApi.canPresent !== record.canPresent) { - if (record.canPresent) { - StreamableGroupUserCacheStore.set( - id, - new Set(resultApi.userIds.concat(resultApi.adminIds)) - ); - } else { - StreamableGroupUserCacheStore.delete(id); - } + setStreamableGroupUsers(resultApi); } return resultApi; }, @@ -242,8 +240,9 @@ function StudentGroup(db: PrismaClient['studentGroup']) { }, include: { users: { select: { userId: true, isAdmin: true } } } }); - StreamableGroupUserCacheStore.set(model.id, new Set([actor.id])); - return asApiRecord(model)!; + const result = asApiRecord(model)!; + setStreamableGroupUsers(result); + return result; }, async deleteModel(actor: User, id: string): Promise { diff --git a/src/server.ts b/src/server.ts index 6058194..cb93198 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,17 +9,24 @@ dotenv.config(); const PORT = process.env.PORT || 3002; -const server = http.createServer(app); -initializeSocketIo(server); +const start = async () => { + const server = http.createServer(app); + initializeSocketIo(server); -configure(app); -initializeStreamableGroupUserCache(); + configure(app); + await initializeStreamableGroupUserCache(); -if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) { - Sentry.setupExpressErrorHandler(app); -} + if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) { + Sentry.setupExpressErrorHandler(app); + } -server.listen(PORT || 3002, () => { - Logger.info(`application is running at: http://localhost:${PORT}`); - Logger.info('Press Ctrl+C to quit.'); + server.listen(PORT || 3002, () => { + Logger.info(`application is running at: http://localhost:${PORT}`); + Logger.info('Press Ctrl+C to quit.'); + }); +}; + +start().catch((error) => { + Logger.error('Failed to initialize server', error); + process.exit(1); }); From cb47b3b69beea39c9250a801d3db3702f11a4b2e Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 22 Jul 2026 16:13:02 +0000 Subject: [PATCH 4/9] recreate cache all 45' --- src/models/StudentGroup.ts | 8 +++++++- src/server.ts | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index a1d1629..cce2da1 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -23,7 +23,12 @@ const setStreamableGroupUsers = (group: ApiStudentGroup) => { StreamableGroupUserCacheStore.set(group.id, new Set(group.userIds.concat(group.adminIds))); }; -export const initializeStreamableGroupUserCache = async () => { +let lastCacheRecreation: number | null = null; +const MS_IN_45_MINUTES = 1000 * 60 * 45; +export const recreateStreamableGroupUserCache = async () => { + if (lastCacheRecreation && Date.now() - lastCacheRecreation < MS_IN_45_MINUTES) { + return; + } const all = await prisma.studentGroup.findMany({ include: { users: true } }); @@ -34,6 +39,7 @@ export const initializeStreamableGroupUserCache = async () => { Logger.info( `☄️ Initialized StreamableGroupUserCacheStore with ${StreamableGroupUserCacheStore.size} groups` ); + lastCacheRecreation = Date.now(); }; function asApiRecord( diff --git a/src/server.ts b/src/server.ts index cb93198..e68301d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ import http from 'http'; import * as Sentry from '@sentry/node'; import Logger from './utils/logger.js'; import dotenv from 'dotenv'; -import { initializeStreamableGroupUserCache } from './models/StudentGroup.js'; +import { recreateStreamableGroupUserCache } from './models/StudentGroup.js'; dotenv.config(); const PORT = process.env.PORT || 3002; @@ -14,7 +14,7 @@ const start = async () => { initializeSocketIo(server); configure(app); - await initializeStreamableGroupUserCache(); + await recreateStreamableGroupUserCache(); if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) { Sentry.setupExpressErrorHandler(app); From c572833aad6f9202f988055517628908ac28a0f9 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 3 Aug 2026 17:00:03 +0000 Subject: [PATCH 5/9] use server generated updated at timestamp --- src/routes/event-handlers/streamUpdate.handler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/event-handlers/streamUpdate.handler.ts b/src/routes/event-handlers/streamUpdate.handler.ts index c8bc334..cc38d96 100644 --- a/src/routes/event-handlers/streamUpdate.handler.ts +++ b/src/routes/event-handlers/streamUpdate.handler.ts @@ -19,13 +19,13 @@ const onStreamUpdate: ( const pkg: ChangedDocument = { data: payload.data, id: payload.id, - updatedAt: payload.updatedAt + updatedAt: new Date() }; if (payload.meta) { pkg.meta = payload.meta; } - socket.to(payload.roomId).emit(IoEvent.CHANGED_DOCUMENT, pkg); + socket.to(payload.roomId).except(socket.id).emit(IoEvent.CHANGED_DOCUMENT, pkg); }; export const onStreamDynamicRoomUpdate: ( @@ -38,13 +38,13 @@ export const onStreamDynamicRoomUpdate: ( const pkg: ChangedDocument = { data: payload.data, id: payload.id, - updatedAt: payload.updatedAt + updatedAt: new Date() }; if (payload.meta) { pkg.meta = payload.meta; } - socket.to(payload.roomId).emit(IoEvent.CHANGED_DOCUMENT, pkg); + socket.to(payload.roomId).except(socket.id).emit(IoEvent.CHANGED_DOCUMENT, pkg); }; export default onStreamUpdate; From 9e49dff67848ad096e7404e746ead7f4d3410e2c Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 3 Aug 2026 17:05:12 +0000 Subject: [PATCH 6/9] ensure using iso string format --- src/routes/event-handlers/streamUpdate.handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/event-handlers/streamUpdate.handler.ts b/src/routes/event-handlers/streamUpdate.handler.ts index cc38d96..70afb5a 100644 --- a/src/routes/event-handlers/streamUpdate.handler.ts +++ b/src/routes/event-handlers/streamUpdate.handler.ts @@ -19,7 +19,7 @@ const onStreamUpdate: ( const pkg: ChangedDocument = { data: payload.data, id: payload.id, - updatedAt: new Date() + updatedAt: new Date().toISOString() as unknown as Date }; if (payload.meta) { pkg.meta = payload.meta; @@ -38,7 +38,7 @@ export const onStreamDynamicRoomUpdate: ( const pkg: ChangedDocument = { data: payload.data, id: payload.id, - updatedAt: new Date() + updatedAt: new Date().toISOString() as unknown as Date }; if (payload.meta) { pkg.meta = payload.meta; From a31a4820ef31db1112ebdd6b135d5a5549f25f74 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 3 Aug 2026 20:57:37 +0000 Subject: [PATCH 7/9] cleanup --- src/helpers/StudentGroup.asApiRecord.ts | 27 ++++++++++ src/models/StudentGroup.ts | 65 ++++--------------------- src/routes/socketEventTypes.ts | 2 +- src/server.ts | 4 +- src/utils/StudentGroup.cache.ts | 42 ++++++++++++++++ 5 files changed, 81 insertions(+), 59 deletions(-) create mode 100644 src/helpers/StudentGroup.asApiRecord.ts create mode 100644 src/utils/StudentGroup.cache.ts diff --git a/src/helpers/StudentGroup.asApiRecord.ts b/src/helpers/StudentGroup.asApiRecord.ts new file mode 100644 index 0000000..494d40b --- /dev/null +++ b/src/helpers/StudentGroup.asApiRecord.ts @@ -0,0 +1,27 @@ +import { StudentGroup } from '../../prisma/generated/client.js'; + +export type ApiStudentGroup = StudentGroup & { userIds: string[]; adminIds: string[] }; + +function asApiRecord( + record: StudentGroup & { users: { userId: string; isAdmin: boolean }[] } +): ApiStudentGroup; +function asApiRecord(record: null): null; +function asApiRecord( + record: (StudentGroup & { users: { userId: string; isAdmin: boolean }[] }) | null +): ApiStudentGroup | null; +function asApiRecord( + record: (StudentGroup & { users: { userId: string; isAdmin: boolean }[] }) | null +): ApiStudentGroup | null { + if (!record) { + return null; + } + const group = { + ...record, + userIds: record.users.map((user) => user.userId), + adminIds: record.users.filter((u) => u.isAdmin).map((u) => u.userId) + }; + delete (group as any).users; + return group; +} + +export default asApiRecord; diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index cce2da1..9c5b167 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -4,66 +4,16 @@ import { HTTP403Error, HTTP404Error } from '../utils/errors/Errors.js'; import { createDataExtractor } from '../helpers/dataExtractor.js'; import { hasElevatedAccess, Role } from './User.js'; import { JsonObject } from '@prisma/client/runtime/client'; -import Logger from '../utils/logger.js'; +import asApiRecord, { type ApiStudentGroup } from '../helpers/StudentGroup.asApiRecord.js'; +import StudentGroupCache from '../utils/StudentGroup.cache.js'; + +export const StreamableGroupUserCacheStore = new StudentGroupCache(); const getData = createDataExtractor( ['description', 'name'], ['parentId', 'canPresent', 'presentedDocument'] ); -export type ApiStudentGroup = DbStudentGroup & { userIds: string[]; adminIds: string[] }; - -export const StreamableGroupUserCacheStore = new Map>(); - -const setStreamableGroupUsers = (group: ApiStudentGroup) => { - if (!group.canPresent) { - StreamableGroupUserCacheStore.delete(group.id); - return; - } - StreamableGroupUserCacheStore.set(group.id, new Set(group.userIds.concat(group.adminIds))); -}; - -let lastCacheRecreation: number | null = null; -const MS_IN_45_MINUTES = 1000 * 60 * 45; -export const recreateStreamableGroupUserCache = async () => { - if (lastCacheRecreation && Date.now() - lastCacheRecreation < MS_IN_45_MINUTES) { - return; - } - const all = await prisma.studentGroup.findMany({ - include: { users: true } - }); - StreamableGroupUserCacheStore.clear(); - for (const group of all.map((record) => asApiRecord(record)!)) { - setStreamableGroupUsers(group); - } - Logger.info( - `☄️ Initialized StreamableGroupUserCacheStore with ${StreamableGroupUserCacheStore.size} groups` - ); - lastCacheRecreation = Date.now(); -}; - -function asApiRecord( - record: DbStudentGroup & { users: { userId: string; isAdmin: boolean }[] } -): ApiStudentGroup; -function asApiRecord(record: null): null; -function asApiRecord( - record: (DbStudentGroup & { users: { userId: string; isAdmin: boolean }[] }) | null -): ApiStudentGroup | null; -function asApiRecord( - record: (DbStudentGroup & { users: { userId: string; isAdmin: boolean }[] }) | null -): ApiStudentGroup | null { - if (!record) { - return null; - } - const group = { - ...record, - userIds: record.users.map((user) => user.userId), - adminIds: record.users.filter((u) => u.isAdmin).map((u) => u.userId) - }; - delete (group as any).users; - return group; -} - /** * returns true if the user can administer the group. * The user is either @@ -118,6 +68,9 @@ function StudentGroup(db: PrismaClient['studentGroup']) { throw new HTTP403Error('Not authorized to create subgroup in this group'); } } + if (sanitized.canPresent === false) { + sanitized.presentedDocument = null as unknown as Prisma.NullableJsonNullValueInput; + } const result = await db.update({ where: { id: id }, data: sanitized, @@ -125,7 +78,7 @@ function StudentGroup(db: PrismaClient['studentGroup']) { }); const resultApi = asApiRecord(result); if (resultApi.canPresent !== record.canPresent) { - setStreamableGroupUsers(resultApi); + StreamableGroupUserCacheStore.setStreamableGroupUsers(resultApi); } return resultApi; }, @@ -247,7 +200,7 @@ function StudentGroup(db: PrismaClient['studentGroup']) { include: { users: { select: { userId: true, isAdmin: true } } } }); const result = asApiRecord(model)!; - setStreamableGroupUsers(result); + StreamableGroupUserCacheStore.setStreamableGroupUsers(result); return result; }, diff --git a/src/routes/socketEventTypes.ts b/src/routes/socketEventTypes.ts index 7cfbdf2..ef31edd 100644 --- a/src/routes/socketEventTypes.ts +++ b/src/routes/socketEventTypes.ts @@ -3,8 +3,8 @@ import { ApiDocument } from '../models/Document.js'; import { ApiUserPermission } from '../models/RootUserPermission.js'; import { ApiGroupPermission } from '../models/RootGroupPermission.js'; import { ApiDocumentRootWithoutDocuments } from '../models/DocumentRoot.js'; -import { ApiStudentGroup } from '../models/StudentGroup.js'; import { ApiUser } from '../models/User.js'; +import { ApiStudentGroup } from '../helpers/StudentGroup.asApiRecord.js'; export enum IoEvent { NEW_RECORD = 'NEW_RECORD', diff --git a/src/server.ts b/src/server.ts index e68301d..2491c48 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ import http from 'http'; import * as Sentry from '@sentry/node'; import Logger from './utils/logger.js'; import dotenv from 'dotenv'; -import { recreateStreamableGroupUserCache } from './models/StudentGroup.js'; +import { StreamableGroupUserCacheStore } from './models/StudentGroup.js'; dotenv.config(); const PORT = process.env.PORT || 3002; @@ -14,7 +14,7 @@ const start = async () => { initializeSocketIo(server); configure(app); - await recreateStreamableGroupUserCache(); + await StreamableGroupUserCacheStore.recreate(); if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) { Sentry.setupExpressErrorHandler(app); diff --git a/src/utils/StudentGroup.cache.ts b/src/utils/StudentGroup.cache.ts new file mode 100644 index 0000000..75adeb6 --- /dev/null +++ b/src/utils/StudentGroup.cache.ts @@ -0,0 +1,42 @@ +import asApiRecord, { ApiStudentGroup } from '../helpers/StudentGroup.asApiRecord.js'; +import prisma from '../prisma.js'; +import Logger from './logger.js'; + +const MS_IN_45_MINUTES = 1000 * 60 * 45; +class StudentGroupCache { + private cache = new Map>(); + private lastCacheRecreation: number | null = null; + + setStreamableGroupUsers(group: ApiStudentGroup) { + if (!group.canPresent) { + this.cache.delete(group.id); + return; + } + this.cache.set(group.id, new Set(group.userIds.concat(group.adminIds))); + } + + async recreate() { + if (this.lastCacheRecreation && Date.now() - this.lastCacheRecreation < MS_IN_45_MINUTES) { + return Promise.resolve(); + } + const all = await prisma.studentGroup.findMany({ + include: { users: true } + }); + this.cache.clear(); + for (const group of all.map((record) => asApiRecord(record)!)) { + this.setStreamableGroupUsers(group); + } + Logger.info(`☄️ Created cache with ${this.cache.size} groups`); + this.lastCacheRecreation = Date.now(); + } + + delete(groupId: string) { + this.cache.delete(groupId); + } + + get(groupId: string) { + return this.cache.get(groupId); + } +} + +export default StudentGroupCache; From a09b617d045b9365aabfdb6b5b2eed18464953eb Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 3 Aug 2026 22:27:04 +0000 Subject: [PATCH 8/9] typefix --- src/routes/socketEventTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/socketEventTypes.ts b/src/routes/socketEventTypes.ts index ef31edd..54d4d86 100644 --- a/src/routes/socketEventTypes.ts +++ b/src/routes/socketEventTypes.ts @@ -54,7 +54,7 @@ export interface ChangedDocument { meta?: T; } -export interface StreamedDynamicDocument extends ChangedDocument { +export interface StreamedDynamicDocument extends Omit, 'updatedAt'> { roomId: string; } From f7b1b43390952fe801d71a237e6c730a99780beb Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 4 Aug 2026 15:28:28 +0000 Subject: [PATCH 9/9] add some docs --- src/routes/event-handlers/streamUpdate.handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/event-handlers/streamUpdate.handler.ts b/src/routes/event-handlers/streamUpdate.handler.ts index 70afb5a..4949c91 100644 --- a/src/routes/event-handlers/streamUpdate.handler.ts +++ b/src/routes/event-handlers/streamUpdate.handler.ts @@ -28,6 +28,7 @@ const onStreamUpdate: ( socket.to(payload.roomId).except(socket.id).emit(IoEvent.CHANGED_DOCUMENT, pkg); }; +// is only for dynamic rooms (e.g. text message room) where no shared student group is available. export const onStreamDynamicRoomUpdate: ( roomId: string, socket: Socket