Skip to content
176 changes: 176 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# 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.


### 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_root_constraint = 'main'
WHERE type='cms_text';
```

#### `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 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;
```
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
-- 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,
'documentRootId', d.document_root_id,
'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
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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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'
)
)
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
}
Expand Down
13 changes: 12 additions & 1 deletion prisma/view-migrations/views/view__users_documents.sql
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +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,
'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
Expand Down
Loading
Loading