From 80a9b4cf6a590b6910ccc8e7670c33532704e433 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 6 Oct 2026 22:21:43 +0000 Subject: [PATCH 01/11] feature: add document constraints for root and parent docs --- README.md | 2 +- .../migration.sql | 10 ++ .../migration.sql | 54 +++++++++++ prisma/schema.prisma | 4 + .../views/view__users_documents.sql | 2 + src/controllers/documents.ts | 18 ++-- src/models/Document.ts | 41 ++++---- src/tests/integration/documents.test.ts | 96 +++++++++++++++++++ 8 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 prisma/migrations/20261006201734_add_document_uniqueness_constraints/migration.sql create mode 100644 prisma/migrations/20261006215734_create_views__users_documents/migration.sql diff --git a/README.md b/README.md index 887366d..71747bc 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The access policies and users documents are implemented as database views. To ke 2. Make sure the dependencies are correct in [migrate.config.yml](prisma/view-migrations/migrate.config.yml). 3. Run `yarn db:migrate-view` to create a new migration for the changed views (this won't run `prisma migrate:dev`, it only creates the migration files). 4. Eventually change the [schema.prisma](prisma/schema.prisma) file to reflect changes in the views (e.g. new fields). -5. Run `yarn run prisma migrate:dev` to create a new migration for the schema changes. +5. Run `yarn run prisma migrate dev` to create a new migration for the schema changes. > [!WARNING] > Never edit views directly in a prisma migration file (under `prisma/migrations/`), as these files are auto-generated and will be overwritten the next time `yarn db:migrate-views` is run. diff --git a/prisma/migrations/20261006201734_add_document_uniqueness_constraints/migration.sql b/prisma/migrations/20261006201734_add_document_uniqueness_constraints/migration.sql new file mode 100644 index 0000000..8b0a933 --- /dev/null +++ b/prisma/migrations/20261006201734_add_document_uniqueness_constraints/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE "documents" + ADD COLUMN "uniq_on_root_constraint" TEXT COLLATE "C", + ADD COLUMN "uniq_on_parent_constraint" TEXT COLLATE "C"; + +ALTER TABLE "documents" + ADD CONSTRAINT unique_on_root_constraint UNIQUE (document_root_id, author_id, type, uniq_on_root_constraint); + +ALTER TABLE "documents" + ADD CONSTRAINT unique_on_parent_constraint UNIQUE (document_root_id, author_id, type, parent_id, uniq_on_parent_constraint); diff --git a/prisma/migrations/20261006215734_create_views__users_documents/migration.sql b/prisma/migrations/20261006215734_create_views__users_documents/migration.sql new file mode 100644 index 0000000..a4b75d2 --- /dev/null +++ b/prisma/migrations/20261006215734_create_views__users_documents/migration.sql @@ -0,0 +1,54 @@ +-- NEVER MODIFY THIS FILE MANUALLY! IT IS AUTO-GENERATED USING prisma/view-migrations/create-view-migration.ts + +DROP VIEW IF EXISTS view__users_documents; + + +CREATE VIEW view__users_documents AS + -- view: view__users_documents + + SELECT + view__document_user_permissions.user_id AS user_id, + document_roots.*, + COALESCE( + JSONB_AGG( + DISTINCT JSONB_BUILD_OBJECT( + 'id', view__document_user_permissions.root_group_permission_id, + 'access', view__document_user_permissions.access, + 'groupId', view__document_user_permissions.group_id + ) + ) FILTER (WHERE view__document_user_permissions.root_group_permission_id IS NOT NULL), + '[]'::jsonb + ) AS "groupPermissions", + COALESCE( + JSONB_AGG( + DISTINCT JSONB_BUILD_OBJECT( + 'id', view__document_user_permissions.root_user_permission_id, + 'access', view__document_user_permissions.access, + 'userId', view__document_user_permissions.user_id + ) + ) FILTER (WHERE view__document_user_permissions.root_user_permission_id IS NOT NULL), + '[]'::jsonb + ) AS "userPermissions", + COALESCE( + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', d.id, + 'authorId', d.author_id, + 'type', d.type, + 'data', CASE WHEN (view__document_user_permissions.access='None_DocumentRoot' OR view__document_user_permissions.access='None_StudentGroup' OR view__document_user_permissions.access='None_User') THEN NULL ELSE d.data END, + 'parentId', d.parent_id, + 'documentRootId', d.document_root_id, + 'uniqOnRoot', d.uniq_on_root_constraint, + 'uniqOnParent', d.uniq_on_parent_constraint, + 'createdAt', d.created_at, + 'updatedAt', d.updated_at + ) + ) FILTER (WHERE d.id IS NOT NULL), + '[]'::jsonb + ) AS documents + FROM + document_roots + LEFT JOIN view__document_user_permissions ON document_roots.id=view__document_user_permissions.document_root_id + LEFT JOIN documents d ON document_roots.id=d.document_root_id AND view__document_user_permissions.document_id=d.id + WHERE view__document_user_permissions.user_id IS NOT NULL + GROUP BY document_roots.id, view__document_user_permissions.user_id; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b7078a..0ddd788 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -158,6 +158,8 @@ model Document { type String data Json parentId String? @map("parent_id") @db.Uuid + uniqOnParent String? @map("uniq_on_parent_constraint") + uniqOnRoot String? @map("uniq_on_root_constraint") documentRootId String @map("document_root_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @@ -168,6 +170,8 @@ model Document { view_DocumentUserPermissions view_DocumentUserPermissions[] view_AllDocumentUserPermissions view_AllDocumentUserPermissions[] + @@unique([documentRootId, authorId, type, parentId, uniqOnParent], map: "unique_on_parent_constraint") + @@unique([documentRootId, authorId, type, uniqOnRoot], map: "unique_on_root_constraint") @@index([authorId], map: "document_author_id_index") @@map("documents") } diff --git a/prisma/view-migrations/views/view__users_documents.sql b/prisma/view-migrations/views/view__users_documents.sql index e4aacbb..5c28307 100644 --- a/prisma/view-migrations/views/view__users_documents.sql +++ b/prisma/view-migrations/views/view__users_documents.sql @@ -32,6 +32,8 @@ SELECT 'data', CASE WHEN (view__document_user_permissions.access='None_DocumentRoot' OR view__document_user_permissions.access='None_StudentGroup' OR view__document_user_permissions.access='None_User') THEN NULL ELSE d.data END, 'parentId', d.parent_id, 'documentRootId', d.document_root_id, + 'uniqOnRoot', d.uniq_on_root_constraint, + 'uniqOnParent', d.uniq_on_parent_constraint, 'createdAt', d.created_at, 'updatedAt', d.updated_at ) diff --git a/src/controllers/documents.ts b/src/controllers/documents.ts index f517450..d5a04fd 100644 --- a/src/controllers/documents.ts +++ b/src/controllers/documents.ts @@ -13,14 +13,13 @@ export const find: RequestHandler<{ id: string }> = async (req, res, next) => { res.json(document); }; -export const create: RequestHandler< - any, - any, - DbDocument, - { onBehalfOf?: 'true'; uniqueMain?: 'true' } -> = async (req, res, next) => { - const { type, documentRootId, data, parentId } = req.body; - const { onBehalfOf, uniqueMain } = req.query; +export const create: RequestHandler = async ( + req, + res, + next +) => { + const { type, documentRootId, data, parentId, uniqOnRoot, uniqOnParent } = req.body; + const { onBehalfOf } = req.query; const onBehalfUserId = onBehalfOf === 'true' ? req.body.authorId : undefined; const { model, permissions } = await Document.createModel( (req as any).user!, @@ -28,7 +27,8 @@ export const create: RequestHandler< documentRootId, data, !parentId ? undefined : parentId, - uniqueMain === 'true', + uniqOnRoot, + uniqOnParent, onBehalfUserId ); /** diff --git a/src/models/Document.ts b/src/models/Document.ts index e248f0f..5f0797c 100644 --- a/src/models/Document.ts +++ b/src/models/Document.ts @@ -1,4 +1,4 @@ -import type { JsonObject } from '@prisma/client/runtime/client'; +import { PrismaClientKnownRequestError, type JsonObject } from '@prisma/client/runtime/client'; import { Access, Document as DbDocument, PrismaClient, User } from '../../prisma/generated/client.js'; import { highestAccess, NoneAccess, RWAccess } from '../helpers/accessPolicy.js'; import prisma from '../prisma.js'; @@ -11,7 +11,8 @@ import { hasElevatedAccess, Role, whereStudentGroupAccess } from './User.js'; type AccessCheckableDocument = DbDocument & { documentRoot: AccessCheckableDocumentRoot }; -export type ApiDocument = DbDocument; +export type ApiDocument = Omit & + Partial>; interface DocumentWithPermission { document: ApiDocument; @@ -47,6 +48,15 @@ export const prepareDocument = (actorId: string, document: AccessCheckableDocume return null; } const model: ApiDocument = { ...document }; + if (!model.parentId) { + delete model.parentId; + } + if (!model.uniqOnParent) { + delete model.uniqOnParent; + } + if (!model.uniqOnRoot) { + delete model.uniqOnRoot; + } delete (model as Partial).documentRoot; if (NoneAccess.has(permission)) { model.data = null; @@ -89,7 +99,8 @@ function Document(db: PrismaClient['document']) { documentRootId: string, data: any, parentId?: string, - uniqueMain?: boolean, + uniqOnRoot?: string | null, + uniqOnParent?: string | null, _onBehalfOfUserId?: string /** this flag enables creation of documents on behalf of another user */ ): Promise> { const documentRoot = await DocumentRoot.findModel(actor, documentRootId); @@ -127,18 +138,6 @@ function Document(db: PrismaClient['document']) { throw new HTTP403Error('Insufficient access permission'); } } - if (uniqueMain) { - const mainDoc = await db.findFirst({ - where: { documentRootId: documentRootId, authorId: authorId, type: type } - }); - if (mainDoc) { - Logger.warn( - `[not unique]: Main document fro documentRoot "${documentRootId}" already exists for user "${authorId}"` - ); - // the frontend may depend on the error message (try to not change: status code + [not unique]) - throw new HTTP403Error('[not unique] Main document already exists for this user'); - } - } /** * Since it is easyier to check wheter a user has permissions to create a model * when the model actually exists, we create the model first and then check the permissions. @@ -150,7 +149,9 @@ function Document(db: PrismaClient['document']) { documentRootId: documentRootId, data: data, parentId: parentId, - authorId: authorId + authorId: authorId, + uniqOnRoot: uniqOnRoot, + uniqOnParent: uniqOnParent }, include: { documentRoot: { @@ -163,7 +164,13 @@ function Document(db: PrismaClient['document']) { } } }) - .then((doc) => prepareDocument(authorId, doc)!); + .then((doc) => prepareDocument(authorId, doc)!) + .catch((err) => { + if (err instanceof PrismaClientKnownRequestError && err.code === 'P2002') { + throw new HTTP403Error('Unique constraint violation'); + } + throw err; + }); /** * Check if the user has the required permissions to create the model. * If not, delete the model and throw an error. diff --git a/src/tests/integration/documents.test.ts b/src/tests/integration/documents.test.ts index 0cb4d7f..e8b414e 100644 --- a/src/tests/integration/documents.test.ts +++ b/src/tests/integration/documents.test.ts @@ -187,3 +187,99 @@ describe('Documents (integration)', () => { expect(teacher2Res2.body.length).toBe(0); }); }); + +describe('Document creation', () => { + it('prevents creating multiple main documents for the same document root', async () => { + const user = await createTestUser(Role.STUDENT); + const agent = agentAs(user.id); + + const documentRootId = randomUUID(); + await agent + .post(`${API_URL}/documentRoots/${documentRootId}`) + .send({ access: Access.RW_DocumentRoot }); + + const createRes = await agent.post(`${API_URL}/documents`).send({ + type: 'test-type', + documentRootId, + data: { foo: 'bar' }, + uniqOnRoot: 'main' + }); + expect(createRes.status).toBe(200); + expect(createRes.body.documentRootId).toBe(documentRootId); + expect(createRes.body.authorId).toBe(user.id); + expect(createRes.body.data).toEqual({ foo: 'bar' }); + expect(createRes.body.uniqOnRoot).toBe('main'); + expect(createRes.body.uniqOnParent).toBeUndefined(); + + const createSecond = await agent.post(`${API_URL}/documents`).send({ + type: 'test-type', + documentRootId, + data: { foo: 'another bar' }, + uniqOnRoot: 'main' + }); + expect(createSecond.status).toBe(403); + expect(createSecond.body.errors[0].message).toBe('[403] Unique constraint violation'); + + const createDifferentType = await agent.post(`${API_URL}/documents`).send({ + type: 'demo-type', + documentRootId, + data: { foo: 'another bar' }, + uniqOnRoot: 'main' + }); + expect(createDifferentType.status).toBe(200); + expect(createDifferentType.body.documentRootId).toBe(documentRootId); + expect(createDifferentType.body.authorId).toBe(user.id); + }); + it('prevents creating multiple child documents for the same parent document', async () => { + const user = await createTestUser(Role.STUDENT); + const agent = agentAs(user.id); + + const documentRootId = randomUUID(); + await agent + .post(`${API_URL}/documentRoots/${documentRootId}`) + .send({ access: Access.RW_DocumentRoot }); + + const parentDoc = await agent.post(`${API_URL}/documents`).send({ + type: 'quiz', + documentRootId, + data: { foo: 'bar' }, + uniqOnRoot: 'main' + }); + expect(parentDoc.status).toBe(200); + expect(parentDoc.body.documentRootId).toBe(documentRootId); + + const createChild = await agent.post(`${API_URL}/documents`).send({ + type: 'choice_answer', + documentRootId, + parentId: parentDoc.body.id, + data: { qid: 'q1' }, + uniqOnParent: 'q1' + }); + expect(createChild.status).toBe(200); + expect(createChild.body.parentId).toBe(parentDoc.body.id); + expect(createChild.body.uniqOnParent).toBe('q1'); + + const createSecond = await agent.post(`${API_URL}/documents`).send({ + type: 'choice_answer', + documentRootId, + parentId: parentDoc.body.id, + data: { qid: 'q1' }, + uniqOnParent: 'q1' + }); + expect(createSecond.status).toBe(403); + expect(createSecond.body.errors[0].message).toBe('[403] Unique constraint violation'); + + const createQ2 = await agent.post(`${API_URL}/documents`).send({ + type: 'demo-type', + documentRootId, + parentId: parentDoc.body.id, + data: { qid: 'q2' }, + uniqOnParent: 'q2' + }); + expect(createQ2.status).toBe(200); + expect(createQ2.body.documentRootId).toBe(documentRootId); + expect(createQ2.body.authorId).toBe(user.id); + expect(createQ2.body.parentId).toBe(parentDoc.body.id); + expect(createQ2.body.uniqOnParent).toBe('q2'); + }); +}); From 298ed77521beee025b5546dc5cebd44c8b78c095 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 7 Oct 2026 07:00:06 +0000 Subject: [PATCH 02/11] use status 201 for [post] --- src/controllers/documentRoots.ts | 2 +- src/controllers/documents.ts | 2 +- src/tests/integration/documentRoots.test.ts | 2 +- src/tests/integration/documents.test.ts | 18 +++++++++--------- src/tests/integration/users.test.ts | 10 +++++----- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/controllers/documentRoots.ts b/src/controllers/documentRoots.ts index ae536c2..c9382e7 100644 --- a/src/controllers/documentRoots.ts +++ b/src/controllers/documentRoots.ts @@ -79,7 +79,7 @@ export const create: RequestHandler<{ id: string }, any, CreateConfig | undefine to: [...groupIds, ...userIds, sharedAccess, (req as any).user!.id] // overlappings are handled by socket.io: https://socket.io/docs/v3/rooms/#joining-and-leaving } ]; - res.json(documentRoot); + res.status(201).json(documentRoot); }; export const update: RequestHandler<{ id: string }, any, UpdateConfig> = async (req, res, next) => { diff --git a/src/controllers/documents.ts b/src/controllers/documents.ts index d5a04fd..6dd7b66 100644 --- a/src/controllers/documents.ts +++ b/src/controllers/documents.ts @@ -47,7 +47,7 @@ export const create: RequestHandler { .post(`${API_URL}/documentRoots/${documentRootId}`) .send({ access: Access.RW_DocumentRoot }); - expect(createRes.status).toBe(200); + expect(createRes.status).toBe(201); expect(createRes.body.id).toBe(documentRootId); expect(createRes.body.access).toBe(Access.RW_DocumentRoot); diff --git a/src/tests/integration/documents.test.ts b/src/tests/integration/documents.test.ts index e8b414e..a8169f6 100644 --- a/src/tests/integration/documents.test.ts +++ b/src/tests/integration/documents.test.ts @@ -19,7 +19,7 @@ describe('Documents (integration)', () => { documentRootId, data: { foo: 'bar' } }); - expect(createRes.status).toBe(200); + expect(createRes.status).toBe(201); expect(createRes.body.documentRootId).toBe(documentRootId); expect(createRes.body.authorId).toBe(user.id); expect(createRes.body.data).toEqual({ foo: 'bar' }); @@ -62,7 +62,7 @@ describe('Documents (integration)', () => { documentRootId, data: { secret: true } }); - expect(createRes.status).toBe(200); + expect(createRes.status).toBe(201); const documentId = createRes.body.id as string; const strangerRes = await agentAs(stranger.id).get(`${API_URL}/documents/${documentId}`); @@ -103,8 +103,8 @@ describe('Documents (integration)', () => { documentRootId, data: { secret: true } }); - expect(createRes.status).toBe(200); - expect(strangersDocRes.status).toBe(200); + expect(createRes.status).toBe(201); + expect(strangersDocRes.status).toBe(201); const documentId = createRes.body.id as string; const strangersDocumentId = strangersDocRes.body.id as string; @@ -204,7 +204,7 @@ describe('Document creation', () => { data: { foo: 'bar' }, uniqOnRoot: 'main' }); - expect(createRes.status).toBe(200); + expect(createRes.status).toBe(201); expect(createRes.body.documentRootId).toBe(documentRootId); expect(createRes.body.authorId).toBe(user.id); expect(createRes.body.data).toEqual({ foo: 'bar' }); @@ -226,7 +226,7 @@ describe('Document creation', () => { data: { foo: 'another bar' }, uniqOnRoot: 'main' }); - expect(createDifferentType.status).toBe(200); + expect(createDifferentType.status).toBe(201); expect(createDifferentType.body.documentRootId).toBe(documentRootId); expect(createDifferentType.body.authorId).toBe(user.id); }); @@ -245,7 +245,7 @@ describe('Document creation', () => { data: { foo: 'bar' }, uniqOnRoot: 'main' }); - expect(parentDoc.status).toBe(200); + expect(parentDoc.status).toBe(201); expect(parentDoc.body.documentRootId).toBe(documentRootId); const createChild = await agent.post(`${API_URL}/documents`).send({ @@ -255,7 +255,7 @@ describe('Document creation', () => { data: { qid: 'q1' }, uniqOnParent: 'q1' }); - expect(createChild.status).toBe(200); + expect(createChild.status).toBe(201); expect(createChild.body.parentId).toBe(parentDoc.body.id); expect(createChild.body.uniqOnParent).toBe('q1'); @@ -276,7 +276,7 @@ describe('Document creation', () => { data: { qid: 'q2' }, uniqOnParent: 'q2' }); - expect(createQ2.status).toBe(200); + expect(createQ2.status).toBe(201); expect(createQ2.body.documentRootId).toBe(documentRootId); expect(createQ2.body.authorId).toBe(user.id); expect(createQ2.body.parentId).toBe(parentDoc.body.id); diff --git a/src/tests/integration/users.test.ts b/src/tests/integration/users.test.ts index 82c9392..a345a2f 100644 --- a/src/tests/integration/users.test.ts +++ b/src/tests/integration/users.test.ts @@ -38,14 +38,14 @@ describe('Users (integration)', () => { const createRootRes = await agent.post(`${API_URL}/documentRoots/${documentRootId}`).send({ access: Access.RW_DocumentRoot }); - expect(createRootRes.status).toBe(200); + expect(createRootRes.status).toBe(201); const ownDocumentRes = await agent.post(`${API_URL}/documents`).send({ type: 'document', documentRootId, data: { owner: user.id } }); - expect(ownDocumentRes.status).toBe(200); + expect(ownDocumentRes.status).toBe(201); await prisma.document.create({ data: { @@ -79,14 +79,14 @@ describe('Users (integration)', () => { sharedAccess: Access.None_DocumentRoot, userPermissions: [{ userId: user.id, access: Access.RW_User }] }); - expect(createRootRes.status).toBe(200); + expect(createRootRes.status).toBe(201); const ownDocumentRes = await agent.post(`${API_URL}/documents`).send({ type: 'document', documentRootId, data: { owner: user.id } }); - expect(ownDocumentRes.status).toBe(200); + expect(ownDocumentRes.status).toBe(201); await prisma.document.create({ data: { @@ -121,7 +121,7 @@ describe('Users (integration)', () => { sharedAccess: Access.RW_DocumentRoot, userPermissions: [{ userId: user.id, access: Access.RW_User }] }); - expect(createRootRes.status).toBe(200); + expect(createRootRes.status).toBe(201); await prisma.document.create({ data: { From eac4b219d3a617ae8dd9e15ef1936a7ed84cb8f8 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 7 Oct 2026 07:55:57 +0000 Subject: [PATCH 03/11] return existing document when trying to create it --- src/controllers/documentRoots.ts | 64 ++++++++++++++------- src/controllers/documents.ts | 43 ++++++++++++-- src/models/Document.ts | 20 +------ src/tests/integration/documentRoots.test.ts | 15 +++++ src/tests/integration/documents.test.ts | 23 ++++++-- 5 files changed, 116 insertions(+), 49 deletions(-) diff --git a/src/controllers/documentRoots.ts b/src/controllers/documentRoots.ts index c9382e7..4911258 100644 --- a/src/controllers/documentRoots.ts +++ b/src/controllers/documentRoots.ts @@ -5,7 +5,7 @@ import DocumentRoot, { Config as CreateConfig, UpdateConfig } from '../models/Do import { hasElevatedAccess } from '../models/User.js'; import { ChangedRecord, IoEvent, RecordType } from '../routes/socketEventTypes.js'; import { IoRoom } from '../routes/socketEvents.js'; -import { HTTP400Error, HTTP403Error } from '../utils/errors/Errors.js'; +import { HTTP400Error, HTTP403Error, HTTP404Error } from '../utils/errors/Errors.js'; export const findMultipleFor: RequestHandler< { id: string /** userId */ }, @@ -58,28 +58,48 @@ export const create: RequestHandler<{ id: string }, any, CreateConfig | undefine res, next ) => { - const documentRoot = await DocumentRoot.createModel(req.params.id, req.body); - /** - * Notifications to - * - the user who created the document - * - users with ro/rw access to the document root - * - student groups with ro/rw access to the document root - */ - const groupIds = documentRoot.groupPermissions - .filter((p) => !NoneAccess.has(p.access)) - .map((p) => p.groupId); - const userIds = documentRoot.userPermissions - .filter((p) => !NoneAccess.has(p.access)) - .map((p) => p.userId); - const sharedAccess = RO_RW_DocumentRootAccess.has(documentRoot.sharedAccess) ? IoRoom.ALL : IoRoom.ADMIN; - res.notifications = [ - { - event: IoEvent.NEW_RECORD, - message: { type: RecordType.DocumentRoot, record: documentRoot }, - to: [...groupIds, ...userIds, sharedAccess, (req as any).user!.id] // overlappings are handled by socket.io: https://socket.io/docs/v3/rooms/#joining-and-leaving + let exists = false; + const documentRoot = await DocumentRoot.createModel(req.params.id, req.body).catch((err) => { + if (err.code === 'P2002') { + exists = true; + // fetch the existing document root and return it + return DocumentRoot.findModel((req as any).user!, req.params.id).then((existing) => { + if (!existing) { + throw new HTTP404Error('DocumentRoot not found'); + } + return existing; + }); } - ]; - res.status(201).json(documentRoot); + throw err; + }); + + if (!exists) { + /** + * Notifications to + * - the user who created the document + * - users with ro/rw access to the document root + * - student groups with ro/rw access to the document root + */ + const groupIds = documentRoot.groupPermissions + .filter((p) => !NoneAccess.has(p.access)) + .map((p) => p.groupId); + const userIds = documentRoot.userPermissions + .filter((p) => !NoneAccess.has(p.access)) + .map((p) => p.userId); + const sharedAccess = RO_RW_DocumentRootAccess.has(documentRoot.sharedAccess) + ? IoRoom.ALL + : IoRoom.ADMIN; + res.notifications = [ + { + event: IoEvent.NEW_RECORD, + message: { type: RecordType.DocumentRoot, record: documentRoot }, + to: [...groupIds, ...userIds, sharedAccess, (req as any).user!.id] // overlappings are handled by socket.io: https://socket.io/docs/v3/rooms/#joining-and-leaving + } + ]; + } + + const statusCode = exists ? 200 : 201; + res.status(statusCode).json(documentRoot); }; export const update: RequestHandler<{ id: string }, any, UpdateConfig> = async (req, res, next) => { diff --git a/src/controllers/documents.ts b/src/controllers/documents.ts index 6dd7b66..f520fe7 100644 --- a/src/controllers/documents.ts +++ b/src/controllers/documents.ts @@ -2,7 +2,8 @@ import type { JsonObject } from '@prisma/client/runtime/client'; import { RequestHandler } from 'express'; import { Document as DbDocument, Prisma } from '../../prisma/generated/client.js'; import { NoneAccess, RO_RW_DocumentRootAccess, RWAccess } from '../helpers/accessPolicy.js'; -import Document from '../models/Document.js'; +import Document, { ApiDocument } from '../models/Document.js'; +import DocumentRoot from '../models/DocumentRoot.js'; import prisma from '../prisma.js'; import { ChangedDocument, IoEvent, RecordType } from '../routes/socketEventTypes.js'; import { IoRoom } from '../routes/socketEvents.js'; @@ -21,7 +22,7 @@ export const create: RequestHandler { + if (err.code === 'P2002') { + return DocumentRoot.findModel((req as any).user!, documentRootId).then((existing) => { + const uid = onBehalfUserId ?? (req as any).user!.id; + const isExisting = (doc: ApiDocument) => { + if (doc.authorId !== uid || doc.type !== type || doc.documentRootId !== documentRootId) { + return false; + } + if (uniqOnRoot && doc.uniqOnRoot === uniqOnRoot) { + return true; + } + if (uniqOnParent && doc.parentId === parentId && doc.uniqOnParent === uniqOnParent) { + return true; + } + return false; + }; + const doc = existing?.documents.find(isExisting); + if (doc) { + return { + model: doc, + exists: true, + permissions: { + access: existing!.access, + user: existing!.userPermissions, + group: existing!.groupPermissions, + sharedAccess: existing!.sharedAccess + } + }; + } + throw new HTTP403Error('Unique constraint violation'); + }); + } + throw err; + }); /** * Notifications to * - the user who created the document @@ -47,7 +81,8 @@ export const create: RequestHandler).documentRoot; if (NoneAccess.has(permission)) { model.data = null; @@ -66,6 +57,7 @@ export const prepareDocument = (actorId: string, document: AccessCheckableDocume type Response = { model: T; + exists?: boolean; // wheter the model already existed and was returned instead of created permissions: { access: Access; sharedAccess: Access; @@ -164,13 +156,7 @@ function Document(db: PrismaClient['document']) { } } }) - .then((doc) => prepareDocument(authorId, doc)!) - .catch((err) => { - if (err instanceof PrismaClientKnownRequestError && err.code === 'P2002') { - throw new HTTP403Error('Unique constraint violation'); - } - throw err; - }); + .then((doc) => prepareDocument(authorId, doc)!); /** * Check if the user has the required permissions to create the model. * If not, delete the model and throw an error. diff --git a/src/tests/integration/documentRoots.test.ts b/src/tests/integration/documentRoots.test.ts index 84f8194..f08dfd3 100644 --- a/src/tests/integration/documentRoots.test.ts +++ b/src/tests/integration/documentRoots.test.ts @@ -55,6 +55,21 @@ describe('DocumentRoots (integration)', () => { expect(res.status).toBe(403); }); + it('returns the existing document root when creating it again', async () => { + const user = await createTestUser(Role.STUDENT); + const documentRootId = randomUUID(); + + const agent = agentAs(user.id); + + const res = await agent.post(`${API_URL}/documentRoots/${documentRootId}`).send({}); + expect(res.status).toBe(201); + expect(res.body.id).toBe(documentRootId); + + const res2 = await agent.post(`${API_URL}/documentRoots/${documentRootId}`).send({}); + expect(res2.status).toBe(200); + expect(res2.body.id).toBe(documentRootId); + }); + it('only allows an admin to delete a document root', async () => { const student = await createTestUser(Role.STUDENT); const admin = await createTestUser(Role.ADMIN); diff --git a/src/tests/integration/documents.test.ts b/src/tests/integration/documents.test.ts index a8169f6..5ca06f4 100644 --- a/src/tests/integration/documents.test.ts +++ b/src/tests/integration/documents.test.ts @@ -209,7 +209,7 @@ describe('Document creation', () => { expect(createRes.body.authorId).toBe(user.id); expect(createRes.body.data).toEqual({ foo: 'bar' }); expect(createRes.body.uniqOnRoot).toBe('main'); - expect(createRes.body.uniqOnParent).toBeUndefined(); + expect(createRes.body.uniqOnParent).toBeNull(); const createSecond = await agent.post(`${API_URL}/documents`).send({ type: 'test-type', @@ -217,8 +217,12 @@ describe('Document creation', () => { data: { foo: 'another bar' }, uniqOnRoot: 'main' }); - expect(createSecond.status).toBe(403); - expect(createSecond.body.errors[0].message).toBe('[403] Unique constraint violation'); + expect(createSecond.status).toBe(200); + expect(createSecond.body.documentRootId).toBe(documentRootId); + expect(createSecond.body.authorId).toBe(user.id); + expect(createSecond.body.data).toEqual({ foo: 'bar' }); + expect(createSecond.body.uniqOnRoot).toBe('main'); + expect(createSecond.body.uniqOnParent).toBeNull(); const createDifferentType = await agent.post(`${API_URL}/documents`).send({ type: 'demo-type', @@ -229,6 +233,9 @@ describe('Document creation', () => { expect(createDifferentType.status).toBe(201); expect(createDifferentType.body.documentRootId).toBe(documentRootId); expect(createDifferentType.body.authorId).toBe(user.id); + expect(createDifferentType.body.data).toEqual({ foo: 'another bar' }); + expect(createDifferentType.body.uniqOnRoot).toBe('main'); + expect(createDifferentType.body.uniqOnParent).toBeNull(); }); it('prevents creating multiple child documents for the same parent document', async () => { const user = await createTestUser(Role.STUDENT); @@ -258,16 +265,19 @@ describe('Document creation', () => { expect(createChild.status).toBe(201); expect(createChild.body.parentId).toBe(parentDoc.body.id); expect(createChild.body.uniqOnParent).toBe('q1'); + expect(createChild.body.data.qid).toBe('q1'); const createSecond = await agent.post(`${API_URL}/documents`).send({ type: 'choice_answer', documentRootId, parentId: parentDoc.body.id, - data: { qid: 'q1' }, + data: { qid: 'q-whatever-doesnt-matter' }, uniqOnParent: 'q1' }); - expect(createSecond.status).toBe(403); - expect(createSecond.body.errors[0].message).toBe('[403] Unique constraint violation'); + expect(createSecond.status).toBe(200); + expect(createSecond.body.parentId).toBe(parentDoc.body.id); + expect(createSecond.body.uniqOnParent).toBe('q1'); + expect(createSecond.body.data.qid).toBe('q1'); const createQ2 = await agent.post(`${API_URL}/documents`).send({ type: 'demo-type', @@ -281,5 +291,6 @@ describe('Document creation', () => { expect(createQ2.body.authorId).toBe(user.id); expect(createQ2.body.parentId).toBe(parentDoc.body.id); expect(createQ2.body.uniqOnParent).toBe('q2'); + expect(createQ2.body.data.qid).toBe('q2'); }); }); From edddf455b4fe49240a655df1f9a3ef9688548a04 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 7 Oct 2026 08:45:41 +0000 Subject: [PATCH 04/11] cleanup response format --- .../migration.sql | 15 ++++++++++++--- .../views/view__users_documents.sql | 15 ++++++++++++--- src/controllers/documents.ts | 4 ++-- src/models/Document.ts | 19 +++++++++++++++++-- src/tests/integration/documents.test.ts | 6 +++--- src/tests/integration/users.test.ts | 2 +- 6 files changed, 47 insertions(+), 14 deletions(-) rename prisma/migrations/{20261006215734_create_views__users_documents => 20261007083407_create_views__users_documents}/migration.sql (79%) diff --git a/prisma/migrations/20261006215734_create_views__users_documents/migration.sql b/prisma/migrations/20261007083407_create_views__users_documents/migration.sql similarity index 79% rename from prisma/migrations/20261006215734_create_views__users_documents/migration.sql rename to prisma/migrations/20261007083407_create_views__users_documents/migration.sql index a4b75d2..fd08e5c 100644 --- a/prisma/migrations/20261006215734_create_views__users_documents/migration.sql +++ b/prisma/migrations/20261007083407_create_views__users_documents/migration.sql @@ -36,13 +36,22 @@ CREATE VIEW view__users_documents AS 'authorId', d.author_id, 'type', d.type, 'data', CASE WHEN (view__document_user_permissions.access='None_DocumentRoot' OR view__document_user_permissions.access='None_StudentGroup' OR view__document_user_permissions.access='None_User') THEN NULL ELSE d.data END, - 'parentId', d.parent_id, 'documentRootId', d.document_root_id, - 'uniqOnRoot', d.uniq_on_root_constraint, - 'uniqOnParent', d.uniq_on_parent_constraint, 'createdAt', d.created_at, 'updatedAt', d.updated_at ) + || CASE WHEN d.parent_id IS NOT NULL + THEN JSONB_BUILD_OBJECT('parentId', d.parent_id) + ELSE '{}'::jsonb + END + || CASE WHEN d.uniq_on_root_constraint IS NOT NULL + THEN JSONB_BUILD_OBJECT('uniqOnRoot', d.uniq_on_root_constraint) + ELSE '{}'::jsonb + END + || CASE WHEN d.uniq_on_parent_constraint IS NOT NULL + THEN JSONB_BUILD_OBJECT('uniqOnParent', d.uniq_on_parent_constraint) + ELSE '{}'::jsonb + END ) FILTER (WHERE d.id IS NOT NULL), '[]'::jsonb ) AS documents diff --git a/prisma/view-migrations/views/view__users_documents.sql b/prisma/view-migrations/views/view__users_documents.sql index 5c28307..0daae55 100644 --- a/prisma/view-migrations/views/view__users_documents.sql +++ b/prisma/view-migrations/views/view__users_documents.sql @@ -30,13 +30,22 @@ SELECT 'authorId', d.author_id, 'type', d.type, 'data', CASE WHEN (view__document_user_permissions.access='None_DocumentRoot' OR view__document_user_permissions.access='None_StudentGroup' OR view__document_user_permissions.access='None_User') THEN NULL ELSE d.data END, - 'parentId', d.parent_id, 'documentRootId', d.document_root_id, - 'uniqOnRoot', d.uniq_on_root_constraint, - 'uniqOnParent', d.uniq_on_parent_constraint, 'createdAt', d.created_at, 'updatedAt', d.updated_at ) + || CASE WHEN d.parent_id IS NOT NULL + THEN JSONB_BUILD_OBJECT('parentId', d.parent_id) + ELSE '{}'::jsonb + END + || CASE WHEN d.uniq_on_root_constraint IS NOT NULL + THEN JSONB_BUILD_OBJECT('uniqOnRoot', d.uniq_on_root_constraint) + ELSE '{}'::jsonb + END + || CASE WHEN d.uniq_on_parent_constraint IS NOT NULL + THEN JSONB_BUILD_OBJECT('uniqOnParent', d.uniq_on_parent_constraint) + ELSE '{}'::jsonb + END ) FILTER (WHERE d.id IS NOT NULL), '[]'::jsonb ) AS documents diff --git a/src/controllers/documents.ts b/src/controllers/documents.ts index f520fe7..c0550c5 100644 --- a/src/controllers/documents.ts +++ b/src/controllers/documents.ts @@ -2,7 +2,7 @@ import type { JsonObject } from '@prisma/client/runtime/client'; import { RequestHandler } from 'express'; import { Document as DbDocument, Prisma } from '../../prisma/generated/client.js'; import { NoneAccess, RO_RW_DocumentRootAccess, RWAccess } from '../helpers/accessPolicy.js'; -import Document, { ApiDocument } from '../models/Document.js'; +import Document, { ApiDocument, cleanupDocument } from '../models/Document.js'; import DocumentRoot from '../models/DocumentRoot.js'; import prisma from '../prisma.js'; import { ChangedDocument, IoEvent, RecordType } from '../routes/socketEventTypes.js'; @@ -50,7 +50,7 @@ export const create: RequestHandler { + if (!doc?.uniqOnParent) { + delete (doc as any).uniqOnParent; + } + if (!doc?.uniqOnRoot) { + delete (doc as any).uniqOnRoot; + } + if (!doc?.parentId) { + delete (doc as any).parentId; + } + if ((doc as Partial).documentRoot) { + delete (doc as Partial).documentRoot; + } + return doc; +}; + export const prepareDocument = (actorId: string, document: AccessCheckableDocument | null) => { if (!document) { return null; @@ -47,8 +63,7 @@ export const prepareDocument = (actorId: string, document: AccessCheckableDocume if (!permission) { return null; } - const model: ApiDocument = { ...document }; - delete (model as Partial).documentRoot; + const model: ApiDocument = cleanupDocument({ ...document }); if (NoneAccess.has(permission)) { model.data = null; } diff --git a/src/tests/integration/documents.test.ts b/src/tests/integration/documents.test.ts index 5ca06f4..4a35b3d 100644 --- a/src/tests/integration/documents.test.ts +++ b/src/tests/integration/documents.test.ts @@ -209,7 +209,7 @@ describe('Document creation', () => { expect(createRes.body.authorId).toBe(user.id); expect(createRes.body.data).toEqual({ foo: 'bar' }); expect(createRes.body.uniqOnRoot).toBe('main'); - expect(createRes.body.uniqOnParent).toBeNull(); + expect(createRes.body.uniqOnParent).toBeUndefined(); const createSecond = await agent.post(`${API_URL}/documents`).send({ type: 'test-type', @@ -222,7 +222,7 @@ describe('Document creation', () => { expect(createSecond.body.authorId).toBe(user.id); expect(createSecond.body.data).toEqual({ foo: 'bar' }); expect(createSecond.body.uniqOnRoot).toBe('main'); - expect(createSecond.body.uniqOnParent).toBeNull(); + expect(createSecond.body.uniqOnParent).toBeUndefined(); const createDifferentType = await agent.post(`${API_URL}/documents`).send({ type: 'demo-type', @@ -235,7 +235,7 @@ describe('Document creation', () => { expect(createDifferentType.body.authorId).toBe(user.id); expect(createDifferentType.body.data).toEqual({ foo: 'another bar' }); expect(createDifferentType.body.uniqOnRoot).toBe('main'); - expect(createDifferentType.body.uniqOnParent).toBeNull(); + expect(createDifferentType.body.uniqOnParent).toBeUndefined(); }); it('prevents creating multiple child documents for the same parent document', async () => { const user = await createTestUser(Role.STUDENT); diff --git a/src/tests/integration/users.test.ts b/src/tests/integration/users.test.ts index a345a2f..1f811e2 100644 --- a/src/tests/integration/users.test.ts +++ b/src/tests/integration/users.test.ts @@ -155,7 +155,7 @@ describe('Users (integration)', () => { expect(resOtherUser.body[0].id).toBe(documentRootId); expect(resOtherUser.body[0].documents).toHaveLength(1); expect(resOtherUser.body[0].documents[0].authorId).toBe(otherUser.id); - expect(resOtherUser.body[0].documents[0].data).toEqual(null); + expect(resOtherUser.body[0].documents[0].data).toBeNull(); }); it('does not allow a user to request another users document roots', async () => { From b802969a0fa189966dc2f6b85b88bebfa52bc334 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 9 Oct 2026 10:05:28 +0000 Subject: [PATCH 05/11] add data migration --- CHANGELOG.md | 21 +++++++++++++++++++ .../migration.sql | 11 ++++++++++ .../migration.sql | 12 +++++++++++ 3 files changed, 44 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 prisma/migrations/20261009090615_add_parent_id_to_quiz_children/migration.sql create mode 100644 prisma/migrations/20261009092644_set_main_constraint_for_single_childed_roots/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d7fab86 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog +## 09.10.2026 + +Introduced new columns `uniq_on_parent_constraint` and `uniq_on_root_constraint` to the `documents` table to enforce client specified uniqueness constraints. + +The `uniq_on_parent_constraint` is possible to fail, so the migration was not applied by default. Do it by hand: +```sql +UPDATE documents +SET uniq_on_parent_constraint = data->>'qid' +WHERE parent_id is not null and data->>'qid' is not null; +``` +and find duplicates and handle them by hand: + +```sql +select count(data->>'qid'), array_agg(id) as ids, data->>'qid', author_id, document_root_id, parent_id + from documents + where data->>'qid' is not null + group by data->>'qid', author_id, document_root_id, parent_id + order by count(data->>'qid') desc + limit 100; +``` \ No newline at end of file diff --git a/prisma/migrations/20261009090615_add_parent_id_to_quiz_children/migration.sql b/prisma/migrations/20261009090615_add_parent_id_to_quiz_children/migration.sql new file mode 100644 index 0000000..97e1e7e --- /dev/null +++ b/prisma/migrations/20261009090615_add_parent_id_to_quiz_children/migration.sql @@ -0,0 +1,11 @@ +-- link assessable items to their parent quiz document +UPDATE documents d + SET parent_id = ( + SELECT id FROM documents + WHERE document_root_id = d.document_root_id + AND type = 'quiz' + AND author_id = d.author_id + ORDER BY created_at ASC + LIMIT 1 + ) +WHERE (d.type = 'choice_answer' or d.type = 'true_false_answer') AND d.data->>'qid' IS NOT NULL; \ No newline at end of file diff --git a/prisma/migrations/20261009092644_set_main_constraint_for_single_childed_roots/migration.sql b/prisma/migrations/20261009092644_set_main_constraint_for_single_childed_roots/migration.sql new file mode 100644 index 0000000..16a9e9b --- /dev/null +++ b/prisma/migrations/20261009092644_set_main_constraint_for_single_childed_roots/migration.sql @@ -0,0 +1,12 @@ +UPDATE documents + SET uniq_on_root_constraint = 'main' +WHERE parent_id IS NULL AND id::text IN ( + SELECT documents->0->>'id' + FROM view__users_documents + WHERE jsonb_array_length(documents) = 1 + AND documents->0->>'type' NOT IN ( + 'dynamic_document_roots', + 'mdx_comment', + 'cms_text' + ) +) \ No newline at end of file From 4470ac251da2a30c2dd565eae1ab635887efbc5f Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 9 Oct 2026 10:56:37 +0000 Subject: [PATCH 06/11] add update constraints endpoint --- src/controllers/documents.ts | 38 +++++++++++++++++++++++++++++++++++- src/models/Document.ts | 28 ++++++++++++++++++++++++++ src/routes/router.ts | 4 +++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/controllers/documents.ts b/src/controllers/documents.ts index c0550c5..4684b48 100644 --- a/src/controllers/documents.ts +++ b/src/controllers/documents.ts @@ -2,7 +2,7 @@ import type { JsonObject } from '@prisma/client/runtime/client'; import { RequestHandler } from 'express'; import { Document as DbDocument, Prisma } from '../../prisma/generated/client.js'; import { NoneAccess, RO_RW_DocumentRootAccess, RWAccess } from '../helpers/accessPolicy.js'; -import Document, { ApiDocument, cleanupDocument } from '../models/Document.js'; +import Document, { ApiDocument, cleanupDocument, UniquenessConstraints } from '../models/Document.js'; import DocumentRoot from '../models/DocumentRoot.js'; import prisma from '../prisma.js'; import { ChangedDocument, IoEvent, RecordType } from '../routes/socketEventTypes.js'; @@ -128,6 +128,42 @@ export const update: RequestHandler< res.status(204).send(); }; +export const updateConstraints: RequestHandler< + { id: string }, + any, + { data: { uniqOnRoot?: string; uniqOnParent?: string } } +> = async (req, res, next) => { + const change: UniquenessConstraints = {}; + const { data } = req.body; + if ('uniqOnRoot' in data && (!!data.uniqOnRoot || data.uniqOnRoot === null)) { + change.uniqOnRoot = data.uniqOnRoot; + } + if ('uniqOnParent' in data && (!!data.uniqOnParent || data.uniqOnParent === null)) { + change.uniqOnParent = data.uniqOnParent; + } + if (Object.keys(change).length === 0) { + throw new HTTP403Error('No valid constraints to update'); + } + const model = await Document.updateConstraints((req as any).user!, req.params.id, change); + const groupIds = model.documentRoot.rootGroupPermissions + .filter((p) => !NoneAccess.has(p.access)) + .map((p) => p.studentGroupId); + const userIds = model.documentRoot.rootUserPermissions + .filter((p) => !NoneAccess.has(p.access)) + .map((p) => p.userId); + const sharedAccess = RO_RW_DocumentRootAccess.has(model.documentRoot.sharedAccess) ? [IoRoom.ALL] : []; + + const updated = cleanupDocument(model); + res.notifications = [ + { + event: IoEvent.CHANGED_RECORD, + message: { type: RecordType.Document, record: updated }, + to: [...groupIds, ...sharedAccess, ...userIds, IoRoom.ADMIN, (req as any).user!.id] // overlappings are handled by socket.io: https://socket.io/docs/v3/rooms/#joining-and-leaving + } + ]; + res.status(200).json(updated); +}; + const childrenSql = (parentId: string) => { return Prisma.sql` WITH RECURSIVE tree as ( diff --git a/src/models/Document.ts b/src/models/Document.ts index cdd9e85..7f62c60 100644 --- a/src/models/Document.ts +++ b/src/models/Document.ts @@ -19,6 +19,11 @@ interface DocumentWithPermission { highestPermission: Access; } +export interface UniquenessConstraints { + uniqOnRoot?: string | null; + uniqOnParent?: string | null; +} + const extractPermission = (actorId: string, document: AccessCheckableDocument): Access | null => { const hasBaseAccess = document.authorId === actorId || !NoneAccess.has(document.documentRoot.sharedAccess); @@ -272,6 +277,29 @@ function Document(db: PrismaClient['document']) { return model; }, + async updateConstraints(actor: User, id: string, update: UniquenessConstraints) { + const record = await this.findModel(actor, id); + if (!record) { + throw new HTTP404Error('Document not found'); + } + if (record.document.authorId !== actor.id && !RWAccess.has(record.highestPermission)) { + throw new HTTP403Error('Not authorized'); + } + const model = await db.update({ + where: { id: id }, + data: update, + include: { + documentRoot: { + include: { + rootGroupPermissions: { select: { access: true, studentGroupId: true } }, + rootUserPermissions: { select: { access: true, userId: true } } + } + } + } + }); + return model; + }, + async allOfDocumentRoots( actor: User, documentRootIds: string[], diff --git a/src/routes/router.ts b/src/routes/router.ts index 6c64d22..8a0ab57 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -26,7 +26,8 @@ import { destroy as deleteDocument, find as findDocument, linkTo as linkDocument, - update as updateDocument + update as updateDocument, + updateConstraints as updateDocumentConstraints } from '../controllers/documents.js'; import { create as createStudentGroupPermission, @@ -96,6 +97,7 @@ router.post('/documents', createDocument); router.post('/documents/multiple', multipleDocuments); router.get('/documents/:id', findDocument); router.put('/documents/:id', updateDocument); +router.put('/documents/:id/constraints', updateDocumentConstraints); router.put('/documents/:id/linkTo/:parentId', linkDocument); router.delete('/documents/:id', deleteDocument); From 2defc6dcccd9820d272af57de0127bbb865a83e6 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 9 Oct 2026 12:08:53 +0000 Subject: [PATCH 07/11] add tests --- src/models/Document.ts | 2 +- src/tests/integration/documents.test.ts | 235 +++++++++++++++++++++++- 2 files changed, 235 insertions(+), 2 deletions(-) diff --git a/src/models/Document.ts b/src/models/Document.ts index 7f62c60..adb5737 100644 --- a/src/models/Document.ts +++ b/src/models/Document.ts @@ -282,7 +282,7 @@ function Document(db: PrismaClient['document']) { if (!record) { throw new HTTP404Error('Document not found'); } - if (record.document.authorId !== actor.id && !RWAccess.has(record.highestPermission)) { + if (record.document.authorId !== actor.id || !RWAccess.has(record.highestPermission)) { throw new HTTP403Error('Not authorized'); } const model = await db.update({ diff --git a/src/tests/integration/documents.test.ts b/src/tests/integration/documents.test.ts index 4a35b3d..72041f9 100644 --- a/src/tests/integration/documents.test.ts +++ b/src/tests/integration/documents.test.ts @@ -1,7 +1,14 @@ import { randomUUID } from 'crypto'; -import { describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; import { Access } from '../../../prisma/generated/enums.js'; +import app from '../../app.js'; +import type { UniquenessConstraints } from '../../models/Document.js'; import { Role } from '../../models/User.js'; +import prisma from '../../prisma.js'; +import { IoEvent, RecordType } from '../../routes/socketEventTypes.js'; +import { IoRoom } from '../../routes/socketEvents.js'; +import { notify } from '../../socketIoServer.js'; import { API_URL, agentAs, createTestStudentGroup, createTestUser } from './helpers.js'; describe('Documents (integration)', () => { @@ -188,6 +195,232 @@ describe('Documents (integration)', () => { }); }); +describe('Document constraint updates', () => { + const createFixture = async (constraints: UniquenessConstraints = {}) => { + const owner = await createTestUser(); + const agent = agentAs(owner.id); + const documentRootId = randomUUID(); + const root = await agent + .post(`${API_URL}/documentRoots/${documentRootId}`) + .send({ access: Access.RW_DocumentRoot, sharedAccess: Access.RW_DocumentRoot }); + expect(root.status).toBe(201); + const parent = await agent.post(`${API_URL}/documents`).send({ + type: 'constraint-parent', + documentRootId, + data: { title: 'Parent' } + }); + expect(parent.status).toBe(201); + const created = await agent.post(`${API_URL}/documents`).send({ + type: 'constraint-test', + documentRootId, + parentId: parent.body.id, + data: { answer: 'unchanged' }, + ...constraints + }); + expect(created.status).toBe(201); + const document = await prisma.document.findUniqueOrThrow({ where: { id: created.body.id } }); + vi.mocked(notify).mockClear(); + return { + owner, + agent, + document, + endpoint: `${API_URL}/documents/${document.id}/constraints` + }; + }; + + const expectUnchanged = async (document: Awaited>['document']) => { + expect(await prisma.document.findUniqueOrThrow({ where: { id: document.id } })).toEqual(document); + expect(notify).not.toHaveBeenCalled(); + }; + + it.each([ + { name: 'root', first: { uniqOnRoot: 'main' }, replacement: { uniqOnRoot: 'new-main' } }, + { name: 'parent', first: { uniqOnParent: 'answer' }, replacement: { uniqOnParent: 'new-answer' } }, + { + name: 'both', + first: { uniqOnRoot: 'main', uniqOnParent: 'answer' }, + replacement: { uniqOnRoot: 'new-main', uniqOnParent: 'new-answer' } + } + ])( + 'sets and replaces $name constraints, returning and persisting the document', + async ({ first, replacement }) => { + const { agent, document, endpoint } = await createFixture(); + for (const data of [first, replacement]) { + const updated = await agent.put(endpoint).send({ data }); + expect(updated.status).toBe(200); + expect(updated.body).toMatchObject({ + id: document.id, + authorId: document.authorId, + type: document.type, + documentRootId: document.documentRootId, + parentId: document.parentId, + data: document.data, + createdAt: document.createdAt.toISOString(), + ...data + }); + expect(updated.body).not.toHaveProperty('documentRoot'); + expect(updated.body.updatedAt).toEqual(expect.any(String)); + const fetched = await agent.get(`${API_URL}/documents/${document.id}`); + expect(fetched.status).toBe(200); + expect(fetched.body.document).toEqual(updated.body); + expect(await prisma.document.findUniqueOrThrow({ where: { id: document.id } })).toMatchObject( + data + ); + } + } + ); + + it.each(['uniqOnRoot', 'uniqOnParent'] as const)( + 'preserves other fields when updating only %s', + async (field) => { + const { agent, document, endpoint } = await createFixture({ + uniqOnRoot: 'main', + uniqOnParent: 'answer' + }); + const updated = await agent.put(endpoint).send({ data: { [field]: 'replacement' } }); + expect(updated.status).toBe(200); + const persisted = await prisma.document.findUniqueOrThrow({ where: { id: document.id } }); + expect(persisted).toEqual({ + ...document, + [field]: 'replacement', + updatedAt: persisted.updatedAt + }); + expect(updated.body).toMatchObject({ + uniqOnRoot: persisted.uniqOnRoot, + uniqOnParent: persisted.uniqOnParent, + data: document.data, + authorId: document.authorId, + type: document.type, + documentRootId: document.documentRootId, + parentId: document.parentId + }); + } + ); + + it.each([ + { name: 'root', data: { uniqOnRoot: null }, cleared: ['uniqOnRoot'] }, + { name: 'parent', data: { uniqOnParent: null }, cleared: ['uniqOnParent'] }, + { + name: 'both', + data: { uniqOnRoot: null, uniqOnParent: null }, + cleared: ['uniqOnRoot', 'uniqOnParent'] + } + ] as const)('clears $name constraints with null', async ({ data, cleared }) => { + const { agent, document, endpoint } = await createFixture({ + uniqOnRoot: 'main', + uniqOnParent: 'answer' + }); + const updated = await agent.put(endpoint).send({ data }); + expect(updated.status).toBe(200); + const persisted = await prisma.document.findUniqueOrThrow({ where: { id: document.id } }); + expect(persisted).toEqual({ ...document, ...data, updatedAt: persisted.updatedAt }); + for (const field of cleared) { + expect(persisted[field]).toBeNull(); + expect(updated.body).not.toHaveProperty(field); + } + const fetched = await agent.get(`${API_URL}/documents/${document.id}`); + expect(fetched.status).toBe(200); + expect(fetched.body.document).toEqual(updated.body); + }); + + it.each([Role.STUDENT, Role.TEACHER, Role.ADMIN])( + 'rejects a non-author %s even with RW access', + async (role) => { + const { document, endpoint } = await createFixture({ uniqOnRoot: 'main' }); + const other = await createTestUser(role); + const agent = agentAs(other.id); + const fetched = await agent.get(`${API_URL}/documents/${document.id}`); + expect(fetched.status).toBe(200); + expect(fetched.body.highestPermission).toBe(Access.RW_DocumentRoot); + const updated = await agent.put(endpoint).send({ data: { uniqOnRoot: null } }); + expect(updated.status).toBe(403); + await expectUnchanged(document); + } + ); + + it.each([Access.RO_DocumentRoot, Access.None_DocumentRoot])( + 'rejects the author with %s access', + async (access) => { + const { agent, document, endpoint } = await createFixture({ uniqOnRoot: 'main' }); + await prisma.documentRoot.update({ where: { id: document.documentRootId }, data: { access } }); + const fetched = await agent.get(`${API_URL}/documents/${document.id}`); + expect(fetched.status).toBe(200); + expect(fetched.body.highestPermission).toBe(access); + const updated = await agent.put(endpoint).send({ data: { uniqOnRoot: null } }); + expect(updated.status).toBe(403); + await expectUnchanged(document); + } + ); + + it('returns 404 for a nonexistent document', async () => { + const owner = await createTestUser(); + vi.mocked(notify).mockClear(); + const updated = await agentAs(owner.id) + .put(`${API_URL}/documents/${randomUUID()}/constraints`) + .send({ data: { uniqOnRoot: 'main' } }); + expect(updated.status).toBe(404); + expect(notify).not.toHaveBeenCalled(); + }); + + it('returns 401 without authentication', async () => { + const { document, endpoint } = await createFixture({ uniqOnRoot: 'main' }); + const updated = await request(app) + .put(endpoint) + .send({ data: { uniqOnRoot: null } }); + expect(updated.status).toBe(401); + await expectUnchanged(document); + }); + + it.each(['uniqOnRoot', 'uniqOnParent'] as const)( + 'rejects a conflicting %s without changing either document', + async (field) => { + const { agent, document, endpoint } = await createFixture({ [field]: 'original' }); + const created = await agent.post(`${API_URL}/documents`).send({ + type: document.type, + documentRootId: document.documentRootId, + parentId: document.parentId, + data: { answer: 'other' }, + [field]: 'taken' + }); + expect(created.status).toBe(201); + const other = await prisma.document.findUniqueOrThrow({ where: { id: created.body.id } }); + vi.mocked(notify).mockClear(); + const updated = await agent.put(endpoint).send({ data: { [field]: 'taken' } }); + expect(updated.status).toBeGreaterThanOrEqual(400); + await expectUnchanged(document); + await expectUnchanged(other); + } + ); + + it('notifies clients with the cleaned document after a successful update', async () => { + const { owner, agent, document, endpoint } = await createFixture({ uniqOnRoot: 'main' }); + const updated = await agent + .put(endpoint) + .set('x-metadata-sid', 'constraint-update-socket') + .send({ data: { uniqOnRoot: null, uniqOnParent: 'answer' } }); + expect(updated.status).toBe(200); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith( + { + event: IoEvent.CHANGED_RECORD, + message: { + type: RecordType.Document, + record: { + ...updated.body, + createdAt: new Date(updated.body.createdAt), + updatedAt: new Date(updated.body.updatedAt) + } + }, + to: expect.arrayContaining([owner.id, IoRoom.ADMIN, IoRoom.ALL]) + }, + 'constraint-update-socket' + ); + expect(updated.body).not.toHaveProperty('documentRoot'); + expect(updated.body).not.toHaveProperty('uniqOnRoot'); + expect(updated.body.parentId).toBe(document.parentId); + }); +}); + describe('Document creation', () => { it('prevents creating multiple main documents for the same document root', async () => { const user = await createTestUser(Role.STUDENT); From bcd57d7ed787cccc76ef133e8810858932771df3 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 9 Oct 2026 13:20:23 +0000 Subject: [PATCH 08/11] add cleanup on create model post --- src/models/Document.ts | 144 +++++++++++++++++++++++++++++++---------- 1 file changed, 110 insertions(+), 34 deletions(-) diff --git a/src/models/Document.ts b/src/models/Document.ts index adb5737..2e666a2 100644 --- a/src/models/Document.ts +++ b/src/models/Document.ts @@ -1,5 +1,5 @@ import { type JsonObject } from '@prisma/client/runtime/client'; -import { Access, Document as DbDocument, PrismaClient, User } from '../../prisma/generated/client.js'; +import { Access, Document as DbDocument, Prisma, PrismaClient, User } from '../../prisma/generated/client.js'; import { highestAccess, NoneAccess, RWAccess } from '../helpers/accessPolicy.js'; import prisma from '../prisma.js'; import { HTTP403Error, HTTP404Error } from '../utils/errors/Errors.js'; @@ -150,45 +150,121 @@ function Document(db: PrismaClient['document']) { throw new HTTP403Error('Insufficient access permission'); } } - /** - * Since it is easyier to check wheter a user has permissions to create a model - * when the model actually exists, we create the model first and then check the permissions. - */ - const model = await db - .create({ - data: { - type: type, - documentRootId: documentRootId, - data: data, - parentId: parentId, - authorId: authorId, - uniqOnRoot: uniqOnRoot, - uniqOnParent: uniqOnParent - }, - include: { - documentRoot: { - include: { - rootGroupPermissions: { - where: { studentGroup: { users: { some: { userId: authorId } } } } + const model: + | { exists: true } + | { + exists: false; + model: { + document: ApiDocument; + highestPermission: Access; + }; + } = await prisma.$transaction(async (tx) => { + // TODO: Remove this once the unique main check is distributed and applied on the database level. + if (uniqOnRoot === 'main') { + const mainDocs = await tx.document.findMany({ + where: { + documentRootId: documentRootId, + authorId: authorId, + OR: [{ uniqOnRoot: 'main' }, { uniqOnRoot: null }], + parentId: null, + type: type + }, + orderBy: { createdAt: 'asc' } + }); + if (mainDocs.length > 0) { + const enforcedMain = mainDocs.find((doc) => doc.uniqOnRoot === 'main') ?? mainDocs[0]; + // heuristic: use the doc with the biggest data size and delete the rest + let data = enforcedMain.data; + for (const doc of mainDocs) { + if (doc.data && JSON.stringify(doc.data).length > JSON.stringify(data).length) { + data = doc.data; + } + } + if (data !== enforcedMain.data || enforcedMain.uniqOnRoot !== 'main') { + await tx.document.update({ + where: { id: enforcedMain.id }, + data: { + data: data!, + uniqOnRoot: 'main' + } + }); + } + if (mainDocs.length > 1) { + // attach all others mainDocs children to the enforced main document and delete the rest + await tx.document.updateMany({ + where: { + parentId: { + in: mainDocs + .map((doc) => doc.id) + .filter((id) => id !== enforcedMain.id) + }, + uniqOnParent: null // delete children having a uniqOnParent constraint - we don't want to risk overwriting a child with a unique constraint }, - rootUserPermissions: { where: { user: { id: authorId } } } + data: { parentId: enforcedMain.id } + }); + await tx.document.deleteMany({ + where: { + id: { + in: mainDocs + .filter((doc) => doc.id !== enforcedMain.id) + .map((doc) => doc.id) + } + } + }); + } + return { exists: true }; + } + } + /** + * Since it is easyier to check wheter a user has permissions to create a model + * when the model actually exists, we create the model first and then check the permissions. + */ + const model = await tx.document + .create({ + data: { + type: type, + documentRootId: documentRootId, + data: data, + parentId: parentId, + authorId: authorId, + uniqOnRoot: uniqOnRoot, + uniqOnParent: uniqOnParent + }, + include: { + documentRoot: { + include: { + rootGroupPermissions: { + where: { studentGroup: { users: { some: { userId: authorId } } } } + }, + rootUserPermissions: { where: { user: { id: authorId } } } + } } } + }) + .then((doc) => prepareDocument(authorId, doc)!); + /** + * Check if the user has the required permissions to create the model. + * If not, delete the model and throw an error. + */ + const canCreate = RWAccess.has(model.highestPermission); + if (!canCreate && !onBehalfOf) { + Logger.info(`❌ New Model [${model.document.id}]: ${model.highestPermission}`); + throw new HTTP403Error('Insufficient access permission'); + } + return { exists: false, model: model }; + }); + if (model.exists) { + // throw new prisma P2002 error to indicate that the model already exists and was not created + throw new Prisma.PrismaClientKnownRequestError('Unique constraint failed on main document', { + code: 'P2002', + clientVersion: Prisma.prismaVersion.client, + meta: { + target: ['uniqOnRoot'] } - }) - .then((doc) => prepareDocument(authorId, doc)!); - /** - * Check if the user has the required permissions to create the model. - * If not, delete the model and throw an error. - */ - const canCreate = RWAccess.has(model.highestPermission); - if (!canCreate && !onBehalfOf) { - Logger.info(`❌ New Model [${model.document.id}]: ${model.highestPermission}`); - db.delete({ where: { id: model.document.id } }); - throw new HTTP403Error('Insufficient access permission'); + }); } return { - model: model.document, + model: model.model.document, permissions: { access: documentRoot.access, sharedAccess: documentRoot.sharedAccess, From 52cbfe803bf7cd2a8577ae14ebbd2770344bb90f Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 9 Oct 2026 15:35:50 +0000 Subject: [PATCH 09/11] add document main cleanup logic and tests for legacy document handling --- src/models/Document.ts | 36 ++- src/tests/integration/documents.test.ts | 329 +++++++++++++++++++++++- 2 files changed, 353 insertions(+), 12 deletions(-) diff --git a/src/models/Document.ts b/src/models/Document.ts index 2e666a2..c293c45 100644 --- a/src/models/Document.ts +++ b/src/models/Document.ts @@ -171,8 +171,24 @@ function Document(db: PrismaClient['document']) { }, orderBy: { createdAt: 'asc' } }); - if (mainDocs.length > 0) { - const enforcedMain = mainDocs.find((doc) => doc.uniqOnRoot === 'main') ?? mainDocs[0]; + if (mainDocs.length > 0 && mainDocs.every((doc) => !doc.uniqOnRoot)) { + const enforcedMain = mainDocs[0]; + const cleanupRoot = await tx.documentRoot.findUniqueOrThrow({ + where: { id: documentRootId }, + include: { + rootGroupPermissions: { + where: { studentGroup: { users: { some: { userId: actor.id } } } } + }, + rootUserPermissions: { where: { userId: actor.id } } + } + }); + const permission = extractPermission(actor.id, { + ...enforcedMain, + documentRoot: cleanupRoot + }); + if (!permission || !RWAccess.has(permission)) { + throw new HTTP403Error('Insufficient access permission'); + } // heuristic: use the doc with the biggest data size and delete the rest let data = enforcedMain.data; for (const doc of mainDocs) { @@ -180,15 +196,13 @@ function Document(db: PrismaClient['document']) { data = doc.data; } } - if (data !== enforcedMain.data || enforcedMain.uniqOnRoot !== 'main') { - await tx.document.update({ - where: { id: enforcedMain.id }, - data: { - data: data!, - uniqOnRoot: 'main' - } - }); - } + await tx.document.update({ + where: { id: enforcedMain.id }, + data: { + data: data!, + uniqOnRoot: 'main' + } + }); if (mainDocs.length > 1) { // attach all others mainDocs children to the enforced main document and delete the rest await tx.document.updateMany({ diff --git a/src/tests/integration/documents.test.ts b/src/tests/integration/documents.test.ts index 72041f9..6cb8a92 100644 --- a/src/tests/integration/documents.test.ts +++ b/src/tests/integration/documents.test.ts @@ -1,14 +1,16 @@ import { randomUUID } from 'crypto'; import request from 'supertest'; import { describe, expect, it, vi } from 'vitest'; +import { Prisma } from '../../../prisma/generated/client.js'; import { Access } from '../../../prisma/generated/enums.js'; import app from '../../app.js'; -import type { UniquenessConstraints } from '../../models/Document.js'; +import Document, { type UniquenessConstraints } from '../../models/Document.js'; import { Role } from '../../models/User.js'; import prisma from '../../prisma.js'; import { IoEvent, RecordType } from '../../routes/socketEventTypes.js'; import { IoRoom } from '../../routes/socketEvents.js'; import { notify } from '../../socketIoServer.js'; +import { HTTP403Error } from '../../utils/errors/Errors.js'; import { API_URL, agentAs, createTestStudentGroup, createTestUser } from './helpers.js'; describe('Documents (integration)', () => { @@ -421,6 +423,331 @@ describe('Document constraint updates', () => { }); }); +describe('Document main cleanup', () => { + const createFixture = async () => { + const owner = await createTestUser(); + const root = await prisma.documentRoot.create({ data: { id: randomUUID() } }); + const type = 'cleanup-test'; + // Seed legacy rows directly so fixture creation does not trigger cleanup. + const createDocument = (overrides: Partial = {}) => + prisma.document.create({ + data: { + authorId: owner.id, + documentRootId: root.id, + type, + data: { text: 'short' }, + ...overrides + } + }); + const requestMain = () => + agentAs(owner.id) + .post(`${API_URL}/documents`) + .send({ + documentRootId: root.id, + type, + uniqOnRoot: 'main', + data: { text: 'new request data must not replace existing data' } + }); + return { owner, root, type, createDocument, requestMain }; + }; + + it.each([ + { name: 'object', data: { text: 'legacy' }, expected: { text: 'legacy' } }, + { name: 'JSON null', data: Prisma.JsonNull, expected: null } + ])( + 'promotes a single legacy document with $name data and commits before P2002', + async ({ data, expected }) => { + const { owner, root, type, createDocument } = await createFixture(); + const legacy = await createDocument({ data }); + + await expect( + Document.createModel(owner, type, root.id, { text: 'new' }, undefined, 'main') + ).rejects.toMatchObject({ + code: 'P2002' + }); + + expect(await prisma.document.findMany({ where: { documentRootId: root.id } })).toEqual([ + expect.objectContaining({ + id: legacy.id, + data: expected, + uniqOnRoot: 'main', + createdAt: legacy.createdAt + }) + ]); + } + ); + + it('retains the oldest legacy ID, selects the largest payload, and returns it without creating a row', async () => { + const { root, createDocument, requestMain } = await createFixture(); + const oldest = await createDocument({ createdAt: new Date('2020-01-01') }); + const largestData = { text: 'the longest saved document content' }; + await createDocument({ data: largestData, createdAt: new Date('2020-01-02') }); + await createDocument({ data: { text: 'medium' }, createdAt: new Date('2020-01-03') }); + + const response = await requestMain(); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: oldest.id, data: largestData, uniqOnRoot: 'main' }); + expect(await prisma.document.findMany({ where: { documentRootId: root.id } })).toEqual([ + expect.objectContaining({ id: oldest.id, data: largestData, uniqOnRoot: 'main' }) + ]); + }); + + it.each(['2020-01-01', '2020-01-03', '2020-01-05'])( + 'leaves all documents unchanged when a main exists with creation date %s', + async (mainCreatedAt) => { + const { root, createDocument, requestMain } = await createFixture(); + const legacy = await createDocument({ + data: { text: 'the longest saved document content' }, + createdAt: new Date('2020-01-02') + }); + const main = await createDocument({ uniqOnRoot: 'main', createdAt: new Date(mainCreatedAt) }); + await createDocument({ createdAt: new Date('2020-01-04') }); + await createDocument({ parentId: main.id, type: 'child', uniqOnParent: 'q1' }); + await createDocument({ parentId: legacy.id, type: 'child', uniqOnParent: 'q1' }); + const child = await createDocument({ parentId: legacy.id, type: 'child' }); + await createDocument({ parentId: child.id, type: 'grandchild' }); + const query = { where: { documentRootId: root.id }, orderBy: { id: 'asc' as const } }; + const before = await prisma.document.findMany(query); + + const response = await requestMain(); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: main.id, data: main.data, uniqOnRoot: 'main' }); + expect(await prisma.document.findMany(query)).toEqual(before); + } + ); + + it('moves unconstrained children and preserves their descendants while deleting constrained children', async () => { + const { createDocument, requestMain } = await createFixture(); + const main = await createDocument({ createdAt: new Date('2020-01-01') }); + const duplicate = await createDocument({ createdAt: new Date('2020-01-02') }); + const child = await createDocument({ parentId: duplicate.id, type: 'child' }); + const grandchild = await createDocument({ + parentId: child.id, + type: 'grandchild', + uniqOnParent: 'answer' + }); + const retainedChild = await createDocument({ parentId: main.id, type: 'child', uniqOnParent: 'q1' }); + const conflictingChild = await createDocument({ + parentId: duplicate.id, + type: 'child', + uniqOnParent: 'q1' + }); + const distinctConstrainedChild = await createDocument({ + parentId: duplicate.id, + type: 'child', + uniqOnParent: 'q2' + }); + const deletedGrandchild = await createDocument({ parentId: conflictingChild.id, type: 'grandchild' }); + + const response = await requestMain(); + + expect(response.status).toBe(200); + expect(response.body.id).toBe(main.id); + expect(await prisma.document.findUniqueOrThrow({ where: { id: child.id } })).toMatchObject({ + id: child.id, + parentId: main.id, + data: child.data, + uniqOnParent: null + }); + expect(await prisma.document.findUniqueOrThrow({ where: { id: grandchild.id } })).toEqual(grandchild); + expect(await prisma.document.findUniqueOrThrow({ where: { id: retainedChild.id } })).toEqual( + retainedChild + ); + expect( + await prisma.document.findMany({ + where: { + id: { + in: [ + duplicate.id, + conflictingChild.id, + distinctConstrainedChild.id, + deletedGrandchild.id + ] + } + } + }) + ).toEqual([]); + }); + + it.each([ + { name: 'read-only root', rootAccess: Access.RO_DocumentRoot, allowed: false }, + { name: 'no root access', rootAccess: Access.None_DocumentRoot, allowed: false }, + { + name: 'read-only user override', + rootAccess: Access.RW_DocumentRoot, + userAccess: Access.RO_User, + allowed: false + }, + { + name: 'no-access user override', + rootAccess: Access.RW_DocumentRoot, + userAccess: Access.None_User, + allowed: false + }, + { + name: 'read-only group override', + rootAccess: Access.RW_DocumentRoot, + groupAccess: Access.RO_StudentGroup, + allowed: false + }, + { + name: 'no-access group override', + rootAccess: Access.RW_DocumentRoot, + groupAccess: Access.None_StudentGroup, + allowed: false + }, + { + name: 'write user grant', + rootAccess: Access.RO_DocumentRoot, + userAccess: Access.RW_User, + allowed: true + }, + { + name: 'write group grant', + rootAccess: Access.RO_DocumentRoot, + groupAccess: Access.RW_StudentGroup, + allowed: true + }, + { + name: 'user restriction over write group grant', + rootAccess: Access.RO_DocumentRoot, + groupAccess: Access.RW_StudentGroup, + userAccess: Access.RO_User, + allowed: false + } + ])( + 'requires effective RW permission for cleanup: $name', + async ({ rootAccess, userAccess, groupAccess, allowed }) => { + const { owner, root, createDocument, requestMain } = await createFixture(); + const oldest = await createDocument({ createdAt: new Date('2020-01-01') }); + const largestData = { text: 'the longest saved document content' }; + const duplicate = await createDocument({ data: largestData }); + const child = await createDocument({ parentId: duplicate.id, type: 'child' }); + const constrainedChild = await createDocument({ + parentId: duplicate.id, + type: 'child', + uniqOnParent: 'q1' + }); + await createDocument({ parentId: constrainedChild.id, type: 'grandchild' }); + await prisma.documentRoot.update({ where: { id: root.id }, data: { access: rootAccess } }); + if (userAccess) { + await prisma.rootUserPermission.create({ + data: { documentRootId: root.id, userId: owner.id, access: userAccess } + }); + } + if (groupAccess) { + const group = await createTestStudentGroup('Cleanup permissions', [], [owner.id]); + await prisma.rootGroupPermission.create({ + data: { documentRootId: root.id, studentGroupId: group.id, access: groupAccess } + }); + } + const query = { where: { documentRootId: root.id }, orderBy: { id: 'asc' as const } }; + const before = await prisma.document.findMany(query); + + const response = await requestMain(); + + if (!allowed) { + expect(response.status).toBe(403); + expect(await prisma.document.findMany(query)).toEqual(before); + return; + } + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: oldest.id, data: largestData, uniqOnRoot: 'main' }); + expect(await prisma.document.findMany(query)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: oldest.id, data: largestData, uniqOnRoot: 'main' }), + expect.objectContaining({ id: child.id, parentId: oldest.id }) + ]) + ); + expect(await prisma.document.count({ where: { documentRootId: root.id } })).toBe(2); + } + ); + + it('returns an existing main without changing documents when the author has read-only access', async () => { + const { root, createDocument, requestMain } = await createFixture(); + const main = await createDocument({ uniqOnRoot: 'main' }); + await createDocument({ data: { text: 'a larger legacy payload' } }); + await prisma.documentRoot.update({ + where: { id: root.id }, + data: { access: Access.RO_DocumentRoot } + }); + const query = { where: { documentRootId: root.id }, orderBy: { id: 'asc' as const } }; + const before = await prisma.document.findMany(query); + + const response = await requestMain(); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: main.id, data: main.data }); + expect(await prisma.document.findMany(query)).toEqual(before); + }); + + it.each([ + { sharedAccess: Access.None_DocumentRoot, allowed: false }, + { sharedAccess: Access.RO_DocumentRoot, allowed: false }, + { sharedAccess: Access.RW_DocumentRoot, allowed: true } + ])( + 'requires RW access for on-behalf-of cleanup with $sharedAccess', + async ({ sharedAccess, allowed }) => { + const { owner, root, type, createDocument } = await createFixture(); + const admin = await createTestUser(Role.ADMIN); + const oldest = await createDocument({ createdAt: new Date('2020-01-01') }); + const largestData = { text: 'the longest saved document content' }; + await createDocument({ data: largestData }); + await prisma.documentRoot.update({ where: { id: root.id }, data: { sharedAccess } }); + const query = { where: { documentRootId: root.id }, orderBy: { id: 'asc' as const } }; + const before = await prisma.document.findMany(query); + + const cleanup = Document.createModel( + admin, + type, + root.id, + { text: 'new' }, + undefined, + 'main', + undefined, + owner.id + ); + + if (!allowed) { + await expect(cleanup).rejects.toBeInstanceOf(HTTP403Error); + expect(await prisma.document.findMany(query)).toEqual(before); + return; + } + await expect(cleanup).rejects.toMatchObject({ code: 'P2002' }); + expect(await prisma.document.findMany(query)).toEqual([ + expect.objectContaining({ + id: oldest.id, + authorId: owner.id, + data: largestData, + uniqOnRoot: 'main' + }) + ]); + } + ); + + it('does not clean up legacy documents when creating a different uniqueness key', async () => { + const { owner, root, type, createDocument } = await createFixture(); + const legacy = [await createDocument(), await createDocument()]; + + const result = await Document.createModel( + owner, + type, + root.id, + { text: 'secondary' }, + undefined, + 'secondary' + ); + + expect(result.model).toMatchObject({ uniqOnRoot: 'secondary', data: { text: 'secondary' } }); + expect(await prisma.document.count({ where: { documentRootId: root.id } })).toBe(3); + for (const document of legacy) { + expect(await prisma.document.findUniqueOrThrow({ where: { id: document.id } })).toEqual(document); + } + }); +}); + describe('Document creation', () => { it('prevents creating multiple main documents for the same document root', async () => { const user = await createTestUser(Role.STUDENT); From 7ac0014718afc2f06600ea75a4e43c9a9780a440 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sat, 10 Oct 2026 15:37:47 +0000 Subject: [PATCH 10/11] add admin user group handling and permission checks in DocumentRoot --- src/models/DocumentRoot.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index b55c5f7..7058afe 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -63,6 +63,10 @@ const prepareUserPermission = (permission: Omit { const groupPermissions = config.groupPermissions || []; const access = asDocumentRootAccess(config.access); + if (!ADMIN_USER_GROUP.checked) { + await this._checkAdminUserGroup(); + } if ( access !== Access.RW_DocumentRoot && - ADMIN_USER_GROUP_ID && - !groupPermissions.some((gp) => gp.groupId === ADMIN_USER_GROUP_ID) + ADMIN_USER_GROUP.id && + !groupPermissions.some((gp) => gp.groupId === ADMIN_USER_GROUP.id) ) { - groupPermissions.push({ groupId: ADMIN_USER_GROUP_ID, access: Access.RW_DocumentRoot }); + groupPermissions.push({ groupId: ADMIN_USER_GROUP.id, access: Access.RW_DocumentRoot }); } const model = await db.create({ data: { @@ -260,6 +267,18 @@ function DocumentRoot(db: PrismaClient['documentRoot']) { } }); return model; + }, + async _checkAdminUserGroup(): Promise { + if (!ADMIN_USER_GROUP_ID || ADMIN_USER_GROUP.checked) { + return Promise.resolve(false); + } + const group = await prisma.studentGroup.findUnique({ + where: { id: ADMIN_USER_GROUP_ID }, + select: { id: true } + }); + ADMIN_USER_GROUP.checked = true; + ADMIN_USER_GROUP.id = group?.id || null; + return !!group; } }); } From fae6604fb797015a23f1ef4beca6027a94d85774 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sun, 11 Oct 2026 15:14:51 +0000 Subject: [PATCH 11/11] update changelog --- CHANGELOG.md | 177 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 166 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fab86..72c561e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,19 +3,174 @@ Introduced new columns `uniq_on_parent_constraint` and `uniq_on_root_constraint` to the `documents` table to enforce client specified uniqueness constraints. -The `uniq_on_parent_constraint` is possible to fail, so the migration was not applied by default. Do it by hand: + +### Special document types needing manual migration + +#### Assessable documents + +The `uniq_on_parent_constraint` ensures a quiz only ever has one uniq `qid` per user. The migration must be done manually: + +1. Check whether there are duplicates: + + ```sql + SELECT + COUNT(data->>'qid') AS duplication_count, + ARRAY_AGG(id) AS ids, + data->>'qid' AS qid, + author_id, + document_root_id, + parent_id + FROM documents + WHERE data->>'qid' IS NOT NULL + GROUP BY data->>'qid', author_id, document_root_id, parent_id + HAVING COUNT(data->>'qid') > 1 + ORDER BY duplication_count DESC; + ``` +2. Handle duplicates: proposed is the solution to only keep the most recent document and delete the others: + ```sql + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY data->>'qid', author_id, document_root_id, parent_id + ORDER BY created_at DESC NULLS LAST, id DESC + ) AS rn + FROM documents + WHERE data->>'qid' IS NOT NULL + ) + DELETE FROM documents d + USING ranked r + WHERE d.id = r.id + AND r.rn > 1 + RETURNING d.*; + ``` +3. Update the `uniq_on_parent_constraint` for all assessable documents that have a `qid`: + ```sql + UPDATE documents + SET uniq_on_parent_constraint = data->>'qid' + WHERE parent_id is not null and data->>'qid' is not null; + ``` + +#### children of `mdx_comment` + +Per `mdx_comment` exists one uniq `quill_v2` child: add the constraint: + +```sql +UPDATE documents +SET uniq_on_parent_constraint = 'comment' +WHERE type='quill_v2' and parent_id in (select id from documents where type='mdx_comment'); +``` + +#### children of `file` documents + +Per `file` document exists one uniq `file` child: add the constraint: + +```sql +UPDATE documents +SET uniq_on_parent_constraint = 'file' +WHERE parent_id in (select id from documents where type='file'); +``` + +#### `cms_text` documents + +Check wheter there are multiple `cms_text`s per user linked to the same document root: + +```sql +select document_root_id, author_id, count(id), array_agg(id) +from documents +where type='cms_text' +group by document_root_id, author_id +order by count(id) desc +``` + +If so, keep only the most recent one and delete the others, before you add the `uniq_on_root_constraint` to the remaining `cms_text` documents. + +```sql +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY document_root_id, author_id + ORDER BY created_at DESC NULLS LAST, id DESC + ) AS rn + FROM documents + WHERE type = 'cms_text' +) +DELETE FROM documents d +USING ranked r +WHERE d.id = r.id + AND r.rn > 1 +RETURNING d.*; +``` + +and finally, update the `uniq_on_root_constraint` for the remaining `cms_text` documents: + ```sql UPDATE documents -SET uniq_on_parent_constraint = data->>'qid' -WHERE parent_id is not null and data->>'qid' is not null; +SET uniq_on_root_constraint = 'main' +WHERE type='cms_text'; ``` -and find duplicates and handle them by hand: + +#### `dir` documents + +Check wheter there are multiple `dir`s per user linked to the same document root. Make sure to check that the `parent_id` is null, as we only want to enforce uniqueness on the root `dir` documents. ```sql -select count(data->>'qid'), array_agg(id) as ids, data->>'qid', author_id, document_root_id, parent_id - from documents - where data->>'qid' is not null - group by data->>'qid', author_id, document_root_id, parent_id - order by count(data->>'qid') desc - limit 100; -``` \ No newline at end of file +SELECT document_root_id, author_id, count(id), array_agg(id) +FROM documents +WHERE type='dir' AND parent_id is null +GROUP BY document_root_id, author_id +ORDER BY count(id) DESC; +``` + +if there are no duplicates, proceed with step 3 directly. + +1. Check for duplicates explicitely: + ```sql + WITH root_ids AS ( + SELECT unnest(array_agg(id)) AS id + FROM documents + WHERE type = 'dir' + AND parent_id IS NULL + GROUP BY document_root_id, author_id + HAVING COUNT(id) > 1 + ) + SELECT + r.id, + COUNT(d.id) AS document_count + FROM root_ids r + LEFT JOIN documents d ON d.parent_id = r.id + GROUP BY r.id + ORDER BY document_count DESC; + ``` +2. delete every duplicated `dir` document, where the document_count is 0 (meaning we don't have to manually decide which one to keep - in case you have multiple `dir` documents with children, you will have to manually decide which one to keep): + + ```sql + WITH root_ids AS ( + SELECT unnest(array_agg(id)) AS id + FROM documents + WHERE type = 'dir' + AND parent_id IS NULL + GROUP BY document_root_id, author_id + HAVING COUNT(id) > 1 + ), + empty_roots AS ( + SELECT r.id + FROM root_ids r + LEFT JOIN documents d ON d.parent_id = r.id + GROUP BY r.id + HAVING COUNT(d.id) = 0 + ) + DELETE FROM documents + WHERE id IN (SELECT id FROM empty_roots) + RETURNING *; + ); + ``` + +3. Finally, update the `uniq_on_root_constraint` for the remaining root documents: + + ```sql + UPDATE documents + SET uniq_on_root_constraint = 'main' + WHERE type='dir' AND parent_id is null; + ```