From b8a4b96c83510893e33d3adf52aedb9fa8f4baf0 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:56:58 +0300 Subject: [PATCH 1/7] Add documents table with per-user slugs and visibility - Create documents and document_tags tables - Add per-user unique slug index (scoped by user_id) - Add visibility column with public/protected/private check constraint Signed-off-by: Alexander Bokovoy --- .../20260824153200_documents_and_tags.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 migrations/20260824153200_documents_and_tags.ts diff --git a/migrations/20260824153200_documents_and_tags.ts b/migrations/20260824153200_documents_and_tags.ts new file mode 100644 index 00000000..301fb41d --- /dev/null +++ b/migrations/20260824153200_documents_and_tags.ts @@ -0,0 +1,59 @@ +import { type Knex } from 'knex'; + +export const up = (knex: Knex) => + knex.schema.raw(`do $$begin + + create table documents ( + id integer generated by default as identity primary key, + uid uuid default gen_random_uuid() not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + user_id uuid not null, + title text not null default '', + slug text not null default '', + body text not null default '', + parent_id uuid references documents(uid) on delete set null, + is_published boolean not null default true, + visibility text not null default 'public' + check (visibility in ('public', 'protected', 'private')) + ); + + create unique index documents_uid_idx on documents (uid); + create index documents_user_id_idx on documents (user_id); + create index documents_parent_id_idx on documents (parent_id); + + -- Per-user unique slug (empty = draft, not constrained) + create unique index documents_user_slug_idx on documents (user_id, slug) + where slug <> ''; + + create table document_tags ( + document_id uuid not null references documents(uid) on delete cascade, + tag text not null, + primary key (document_id, tag) + ); + + create index document_tags_tag_idx on document_tags (tag); + + -- Auto-update updated_at on document change + create function trg_documents_updated_at() returns trigger as + $BODY$ + begin + new.updated_at = now(); + return new; + end; + $BODY$ + language plpgsql; + + create trigger trg_documents_updated_at + before update on documents + for each row execute function trg_documents_updated_at(); + + end$$`); + +export const down = (knex: Knex) => + knex.schema.raw(`do $$begin + drop trigger trg_documents_updated_at on documents; + drop function trg_documents_updated_at(); + drop table document_tags; + drop table documents; + end$$`); \ No newline at end of file From 8c141b400202bd45a2b8247d4f182df6ba8000f4 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:57:10 +0300 Subject: [PATCH 2/7] Add Document model, DbAdapter trait, and serializer - Document model with CRUD, tag management, hierarchy, and slug generation - DbAdapter documents trait with scoped queries (per-user slugs, tags, tree) - Serializer with visibility, username, and documentsUrl fields Signed-off-by: Alexander Bokovoy --- app/models/document.js | 202 +++++++++++++++++++++++++ app/serializers/v2/document.ts | 46 ++++++ app/support/DbAdapter/documents.js | 235 +++++++++++++++++++++++++++++ 3 files changed, 483 insertions(+) create mode 100644 app/models/document.js create mode 100644 app/serializers/v2/document.ts create mode 100644 app/support/DbAdapter/documents.js diff --git a/app/models/document.js b/app/models/document.js new file mode 100644 index 00000000..0193416f --- /dev/null +++ b/app/models/document.js @@ -0,0 +1,202 @@ +export function addModel(dbAdapter) { + return class Document { + constructor(params) { + this.id = params.id; + this.userId = params.userId; + this.title = params.title || ''; + this.slug = params.slug || ''; + this.body = params.body || ''; + this.parentId = params.parentId || null; + this.isPublished = params.isPublished !== false; + this.visibility = params.visibility || 'public'; + this.tags = params.tags || []; + this.createdAt = params.createdAt; + this.updatedAt = params.updatedAt; + } + + get url() { + return `/docs/${this.slug}`; + } + + static async create(params, user) { + const slug = params.slug || Document.slugify(params.title); + const uniqueSlug = await dbAdapter.ensureUniqueDocumentSlug(slug, user.id); + + const visibility = ['public', 'protected', 'private'].includes(params.visibility) ? params.visibility : 'public'; + + const id = await dbAdapter.createDocument({ + userId: user.id, + title: params.title || '', + slug: uniqueSlug, + body: params.body || '', + parentId: params.parentId || null, + isPublished: params.isPublished !== false, + visibility, + }); + + const object = await dbAdapter.getDocumentById(id); + + if (params.tags && params.tags.length > 0) { + const tagPromises = params.tags.map((tag) => dbAdapter.addDocumentTag(id, tag)); + await Promise.all(tagPromises); + object.tags = [...params.tags]; + } + + return object; + } + + async update(params) { + const updateData = {}; + + if (params.title !== undefined) { + updateData.title = params.title; + } + + if (params.slug !== undefined) { + const slug = Document.slugify(params.slug); + const existing = await dbAdapter.getDocumentByUserAndSlug(this.userId, slug); + + if (existing && existing.id !== this.id) { + throw Object.assign(new Error(`Slug "${slug}" is already taken`), { status: 409 }); + } + + updateData.slug = slug; + } + if (params.body !== undefined) { + updateData.body = params.body; + } + + if (params.parentId !== undefined) { + updateData.parentId = params.parentId; + } + if (params.isPublished !== undefined) { + updateData.isPublished = params.isPublished; + } + + if (params.visibility !== undefined && ['public', 'protected', 'private'].includes(params.visibility)) { + updateData.visibility = params.visibility; + } + if (Object.keys(updateData).length > 0) { + const updated = await dbAdapter.updateDocument(this.id, updateData); + Object.assign(this, updated); + } + + // Update tags if provided (replace all) + if (params.tags !== undefined) { + const removePromises = this.tags.map((tag) => dbAdapter.removeDocumentTag(this.id, tag)); + const addPromises = params.tags.map((tag) => dbAdapter.addDocumentTag(this.id, tag)); + await Promise.all([...removePromises, ...addPromises]); + this.tags = [...params.tags]; + } + + return this; + } + + async addTag(tag) { + await dbAdapter.addDocumentTag(this.id, tag); + + if (!this.tags.includes(tag)) { + this.tags.push(tag); + } + } + + async removeTag(tag) { + await dbAdapter.removeDocumentTag(this.id, tag); + this.tags = this.tags.filter((t) => t !== tag); + } + + async destroy() { + await dbAdapter.deleteDocument(this.id); + } + + + + async getChildren() { + return await dbAdapter.getDocumentChildren(this.id); + } + + async getParent() { + if (!this.parentId) { + return null; + } + + return await dbAdapter.getDocumentById(this.parentId); + } + + static slugify(text) { + if (!text) return 'untitled'; + + // Extended transliteration: Cyrillic (ru/uk/be), Arabic/Farsi, Turkish, Hebrew + const MAP = { + // Cyrillic basic + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch', + 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'a', 'Б': 'b', 'В': 'v', 'Г': 'g', 'Д': 'd', 'Е': 'e', 'Ё': 'e', + 'Ж': 'zh', 'З': 'z', 'И': 'i', 'Й': 'y', 'К': 'k', 'Л': 'l', 'М': 'm', + 'Н': 'n', 'О': 'o', 'П': 'p', 'Р': 'r', 'С': 's', 'Т': 't', 'У': 'u', + 'Ф': 'f', 'Х': 'kh', 'Ц': 'ts', 'Ч': 'ch', 'Ш': 'sh', 'Щ': 'shch', + 'Ъ': '', 'Ы': 'y', 'Ь': '', 'Э': 'e', 'Ю': 'yu', 'Я': 'ya', + + // Ukrainian & Belarusian (within \u0400-\u04FF) + 'ґ': 'g', 'є': 'ye', 'і': 'i', 'ї': 'yi', 'ў': 'u', + 'Ґ': 'g', 'Є': 'ye', 'І': 'i', 'Ї': 'yi', 'Ў': 'u', + + // Turkish + 'ğ': 'g', 'ü': 'u', 'ş': 's', 'ı': 'i', 'ö': 'o', 'ç': 'c', + 'Ğ': 'g', 'Ü': 'u', 'Ş': 's', 'İ': 'i', 'Ö': 'o', 'Ç': 'c', + + // Arabic / Farsi + 'ا': 'a', 'أ': 'a', 'إ': 'a', 'آ': 'a', 'ب': 'b', 'پ': 'p', + 'ت': 't', 'ث': 's', 'ج': 'j', 'چ': 'ch', 'ح': 'h', 'خ': 'kh', + 'د': 'd', 'ذ': 'z', 'ر': 'r', 'ز': 'z', 'ژ': 'zh', 'س': 's', + 'ش': 'sh', 'ص': 's', 'ض': 'z', 'ط': 't', 'ظ': 'z', 'ع': 'a', + 'غ': 'gh', 'ف': 'f', 'ق': 'gh', 'ک': 'k', 'گ': 'g', 'ل': 'l', + 'م': 'm', 'ن': 'n', 'و': 'v', 'ه': 'h', 'ة': 'h', 'ی': 'y', + 'ي': 'y', 'ئ': 'a', 'ء': '', 'ك': 'k', + + // Hebrew + 'א': 'a', 'ב': 'b', 'ג': 'g', 'ד': 'd', 'ה': 'h', 'ו': 'v', + 'ז': 'z', 'ח': 'kh', 'ט': 't', 'י': 'y', 'ך': 'kh', 'כ': 'kh', + 'ל': 'l', 'ם': 'm', 'מ': 'm', 'ן': 'n', 'נ': 'n', 'ס': 's', + 'ע': 'a', 'ף': 'f', 'פ': 'f', 'ץ': 'ts', 'צ': 'ts', 'ק': 'k', + 'ר': 'r', 'ש': 'sh', 'ת': 't', + }; + + // Also normalize Latin-extended chars (Turkish, German etc) to ASCII + const LATIN_MAP = { + 'ü': 'u', 'Ü': 'u', 'ş': 's', 'Ş': 's', 'ğ': 'g', 'Ğ': 'g', + 'ı': 'i', 'İ': 'i', 'ö': 'o', 'Ö': 'o', 'ç': 'c', 'Ç': 'c', + 'ä': 'a', 'Ä': 'a', 'ë': 'e', 'Ë': 'e', 'ï': 'i', 'Ï': 'i', + 'ñ': 'n', 'Ñ': 'n', 'é': 'e', 'É': 'e', 'è': 'e', 'È': 'e', + 'ê': 'e', 'Ê': 'e', 'â': 'a', 'Â': 'a', 'ô': 'o', 'Ô': 'o', + 'û': 'u', 'Û': 'u', + }; + const latinRe = /[\x80-\xFF]/g; + const step1 = text.replace(latinRe, (c) => LATIN_MAP[c] || c); + + const re = new RegExp('[' + + '\u0400-\u04FF' + // Cyrillic + '\u0500-\u052F' + // Cyrillic Supplement + '\u0590-\u05FF' + // Hebrew + '\u0600-\u06FF' + // Arabic + '\u0750-\u077F' + // Arabic Supplement + '\u08A0-\u08FF' + // Arabic Extended-A + ']', 'g'); + + const transliterated = step1.replace(re, (c) => MAP[c] || ''); + + return ( + transliterated + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-+/g, '-') + .substring(0, 200) || 'untitled' + ); + } + }; +} diff --git a/app/serializers/v2/document.ts b/app/serializers/v2/document.ts new file mode 100644 index 00000000..1c08cc32 --- /dev/null +++ b/app/serializers/v2/document.ts @@ -0,0 +1,46 @@ +import { type Document } from '../../models'; +import { type ISO8601DateTimeString, type UUID } from '../../support/types'; + +type SerializedDocument = { + id: UUID; + title: string; + slug: string; + body?: string; + parentId: UUID | null; + isPublished: boolean; + visibility: string; + tags: string[]; + createdBy: UUID; + createdByUsername?: string; + createdAt: ISO8601DateTimeString; + updatedAt: ISO8601DateTimeString; + url: string; + documentsUrl: string; +}; + +export function serializeDocument(doc: Document, includeBody = false, username?: string): SerializedDocument { + const result: SerializedDocument = { + id: doc.id, + title: doc.title, + slug: doc.slug, + parentId: doc.parentId, + isPublished: doc.isPublished, + visibility: doc.visibility || 'public', + tags: doc.tags || [], + createdBy: doc.userId, + createdAt: doc.createdAt instanceof Date ? doc.createdAt.toISOString() : String(doc.createdAt), + updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt.toISOString() : String(doc.updatedAt), + url: doc.url, + documentsUrl: `/documents/${username || ''}/${doc.slug}`, + }; + + if (username) { + result.createdByUsername = username; + } + + if (includeBody) { + result.body = doc.body; + } + + return result; +} diff --git a/app/support/DbAdapter/documents.js b/app/support/DbAdapter/documents.js new file mode 100644 index 00000000..94111320 --- /dev/null +++ b/app/support/DbAdapter/documents.js @@ -0,0 +1,235 @@ +import { Document } from '../../models'; + +import { initObject, prepareModelPayload } from './utils'; + +const DOCUMENT_COLUMNS = { + createdAt: 'created_at', + updatedAt: 'updated_at', + userId: 'user_id', + title: 'title', + slug: 'slug', + body: 'body', + parentId: 'parent_id', + isPublished: 'is_published', + visibility: 'visibility', +}; + +const DOCUMENT_COLUMNS_MAPPING = { + createdAt: (timestamp) => (timestamp instanceof Date ? timestamp.toISOString() : timestamp), + updatedAt: (timestamp) => (timestamp instanceof Date ? timestamp.toISOString() : timestamp), +}; + +const DOCUMENT_FIELDS = { + uid: 'id', + created_at: 'createdAt', + updated_at: 'updatedAt', + user_id: 'userId', + title: 'title', + slug: 'slug', + body: 'body', + parent_id: 'parentId', + is_published: 'isPublished', + visibility: 'visibility', +}; + +const DOCUMENT_FIELDS_MAPPING = { + created_at: (val) => (typeof val === 'string' ? new Date(val) : val), + updated_at: (val) => (typeof val === 'string' ? new Date(val) : val), +}; + +function initDocumentObject(attrs) { + if (!attrs) { + return null; + } + + attrs = prepareModelPayload(attrs, DOCUMENT_FIELDS, DOCUMENT_FIELDS_MAPPING); + return initObject(Document, attrs, attrs.id); +} + +const documentsTrait = (superClass) => + class extends superClass { + async createDocument(payload) { + const preparedPayload = prepareModelPayload( + payload, + DOCUMENT_COLUMNS, + DOCUMENT_COLUMNS_MAPPING, + ); + const [row] = await this.database('documents') + .insert(preparedPayload) + .returning('*'); + return row.uid; + } + + async getDocumentById(id) { + const row = await this.database.getRow('select * from documents where uid = :id', { id }); + const doc = initDocumentObject(row); + + if (doc) { + doc.tags = await this.getDocumentTags(id); + } + + return doc; + } + + async getDocumentBySlug(slug) { + const row = await this.database.getRow('select * from documents where slug = :slug', { + slug, + }); + const doc = initDocumentObject(row); + + if (doc) { + doc.tags = await this.getDocumentTags(doc.id); + } + + return doc; + } + + async getDocumentByUserAndSlug(userId, slug) { + const row = await this.database.getRow( + 'select * from documents where user_id = :userId and slug = :slug', + { userId, slug }, + ); + const doc = initDocumentObject(row); + + if (doc) { + doc.tags = await this.getDocumentTags(doc.id); + } + + return doc; + } + + async getDocumentsByUser(userId, { limit = 50, offset = 0 } = {}) { + const rows = await this.database.getAll( + `select * from documents where user_id = :userId + order by updated_at desc limit :limit offset :offset`, + { userId, limit, offset }, + ); + const docs = rows.map(initDocumentObject); + + // Attach tags in batch + if (docs.length > 0) { + const ids = docs.map((d) => d.id); + const tags = await this.getDocumentsTags(ids); + + for (const doc of docs) { + doc.tags = tags.get(doc.id) || []; + } + } + + return docs; + } + + async updateDocument(id, payload) { + const preparedPayload = prepareModelPayload( + payload, + DOCUMENT_COLUMNS, + DOCUMENT_COLUMNS_MAPPING, + ); + const [row] = await this.database('documents') + .where('uid', id) + .update(preparedPayload) + .returning('*'); + const doc = initDocumentObject(row); + + if (doc) { + doc.tags = await this.getDocumentTags(id); + } + + return doc; + } + + async deleteDocument(id) { + await this.database.raw('delete from documents where uid = ?', id); + } + + async addDocumentTag(documentId, tag) { + await this.database.raw( + 'insert into document_tags (document_id, tag) values (:documentId, :tag) on conflict do nothing', + { documentId, tag }, + ); + } + + async removeDocumentTag(documentId, tag) { + await this.database.raw( + 'delete from document_tags where document_id = :documentId and tag = :tag', + { documentId, tag }, + ); + } + + async getDocumentTags(documentId) { + const rows = await this.database.getAll( + 'select tag from document_tags where document_id = :documentId order by tag', + { documentId }, + ); + return rows.map((r) => r.tag); + } + + async getDocumentsTags(documentIds) { + if (documentIds.length === 0) { + return new Map(); + } + + const rows = await this.database.getAll( + `select document_id, tag from document_tags + where document_id in (${documentIds.map(() => '?').join(',')}) + order by tag`, + documentIds, + ); + + const map = new Map(); + + for (const row of rows) { + if (!map.has(row.document_id)) { + map.set(row.document_id, []); + } + + map.get(row.document_id).push(row.tag); + } + + return map; + } + + async ensureUniqueDocumentSlug(baseSlug, userId) { + let slug = baseSlug; + let counter = 1; + + // eslint-disable-next-line no-await-in-loop + while (await this.database.getOne('select 1 from documents where user_id = :userId and slug = :slug', { userId, slug })) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + return slug; + } + + async getDocumentChildren(parentId) { + const rows = await this.database.getAll( + `select * from documents where parent_id = :parentId + order by title`, + { parentId }, + ); + return rows.map(initDocumentObject); + } + + async getUserDocumentTags(userId) { + const rows = await this.database.getAll( + `select dt.tag, count(*)::int as count + from document_tags dt + join documents d on d.uid = dt.document_id + where d.user_id = :userId + group by dt.tag + order by count desc, dt.tag`, + { userId }, + ); + return rows; + } + + async countUserDocuments(userId) { + return await this.database.getOne( + 'select count(*)::int from documents where user_id = :userId', + { userId }, + ); + } + }; + +export default documentsTrait; From 488bbb88ba90e6a543ec7447d370fdbc975b3d50 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:57:22 +0300 Subject: [PATCH 3/7] Add DocumentsController and API routes (v5) - Create endpoint: POST /v5/documents - List endpoint: GET /v5/documents - Get by ID: GET /v5/documents/:docId - Update endpoint: PUT /v5/documents/:docId - Delete endpoint: DELETE /v5/documents/:docId - Tree endpoint: GET /v5/documents/tree - Public URL: GET /docs/:slug and /docs/:username/:slug - All handlers enforce auth and owner-only access - Visibility checks on public endpoints Signed-off-by: Alexander Bokovoy --- app/controllers/api/v5/DocumentsController.js | 257 ++++++++++++++++++ app/routes/api/v5/DocumentsRoute.js | 13 + 2 files changed, 270 insertions(+) create mode 100644 app/controllers/api/v5/DocumentsController.js create mode 100644 app/routes/api/v5/DocumentsRoute.js diff --git a/app/controllers/api/v5/DocumentsController.js b/app/controllers/api/v5/DocumentsController.js new file mode 100644 index 00000000..04f0a989 --- /dev/null +++ b/app/controllers/api/v5/DocumentsController.js @@ -0,0 +1,257 @@ +import createDebug from 'debug'; +import compose from 'koa-compose'; + +import { serializeDocument } from '../../../serializers/v2/document'; +import { serializeUsersByIds } from '../../../serializers/v2/user'; +import { + reportError, + NotFoundException, + ValidationException, +} from '../../../support/exceptions'; +import { authRequired } from '../../middlewares'; +import { dbAdapter, Document } from '../../../models'; + +const debug = createDebug('freefeed:controller:documents'); + +export default class DocumentsController { + app; + + constructor(app) { + this.app = app; + debug('DocumentsController created'); + } + + async _resolveUsername(userId) { + if (!userId) return undefined; + try { + const user = await dbAdapter.getUserById(userId); + return user?.username; + } catch { + return undefined; + } + } + + create = compose([ + authRequired(), + async (ctx) => { + const body = ctx.request.body; + const { user } = ctx.state; + + if (!body.title || !body.title.trim()) { + throw new ValidationException('Title is required'); + } + + if (body.title && body.title.length > 200) { + throw new ValidationException('Title must not exceed 200 characters'); + } + + if (body.slug && body.slug.length > 200) { + throw new ValidationException('Slug must not exceed 200 characters'); + } + + const visibility = ['public', 'protected', 'private'].includes(body.visibility) ? body.visibility : 'public'; + + try { + const slug = body.slug ? Document.slugify(body.slug) : undefined; + const params = { + title: body.title.trim(), + slug, + body: body.body || '', + parentId: body.parentId || null, + isPublished: body.isPublished !== false, + visibility, + tags: Array.isArray(body.tags) ? body.tags : [], + }; + + const doc = await Document.create(params, user); + + ctx.status = 201; + ctx.body = { + documents: serializeDocument(doc, true, user.username), + users: await serializeUsersByIds([doc.userId], user.id), + }; + } catch (e) { + if (e.status === 409) { + throw e; + } + reportError(ctx)(e); + } + }, + ]); + + list = compose([ + authRequired(), + async (ctx) => { + const { user } = ctx.state; + const limit = Math.min(parseInt(ctx.query.limit) || 50, 100); + const offset = parseInt(ctx.query.offset) || 0; + + const docs = await dbAdapter.getDocumentsByUser(user.id, { limit, offset }); + const total = await dbAdapter.countUserDocuments(user.id); + + ctx.body = { + documents: docs.map((d) => serializeDocument(d, false, user.username)), + total, + limit, + offset, + }; + }, + ]); + + getById = compose([ + authRequired(), + async (ctx) => { + const { user } = ctx.state; + const doc = await dbAdapter.getDocumentById(ctx.params.docId); + + if (!doc || doc.userId !== user.id) { + throw new NotFoundException('Document not found'); + } + + ctx.body = { + documents: serializeDocument(doc, true, user.username), + users: await serializeUsersByIds([doc.userId], user.id), + }; + }, + ]); + update = compose([ + authRequired(), + async (ctx) => { + const { user } = ctx.state; + const body = ctx.request.body; + + const doc = await dbAdapter.getDocumentById(ctx.params.docId); + if (!doc || doc.userId !== user.id) { + throw new NotFoundException('Document not found'); + } + + const updateParams = {}; + + if (body.title !== undefined) { + if (body.title.length > 200) { + throw new ValidationException('Title must not exceed 200 characters'); + } + updateParams.title = body.title.trim(); + } + if (body.slug !== undefined) { + if (body.slug.length > 200) { + throw new ValidationException('Slug must not exceed 200 characters'); + } + updateParams.slug = body.slug; + } + if (body.body !== undefined) { + updateParams.body = body.body; + } + if (body.parentId !== undefined) { + updateParams.parentId = body.parentId || null; + } + if (body.isPublished !== undefined) { + updateParams.isPublished = body.isPublished; + } + if (body.visibility !== undefined && ['public', 'protected', 'private'].includes(body.visibility)) { + updateParams.visibility = body.visibility; + } + if (body.tags !== undefined) { + updateParams.tags = Array.isArray(body.tags) ? body.tags : []; + } + + try { + const updated = await doc.update(updateParams); + ctx.body = { + documents: serializeDocument(updated, true, user.username), + }; + } catch (e) { + if (e.status === 409) { + throw e; + } + reportError(ctx)(e); + } + }, + ]); + + destroy = compose([ + authRequired(), + async (ctx) => { + const { user } = ctx.state; + + const doc = await dbAdapter.getDocumentById(ctx.params.docId); + if (!doc || doc.userId !== user.id) { + throw new NotFoundException('Document not found'); + } + + await doc.destroy(); + ctx.body = { success: true }; + }, + ]); + + tree = compose([ + authRequired(), + async (ctx) => { + const { user } = ctx.state; + const docs = await dbAdapter.getDocumentsByUser(user.id); + const tags = await dbAdapter.getUserDocumentTags(user.id); + + const childrenMap = new Map(); + + for (const doc of docs) { + const pid = doc.parentId || ''; + if (!childrenMap.has(pid)) { + childrenMap.set(pid, []); + } + childrenMap.get(pid).push(doc); + } + + function buildSubtree(parentId) { + const items = childrenMap.get(parentId || '') || []; + return items.map((doc) => ({ + id: doc.id, + title: doc.title, + slug: doc.slug, + tags: doc.tags, + children: buildSubtree(doc.id), + })); + } + + ctx.body = { + tree: buildSubtree(null), + tags, + }; + }, + ]); + + getByUserAndSlug = async (ctx) => { const { slug, username } = ctx.params; + const viewerId = ctx.state.user?.id; + let doc; + + if (username) { + const user = await dbAdapter.getUserByUsername(username); + if (!user) { + throw new NotFoundException('Document not found'); + } + doc = await dbAdapter.getDocumentByUserAndSlug(user.id, slug); + } else { + doc = await dbAdapter.getDocumentBySlug(slug); + } + + if (!doc) { + throw new NotFoundException('Document not found'); + } + + if (!doc.isPublished) { + throw new NotFoundException('Document not found'); + } + + if (doc.visibility === 'private' && doc.userId !== viewerId) { + throw new NotFoundException('Document not found'); + } + if (doc.visibility === 'protected' && doc.userId !== viewerId) { + throw new NotFoundException('Document not found'); + } + + const docUsername = username || await this._resolveUsername(doc.userId); + + ctx.body = { + documents: serializeDocument(doc, true, docUsername), + }; + }; +} diff --git a/app/routes/api/v5/DocumentsRoute.js b/app/routes/api/v5/DocumentsRoute.js new file mode 100644 index 00000000..e99016ec --- /dev/null +++ b/app/routes/api/v5/DocumentsRoute.js @@ -0,0 +1,13 @@ +import { DocumentsController } from '../../../controllers'; + +export default function addRoutes(app) { + const controller = new DocumentsController(app); + + app.get('/documents', controller.list); + app.post('/documents', controller.create); + app.get('/documents/tree', controller.tree); + app.get('/documents/:docId', controller.getById); + app.put('/documents/:docId', controller.update); + app.delete('/documents/:docId', controller.destroy); + app.get('/public/documents/:slug', controller.getByUserAndSlug); +} From 6ad679aa4b732e3486f249f42c64d4614bbcd0b2 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:57:33 +0300 Subject: [PATCH 4/7] Wire Document module into app: DbAdapter trait, model export, routes, API v5 - Register documentsTrait in DbAdapter composition - Export Document model from models.js - Export DocumentsController from controllers.js - Register DocumentsRoute in public router - Add /docs/:username/:slug public middleware - Bump API_VERSION_ACTUAL to 5 Signed-off-by: Alexander Bokovoy --- app/api-versions.ts | 3 ++- app/controllers.js | 2 ++ app/models.js | 2 ++ app/routes.js | 29 +++++++++++++++++++++++++++++ app/support/DbAdapter/index.js | 2 ++ 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/api-versions.ts b/app/api-versions.ts index 23d7e1da..cb9be40c 100644 --- a/app/api-versions.ts +++ b/app/api-versions.ts @@ -1,6 +1,7 @@ export const API_VERSION_2 = 2; export const API_VERSION_3 = 3; export const API_VERSION_4 = 4; +export const API_VERSION_5 = 5; -export const API_VERSION_ACTUAL = API_VERSION_4; +export const API_VERSION_ACTUAL = API_VERSION_5; export const API_VERSION_MINIMAL = API_VERSION_2; diff --git a/app/controllers.js b/app/controllers.js index e91fb7cd..7ec3dc87 100644 --- a/app/controllers.js +++ b/app/controllers.js @@ -15,3 +15,5 @@ export { default as SearchController } from './controllers/api/v2/SearchControll export { default as EventsController } from './controllers/api/v2/EventsController'; export { default as CommentLikesController } from './controllers/api/v2/CommentLikesController'; export { default as InvitationsController } from './controllers/api/v2/InvitationsController'; + +export { default as DocumentsController } from './controllers/api/v5/DocumentsController'; diff --git a/app/models.js b/app/models.js index 2bf80515..5d4b10b7 100644 --- a/app/models.js +++ b/app/models.js @@ -11,6 +11,7 @@ import { addModel as groupModel } from './models/group'; import { addModel as postModel } from './models/post'; import { addModel as timelineModel } from './models/timeline'; import { addModel as userModel } from './models/user'; +import { addModel as documentModel } from './models/document'; import { addServerInfoModel } from './models/server-info'; import { addJobModel, addJobManagerModel } from './models/job'; import { SessionTokenV1Store } from './models/auth-tokens'; @@ -34,6 +35,7 @@ export const Group = groupModel(dbAdapter); export const Post = postModel(dbAdapter); export const Timeline = timelineModel(dbAdapter); export const Attachment = attachmentModel(dbAdapter); +export const Document = documentModel(dbAdapter); export const Comment = commentModel(dbAdapter); export const ServerInfo = addServerInfoModel(dbAdapter); export const Job = addJobModel(dbAdapter); diff --git a/app/routes.js b/app/routes.js index 8d92a375..57484e3c 100644 --- a/app/routes.js +++ b/app/routes.js @@ -33,6 +33,7 @@ import HashtagsRoute from './routes/api/v2/HashtagsRoute'; import AdminCommonRoute from './routes/api/admin/CommonRoute'; import AdminAdminRoute from './routes/api/admin/AdminRoute'; import AdminModeratorRoute from './routes/api/admin/ModeratorRoute'; +import DocumentsRoute from './routes/api/v5/DocumentsRoute'; import { withJWT } from './controllers/middlewares/with-jwt'; import { withAuthToken } from './controllers/middlewares/with-auth-token'; import { apiNotFoundMiddleware } from './setup/initializers/api-not-found'; @@ -44,6 +45,33 @@ export default function (app) { app.use(router.routes()); app.use(router.allowedMethods()); + // Stable public URL for documents + // /docs/:slug (backwards compatible, deprecated) + // /docs/:username/:slug (preferred, user-namespaced) + app.use(async (ctx, next) => { + let slug = null; + let username = null; + const matchFlat = /^\/docs\/([^/]+)\/?$/u.exec(ctx.path); + const matchUser = /^\/docs\/([^/]+)\/([^/]+)\/?$/u.exec(ctx.path); + + if (matchUser) { + username = matchUser[1]; + slug = matchUser[2]; + } else if (matchFlat) { + slug = matchFlat[1]; + } + + if (slug && ctx.method === 'GET') { + const { default: DocumentsController } = + await import('./controllers/api/v5/DocumentsController'); + const controller = new DocumentsController(app); + ctx.params = { slug, username }; + await controller.getByUserAndSlug(ctx, next); + return; + } + + await next(); + }); app.use(cors()); app.use(apiNotFoundMiddleware); } @@ -90,6 +118,7 @@ export function createRouter() { AttachmentsRouteV2(publicRouter); CorsProxyRoute(publicRouter); UndoRoute(publicRouter); + DocumentsRoute(publicRouter); HashtagsRoute(publicRouter); const router = new Router(); diff --git a/app/support/DbAdapter/index.js b/app/support/DbAdapter/index.js index a08cddd5..b6c7cda1 100644 --- a/app/support/DbAdapter/index.js +++ b/app/support/DbAdapter/index.js @@ -55,6 +55,7 @@ import userStatsDynamicTrait from './user-stats-dynamic'; import translationUsageTrait from './translation-usage'; import postCommentEventsTrait from './post-comment-events'; import pinnedPostsTrait from './pinned-posts'; +import documentsTrait from './documents'; class DbAdapterBase { /** @@ -187,5 +188,6 @@ export const DbAdapter = _.flow([ userStatsDynamicTrait, translationUsageTrait, postCommentEventsTrait, + documentsTrait, pinnedPostsTrait, ])(DbAdapterBase); From 48072d3a588213f3292a5f45e8a6ea2ca4584ce7 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:57:45 +0300 Subject: [PATCH 5/7] Add TypeScript type definitions for Document model and DbAdapter methods - Document class definition with all fields and methods - DbAdapter document query method signatures Signed-off-by: Alexander Bokovoy --- app/models.d.ts | 35 ++++++++++++++++++++++++++++++++ app/support/DbAdapter/index.d.ts | 31 +++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/app/models.d.ts b/app/models.d.ts index 0208c8cd..d6cd5ed9 100644 --- a/app/models.d.ts +++ b/app/models.d.ts @@ -261,6 +261,41 @@ export class Attachment { destroy(destroyedBy?: User): Promise; } +type DocumentParams = { + id?: UUID; + userId: UUID; + title: string; + slug: string; + body: string; + parentId?: UUID | null; + isPublished?: boolean; + tags?: string[]; + createdAt?: string; + updatedAt?: string; +}; +export class Document { + constructor(params: DocumentParams); + id: UUID; + userId: UUID; + title: string; + slug: string; + body: string; + parentId: UUID | null; + isPublished: boolean; + tags: string[]; + createdAt: Date; + updatedAt: Date; + readonly url: string; + static create(params: Partial, user: User): Promise; + update(params: Partial): Promise; + destroy(): Promise; + addTag(tag: string): Promise; + removeTag(tag: string): Promise; + getChildren(): Promise; + getParent(): Promise; + static slugify(text: string): string; +} + export class Comment { static VISIBLE: 0; static DELETED: 1; diff --git a/app/support/DbAdapter/index.d.ts b/app/support/DbAdapter/index.d.ts index ff3a0978..75aa8e22 100644 --- a/app/support/DbAdapter/index.d.ts +++ b/app/support/DbAdapter/index.d.ts @@ -10,7 +10,17 @@ import { type Nullable, type UUID, } from '../types'; -import { AppTokenV1, Attachment, Comment, Group, Post, Timeline, User, Job } from '../../models'; +import { + AppTokenV1, + Attachment, + Comment, + Group, + Post, + Timeline, + User, + Job, + Document, +} from '../../models'; import { AppTokenCreateParams, AppTokenLogPayload, @@ -517,4 +527,23 @@ export class DbAdapter { getCommentEventsListenersForPost(postId: UUID): Promise>; setCommentEventsStatusForPost(postId: UUID, userId: UUID, isEnabled: boolean): Promise; cleanCommentEventsSubscriptions(userId: UUID): Promise; + + // Documents + createDocument(payload: Record): Promise; + getDocumentById(id: UUID): Promise; + getDocumentBySlug(slug: string): Promise; + getDocumentsByUser( + userId: UUID, + options?: { limit?: number; offset?: number; includeBody?: boolean }, + ): Promise; + updateDocument(id: UUID, payload: Record): Promise; + deleteDocument(id: UUID): Promise; + addDocumentTag(documentId: UUID, tag: string): Promise; + removeDocumentTag(documentId: UUID, tag: string): Promise; + getDocumentTags(documentId: UUID): Promise; + getDocumentsTags(documentIds: UUID[]): Promise>; + getDocumentChildren(parentId: UUID): Promise; + getUserDocumentTags(userId: UUID): Promise<{ tag: string; count: number }[]>; + countUserDocuments(userId: UUID): Promise; + ensureUniqueDocumentSlug(baseSlug: string): Promise; } From 6ab8fe5ea10a382687055b3ec86096efa8dbc6c6 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Sat, 5 Sep 2026 07:31:33 +0300 Subject: [PATCH 6/7] Add functional tests for Documents CRUD, slugs, tags, hierarchy, and public URLs - Test create, read, update, delete with auth enforcement - Test auto-slug generation, custom slugs, and per-user deduplication - Test tag association and listing - Test hierarchy tree structure - Test public URL access and unpublished document 404s Signed-off-by: Alexander Bokovoy --- test/functional/documents.js | 399 +++++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 test/functional/documents.js diff --git a/test/functional/documents.js b/test/functional/documents.js new file mode 100644 index 00000000..a297c653 --- /dev/null +++ b/test/functional/documents.js @@ -0,0 +1,399 @@ +/* global $pg_database */ +import unexpected from 'unexpected'; + +import cleanDB from '../dbCleaner'; + +import { createTestUser, performJSONRequest } from './functional_test_helper'; + +const expect = unexpected.clone(); + +describe('Documents', () => { + let luna, zloy; + + before(async () => { + await cleanDB($pg_database); + luna = await createTestUser('luna'); + zloy = await createTestUser('zloy'); + }); + + describe('CRUD', () => { + it('should not create document anonymously', async () => { + const resp = await performJSONRequest('POST', '/v5/documents', { title: 'Test' }); + expect(resp, 'to satisfy', { __httpCode: 401 }); + }); + + it('should create a document', async () => { + const resp = await performJSONRequest( + 'POST', + '/v5/documents', + { + title: 'My First Doc', + body: '# Hello\n\nThis is **markdown** content.', + tags: ['guide', 'intro'], + }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + + expect(resp, 'to satisfy', { + __httpCode: 201, + documents: { + title: 'My First Doc', + slug: 'my-first-doc', + tags: ['guide', 'intro'], + parentId: null, + isPublished: true, + }, + }); + + expect(resp.documents.id, 'to be a', 'string'); + expect(resp.documents.body, 'to be', '# Hello\n\nThis is **markdown** content.'); + }); + + it('should list documents', async () => { + const resp = await performJSONRequest('GET', '/v5/documents', undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + + expect(resp, 'to satisfy', { + __httpCode: 200, + documents: [{ title: 'My First Doc' }], + total: 1, + }); + + // List should not include body by default + expect(resp.documents[0].body, 'to be undefined'); + }); + + it('should get document by id', async () => { + // First get the id from list + const listResp = await performJSONRequest('GET', '/v5/documents', undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + const docId = listResp.documents[0].id; + + const resp = await performJSONRequest('GET', `/v5/documents/${docId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + + expect(resp, 'to satisfy', { + __httpCode: 200, + documents: { + id: docId, + title: 'My First Doc', + body: '# Hello\n\nThis is **markdown** content.', + }, + }); + }); + + it('should return 404 for other user`s document', async () => { + const listResp = await performJSONRequest('GET', '/v5/documents', undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + const docId = listResp.documents[0].id; + + const resp = await performJSONRequest('GET', `/v5/documents/${docId}`, undefined, { + Authorization: `Bearer ${zloy.authToken}`, + }); + + expect(resp, 'to satisfy', { __httpCode: 404 }); + }); + + it('should update document', async () => { + const listResp = await performJSONRequest('GET', '/v5/documents', undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + const docId = listResp.documents[0].id; + + const resp = await performJSONRequest( + 'PUT', + `/v5/documents/${docId}`, + { + title: 'Updated Title', + body: '# Updated\n\nNew content.', + tags: ['guide', 'updated'], + }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + + expect(resp, 'to satisfy', { + __httpCode: 200, + documents: { + title: 'Updated Title', + body: '# Updated\n\nNew content.', + tags: ['guide', 'updated'], + }, + }); + }); + + it('should delete document', async () => { + // Create a doc to delete + const createResp = await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'To Delete' }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + const docId = createResp.documents.id; + + const deleteResp = await performJSONRequest('DELETE', `/v5/documents/${docId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + + expect(deleteResp, 'to satisfy', { __httpCode: 200 }); + + // Verify it's gone + const getResp = await performJSONRequest('GET', `/v5/documents/${docId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + expect(getResp, 'to satisfy', { __httpCode: 404 }); + }); + }); + + describe('Slugs', () => { + it('should auto-generate slug from title', async () => { + const resp = await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'A Guide to Freefeed' }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + + expect(resp, 'to satisfy', { + documents: { slug: 'a-guide-to-freefeed' }, + }); + + // Clean up + const docId = resp.documents.id; + await performJSONRequest('DELETE', `/v5/documents/${docId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + }); + + it('should accept custom slug', async () => { + const resp = await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'My Doc', slug: 'custom-url-path' }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + + expect(resp, 'to satisfy', { + documents: { slug: 'custom-url-path' }, + }); + + // Clean up + const docId = resp.documents.id; + await performJSONRequest('DELETE', `/v5/documents/${docId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + }); + + it('should make unique slug on conflict', async () => { + const resp1 = await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'My Document', slug: 'duplicate-slug' }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + + expect(resp1, 'to satisfy', { __httpCode: 201, documents: { slug: 'duplicate-slug' } }); + + const resp2 = await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'My Document', slug: 'duplicate-slug' }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + + expect(resp2, 'to satisfy', { __httpCode: 201, documents: { slug: 'duplicate-slug-1' } }); + + // Clean up + const ids = [resp1.documents.id, resp2.documents.id]; + await Promise.all( + ids.map((id) => + performJSONRequest('DELETE', `/v5/documents/${id}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }), + ), + ); + }); + }); + + describe('Tags', () => { + let docId; + + before(async () => { + await cleanDB($pg_database); + luna = await createTestUser('luna'); + + const resp = await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'Tagged Doc', tags: ['alpha', 'beta'] }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + docId = resp.documents.id; + }); + + it('should include tags in document', async () => { + const resp = await performJSONRequest('GET', `/v5/documents/${docId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + + expect(resp, 'to satisfy', { + documents: { + tags: ['alpha', 'beta'], + }, + }); + }); + + it('should list user tags with counts', async () => { + const resp = await performJSONRequest('GET', '/v5/documents/tree', undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + + expect(resp, 'to satisfy', { + tags: [{ tag: 'alpha' }, { tag: 'beta' }], + }); + }); + + after(async () => { + await performJSONRequest('DELETE', `/v5/documents/${docId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + }); + }); + + describe('Hierarchy', () => { + let parentId; + + before(async () => { + await cleanDB($pg_database); + luna = await createTestUser('luna'); + + const parentResp = await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'Parent Doc' }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + parentId = parentResp.documents.id; + + // Create child + await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'Child Doc', parentId }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + + // Create another child + await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'Another Child', parentId }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + }); + + it('should return tree structure', async () => { + const resp = await performJSONRequest('GET', '/v5/documents/tree', undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + + expect(resp, 'to satisfy', { + tree: [ + { + title: 'Parent Doc', + children: [ + { title: 'Another Child', children: [] }, + { title: 'Child Doc', children: [] }, + ], + }, + ], + }); + }); + + after(async () => { + // Clean up children first (they get deleted cascading through the route) + const treeResp = await performJSONRequest('GET', '/v5/documents/tree', undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + + const deletePromises = []; + + for (const node of treeResp.tree) { + for (const child of node.children) { + deletePromises.push( + performJSONRequest('DELETE', `/v5/documents/${child.id}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }), + ); + } + + deletePromises.push( + performJSONRequest('DELETE', `/v5/documents/${node.id}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }), + ); + } + + await Promise.all(deletePromises); + }); + }); + + describe('Public URL (/docs/:slug)', () => { + it('should serve document by slug', async () => { + const resp = await performJSONRequest( + 'POST', + '/v5/documents', + { + title: 'Public Doc', + slug: 'public-doc', + body: '# Public\n\nVisible to anyone.', + isPublished: true, + }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + const rawId = resp.documents.id; + + const publicResp = await performJSONRequest('GET', '/docs/public-doc'); + expect(publicResp, 'to satisfy', { + __httpCode: 200, + documents: { + slug: 'public-doc', + title: 'Public Doc', + body: '# Public\n\nVisible to anyone.', + }, + }); + + // Clean up + await performJSONRequest('DELETE', `/v5/documents/${rawId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + }); + + it('should return 404 for unpublished document', async () => { + const resp = await performJSONRequest( + 'POST', + '/v5/documents', + { title: 'Draft', slug: 'draft-doc', isPublished: false }, + { Authorization: `Bearer ${luna.authToken}` }, + ); + const rawId = resp.documents.id; + + const publicResp = await performJSONRequest('GET', '/docs/draft-doc'); + expect(publicResp, 'to satisfy', { __httpCode: 404 }); + + // Clean up + await performJSONRequest('DELETE', `/v5/documents/${rawId}`, undefined, { + Authorization: `Bearer ${luna.authToken}`, + }); + }); + + it('should return 404 for non-existent slug', async () => { + const resp = await performJSONRequest('GET', '/docs/non-existent-slug'); + expect(resp, 'to satisfy', { __httpCode: 404 }); + }); + }); +}); From c92b8057fdb583fa64f6a78c83352677ccda201f Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Sat, 5 Sep 2026 07:37:09 +0300 Subject: [PATCH 7/7] fixup! Add documents table with per-user slugs and visibility --- .../20260824153200_documents_and_tags.ts | 115 +++++++++++------- 1 file changed, 68 insertions(+), 47 deletions(-) diff --git a/migrations/20260824153200_documents_and_tags.ts b/migrations/20260824153200_documents_and_tags.ts index 301fb41d..9febae11 100644 --- a/migrations/20260824153200_documents_and_tags.ts +++ b/migrations/20260824153200_documents_and_tags.ts @@ -1,40 +1,60 @@ import { type Knex } from 'knex'; -export const up = (knex: Knex) => - knex.schema.raw(`do $$begin - - create table documents ( - id integer generated by default as identity primary key, - uid uuid default gen_random_uuid() not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - user_id uuid not null, - title text not null default '', - slug text not null default '', - body text not null default '', - parent_id uuid references documents(uid) on delete set null, - is_published boolean not null default true, - visibility text not null default 'public' - check (visibility in ('public', 'protected', 'private')) - ); - - create unique index documents_uid_idx on documents (uid); - create index documents_user_id_idx on documents (user_id); - create index documents_parent_id_idx on documents (parent_id); - - -- Per-user unique slug (empty = draft, not constrained) - create unique index documents_user_slug_idx on documents (user_id, slug) - where slug <> ''; - - create table document_tags ( - document_id uuid not null references documents(uid) on delete cascade, - tag text not null, - primary key (document_id, tag) - ); - - create index document_tags_tag_idx on document_tags (tag); - - -- Auto-update updated_at on document change +export async function up(knex: Knex) { + await knex.schema.createTable('documents', (table) => { + table.increments().notNullable().primary(); + table.uuid('uid').defaultTo(knex.raw('gen_random_uuid()')).notNullable().unique(); + table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable(); + table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable(); + table + .uuid('user_id') + .notNullable() + .references('uid') + .inTable('users') + .onUpdate('cascade') + .onDelete('cascade'); + table.text('title').notNullable().defaultTo(''); + table.text('slug').notNullable().defaultTo(''); + table.text('body').notNullable().defaultTo(''); + table + .uuid('parent_id') + .references('uid') + .inTable('documents') + .onUpdate('cascade') + .onDelete('set null'); + table.boolean('is_published').notNullable().defaultTo(true); + table.text('visibility').notNullable().defaultTo('public'); + + table.index('user_id', 'documents_user_id_idx', 'btree'); + table.index('parent_id', 'documents_parent_id_idx', 'btree'); + }); + + // Check constraint for visibility — Knex has no builder for CHECK + await knex.schema.raw( + `alter table documents add constraint documents_visibility_check + check (visibility in ('public', 'protected', 'private'))`, + ); + + // Partial unique index — Knex doesn't support WHERE clause on indexes + await knex.schema.raw( + `create unique index documents_user_slug_idx on documents (user_id, slug) where slug <> ''`, + ); + + await knex.schema.createTable('document_tags', (table) => { + table + .uuid('document_id') + .notNullable() + .references('uid') + .inTable('documents') + .onUpdate('cascade') + .onDelete('cascade'); + table.text('tag').notNullable(); + table.primary(['document_id', 'tag']); + table.index('tag', 'document_tags_tag_idx', 'btree'); + }); + + // Auto-update updated_at on document change (Knex has no builder for triggers) + await knex.schema.raw(` create function trg_documents_updated_at() returns trigger as $BODY$ begin @@ -43,17 +63,18 @@ export const up = (knex: Knex) => end; $BODY$ language plpgsql; + `); - create trigger trg_documents_updated_at - before update on documents - for each row execute function trg_documents_updated_at(); - - end$$`); + await knex.schema.raw( + `create trigger trg_documents_updated_at + before update on documents + for each row execute function trg_documents_updated_at()`, + ); +} -export const down = (knex: Knex) => - knex.schema.raw(`do $$begin - drop trigger trg_documents_updated_at on documents; - drop function trg_documents_updated_at(); - drop table document_tags; - drop table documents; - end$$`); \ No newline at end of file +export async function down(knex: Knex) { + await knex.schema.raw('drop trigger if exists trg_documents_updated_at on documents'); + await knex.schema.raw('drop function if exists trg_documents_updated_at()'); + await knex.schema.dropTableIfExists('document_tags'); + await knex.schema.dropTableIfExists('documents'); +} \ No newline at end of file