Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "student_groups" ADD COLUMN "can_present" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "presented_document" JSONB;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +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")
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")
Expand Down
8 changes: 3 additions & 5 deletions prisma/view-migrations/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
"target": "esnext",
"module": "nodenext",
"outDir": "../../dist/view-migrations",
"rootDir": "./",
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": "../../",
"paths": {
"*": ["node_modules/*"]
}
"types": [
"node"
],
}
}
11 changes: 6 additions & 5 deletions src/controllers/studentGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -14,11 +15,11 @@ export const create: RequestHandler<any, any, DbStudentGroup> = 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<Omit<DbStudentGroup, 'presentedDocument'> & { 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' });
Expand Down
27 changes: 27 additions & 0 deletions src/helpers/StudentGroup.asApiRecord.ts
Original file line number Diff line number Diff line change
@@ -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;
54 changes: 26 additions & 28 deletions src/models/StudentGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,17 @@ 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 asApiRecord, { type ApiStudentGroup } from '../helpers/StudentGroup.asApiRecord.js';
import StudentGroupCache from '../utils/StudentGroup.cache.js';

export const StreamableGroupUserCacheStore = new StudentGroupCache();

const getData = createDataExtractor<Prisma.StudentGroupUncheckedUpdateInput>(
['description', 'name'],
['parentId']
['parentId', 'canPresent', 'presentedDocument']
);

export type ApiStudentGroup = DbStudentGroup & { userIds: string[]; adminIds: string[] };

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
Expand Down Expand Up @@ -65,13 +46,18 @@ function StudentGroup(db: PrismaClient['studentGroup']) {
return asApiRecord(model);
},

async updateModel(actor: User, id: string, data: Partial<DbStudentGroup>): Promise<ApiStudentGroup> {
async updateModel(
actor: User,
id: string,
data: Partial<Omit<DbStudentGroup, 'presentedDocument'> & { presentedDocument: JsonObject }>
): Promise<ApiStudentGroup> {
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) {
Expand All @@ -82,12 +68,19 @@ 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,
include: { users: { select: { userId: true, isAdmin: true } } }
});
return asApiRecord(result);
const resultApi = asApiRecord(result);
if (resultApi.canPresent !== record.canPresent) {
StreamableGroupUserCacheStore.setStreamableGroupUsers(resultApi);
}
return resultApi;
},

async setAdminRole(
Expand Down Expand Up @@ -143,6 +136,7 @@ function StudentGroup(db: PrismaClient['studentGroup']) {
},
include: { users: { select: { userId: true, isAdmin: true } } }
});
StreamableGroupUserCacheStore.get(id)?.add(userId);
return asApiRecord(result)!;
},

Expand All @@ -160,6 +154,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)!;
},

Expand Down Expand Up @@ -204,14 +199,17 @@ function StudentGroup(db: PrismaClient['studentGroup']) {
},
include: { users: { select: { userId: true, isAdmin: true } } }
});
return asApiRecord(model)!;
const result = asApiRecord(model)!;
StreamableGroupUserCacheStore.setStreamableGroupUsers(result);
return result;
},

async deleteModel(actor: User, id: string): Promise<DbStudentGroup> {
const record = await this.findModel(actor, id);
if (!hasAdminAccess(actor, record)) {
throw new HTTP403Error('Not authorized');
}
StreamableGroupUserCacheStore.delete(id);
return db.delete({ where: { id: id } });
}
});
Expand Down
13 changes: 7 additions & 6 deletions src/routes/event-handlers/joinRoom.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ 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';
import Logger from '../../utils/logger.js';
type SocketType = Socket<ClientToServerEvents, ServerToClientEvents, DefaultEventsMap, any>;

const isDocumentRoot = (roomId: string) => {
Expand Down Expand Up @@ -37,16 +38,16 @@ 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));
}
};

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.canPresent));
callback(true);
})
.catch(() => {
Expand All @@ -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.canPresent);
callback(true);
} else {
if (user.role === Role.TEACHER) {
Expand Down
41 changes: 37 additions & 4 deletions src/routes/event-handlers/streamUpdate.handler.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,51 @@
import { ClientToServerEvents, IoClientEvent, IoEvent, ServerToClientEvents } from '../socketEventTypes.js';
import {
ChangedDocument,
ClientToServerEvents,
IoClientEvent,
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, ServerToClientEvents, DefaultEventsMap, any>
) => 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: new Date().toISOString() as unknown as Date
};
if (payload.meta) {
pkg.meta = payload.meta;
}

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<ClientToServerEvents, ServerToClientEvents, DefaultEventsMap, any>
) => ClientToServerEvents[IoClientEvent.STREAM_UPDATE] = (roomId, socket) => (payload) => {
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
});
updatedAt: new Date().toISOString() as unknown as Date
};
if (payload.meta) {
pkg.meta = payload.meta;
}

socket.to(payload.roomId).except(socket.id).emit(IoEvent.CHANGED_DOCUMENT, pkg);
};

export default onStreamUpdate;
7 changes: 4 additions & 3 deletions src/routes/socketEventTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -47,13 +47,14 @@ export interface ChangedRecord<T extends RecordType> {
record: TypeRecordMap[T];
}

export interface ChangedDocument {
export interface ChangedDocument<T = any> {
id: string;
data: Prisma.JsonValue;
updatedAt: Date;
meta?: T;
}

export interface StreamedDynamicDocument extends ChangedDocument {
export interface StreamedDynamicDocument<T = any> extends Omit<ChangedDocument<T>, 'updatedAt'> {
roomId: string;
}

Expand Down
2 changes: 2 additions & 0 deletions src/routes/socketEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -44,6 +45,7 @@ const EventRouter = (io: Server<ClientToServerEvents, ServerToClientEvents>) =>
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) {
Expand Down
27 changes: 18 additions & 9 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,29 @@ import http from 'http';
import * as Sentry from '@sentry/node';
import Logger from './utils/logger.js';
import dotenv from 'dotenv';
import { StreamableGroupUserCacheStore } from './models/StudentGroup.js';
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);
configure(app);
await StreamableGroupUserCacheStore.recreate();

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);
});
Loading
Loading