diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c29cacde..606245f8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -114,7 +114,7 @@ jobs: ghcr.io/requarks/wiki tags: | type=semver,pattern={{version}},value=${{ env.REL_VERSION }} - type=raw,value=3.0.0-beta + type=raw,value=3.0.0-beta,enable=${{ github.event_name == 'workflow_dispatch' }} labels: | org.opencontainers.image.title=Wiki.js org.opencontainers.image.description=Wiki.js - Next Generation Open Source Wiki @@ -155,9 +155,11 @@ jobs: # BUILD HELM/OCI CHARTS # --------------------------------------------------- - name: Set up Helm + if: github.event_name == 'workflow_dispatch' uses: azure/setup-helm@v5 - name: Package and push Helm chart + if: github.event_name == 'workflow_dispatch' env: REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/CLAUDE.md b/CLAUDE.md index a6de0222..9d79a341 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -572,7 +572,7 @@ that is already on it needs no permission from anybody. **Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`, `review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`, `read:history`, `read:assets`, `write:assets`, `manage:assets`, `read:comments`, `write:comments`, -`manage:comments`, `manage:navigation` (`PAGE_PERMISSIONS` in `api/pages.ts`). A group grants them through **rules**: +`manage:comments`, `manage:navigation`, `read:glossary`, `manage:glossary` (`PAGE_PERMISSIONS` in `models/groups.ts`). A group grants them through **rules**: each rule names some of them (`roles`) plus how it addresses pages (`match` + `path`, or tags) and what it does with them (`mode`: ALLOW / DENY / FORCEALLOW). Nothing is granted by default, and when several rules match, the most specific one wins — `helpers/pageRules.ts` documents the ordering. @@ -603,6 +603,22 @@ Consequences worth knowing: it rewrite the menu handed down to it. `api/navigation.ts` has the pair of checks (`mayManageNavAt`, `mayEditNavItems`), and the `inherited` route answers `canEditItems` so the editor knows which of its two halves to offer. +- **`read:glossary` and `manage:glossary` ignore the path.** They are the page permissions about a + site and a locale rather than a page (`LOCALE_PERMISSIONS` in `models/groups.ts`): every rule naming + one that is scoped to the site and locale applies, whatever it matches, and mode alone decides — + ALLOW < DENY < FORCE ALLOW. So a DENY written for one folder closes the whole glossary. Ask + `groups.checkLocaleAccess(actor, permission, siteId, locale)`; `checkAccess` routes them there too. + `manage:glossary` in a locale implies `read:glossary` there. Neither is in the Guests group's seeded + DENY rule, where it would outrank any ALLOW an administrator wrote for guests later. +- **A page that is not live is its editors' alone.** Live is `published`, or `scheduled` inside its + window (`publishStartDate` inclusive, `publishEndDate` exclusive); the dates gate nothing on a + `published` page, where `publishStartDate` is what a blog post is dated by. Any other page is seen — + render, source, history, comments, a listing or search result — only by a signed-in session holding + `write:pages` or `manage:pages` on it; `read:pages` and `read:source` are not enough, and a request + without a session (an API key included) never sees one. `helpers/publishing.ts` is the rule: + `liveCondition` for SQL, `maySeeUnpublished` per page, and `unpublishedFor(req)` in `api/pages.ts` + is what a reader-facing `getPage` passes as `unpublished`. That option defaults to open because most + callers are the wiki acting on its own pages, so a new route answering a reader has to pass it. - **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely required (`reviewerFor` in `api/approvals.ts` is the worked example). @@ -1074,8 +1090,8 @@ document is whether the client will run the app, and each has its own function: — and therefore the only half that is cached and handed on. - **`fragmentsForBrowser`** — for a browser that will. The head alone, describing the page as THAT requester may see it (`pages.describePageForRequest`), which makes exactly the cut the page route - makes: `read:pages` per path, and any signed-in session sees an unpublished page while an API key is - answered as the public is. Never cached, because the answer is one reader's. It carries no body (the + makes: `read:pages` per path, and a page that is not live only for a session that may edit it (see + [Permissions](#permissions)). Never cached, because the answer is one reader's. It carries no body (the app is about to draw the page properly, so a copy is bytes on every hard navigation for content the browser discards), never 404s (whether a path this reader may create is empty is the app's own flow to present) and adds no page-specific `noindex`. @@ -1190,6 +1206,28 @@ instead. A `num` placeholder that is not a number drops its whole snippet for th a prop that had to be kept out of a browser could not be used by a provider in the first place. This is why `api/analytics.ts` is the one module-prop surface with no `maskSensitiveProps` on the way out. +### Glossary + +Terms per site and per locale, read and edited in an overlay opened from the header's **Library** +menu. **`dev/specs/glossary.md` is the design**; `models/glossary.ts`, `api/glossary.ts`, +`components/GlossaryOverlay.vue`. + +- **A name means one term.** A term's name and its aliases share one namespace per site and locale, + compared case-insensitively. The unique index covers term against term; aliases are checked by the + model, which answers 409 naming the holder. `caseSensitive` is about matching in text, never about + uniqueness. +- **Related terms are two-way**: one row per pair in `glossaryTermRelations`, smaller id first, read + from both sides. Saving a term's list replaces every pair touching it. +- **A save names the `updatedAt` it was opened with** (`expectedUpdatedAt`), compared at millisecond + precision inside the UPDATE; a mismatch is 409 with the term as it now stands. Dates are written + from JS rather than `now()` so that what is stored is what the client was given. +- **The definition is basic markdown, rendered in the browser** by `renderers/glossary.js` — the + comment renderer's feature set without `nofollow ugc` or mentions. Never HTML. +- **The frontend learns access from `GET /sites/:siteId/glossary/access`** (`stores/glossary.js`), + once per site and session — the locales the session may read in and may edit in. Neither permission + changes from page to page, and the per-page permissions are empty on every route that is not a page. +- **`autoLink` is stored and not yet acted on**; the spec's §9 is the design it is waiting for. + ### Comments Two things wearing one name, and `models/comments.ts` is the seam between them. **Only one provider diff --git a/backend/api/approvals.ts b/backend/api/approvals.ts index 0004ed6c..9198a2e5 100644 --- a/backend/api/approvals.ts +++ b/backend/api/approvals.ts @@ -1,25 +1,24 @@ import { audit } from '../helpers/audit.ts' import { CustomError } from '../helpers/common.ts' -import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor } from './pages.ts' +import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts' import type { ApprovalPageRef, ApprovalRulePatch, ReviewerScope } from '../models/approvals.ts' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' /** * The page a suggestion is about, with the source it would be edited from. * - * Loaded the way the public page route loads it — an anonymous reader sees published pages only, and a - * password still has to have been entered — so eligibility to suggest an edit never becomes a way to + * Loaded the way the public page route loads it — a page that is not live is there only for whoever + * may edit it, and a password still has to have been entered — so eligibility to suggest an edit never becomes a way to * read something that was not readable. The source itself is fetched regardless of who is asking, * because the caller has to be able to edit what they are looking at; the routes below only hand it * over once a rule says this actor may suggest edits to this page. */ async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId: string) { - const actor = actorFrom(req) const page = await WIKI.models.pages.getPage({ siteId, id: pageId, withContent: true, - publicOnly: !actor, + unpublished: unpublishedFor(req), unlocked: (id: string) => unlockedFor(req, id), withPassword: mayBypassPassword(req) }) diff --git a/backend/api/blogs.ts b/backend/api/blogs.ts index 2f885fea..19f593a1 100644 --- a/backend/api/blogs.ts +++ b/backend/api/blogs.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify' -import { mayOnPage } from './pages.ts' +import { mayOnPage, unpublishedFor } from './pages.ts' import { normalizePagePath } from '../helpers/common.ts' const siteIdParam = { @@ -186,7 +186,7 @@ async function routes(app: FastifyInstance) { siteId: req.params.siteId, blog, actor: WIKI.models.groups.actorForRequest(req), - publicOnly: !req.session?.authenticated, + unpublished: unpublishedFor(req), filter: { tag: req.query.tag, year: req.query.year, diff --git a/backend/api/comments.ts b/backend/api/comments.ts index 2420bd8e..d005b02b 100644 --- a/backend/api/comments.ts +++ b/backend/api/comments.ts @@ -1,6 +1,6 @@ import { audit } from '../helpers/audit.ts' import { maskSensitiveProps } from '../helpers/common.ts' -import { mayOnPage } from './pages.ts' +import { mayOnPage, unpublishedFor } from './pages.ts' import { ANNOTATION_NOTE_MAX_LENGTH, ANNOTATIONS_MAX, @@ -988,14 +988,21 @@ async function requireBuiltInPage( reply.notFound('This site does not use the built-in comments provider.') return null } - const page = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId) - if (!page) { + const row = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId) + if (!row) { reply.notFound('This page does not exist.') return null } // -> Carrying the site, since everything below asks a page rule about this page and a rule may be // limited to particular sites - return { ...page, siteId: req.params.siteId } + const { isLive, ...ref } = { ...row, siteId: req.params.siteId } + // -> A page that is not live is not there for its discussion either, on the page view's own terms + const unpublished = unpublishedFor(req) + if (!isLive && !(unpublished && unpublished(ref))) { + reply.notFound('This page does not exist.') + return null + } + return ref } /** diff --git a/backend/api/glossary.ts b/backend/api/glossary.ts new file mode 100644 index 00000000..554249ee --- /dev/null +++ b/backend/api/glossary.ts @@ -0,0 +1,519 @@ +import { audit } from '../helpers/audit.ts' +import { GLOSSARY_INPUT_PROPERTIES } from './schemas/glossary.ts' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import type { GlossaryTermInput, GlossaryWriteResult } from '../models/glossary.ts' + +const siteIdParam = { + type: 'object', + properties: { + siteId: { type: 'string', format: 'uuid' } + }, + required: ['siteId'] +} + +const termIdParam = { + type: 'object', + properties: { + siteId: { type: 'string', format: 'uuid' }, + termId: { type: 'string', format: 'uuid' } + }, + required: ['siteId', 'termId'] +} + +const localeQuery = { + type: 'object', + properties: { + locale: { type: 'string', minLength: 1, maxLength: 255 } + }, + required: ['locale'] +} + +const conflictResponse = { + description: + 'Another term already holds one of the names (`nameTaken`), or somebody saved this term since it was opened (`stale`, with the term as it now stands).', + $ref: 'GlossaryConflict#' +} + +/** Whether the caller may read this site's glossary in this locale — `read:glossary`, path ignored. */ +function mayRead(req: FastifyRequest, siteId: string, locale: string): boolean { + return WIKI.models.groups.checkLocaleAccess( + WIKI.models.groups.actorForRequest(req), + 'read:glossary', + siteId, + locale + ) +} + +/** Whether the caller may edit this site's glossary in this locale — `manage:glossary`, path ignored. */ +function mayManage(req: FastifyRequest, siteId: string, locale: string): boolean { + return WIKI.models.groups.checkLocaleAccess( + WIKI.models.groups.actorForRequest(req), + 'manage:glossary', + siteId, + locale + ) +} + +/** + * The checks every write to an existing term makes: the site and its switch, the term, then + * `manage:glossary` in the term's own locale. A term the caller may not even read answers 404, as it + * does on the read route. + * + * @returns The term's locale when the handler may go on; when not, the reply has been sent + */ +async function guardWrite( + req: FastifyRequest, + reply: FastifyReply, + siteId: string, + termId: string +): Promise { + if (!WIKI.sites[siteId] || !WIKI.models.glossary.isEnabled(siteId)) { + reply.notFound('This site has no glossary.') + return null + } + const locale = await WIKI.models.glossary.localeOf(siteId, termId) + if (!locale || !mayRead(req, siteId, locale)) { + reply.notFound('This term does not exist.') + return null + } + if (!mayManage(req, siteId, locale)) { + reply.forbidden('You may not edit the glossary in this locale.') + return null + } + return locale +} + +/** + * The checks every locale-scoped read makes, in the order that leaks least: the site, then the + * permission, then whether the locale is one the site offers. + * + * @returns Whether the handler may go on; when not, the reply has been sent + */ +function guardRead(req: FastifyRequest, reply: FastifyReply, siteId: string, locale: string) { + if (!WIKI.sites[siteId]) { + reply.notFound('Site does not exist.') + return false + } + if (!mayRead(req, siteId, locale)) { + reply.forbidden('You may not read the glossary in this locale.') + return false + } + if (!WIKI.models.glossary.isActiveLocale(siteId, locale)) { + reply.badRequest('This locale is not active on this site.') + return false + } + return true +} + +/** Send a refused write as the status it stands for. */ +function sendRefusal( + reply: FastifyReply, + result: Exclude +): FastifyReply { + switch (result.reason) { + case 'notFound': + return reply.notFound('This term does not exist.') + case 'invalidRelated': + return reply.badRequest( + 'A related term does not exist, is in another locale, or is the term itself.' + ) + case 'nameTaken': + return reply.code(409).send({ + ok: false, + statusCode: 409, + error: 'Conflict', + message: `"${result.name}" is already used by the term "${result.holder.term}".`, + reason: 'nameTaken', + field: result.field, + name: result.name, + holder: result.holder + }) + case 'stale': + return reply.code(409).send({ + ok: false, + statusCode: 409, + error: 'Conflict', + message: 'This term was changed by somebody else since it was opened.', + reason: 'stale', + current: result.current + }) + } +} + +/** + * Glossary API Routes + * + * A site's glossary, one per locale. See `dev/specs/glossary.md`. + * + * **Reading** is `read:glossary` and **writing** `manage:glossary`, both PAGE permissions whose rule's + * path is ignored: they are asked per site and locale. So no route here declares route-level + * `permissions` (the hook reads the group-wide list only), and every one asks + * `groups.checkLocaleAccess` in the handler — a write against the locale of the term it touches. + * + * With `features.glossary` off the reads answer as though there were no terms and the writes 404, so + * that the switch hides the glossary without deleting it. + */ +async function routes(app: FastifyInstance) { + /** + * GLOSSARY ACCESS + */ + // -> No route-level permissions: what it reports IS the permission, per locale + app.get<{ Params: { siteId: string } }>( + '/sites/:siteId/glossary/access', + { + schema: { + summary: 'What the caller may do with the glossary', + description: + "Whether the site has a glossary, and the active locales the caller may read it in and edit it in. This is what decides whether the interface offers the glossary at all; it is asked once per site and session rather than per page, since neither permission depends on the page.\n\n`read:glossary` and `manage:glossary` are page permissions whose rule's path is ignored: every rule naming one that is scoped to the site and the locale applies, ALLOW < DENY < FORCE ALLOW. `manage:glossary` in a locale implies `read:glossary` there.", + tags: ['Glossary'], + params: siteIdParam, + response: { + 200: { + description: 'Glossary access of the caller', + type: 'object', + properties: { + enabled: { + type: 'boolean', + description: 'The site’s `features.glossary` switch.' + }, + readableLocales: { + type: 'array', + items: { type: 'string' }, + description: 'The active locales the caller may read the glossary in.' + }, + manageableLocales: { + type: 'array', + items: { type: 'string' }, + description: 'The active locales the caller may edit the glossary in.' + } + } + } + } + } + }, + async (req, reply) => { + const site = WIKI.sites[req.params.siteId] + if (!site) { + return reply.notFound('Site does not exist.') + } + const active: string[] = site.config?.locales?.active ?? [] + return { + enabled: WIKI.models.glossary.isEnabled(req.params.siteId), + readableLocales: active.filter((locale) => mayRead(req, req.params.siteId, locale)), + manageableLocales: active.filter((locale) => mayManage(req, req.params.siteId, locale)) + } + } + ) + + /** + * LIST TERMS + */ + // -> No route-level permissions: `read:glossary` is a page rule. See the note above. + app.get<{ Params: { siteId: string }; Querystring: { locale: string } }>( + '/sites/:siteId/glossary', + { + schema: { + summary: 'List the glossary terms of a locale', + description: + 'Every term in the locale, A–Z, with what the glossary’s list shows. The definition and the rest come from the single-term route.\n\nNeeds `read:glossary` in the locale. Empty while the site’s glossary is switched off.', + tags: ['Glossary'], + params: siteIdParam, + querystring: localeQuery, + response: { + 200: { + description: 'The terms of the locale, A–Z', + type: 'array', + items: { $ref: 'GlossaryTermSummary#' } + } + } + } + }, + async (req, reply) => { + if (!guardRead(req, reply, req.params.siteId, req.query.locale)) { + return reply + } + if (!WIKI.models.glossary.isEnabled(req.params.siteId)) { + return [] + } + return WIKI.models.glossary.list(req.params.siteId, req.query.locale) + } + ) + + /** + * LIST CATEGORIES + */ + // -> No route-level permissions: `read:glossary` is a page rule. See the note above. + app.get<{ Params: { siteId: string }; Querystring: { locale: string } }>( + '/sites/:siteId/glossary/categories', + { + schema: { + summary: 'List the glossary categories of a locale', + description: + 'Every category a term in the locale carries, A–Z — what the form suggests.\n\nNeeds `read:glossary` in the locale. Empty while the site’s glossary is switched off.', + tags: ['Glossary'], + params: siteIdParam, + querystring: localeQuery, + response: { + 200: { + description: 'Categories in use, A–Z', + type: 'array', + items: { type: 'string' } + } + } + } + }, + async (req, reply) => { + if (!guardRead(req, reply, req.params.siteId, req.query.locale)) { + return reply + } + if (!WIKI.models.glossary.isEnabled(req.params.siteId)) { + return [] + } + return WIKI.models.glossary.categories(req.params.siteId, req.query.locale) + } + ) + + /** + * LOOK UP A TERM BY NAME + */ + // -> No route-level permissions: `read:glossary` is a page rule. See the note above. + app.get<{ Params: { siteId: string }; Querystring: { locale: string; name: string } }>( + '/sites/:siteId/glossary/lookup', + { + schema: { + summary: 'Find a glossary term by name', + description: + 'The term a name belongs to, as its own name or as one of its aliases, compared without regard to case. What a link to a term by name resolves through.\n\nNeeds `read:glossary` in the locale.', + tags: ['Glossary'], + params: siteIdParam, + querystring: { + type: 'object', + properties: { + locale: { type: 'string', minLength: 1, maxLength: 255 }, + name: { type: 'string', minLength: 1, maxLength: 255 } + }, + required: ['locale', 'name'] + }, + response: { + 200: { + description: 'The term the name belongs to', + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' } + } + } + } + } + }, + async (req, reply) => { + if (!guardRead(req, reply, req.params.siteId, req.query.locale)) { + return reply + } + const id = WIKI.models.glossary.isEnabled(req.params.siteId) + ? await WIKI.models.glossary.lookup(req.params.siteId, req.query.locale, req.query.name) + : null + if (!id) { + return reply.notFound('No term goes by this name.') + } + return { id } + } + ) + + /** + * GET A TERM + */ + // -> No route-level permissions: `read:glossary` is a page rule. See the note above. + app.get<{ Params: { siteId: string; termId: string } }>( + '/sites/:siteId/glossary/:termId', + { + schema: { + summary: 'Get a glossary term', + description: + 'A term in full: its definition, related terms (from both sides), documentation page as it now resolves, and references.\n\nNeeds `read:glossary` in the term’s locale.', + tags: ['Glossary'], + params: termIdParam, + response: { + 200: { + description: 'The term', + $ref: 'GlossaryTerm#' + } + } + } + }, + async (req, reply) => { + const term = WIKI.models.glossary.isEnabled(req.params.siteId) + ? await WIKI.models.glossary.get(req.params.siteId, req.params.termId) + : null + // -> One answer for a term that is not there and one the caller may not read: the difference + // is not something to tell whoever is asking + if (!term || !mayRead(req, req.params.siteId, term.locale)) { + return reply.notFound('This term does not exist.') + } + return term + } + ) + + /** + * CREATE A TERM + */ + // -> No route-level permissions: `manage:glossary` is a page rule. See the note above. + app.post<{ Params: { siteId: string }; Body: GlossaryTermInput & { locale: string } }>( + '/sites/:siteId/glossary', + { + schema: { + summary: 'Create a glossary term', + description: + 'Needs `manage:glossary` in the locale.\n\nThe term’s name and its aliases must not be held by any other term of the locale, as a name or an alias, compared without regard to case; a clash answers 409 naming the term that holds it.\n\nThe locale is fixed here for good: a term in another locale is a different term.', + tags: ['Glossary'], + params: siteIdParam, + body: { + type: 'object', + required: ['locale', 'term'], + properties: { + locale: { type: 'string', minLength: 1, maxLength: 255 }, + ...GLOSSARY_INPUT_PROPERTIES + } + }, + response: { + 200: { + description: 'The term as created', + $ref: 'GlossaryTerm#' + }, + 409: conflictResponse + } + } + }, + async (req, reply) => { + const { siteId } = req.params + if (!WIKI.sites[siteId] || !WIKI.models.glossary.isEnabled(siteId)) { + return reply.notFound('This site has no glossary.') + } + if (!mayManage(req, siteId, req.body.locale)) { + return reply.forbidden('You may not edit the glossary in this locale.') + } + if (!WIKI.models.glossary.isActiveLocale(siteId, req.body.locale)) { + return reply.badRequest('This locale is not active on this site.') + } + const result = await WIKI.models.glossary.create( + siteId, + req.body.locale, + req.body, + req.session?.user?.id ?? null + ) + if (!result.ok) { + return sendRefusal(reply, result) + } + await audit(req, 'glossary', 'createGlossaryTerm', { + siteId, + locale: result.term.locale, + termId: result.term.id, + term: result.term.term + }) + return result.term + } + ) + + /** + * UPDATE A TERM + */ + // -> No route-level permissions: `manage:glossary` is a page rule. See the note above. + app.put<{ + Params: { siteId: string; termId: string } + Body: GlossaryTermInput & { expectedUpdatedAt: string } + }>( + '/sites/:siteId/glossary/:termId', + { + schema: { + summary: 'Update a glossary term', + description: + 'Needs `manage:glossary` in the term’s locale.\n\nReplaces the term, related terms included: a term taken out of `relatedTerms` loses this one from its own list too.\n\n`expectedUpdatedAt` is the `updatedAt` the term was opened with. When somebody has saved it since, nothing is written and the answer is 409 with the term as it now stands; re-sending with that `updatedAt` saves over it.', + tags: ['Glossary'], + params: termIdParam, + body: { + type: 'object', + required: ['term', 'expectedUpdatedAt'], + properties: { + ...GLOSSARY_INPUT_PROPERTIES, + expectedUpdatedAt: { type: 'string', format: 'date-time' } + } + }, + response: { + 200: { + description: 'The term as saved', + $ref: 'GlossaryTerm#' + }, + 409: conflictResponse + } + } + }, + async (req, reply) => { + const { siteId, termId } = req.params + if (!(await guardWrite(req, reply, siteId, termId))) { + return reply + } + const result = await WIKI.models.glossary.update( + siteId, + termId, + req.body, + req.body.expectedUpdatedAt, + req.session?.user?.id ?? null + ) + if (!result.ok) { + return sendRefusal(reply, result) + } + await audit(req, 'glossary', 'updateGlossaryTerm', { + siteId, + locale: result.term.locale, + termId, + term: result.term.term, + changedFields: result.changedFields + }) + return result.term + } + ) + + /** + * DELETE A TERM + */ + // -> No route-level permissions: `manage:glossary` is a page rule. See the note above. + app.delete<{ Params: { siteId: string; termId: string } }>( + '/sites/:siteId/glossary/:termId', + { + schema: { + summary: 'Delete a glossary term', + description: + 'Needs `manage:glossary` in the term’s locale. The term leaves every other term’s related list with it.', + tags: ['Glossary'], + params: termIdParam, + response: { + 200: { + description: 'Term deleted', + type: 'object', + properties: { + ok: { type: 'boolean' } + } + } + } + } + }, + async (req, reply) => { + const { siteId, termId } = req.params + if (!(await guardWrite(req, reply, siteId, termId))) { + return reply + } + const deleted = await WIKI.models.glossary.remove(siteId, termId) + if (!deleted) { + return reply.notFound('This term does not exist.') + } + await audit(req, 'glossary', 'deleteGlossaryTerm', { + siteId, + locale: deleted.locale, + termId, + term: deleted.term + }) + return { ok: true } + } + ) +} + +export default routes diff --git a/backend/api/index.ts b/backend/api/index.ts index f4dcd6b9..c0cfad25 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -15,6 +15,7 @@ async function routes(app: FastifyInstance) { await import('./schemas/comments.ts').then((m) => m.registerSchemas(app)) await import('./schemas/extension.ts').then((m) => m.registerSchemas(app)) await import('./schemas/flags.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/glossary.ts').then((m) => m.registerSchemas(app)) await import('./schemas/group.ts').then((m) => m.registerSchemas(app)) await import('./schemas/hook.ts').then((m) => m.registerSchemas(app)) await import('./schemas/icon.ts').then((m) => m.registerSchemas(app)) @@ -43,6 +44,7 @@ async function routes(app: FastifyInstance) { app.register(import('./blogs.ts')) app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' }) app.register(import('./comments.ts')) + app.register(import('./glossary.ts')) app.register(import('./groups.ts'), { prefix: '/groups' }) app.register(import('./hooks.ts'), { prefix: '/hooks' }) app.register(import('./icons.ts'), { prefix: '/icons' }) diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 9c1b36e7..f2434366 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -10,6 +10,7 @@ import { type SearchTagsMatch } from '../models/search.ts' import { audit } from '../helpers/audit.ts' +import { maySeeUnpublished, maySeeUnpublishedAnywhere } from '../helpers/publishing.ts' import { generatePathHash, normalizePagePath } from '../helpers/common.ts' import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts' @@ -152,6 +153,26 @@ export function mayReadSource(req: FastifyRequest, page: RulePageRef): boolean { return SOURCE_PERMISSIONS.some((permission) => mayOnPage(req, permission, page)) } +/** + * What `getPage`'s `unpublished` is for this requester: whether a page that is not live — a draft, or + * a scheduled page outside its window — may be handed to them. + * + * Only to a signed-in session holding `write:pages` or `manage:pages` on the page itself, which are + * the people working on it. Reading it, its source included, is not enough. A request without a + * session is held to live pages outright, API keys included — see `helpers/publishing.ts`. + */ +export function unpublishedFor(req: FastifyRequest): false | ((page: RulePageRef) => boolean) { + if (!actorFrom(req)) { + return false + } + const actor = WIKI.models.groups.actorForRequest(req) + // -> Settled in SQL for the reader who may edit nothing, which is most of them + if (!maySeeUnpublishedAnywhere(actor)) { + return false + } + return (page) => maySeeUnpublished(actor, page) +} + /** * Every page permission this requester holds at a path. * @@ -181,16 +202,15 @@ export function pagePermissionsFor(req: FastifyRequest, page: RulePageRef): stri /** * A page, as this requester is allowed to see it — or null when they are not allowed to see it at all. * - * The gate for anything that hangs off a page but is not the page itself. An anonymous requester only - * ever reaches a published page, and a password-protected one comes back with `isLocked` set until the + * The gate for anything that hangs off a page but is not the page itself. A page that is not live is + * only reached by whoever may edit it (`unpublishedFor`), and a password-protected one comes back with `isLocked` set until the * session has satisfied the unlock, which the caller is expected to refuse on. */ async function loadReadablePage(req: FastifyRequest, siteId: string, pageId: string) { - const actor = actorFrom(req) const page = await WIKI.models.pages.getPage({ siteId, id: pageId, - publicOnly: !actor, + unpublished: unpublishedFor(req), unlocked: (id: string) => unlockedFor(req, id) }) // -> Not readable is indistinguishable from not there, for anything hanging off the page @@ -447,8 +467,6 @@ async function routes(app: FastifyInstance) { } }, async (req) => { - const actor = actorFrom(req) - const permissions = actor?.permissions ?? [] return WIKI.models.search.searchPages({ siteId: req.params.siteId, query: req.query.query, @@ -464,13 +482,11 @@ async function routes(app: FastifyInstance) { orderByDirection: req.query.orderByDirection, offset: req.query.offset, limit: req.query.limit, - publicOnly: !actor, // -> So that a page the caller could not open never shows up as a result actor: WIKI.models.groups.actorForRequest(req), - // -> An unpublished page is only of interest to someone who could have written it - includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) => - permissions.includes(p) - ), + // -> The page view's rule, asked per result: a page that is not live is only listed for + // whoever may edit it, and never without a session + unpublished: Boolean(actorFrom(req)), // -> Same rule as the page view: a protected page's text is for whoever holds the password, and // a search excerpt is that text. Its title and description are not covered, so the page is // still listed — see `hideProtectedContent`. @@ -488,7 +504,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Get a page for inclusion', description: - "What an include block needs to draw another page inside the one being read: its title and its stored render, addressed by path rather than by ID, since a path is what an author writes into the page.\n\nThe reader's own access decides the answer, exactly as it would if they opened the page themselves — an anonymous request only ever sees published pages, and a password-protected page comes back with `isLocked: true` and no body unless this session has already unlocked it. So an include can never show content its reader could not have reached on their own.", + "What an include block needs to draw another page inside the one being read: its title and its stored render, addressed by path rather than by ID, since a path is what an author writes into the page.\n\nThe reader's own access decides the answer, exactly as it would if they opened the page themselves — a draft, or a scheduled page outside its publishing window, is only there for a signed-in requester holding `write:pages` or `manage:pages` on it, and a password-protected page comes back with `isLocked: true` and no body unless this session has already unlocked it. So an include can never show content its reader could not have reached on their own.", tags: ['Pages'], params: siteIdParam, querystring: { @@ -513,7 +529,6 @@ async function routes(app: FastifyInstance) { } }, async (req, reply) => { - const actor = actorFrom(req) // -> The stored form of whatever the including page wrote, since that is what it is looked up // by. The site root is the `home` page. const path = normalizePagePath(req.query.path) @@ -521,7 +536,7 @@ async function routes(app: FastifyInstance) { siteId: req.params.siteId, hash: generatePathHash(path || 'home'), locale: req.query.locale, - publicOnly: !actor, + unpublished: unpublishedFor(req), unlocked: (pageId) => unlockedFor(req, pageId), withPassword: false }) @@ -625,7 +640,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Get a single page', description: - "Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in — but an anonymous request only ever sees published pages. `withContent` is answered against `read:source` on the page — or `write:pages`, since the editor loads the source to edit it — which a group's rules grant to whoever they name, guests included.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.", + "Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in. A page that is not live — a draft, or a scheduled page outside its publishing window — answers 404 to everybody but a signed-in requester holding `write:pages` or `manage:pages` on it, its source included. `withContent` is answered against `read:source` on the page — or `write:pages`, since the editor loads the source to edit it — which a group's rules grant to whoever they name, guests included.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.", tags: ['Pages'], params: { type: 'object', @@ -677,7 +692,9 @@ async function routes(app: FastifyInstance) { withContent: req.query.withContent ? (target: RulePageRef) => mayReadSource(req, target) : false, - publicOnly: !actor, + // -> A draft, or a scheduled page outside its window, is not there for anybody who may not + // edit it — which is what keeps its render and its source alike from a reader + unpublished: unpublishedFor(req), // -> Answered once the page is known, since a hash does not say which page it is yet unlocked: (pageId) => unlockedFor(req, pageId), withPassword: mayBypassPassword(req) @@ -819,13 +836,12 @@ async function routes(app: FastifyInstance) { }, async (req, reply) => { const isId = uuidValidate(req.params.pageIdOrHash) - const actor = actorFrom(req) const page = await WIKI.models.pages.unlockPage({ siteId: req.params.siteId, ...(isId ? { id: req.params.pageIdOrHash } : { hash: req.params.pageIdOrHash }), locale: req.query.locale, password: req.body.password, - publicOnly: !actor + unpublished: unpublishedFor(req) }) if (!page) { return reply.unauthorized('Incorrect password.') diff --git a/backend/api/ratings.ts b/backend/api/ratings.ts index e163290d..c2e50a52 100644 --- a/backend/api/ratings.ts +++ b/backend/api/ratings.ts @@ -1,5 +1,5 @@ import { audit } from '../helpers/audit.ts' -import { actorFrom, mayOnPage, unlockedFor } from './pages.ts' +import { actorFrom, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import type { RatingMode } from '../models/pageRatings.ts' import type { Page } from '../models/pages.ts' @@ -51,6 +51,7 @@ async function loadRateable( const page = await WIKI.models.pages.getPage({ siteId: req.params.siteId, id: req.params.pageId, + unpublished: unpublishedFor(req), unlocked: (id: string) => unlockedFor(req, id) }) if (!page || !mayOnPage(req, 'read:pages', page)) { diff --git a/backend/api/schemas/glossary.ts b/backend/api/schemas/glossary.ts new file mode 100644 index 00000000..0bfd4e4b --- /dev/null +++ b/backend/api/schemas/glossary.ts @@ -0,0 +1,204 @@ +import { + GLOSSARY_ALIASES_MAX, + GLOSSARY_DEFINITION_MAX_LENGTH, + GLOSSARY_NAME_MAX_LENGTH, + GLOSSARY_REFERENCES_MAX, + GLOSSARY_RELATED_MAX +} from '../../models/glossary.ts' +import type { FastifyInstance } from 'fastify' + +/** The properties of a term the sidebar lists, shared by the summary and the full term. */ +const summaryProperties = { + id: { type: 'string', format: 'uuid' }, + term: { type: 'string' }, + expansion: { + type: ['string', 'null'], + description: 'The full form, when the term is an abbreviation.' + }, + aliases: { + type: 'array', + items: { type: 'string' }, + description: + 'Alternate forms and inflections. Unique across every term of the locale, like the term itself.' + }, + category: { type: ['string', 'null'] }, + caseSensitive: { + type: 'boolean', + description: + 'Whether the term matches text only with its exact case. About matching in text, never about uniqueness: `REST` and `rest` cannot be two terms either way.' + }, + autoLink: { + type: 'boolean', + description: + 'Whether the term is linked automatically where it appears in page text. Stored, and not yet acted on.' + } +} + +/** + * What a create or a save carries, as properties to spread into a route's body schema: the create adds + * `locale`, the save `expectedUpdatedAt`. Properties rather than a registered schema, because a body + * built from a `$ref` plus a property of its own needs `allOf`, and the coercion and defaults Fastify + * applies are less predictable through one. + */ +export const GLOSSARY_INPUT_PROPERTIES = { + term: { type: 'string', minLength: 1, maxLength: GLOSSARY_NAME_MAX_LENGTH }, + expansion: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH }, + definition: { type: 'string', maxLength: GLOSSARY_DEFINITION_MAX_LENGTH }, + aliases: { + type: 'array', + maxItems: GLOSSARY_ALIASES_MAX, + items: { type: 'string', maxLength: GLOSSARY_NAME_MAX_LENGTH }, + description: + 'Repeats, and copies of the term itself, are dropped rather than refused. A name another term already holds, as its term or an alias, is refused with 409.' + }, + relatedTerms: { + type: 'array', + maxItems: GLOSSARY_RELATED_MAX, + items: { type: 'string', format: 'uuid' }, + description: + 'IDs of terms in the same site and locale. Replaces the whole list, from both sides: a term taken out of this list loses this one from its own.' + }, + documentationPath: { + type: ['string', 'null'], + maxLength: GLOSSARY_NAME_MAX_LENGTH, + description: 'A page path in the term’s own locale, without a locale prefix.' + }, + documentationLabel: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH }, + references: { + type: 'array', + maxItems: GLOSSARY_REFERENCES_MAX, + items: { + type: 'object', + required: ['url'], + properties: { + url: { + type: 'string', + maxLength: 2048, + pattern: '^https?://', + description: '`http:` or `https:` only.' + }, + label: { type: 'string', maxLength: GLOSSARY_NAME_MAX_LENGTH } + } + } + }, + caseSensitive: { type: 'boolean', default: false }, + autoLink: { type: 'boolean', default: true }, + category: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH } +} + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * GLOSSARY TERM SUMMARY - A term as the glossary's list shows it + */ + app.addSchema({ + $id: 'GlossaryTermSummary', + type: 'object', + properties: summaryProperties + }) + + /** + * GLOSSARY TERM - A term in full + */ + app.addSchema({ + $id: 'GlossaryTerm', + type: 'object', + properties: { + ...summaryProperties, + siteId: { type: 'string', format: 'uuid' }, + locale: { type: 'string' }, + definition: { + type: 'string', + description: 'Basic markdown, never HTML. Rendered by the client with raw HTML disabled.' + }, + references: { + type: 'array', + items: { + type: 'object', + properties: { + url: { type: 'string' }, + label: { type: 'string', description: 'Empty when the URL stands in for one.' } + } + } + }, + relatedTerms: { + type: 'array', + description: + 'Related terms, which is two-way: a term lists every term that lists it. A–Z by name.', + items: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + term: { type: 'string' } + } + } + }, + documentation: { + type: ['object', 'null'], + description: + 'The page in the same locale that documents the term, looked up as it is read. Null when none is named; `exists` false when the named path has no page.', + properties: { + path: { type: 'string' }, + label: { + type: ['string', 'null'], + description: 'The link text. Null for the default, which the client supplies.' + }, + title: { type: ['string', 'null'] }, + exists: { type: 'boolean' } + } + }, + author: { + type: ['object', 'null'], + description: 'Who saved it last. Null for an API key, or once the account is gone.', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' } + } + }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { + type: 'string', + format: 'date-time', + description: + 'At millisecond precision. A save names the value it was opened with as `expectedUpdatedAt`.' + } + } + }) + + /** + * GLOSSARY CONFLICT - Why a write answered 409 + */ + app.addSchema({ + $id: 'GlossaryConflict', + type: 'object', + properties: { + ok: { type: 'boolean' }, + statusCode: { type: 'integer' }, + error: { type: 'string' }, + message: { type: 'string' }, + reason: { + type: 'string', + enum: ['nameTaken', 'stale'], + description: + '`nameTaken`: another term already holds one of the names. `stale`: somebody saved the term after the caller opened it.' + }, + field: { + type: 'string', + enum: ['term', 'aliases'], + description: 'For `nameTaken`, which of the caller’s fields holds the name.' + }, + name: { type: 'string', description: 'For `nameTaken`, the name that is held.' }, + holder: { + type: 'object', + description: 'For `nameTaken`, the term holding it.', + properties: { + id: { type: 'string', format: 'uuid' }, + term: { type: 'string' } + } + }, + current: { + $ref: 'GlossaryTerm#', + description: 'For `stale`, the term as it now stands.' + } + } + }) +} diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index f67eac79..54f852df 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -107,6 +107,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { description: 'Whether this site has comments at all. Which provider handles them is `comments.provider`; this turns every one of them off without losing that choice, and a page can still opt out on its own with `allowComments`.' }, + glossary: { + type: 'boolean', + description: + 'Whether this site has a glossary. On by default. Off hides it everywhere — the Library menu has no Glossary row and the glossary routes answer as though there were no terms — and keeps every term for when it is turned back on. Who may read it is the `read:glossary` page permission, and who may edit it `manage:glossary`, both granted per locale.' + }, lastEditedBy: { type: 'boolean', description: diff --git a/backend/api/tree.ts b/backend/api/tree.ts index 7fb01424..60d0a1f5 100644 --- a/backend/api/tree.ts +++ b/backend/api/tree.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy, type TreeRow } from '../models/tree.ts' import { audit } from '../helpers/audit.ts' import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts' -import { actorFrom } from './pages.ts' +import { actorFrom, unpublishedFor } from './pages.ts' interface TreeQuery { parentId?: string @@ -280,7 +280,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Get the page graph', description: - "Every page of one locale the caller may read, the folders they sit in, and the links between them — what the file manager's graph view draws.\n\nA page that is not published is included only for a caller who may also edit it (`write:pages`), and is marked with `isPublished: false`. A link is included only when the caller may see both of its ends. Links reaching a page in another locale bring that page along, so the far end can be named.", + "Every page of one locale the caller may read, the folders they sit in, and the links between them — what the file manager's graph view draws.\n\nA page that is not live — a draft, or a scheduled page outside its publishing window — is included only for a signed-in caller who may also edit it (`write:pages` or `manage:pages`), and is marked with `isPublished: false`. A link is included only when the caller may see both of its ends. Links reaching a page in another locale bring that page along, so the far end can be named.", tags: ['Tree'], params: siteIdParam, querystring: { @@ -301,9 +301,9 @@ async function routes(app: FastifyInstance) { async (req) => { const siteId = req.params.siteId const actor = WIKI.models.groups.actorForRequest(req) - // -> A draft belongs to the people working on it, and an API key is answered as the public is - // -- the same line the page route draws, via `actorFrom` - const isSession = actorFrom(req) !== null + // -> A page that is not live belongs to the people working on it, and an API key is answered as + // the public is -- the same line the page route draws + const unpublished = unpublishedFor(req) return WIKI.models.pageGraph.graphFor({ siteId, locale: req.query.locale || defaultLocale(siteId), @@ -312,12 +312,10 @@ async function routes(app: FastifyInstance) { if (!WIKI.models.groups.checkAccess(actor, 'read:pages', ref)) { return null } - if (page.publishState === 'published') { + if (page.isLive) { return 'published' } - return isSession && WIKI.models.groups.checkAccess(actor, 'write:pages', ref) - ? 'draft' - : null + return unpublished && unpublished(ref) ? 'draft' : null } }) } @@ -398,7 +396,7 @@ async function routes(app: FastifyInstance) { siteId: req.params.siteId, path: req.query.path, locale: req.query.locale ?? defaultLocale(req.params.siteId), - publicOnly: !req.session?.authenticated + unpublished: unpublishedFor(req) }) if (!level) { return reply.notFound('This folder does not exist.') @@ -513,7 +511,7 @@ async function routes(app: FastifyInstance) { orderBy: req.query.orderBy, orderByDirection: req.query.orderByDirection, depth: req.query.depth, - publicOnly: !req.session?.authenticated + unpublished: unpublishedFor(req) }) // -> An index block is drawn inside a page, but it lists other pages: each one still has to be // the reader's to see diff --git a/backend/api/watching.ts b/backend/api/watching.ts index 71fd7f56..611423a1 100644 --- a/backend/api/watching.ts +++ b/backend/api/watching.ts @@ -1,19 +1,20 @@ import { audit } from '../helpers/audit.ts' -import { actorFrom, mayOnPage, unlockedFor } from './pages.ts' +import { actorFrom, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' /** * The page being watched, as this requester is allowed to see it. * * Watching a page is a thing done TO a page, so it goes through the same gate as reading one: an - * anonymous requester never gets here at all, and a page somebody may not read is answered as though - * it were not there. A password is not part of it — the watcher is asking to be told when the page + * anonymous requester never gets here at all, and a page somebody may not read — or may not see + * before it is live — is answered as though it were not there. A password is not part of it — the watcher is asking to be told when the page * changes, not to read what it says. */ async function loadWatchablePage(req: FastifyRequest, siteId: string, pageId: string) { const page = await WIKI.models.pages.getPage({ siteId, id: pageId, + unpublished: unpublishedFor(req), unlocked: (id: string) => unlockedFor(req, id) }) if (!page || !mayOnPage(req, 'read:pages', page)) { diff --git a/backend/db/migrations/20261006025902_glossary/migration.sql b/backend/db/migrations/20261006025902_glossary/migration.sql new file mode 100644 index 00000000..9ba7f5cc --- /dev/null +++ b/backend/db/migrations/20261006025902_glossary/migration.sql @@ -0,0 +1,36 @@ +CREATE TABLE "glossaryTermRelations" ( + "termId" uuid, + "relatedId" uuid, + CONSTRAINT "glossaryTermRelations_pkey" PRIMARY KEY("termId","relatedId"), + CONSTRAINT "glossaryTermRelations_order_check" CHECK ("termId" < "relatedId") +); +--> statement-breakpoint +CREATE TABLE "glossaryTerms" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "siteId" uuid NOT NULL, + "locale" varchar(255) NOT NULL, + "term" varchar(255) NOT NULL, + "expansion" varchar(255), + "definition" text DEFAULT '' NOT NULL, + "aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL, + "documentationPath" varchar(255), + "documentationLabel" varchar(255), + "references" jsonb DEFAULT '[]' NOT NULL, + "caseSensitive" boolean DEFAULT false NOT NULL, + "autoLink" boolean DEFAULT true NOT NULL, + "category" varchar(255), + "creatorId" uuid, + "authorId" uuid, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "glossaryTermRelations_relatedId_idx" ON "glossaryTermRelations" ("relatedId");--> statement-breakpoint +CREATE INDEX "glossaryTerms_site_locale_idx" ON "glossaryTerms" ("siteId","locale");--> statement-breakpoint +CREATE UNIQUE INDEX "glossaryTerms_site_locale_term_key" ON "glossaryTerms" ("siteId","locale",lower("term"));--> statement-breakpoint +CREATE INDEX "glossaryTerms_documentation_idx" ON "glossaryTerms" ("siteId","locale","documentationPath") WHERE "documentationPath" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "glossaryTermRelations" ADD CONSTRAINT "glossaryTermRelations_termId_glossaryTerms_id_fkey" FOREIGN KEY ("termId") REFERENCES "glossaryTerms"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "glossaryTermRelations" ADD CONSTRAINT "glossaryTermRelations_relatedId_glossaryTerms_id_fkey" FOREIGN KEY ("relatedId") REFERENCES "glossaryTerms"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_creatorId_users_id_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/backend/db/migrations/20261006025902_glossary/snapshot.json b/backend/db/migrations/20261006025902_glossary/snapshot.json new file mode 100644 index 00000000..98ca83bb --- /dev/null +++ b/backend/db/migrations/20261006025902_glossary/snapshot.json @@ -0,0 +1,8930 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "b03ef7d5-3d2c-4ffe-8e95-8f8fc9e12c2d", + "prevIds": [ + "64e81c23-7c91-4b5f-98a9-78e5a14fe7db" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "open", + "finished", + "failed" + ], + "name": "importSessionState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "active", + "completed", + "failed", + "interrupted" + ], + "name": "jobHistoryState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "published", + "scheduled" + ], + "name": "pagePublishState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "inherit", + "override", + "overrideExact", + "hide", + "hideExact" + ], + "name": "treeNavigationMode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "folder", + "page", + "asset" + ], + "name": "treeType", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "apiKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "approvalRules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "assets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auditLog", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authentication", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "commentAnnotations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "glossaryTermRelations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "glossaryTerms", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "hooks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "iconSets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "icons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "importIdMap", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "importSessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobLock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobSchedule", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "locales", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notificationEvents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageEditSubmissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageLinks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRatings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRenderQueue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageWatching", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rateLimits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "siteAssets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sites", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storage", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tree", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userAvatars", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userGroups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userNotificationPrefs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "keyShort", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "expiration", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRevoked", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'START'", + "generated": null, + "identity": null, + "name": "match", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "submitterGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "reviewerGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileExt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "assetKind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'other'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'application/octet-stream'", + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileSize", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "clientIP", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "displayName", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "registration", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "allowedEmailRegex", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "autoEnrollGroups", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "block", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isCustom", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "definition", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "packageData", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "checksum", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "commentId", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "note", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "anchor", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorName", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorEmail", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorIP", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "termId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "term", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expansion", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "definition", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "documentationPath", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "documentationLabel", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "references", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "caseSensitive", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "autoLink", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rules", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnFirstLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogout", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "events", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "includeMetadata", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "includeContent", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "acceptUntrusted", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authHeader", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "hookState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "info", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refreshedAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "left", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "top", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "rotate", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "vFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sessionId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "namespace", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "sites", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "includes", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "overwrite", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'markdown'", + "generated": null, + "identity": null, + "name": "htmlConversion", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "importSessionState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "progress", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "warnings", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jobHistoryState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "wasScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "executedBy", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastCheckedBy", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastCheckedAt", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cron", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "retries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "waitUntil", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nativeName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "script", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRTL", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isInstalled", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customCode", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "strings", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completeness", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "items", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'user'", + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "recipients", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cursor", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimedAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimedBy", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processedAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variant", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "commentId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "count", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastEventId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "inApp", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "emailState", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailAfter", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailedAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "patch", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseHash", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestName", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestEmail", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'updated'", + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "versionDate", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetSiteId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetLocale", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetPath", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetRef", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowScripts", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowStyles", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "requestedById", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "alias", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "pagePublishState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "publishState", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishStartDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishEndDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "relations", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localeGroupId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "render", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "searchContent", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "toc", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editor", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentType", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isBrowsable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isSearchable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"pages\".\"publishState\" != 'draft' AND \"pages\".\"isSearchable\"", + "type": "stored" + }, + "identity": null, + "name": "isSearchableComputed", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "ratings", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "scripts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "historyData", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "hits", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "windowStartedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bannedUntil", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "contentTypes", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assetDelivery", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usageCount", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "ltree", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folderPath", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeType", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tree", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeNavigationMode", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'inherit'", + "generated": null, + "identity": null, + "name": "navigationMode", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "navigationId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "validUntil", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "handle", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "passkeys", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "prefs", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasAvatar", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isActive", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isVerified", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastLoginAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "approvalRules_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "assets_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_kind_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "commentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "commentAnnotations_commentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "commentAnnotations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_page_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_parentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "glossaryTermRelations_relatedId_idx", + "entityType": "indexes", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "glossaryTerms_site_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lower(\"term\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "glossaryTerms_site_locale_term_key", + "entityType": "indexes", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "documentationPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"documentationPath\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "glossaryTerms_documentation_idx", + "entityType": "indexes", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "groups_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "groups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "importSessions_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "importSessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "locales_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "locales" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_locale_key", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"processedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notificationEvents_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "notificationEvents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "processedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"processedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notificationEvents_processed_idx", + "entityType": "indexes", + "schema": "public", + "table": "notificationEvents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_updated_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"readAt\" IS NULL AND \"inApp\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "emailAfter", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailState\" IN ('pending', 'sending')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"readAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_group_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "commentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_commentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"authorId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_page_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetSiteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetLocale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetSiteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetRef", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_targetRef_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "href", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_pageId_href_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRatings_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRenderQueue_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_user_site_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_page_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "creatorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_creatorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ownerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_ownerId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isSearchableComputed", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_isSearchableComputed_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "localeGroupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_localeGroupId_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_locale_path_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "rateLimits_updatedAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "rateLimits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "module", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storage_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_folderpath_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gist", + "concurrently": false, + "name": "tree_folderpath_gist_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_fileName_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tree", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationMode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationMode_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "tree_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_groupId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userKeys_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"enabled\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "userNotificationPrefs_optin_idx", + "entityType": "indexes", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastLoginAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_lastLoginAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"handle\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_handle_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "approvalRules_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "auditLog_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "blocks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "commentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "commentAnnotations_commentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "commentAnnotations" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "commentAnnotations_resolvedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "commentAnnotations" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_parentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "comments_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "termId" + ], + "schemaTo": "public", + "tableTo": "glossaryTerms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "glossaryTermRelations_termId_glossaryTerms_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "nameExplicit": false, + "columns": [ + "relatedId" + ], + "schemaTo": "public", + "tableTo": "glossaryTerms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "glossaryTermRelations_relatedId_glossaryTerms_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "glossaryTerms_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "glossaryTerms_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "glossaryTerms_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": false, + "columns": [ + "prefix" + ], + "schemaTo": "public", + "tableTo": "iconSets", + "columnsTo": [ + "prefix" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "icons_prefix_iconSets_prefix_fkey", + "entityType": "fks", + "schema": "public", + "table": "icons" + }, + { + "nameExplicit": false, + "columns": [ + "sessionId" + ], + "schemaTo": "public", + "tableTo": "importSessions", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "importIdMap_sessionId_importSessions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "importIdMap" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "importSessions_actorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "importSessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "navigation_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "commentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_commentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_actorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageEditSubmissions_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageHistory_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageHistory_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "targetSiteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageLinks_targetSiteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageLinks_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageLinks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRatings_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRatings_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRenderQueue_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageRenderQueue_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "requestedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageRenderQueue_requestedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageWatching_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "ownerId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_ownerId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "sessions_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "siteAssets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "siteAssets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "storage_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tags_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tree_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "groupId" + ], + "schemaTo": "public", + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_groupId_groups_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "userKeys_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userNotificationPrefs_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "columns": [ + "termId", + "relatedId" + ], + "nameExplicit": false, + "name": "glossaryTermRelations_pkey", + "entityType": "pks", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "columns": [ + "prefix", + "name" + ], + "nameExplicit": false, + "name": "icons_pkey", + "entityType": "pks", + "schema": "public", + "table": "icons" + }, + { + "columns": [ + "sessionId", + "entity", + "sourceId" + ], + "nameExplicit": false, + "name": "importIdMap_pkey", + "entityType": "pks", + "schema": "public", + "table": "importIdMap" + }, + { + "columns": [ + "pageId", + "userId" + ], + "nameExplicit": false, + "name": "pageRatings_pkey", + "entityType": "pks", + "schema": "public", + "table": "pageRatings" + }, + { + "columns": [ + "siteId", + "kind" + ], + "nameExplicit": false, + "name": "siteAssets_pkey", + "entityType": "pks", + "schema": "public", + "table": "siteAssets" + }, + { + "columns": [ + "userId", + "groupId" + ], + "nameExplicit": false, + "name": "userGroups_pkey", + "entityType": "pks", + "schema": "public", + "table": "userGroups" + }, + { + "columns": [ + "userId", + "category", + "channel" + ], + "nameExplicit": false, + "name": "userNotificationPrefs_pkey", + "entityType": "pks", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "apiKeys_pkey", + "schema": "public", + "table": "apiKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "approvalRules_pkey", + "schema": "public", + "table": "approvalRules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "assets_pkey", + "schema": "public", + "table": "assets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "auditLog_pkey", + "schema": "public", + "table": "auditLog", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "authentication_pkey", + "schema": "public", + "table": "authentication", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blocks_pkey", + "schema": "public", + "table": "blocks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "commentAnnotations_pkey", + "schema": "public", + "table": "commentAnnotations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "comments_pkey", + "schema": "public", + "table": "comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "glossaryTerms_pkey", + "schema": "public", + "table": "glossaryTerms", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "groups_pkey", + "schema": "public", + "table": "groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "hooks_pkey", + "schema": "public", + "table": "hooks", + "entityType": "pks" + }, + { + "columns": [ + "prefix" + ], + "nameExplicit": false, + "name": "iconSets_pkey", + "schema": "public", + "table": "iconSets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "importSessions_pkey", + "schema": "public", + "table": "importSessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobHistory_pkey", + "schema": "public", + "table": "jobHistory", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "jobLock_pkey", + "schema": "public", + "table": "jobLock", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobSchedule_pkey", + "schema": "public", + "table": "jobSchedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobs_pkey", + "schema": "public", + "table": "jobs", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "locales_pkey", + "schema": "public", + "table": "locales", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "navigation_pkey", + "schema": "public", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notificationEvents_pkey", + "schema": "public", + "table": "notificationEvents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notifications_pkey", + "schema": "public", + "table": "notifications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageEditSubmissions_pkey", + "schema": "public", + "table": "pageEditSubmissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageHistory_pkey", + "schema": "public", + "table": "pageHistory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageLinks_pkey", + "schema": "public", + "table": "pageLinks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageRenderQueue_pkey", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageWatching_pkey", + "schema": "public", + "table": "pageWatching", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pages_pkey", + "schema": "public", + "table": "pages", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rateLimits_pkey", + "schema": "public", + "table": "rateLimits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "settings_pkey", + "schema": "public", + "table": "settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sites_pkey", + "schema": "public", + "table": "sites", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "storage_pkey", + "schema": "public", + "table": "storage", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tree_pkey", + "schema": "public", + "table": "tree", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userAvatars_pkey", + "schema": "public", + "table": "userAvatars", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userKeys_pkey", + "schema": "public", + "table": "userKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "customCode" + ], + "nullsNotDistinct": false, + "name": "locales_customCode_key", + "schema": "public", + "table": "locales", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "nullsNotDistinct": false, + "name": "pageRenderQueue_pageId_key", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "hostname" + ], + "nullsNotDistinct": false, + "name": "sites_hostname_key", + "schema": "public", + "table": "sites", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + }, + { + "value": "\"termId\" < \"relatedId\"", + "name": "glossaryTermRelations_order_check", + "entityType": "checks", + "schema": "public", + "table": "glossaryTermRelations" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 8d3e7f39..a9e47c61 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -3,6 +3,7 @@ import { bigint, boolean, bytea, + check, customType, index, integer, @@ -365,6 +366,91 @@ export const commentAnnotations = pgTable( (table) => [index('commentAnnotations_commentId_idx').on(table.commentId, table.position)] ) +// GLOSSARY ---------------------------- + +/** + * One term of a site's glossary, in one locale. See `dev/specs/glossary.md`. + * + * **A name means one term.** The term and every alias share one namespace per site and locale, + * compared with `lower()`. The unique index below is the database's half of that, term against term; + * everything involving an alias is checked by the model, since an index cannot reach into an array + * and compare it against another row's scalar. + * + * `caseSensitive` is about how the term is matched IN TEXT and never about uniqueness: `REST` and + * `rest` cannot be two terms either way. + */ +export const glossaryTerms = pgTable( + 'glossaryTerms', + { + id: uuid().primaryKey().defaultRandom(), + siteId: uuid() + .notNull() + .references(() => sites.id, { onDelete: 'cascade' }), + locale: varchar({ length: 255 }).notNull(), + term: varchar({ length: 255 }).notNull(), + /** The full form, when the term is an abbreviation. */ + expansion: varchar({ length: 255 }), + /** Basic markdown source, rendered in the browser with raw HTML disabled. Never HTML. */ + definition: text().notNull().default(''), + aliases: text() + .array() + .notNull() + .default(sql`ARRAY[]::text[]`), + /** A page path in the term's own locale, with no locale prefix. Looked up when read. */ + documentationPath: varchar({ length: 255 }), + /** Null for the default, "Read more", which is a locale string and not stored. */ + documentationLabel: varchar({ length: 255 }), + /** `[{ url, label }]`, in the order given. */ + references: jsonb().notNull().default([]), + caseSensitive: boolean().notNull().default(false), + /** Whether the term is linked automatically in page text. Nothing reads it yet. */ + autoLink: boolean().notNull().default(true), + category: varchar({ length: 255 }), + creatorId: uuid().references(() => users.id, { onDelete: 'set null' }), + /** Who saved it last. */ + authorId: uuid().references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp().notNull().defaultNow(), + /** Also the concurrency token: a save names the one it was opened with. */ + updatedAt: timestamp().notNull().defaultNow() + }, + (table) => [ + index('glossaryTerms_site_locale_idx').on(table.siteId, table.locale), + uniqueIndex('glossaryTerms_site_locale_term_key').on( + table.siteId, + table.locale, + sql`lower(${table.term})` + ), + // -> What a page move with `updateLinks` looks terms up by + index('glossaryTerms_documentation_idx') + .on(table.siteId, table.locale, table.documentationPath) + .where(sql`"documentationPath" IS NOT NULL`) + ] +) + +/** + * Two related glossary terms. Related is two-way, so a pair is ONE row, read from both sides. + * + * The check stores the smaller id first, which is what makes "A is related to B" and "B is related + * to A" the same row rather than two that could disagree. A join table rather than an array on the + * term, so that deleting a term takes it out of every other term's list by cascade alone. + */ +export const glossaryTermRelations = pgTable( + 'glossaryTermRelations', + { + termId: uuid() + .notNull() + .references(() => glossaryTerms.id, { onDelete: 'cascade' }), + relatedId: uuid() + .notNull() + .references(() => glossaryTerms.id, { onDelete: 'cascade' }) + }, + (table) => [ + primaryKey({ columns: [table.termId, table.relatedId] }), + index('glossaryTermRelations_relatedId_idx').on(table.relatedId), + check('glossaryTermRelations_order_check', sql`${table.termId} < ${table.relatedId}`) + ] +) + // GROUPS ------------------------------ export const groups = pgTable( 'groups', diff --git a/backend/helpers/appShell.ts b/backend/helpers/appShell.ts index 27c837e5..77d9cce5 100644 --- a/backend/helpers/appShell.ts +++ b/backend/helpers/appShell.ts @@ -381,8 +381,7 @@ async function bodyForBlog(siteId: string, page: PageDescription): Promise${htmlEscape(blog.settings.intro)}

` : '' const items = posts diff --git a/backend/helpers/pageRules.ts b/backend/helpers/pageRules.ts index 106476aa..67451cd0 100644 --- a/backend/helpers/pageRules.ts +++ b/backend/helpers/pageRules.ts @@ -192,16 +192,29 @@ function outranks(a: number[], b: number[]): boolean { return false } -/** Whether a rule addresses this page at all, ignoring what it then says about it. */ -export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean { +/** A site and a locale, for the permissions that are about those rather than a page. */ +export interface RuleLocaleRef { + siteId: string + locale?: string +} + +/** Whether a rule speaks for this site and locale at all — its `sites` and `locales`, nothing else. */ +function ruleIsScopedTo(rule: GroupRule, scope: RuleLocaleRef): boolean { // -> A rule may be limited to particular sites; an empty list means every one of them - if (rule.sites?.length > 0 && !rule.sites.includes(page.siteId)) { + if (rule.sites?.length > 0 && !rule.sites.includes(scope.siteId)) { return false } - // -> And to particular locales, the same way. Unlike the site, a reference may leave the locale // out — an asset addressed by path alone — and such a page is not one a locale rule excludes - if (rule.locales?.length > 0 && page.locale && !rule.locales.includes(page.locale)) { + if (rule.locales?.length > 0 && scope.locale && !rule.locales.includes(scope.locale)) { + return false + } + return true +} + +/** Whether a rule addresses this page at all, ignoring what it then says about it. */ +export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean { + if (!ruleIsScopedTo(rule, page)) { return false } @@ -270,6 +283,35 @@ export function resolvePageRule( return winner } +/** + * The rule that decides a LOCALE permission (`LOCALE_PERMISSIONS` in `models/groups.ts`), for which a + * rule's `match`, `path` and `tags` mean nothing. + * + * Every rule naming the permission that is scoped to the site and locale applies. Steps 1 to 3 of the + * ordering above rank rules by how precisely they address a PAGE, and with no page being asked about + * every rule is as precise as every other — so step 4 alone decides: a DENY anywhere overrides every + * ALLOW, and a FORCE ALLOW anywhere overrides every DENY. A rule written for `/docs` therefore speaks + * for the whole locale, in both directions. + * + * @returns The deciding rule, or null when nothing names the permission — which means denied + */ +export function resolveLocaleRule( + rules: GroupRule[], + permission: string, + scope: RuleLocaleRef +): GroupRule | null { + let winner: GroupRule | null = null + for (const rule of rules) { + if (!rule.roles?.includes(permission) || !ruleIsScopedTo(rule, scope)) { + continue + } + if (!winner || MODE_PRIORITY.indexOf(rule.mode) > MODE_PRIORITY.indexOf(winner.mode)) { + winner = rule + } + } + return winner +} + /** * Whether the caller's rules grant a permission on a page. * diff --git a/backend/helpers/publishing.ts b/backend/helpers/publishing.ts new file mode 100644 index 00000000..62208e30 --- /dev/null +++ b/backend/helpers/publishing.ts @@ -0,0 +1,64 @@ +import { sql, type SQL } from 'drizzle-orm' +import type { PgColumn } from 'drizzle-orm/pg-core' +import type { AccessActor } from '../models/groups.ts' +import type { RulePageRef } from './pageRules.ts' + +/** + * Whether a page is open to its readers, and who may see it while it is not. + * + * A page is LIVE when it is `published`, or `scheduled` and inside its window — on or after + * `publishStartDate` and before `publishEndDate`, either end left open when unset. A `draft` never + * is. The dates gate a `scheduled` page only: on a `published` one `publishStartDate` is the date a + * blog post is dated by, which an author may well set in the past or the future without meaning to + * take the post down. + * + * A page that is not live is the business of the people working on it, and of nobody else: whoever + * holds `write:pages` or `manage:pages` on it may see it, its source included, and every other + * reader is answered as though it were not there. A request without a session never sees one — + * anonymous readers and API keys alike — since a draft belongs to people, not to a token. + * + * Every read that answers a reader goes through these two, so that no route, listing or search can + * disagree with the page view about whether a page is there. + */ + +type PublishColumns = { + publishState: PgColumn | SQL + publishStartDate: PgColumn | SQL + publishEndDate: PgColumn | SQL +} + +/** What lets a requester see a page that is not live. */ +export const UNPUBLISHED_PERMISSIONS = ['write:pages', 'manage:pages'] + +/** + * The SQL condition for a page being live now. + * + * Decided by the database so that it is one definition wherever a page is read. Compared against an + * ISO string cast to `timestamp`, which is how drizzle writes the two columns: they hold UTC with no + * zone, and comparing them to `now()` would read them in the session's zone instead. + */ +export function liveCondition(columns: PublishColumns): SQL { + const now = new Date().toISOString() + return sql`(${columns.publishState} = 'published' OR (${columns.publishState} = 'scheduled' + AND (${columns.publishStartDate} IS NULL OR ${columns.publishStartDate} <= ${now}::timestamp) + AND (${columns.publishEndDate} IS NULL OR ${columns.publishEndDate} > ${now}::timestamp)))` +} + +/** Whether this actor may see this page while it is not live. */ +export function maySeeUnpublished(actor: AccessActor, page: RulePageRef): boolean { + return UNPUBLISHED_PERMISSIONS.some((permission) => + WIKI.models.groups.checkAccess(actor, permission, page) + ) +} + +/** + * Whether this actor could see an unpublished page ANYWHERE. + * + * A pre-filter for listings, never the answer for a page: an actor for whom this is false is held to + * live pages in SQL, and one for whom it is true has every row checked with `maySeeUnpublished`. + */ +export function maySeeUnpublishedAnywhere(actor: AccessActor): boolean { + return UNPUBLISHED_PERMISSIONS.some((permission) => + WIKI.models.groups.grantsAnywhere(actor, permission) + ) +} diff --git a/backend/locales/en.json b/backend/locales/en.json index 875c4fe8..19e1e9ca 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -140,6 +140,7 @@ "admin.audit.actions.createAuthStrategy": "Added an authentication strategy", "admin.audit.actions.createComment": "Posted a comment", "admin.audit.actions.createFolder": "Created a folder", + "admin.audit.actions.createGlossaryTerm": "Created a glossary term", "admin.audit.actions.createGroup": "Created a group", "admin.audit.actions.createHook": "Created a webhook", "admin.audit.actions.createPage": "Created a page", @@ -153,6 +154,7 @@ "admin.audit.actions.deleteBlock": "Deleted a custom block", "admin.audit.actions.deleteComment": "Deleted a comment", "admin.audit.actions.deleteFolder": "Deleted a folder", + "admin.audit.actions.deleteGlossaryTerm": "Deleted a glossary term", "admin.audit.actions.deleteGroup": "Deleted a group", "admin.audit.actions.deleteHook": "Deleted a webhook", "admin.audit.actions.deleteIconSet": "Deleted an icon set", @@ -233,6 +235,7 @@ "admin.audit.actions.updateEditorSettings": "Changed their editor settings", "admin.audit.actions.updateFlags": "Changed the system flags", "admin.audit.actions.updateFolder": "Renamed a folder", + "admin.audit.actions.updateGlossaryTerm": "Edited a glossary term", "admin.audit.actions.updateGroup": "Updated a group", "admin.audit.actions.updateHook": "Updated a webhook", "admin.audit.actions.updateIconSet": "Enabled or disabled an icon set", @@ -276,6 +279,7 @@ "admin.audit.kinds.asset": "File", "admin.audit.kinds.auth": "Sign-in", "admin.audit.kinds.comment": "Comment", + "admin.audit.kinds.glossary": "Glossary", "admin.audit.kinds.page": "Page", "admin.audit.kinds.profile": "Profile", "admin.audit.loadActionsFailed": "Could not load the list of actions.", @@ -501,6 +505,8 @@ "admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Changes are still only stored when someone saves the page.", "admin.general.allowComments": "Allow Comments", "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", + "admin.general.allowGlossary": "Glossary", + "admin.general.allowGlossaryHint": "Offer a glossary of terms in the Library menu. Who may read it is set by the Read Glossary page rule, and who may edit it by the Manage Glossary permission.", "admin.general.allowLastEditedBy": "Allow Last Edited By", "admin.general.allowLastEditedByHint": "Can the \"Last Edited By\" info be displayed on pages?", "admin.general.allowListFolderChildren": "List Children on Folder Pages", @@ -649,8 +655,12 @@ "admin.groups.redirectOnLogoutHint": "The path / URL where the user will be redirected upon logout. Leave empty to use the site-defined value.", "admin.groups.refreshSuccess": "Groups refreshed successfully.", "admin.groups.ruleAllow": "Allow", + "admin.groups.ruleAllowHint": "Grants the selected permissions.", + "admin.groups.ruleDelete": "Delete Rule", "admin.groups.ruleDeny": "Deny", + "admin.groups.ruleDenyHint": "Refuses them, over an equally specific Allow.", "admin.groups.ruleForceAllow": "Force Allow", + "admin.groups.ruleForceAllowHint": "Grants them, over an equally specific Deny.", "admin.groups.ruleLocales": "Locale(s)", "admin.groups.ruleMatch": "Match", "admin.groups.ruleMatchEnd": "Path Ends With...", @@ -661,6 +671,14 @@ "admin.groups.ruleMatchTag": "Has Any Tag...", "admin.groups.ruleMatchTagAll": "Has All Tags...", "admin.groups.rulePath": "Path", + "admin.groups.rulePermissionsCount": "No permissions selected | 1 permission selected | {count} permissions selected", + "admin.groups.rulePermissionsLocale": "Site + Locale Only", + "admin.groups.rulePermissionsLocaleHint": "The rule's pattern is ignored: the rule applies to its sites and locales as a whole, and a Deny anywhere outranks an Allow anywhere.", + "admin.groups.rulePermissionsNone": "No permissions selected.", + "admin.groups.rulePermissionsPage": "Site + Locale + Pattern", + "admin.groups.rulePermissionsPageHint": "Applies to the pages the rule's pattern matches, on its sites and in its locales.", + "admin.groups.rulePermissionsSelect": "Select Permissions...", + "admin.groups.rulePermissionsTitle": "Rule Permissions", "admin.groups.ruleSites": "Site(s)", "admin.groups.ruleTags": "Tags", "admin.groups.ruleTagsFailed": "Failed to load the list of existing tags.", @@ -2083,10 +2101,12 @@ "common.header.delete": "Delete", "common.header.duplicate": "Duplicate", "common.header.edit": "Edit", + "common.header.glossary": "Glossary", "common.header.history": "History", "common.header.home": "Home", "common.header.imagesFiles": "Images & Files", "common.header.language": "Language", + "common.header.library": "Library", "common.header.login": "Login", "common.header.logout": "Logout", "common.header.move": "Move / Rename", @@ -2919,6 +2939,75 @@ "folderDeleteDialog.deleteSuccess": "Folder has been deleted successfully.", "folderDeleteDialog.folderId": "Folder ID {id}", "folderDeleteDialog.title": "Confirm Delete Folder", + "glossary.alsoKnownAs": "Also known as", + "glossary.backToList": "Back to the list", + "glossary.category": "Category", + "glossary.createSuccess": "Term created.", + "glossary.deleteConfirm": "Delete the term \"{term}\"? It is also removed from the related terms of every other term.", + "glossary.deleteConfirmTitle": "Delete Term", + "glossary.deleteSuccess": "Term deleted.", + "glossary.documentation": "Documentation", + "glossary.documentationMissing": "The page /{path} was not found.", + "glossary.editTermTitle": "Edit Term", + "glossary.empty": "This glossary has no terms yet.", + "glossary.filter": "Filter terms...", + "glossary.forbidden": "You may not read the glossary in this locale.", + "glossary.form.addReference": "Add Reference", + "glossary.form.aliases": "Aliases", + "glossary.form.aliasesHint": "Alternate forms, spellings and inflections. Press Enter to add each one.", + "glossary.form.autoLink": "Auto-Link", + "glossary.form.autoLinkHint": "Link the term automatically where it appears in pages. Coming soon.", + "glossary.form.caseSensitive": "Case Sensitive", + "glossary.form.caseSensitiveHint": "Only match text written with the exact same case, e.g. \"REST\" but not \"rest\".", + "glossary.form.category": "Category", + "glossary.form.categoryHint": "Groups terms together in the list. Press Enter to use a new one.", + "glossary.form.definition": "Definition", + "glossary.form.definitionHint": "Basic markdown: emphasis, lists, links, quotes and code.", + "glossary.form.documentation": "Documentation Page", + "glossary.form.documentationHint": "The page on this wiki that documents the term, in the same locale.", + "glossary.form.documentationLabel": "Link Label", + "glossary.form.documentationNone": "No page selected", + "glossary.form.expansion": "Expansion", + "glossary.form.expansionHint": "The full form, if the term is an abbreviation.", + "glossary.form.invalidRelated": "A related term no longer exists. The list has been reloaded.", + "glossary.form.keepOldAsAlias": "Keep \"{name}\" as an alias", + "glossary.form.nameTaken": "\"{name}\" is already used by the term \"{holder}\".", + "glossary.form.notFound": "This term no longer exists.", + "glossary.form.preview": "Preview", + "glossary.form.referenceLabel": "Label (optional)", + "glossary.form.referenceUrl": "URL", + "glossary.form.referenceUrlInvalid": "Must be an http:// or https:// address.", + "glossary.form.references": "External References", + "glossary.form.relatedTerms": "Related Terms", + "glossary.form.relatedTermsHint": "Related terms are listed on both terms.", + "glossary.form.selectPage": "Select Page", + "glossary.form.staleMessage": "{name} saved it on {date}, after you started editing.", + "glossary.form.staleMessageNoAuthor": "It was saved on {date}, after you started editing.", + "glossary.form.staleOverwrite": "Save Over It", + "glossary.form.staleReload": "Discard My Changes", + "glossary.form.staleTitle": "This term was changed by somebody else", + "glossary.form.term": "Term", + "glossary.form.write": "Write", + "glossary.groupAz": "A–Z", + "glossary.groupCategory": "Category", + "glossary.grouping": "Group terms by", + "glossary.lastEdited": "Last edited by {name} on {date}", + "glossary.lastEditedNoAuthor": "Last edited on {date}", + "glossary.loadFailed": "Failed to load the glossary.", + "glossary.newTerm": "New Term", + "glossary.newTermTitle": "New Term", + "glossary.noDefinition": "No definition yet.", + "glossary.noMatch": "No term matches the filter.", + "glossary.pickTerm": "Select a term to read its definition.", + "glossary.pickTermManage": "Select a term to read its definition, or create a new one.", + "glossary.readMore": "Read more", + "glossary.references": "References", + "glossary.related": "Related", + "glossary.saveSuccess": "Term saved.", + "glossary.title": "Glossary", + "glossary.uncategorized": "Uncategorized", + "glossary.unsavedMessage": "This term has unsaved changes. Discard them?", + "glossary.unsavedTitle": "Discard Changes?", "history.action.created": "Created", "history.action.deleted": "Deleted", "history.action.moved": "Moved", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 60939165..edc1d57f 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -9,7 +9,15 @@ import { sanitizeMeta } from '../helpers/audit.ts' * migration. The admin area's filter is built from this list, and `admin.audit.kinds.` is the * translation of each. */ -export const AUDIT_KINDS = ['page', 'asset', 'comment', 'auth', 'profile', 'admin'] as const +export const AUDIT_KINDS = [ + 'page', + 'asset', + 'comment', + 'glossary', + 'auth', + 'profile', + 'admin' +] as const export type AuditKind = (typeof AUDIT_KINDS)[number] /** @@ -58,6 +66,7 @@ export const AUDIT_ACTIONS = { 'reopenAnnotation', 'deleteAnnotation' ], + glossary: ['createGlossaryTerm', 'updateGlossaryTerm', 'deleteGlossaryTerm'], auth: [ 'login', 'logout', diff --git a/backend/models/blogs.ts b/backend/models/blogs.ts index ee2a6116..c6ea087b 100644 --- a/backend/models/blogs.ts +++ b/backend/models/blogs.ts @@ -3,6 +3,8 @@ import { pages as pagesTable, tree as treeTable, users as usersTable } from '../ import { decodeTreePath, encodeTreePath } from '../helpers/common.ts' import { isBodylessEditor, parseBlogContent, type BlogContent } from './pages.ts' import type { AccessActor } from './groups.ts' +import type { RulePageRef } from '../helpers/pageRules.ts' +import { liveCondition } from '../helpers/publishing.ts' /** * How many of a blog's posts are read out of the database for one request. @@ -99,6 +101,7 @@ export interface BlogFilter { /** One candidate row, before the page rules and the nesting rule have had their say. */ interface Candidate extends BlogPost { editor: string + isLive: boolean } /** @@ -288,18 +291,22 @@ class Blogs { * - **A page the reader may not open**, by the same rule the page view applies. Filtered here rather * than in SQL because a page rule is not a `WHERE` clause. * - * @param publicOnly Restrict to what a reader with no session may see, i.e. published posts only. + * - **A post that is not live**, unless `unpublished` says this reader may see it — the page view's + * own rule (`helpers/publishing.ts`). Live posts only by default. + * + * @param unpublished As for `pages.getPage`: false holds the listing to live posts in SQL, and a + * predicate is asked of each post that is not live. */ async postsFor({ siteId, blog, actor, - publicOnly = true + unpublished = false }: { siteId: string blog: BlogRef actor?: AccessActor - publicOnly?: boolean + unpublished?: false | ((page: RulePageRef) => boolean) }): Promise<{ posts: BlogPost[]; truncated: boolean }> { const encodedPath = encodeTreePath(blog.path) const depth = blog.settings.depth @@ -326,8 +333,8 @@ class Blogs { eq(treeTable.type, 'page'), sql`${treeTable.folderPath} ~ ${pathQuery}::lquery` ] - if (publicOnly) { - conditions.push(eq(pagesTable.publishState, 'published')) + if (!unpublished) { + conditions.push(liveCondition(pagesTable)) } const rows = await WIKI.db @@ -343,6 +350,7 @@ class Blogs { updatedAt: pagesTable.updatedAt, authorId: pagesTable.authorId, authorName: usersTable.name, + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean), publishedAt }) .from(treeTable) @@ -366,7 +374,8 @@ class Blogs { publishedAt: row.publishedAt, updatedAt: row.updatedAt, authorId: row.authorId, - authorName: row.authorName ?? '' + authorName: row.authorName ?? '', + isLive: row.isLive } }) @@ -386,18 +395,18 @@ class Blogs { if (nestedRoots.some((root) => row.path.startsWith(root))) { return false } + const ref = { siteId, path: row.path, locale: blog.locale, tags: row.tags } + if (!row.isLive && !(unpublished && unpublished(ref))) { + return false + } if (!actor) { return true } - return WIKI.models.groups.checkAccess(actor, 'read:pages', { - siteId, - path: row.path, - locale: blog.locale, - tags: row.tags - }) + return WIKI.models.groups.checkAccess(actor, 'read:pages', ref) }) - // -> Which editor wrote a post is how it was filtered, not something a listing shows + // -> Which editor wrote a post, and whether it is live, are how it was filtered, not something a + // listing shows return { posts: posts.map((post) => ({ id: post.id, @@ -459,16 +468,16 @@ class Blogs { siteId, blog, actor, - publicOnly = true, + unpublished = false, filter = {} }: { siteId: string blog: BlogRef actor?: AccessActor - publicOnly?: boolean + unpublished?: false | ((page: RulePageRef) => boolean) filter?: BlogFilter }): Promise { - const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, publicOnly }) + const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, unpublished }) const facets = this.facetsFor(readable) const matching = readable.filter((post) => { diff --git a/backend/models/comments.ts b/backend/models/comments.ts index bc4ea95d..92317be1 100644 --- a/backend/models/comments.ts +++ b/backend/models/comments.ts @@ -15,6 +15,7 @@ import { parseModuleProps } from '../helpers/common.ts' import type { ModuleProp } from '../helpers/common.ts' +import { liveCondition } from '../helpers/publishing.ts' /** * The key of the provider that IS this wiki, as opposed to the ones that are somebody else's service. @@ -759,7 +760,7 @@ class Comments { * Its path, locale and tags because that is what a page rule is matched against, and * `allowComments` because a page can be closed to discussion from its own properties dialog * whatever the site has configured. Deliberately not `pages.getPage` — that assembles a page for - * reading, and this is four columns and a scoping check. + * reading, and this is a few columns and a scoping check. `isLive` is the caller's to act on. * * @returns The reference, or null when no such page exists on this site */ @@ -771,7 +772,10 @@ class Comments { locale: pagesTable.locale, title: pagesTable.title, tags: pagesTable.tags, - allowComments: sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)` + allowComments: sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`, + // -> A discussion quotes its page, annotations word for word, so it is only there while the + // page is — see `helpers/publishing.ts` + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean) }) .from(pagesTable) .where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId))) diff --git a/backend/models/glossary.ts b/backend/models/glossary.ts new file mode 100644 index 00000000..bc75cca8 --- /dev/null +++ b/backend/models/glossary.ts @@ -0,0 +1,552 @@ +import { and, asc, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm' +import { + glossaryTermRelations as relationsTable, + glossaryTerms as termsTable, + pages as pagesTable, + users as usersTable +} from '../db/schema.ts' + +/** + * How much a term may carry. Shared with the route schemas, which are what actually enforce it. + * + * Per `dev/specs/glossary.md` §3.4. A name — the term, an alias, the expansion, the category, the + * documentation label — is a varchar(255) column or an entry like one. + */ +export const GLOSSARY_NAME_MAX_LENGTH = 255 +export const GLOSSARY_ALIASES_MAX = 50 +export const GLOSSARY_DEFINITION_MAX_LENGTH = 10000 +export const GLOSSARY_REFERENCES_MAX = 20 +export const GLOSSARY_RELATED_MAX = 50 + +/** An external reference: a link out, with an optional label the URL stands in for. */ +export interface GlossaryReference { + url: string + label: string +} + +/** What a create or a save carries. `locale` is fixed at creation, so a save never names one. */ +export interface GlossaryTermInput { + term: string + expansion?: string | null + definition?: string + aliases?: string[] + relatedTerms?: string[] + documentationPath?: string | null + documentationLabel?: string | null + references?: GlossaryReference[] + caseSensitive?: boolean + autoLink?: boolean + category?: string | null +} + +/** + * A term as the sidebar lists it. + * + * Also exactly what auto-linking will need to build its matcher (spec §9), which is why the two case + * and linking flags are here although the sidebar draws neither. + */ +export interface GlossaryTermSummary { + id: string + term: string + expansion: string | null + aliases: string[] + category: string | null + caseSensitive: boolean + autoLink: boolean +} + +/** A term in full, as the main panel shows it and the form edits it. */ +export interface GlossaryTerm extends GlossaryTermSummary { + siteId: string + locale: string + definition: string + references: GlossaryReference[] + relatedTerms: Array<{ id: string; term: string }> + /** + * The documentation page, resolved. Null when the term names none; `exists: false` when it names a + * path with no page, which the view says rather than offering a dead link. + */ + documentation: { + path: string + label: string | null + title: string | null + exists: boolean + } | null + author: { id: string; name: string } | null + createdAt: string + /** At millisecond precision — what a save hands back as `expectedUpdatedAt`. */ + updatedAt: string +} + +/** + * Why a write was refused, for the route to turn into a status code. + * + * Results rather than thrown errors, because two of them carry something the client needs beyond a + * message: who already holds a name, and the term as somebody else just saved it. + */ +export type GlossaryWriteResult = + | { ok: true; term: GlossaryTerm; changedFields: string[] } + | { ok: false; reason: 'notFound' } + | { ok: false; reason: 'invalidRelated' } + | { + ok: false + reason: 'nameTaken' + /** Which of the caller's fields holds the clashing name. */ + field: 'term' | 'aliases' + name: string + holder: { id: string; term: string } + } + | { ok: false; reason: 'stale'; current: GlossaryTerm } + +/** The fields compared to report what a save changed, for the audit log. */ +const COMPARED_FIELDS = [ + 'term', + 'expansion', + 'definition', + 'aliases', + 'relatedTerms', + 'documentationPath', + 'documentationLabel', + 'references', + 'caseSensitive', + 'autoLink', + 'category' +] as const + +/** + * A name as it is stored and compared: NFC, trimmed, inner whitespace collapsed to one space. + * + * So that `REST API`, `REST API` and a decomposed accent are each one name rather than three that + * look identical in the sidebar and collide nowhere. + */ +export function normalizeName(value: string): string { + return value.normalize('NFC').trim().replace(/\s+/g, ' ') +} + +/** An optional text field: normalized like a name, with empty meaning absent. */ +function optionalName(value: string | null | undefined): string | null { + const normalized = normalizeName(value ?? '') + return normalized.length > 0 ? normalized : null +} + +/** Compared without leading or trailing slashes, as every page path is stored. */ +function normalizePath(value: string | null | undefined): string | null { + const path = (value ?? '').trim().replace(/^\/+|\/+$/g, '') + return path.length > 0 ? path : null +} + +/** + * The aliases as they will be stored. + * + * Normalized, then with every repeat and every copy of the term's own name dropped silently rather + * than refused (spec §3.3): saying a name twice is not a mistake worth stopping a save for. + */ +function cleanAliases(aliases: string[] | undefined, term: string): string[] { + const seen = new Set([term.toLowerCase()]) + const out: string[] = [] + for (const alias of aliases ?? []) { + const name = normalizeName(alias) + const key = name.toLowerCase() + if (name.length < 1 || seen.has(key)) { + continue + } + seen.add(key) + out.push(name) + } + return out +} + +/** References with both halves trimmed, and any without a URL dropped. */ +function cleanReferences(references: GlossaryReference[] | undefined): GlossaryReference[] { + return (references ?? []) + .map((ref) => ({ url: (ref.url ?? '').trim(), label: normalizeName(ref.label ?? '') })) + .filter((ref) => ref.url.length > 0) +} + +/** A transaction, or the database outside one. Every read here works inside a write's transaction. */ +type Executor = typeof WIKI.db | Parameters[0]>[0] + +/** `updatedAt` as the API carries it. JS dates are milliseconds, which is the precision promised. */ +function iso(value: Date): string { + return value.toISOString() +} + +/** + * Glossary model. See `dev/specs/glossary.md`. + */ +class Glossary { + /** + * Whether the site has a glossary at all — `features.glossary`, General → Features. + * + * On unless switched off, the way `backlinks` and `comments` read: the switch exists to turn it off. + */ + isEnabled(siteId: string): boolean { + return WIKI.sites[siteId]?.config?.features?.glossary !== false + } + + /** Whether a locale is one this site offers, which is the only kind a term may be written in. */ + isActiveLocale(siteId: string, locale: string): boolean { + const active: string[] = WIKI.sites[siteId]?.config?.locales?.active ?? [] + return active.includes(locale) + } + + /** Every term of a locale, A–Z, as the sidebar lists them. */ + async list(siteId: string, locale: string): Promise { + return WIKI.db + .select({ + id: termsTable.id, + term: termsTable.term, + expansion: termsTable.expansion, + aliases: termsTable.aliases, + category: termsTable.category, + caseSensitive: termsTable.caseSensitive, + autoLink: termsTable.autoLink + }) + .from(termsTable) + .where(and(eq(termsTable.siteId, siteId), eq(termsTable.locale, locale))) + .orderBy(sql`lower(${termsTable.term})`, asc(termsTable.term)) + } + + /** The categories in use in a locale, for the form's suggestions. */ + async categories(siteId: string, locale: string): Promise { + const rows = await WIKI.db + .selectDistinct({ category: termsTable.category }) + .from(termsTable) + .where( + and( + eq(termsTable.siteId, siteId), + eq(termsTable.locale, locale), + isNotNull(termsTable.category) + ) + ) + return rows + .map((row) => row.category as string) + .sort((a, b) => a.localeCompare(b, locale, { sensitivity: 'base' })) + } + + /** + * The term a name or an alias belongs to, for a `?glossary=` link (spec §7). + * + * Aliases count, so that a term renamed with its old name kept as an alias keeps its old links. + */ + async lookup(siteId: string, locale: string, name: string): Promise { + const wanted = normalizeName(name) + if (wanted.length < 1) { + return null + } + const rows = await WIKI.db + .select({ id: termsTable.id }) + .from(termsTable) + .where( + and( + eq(termsTable.siteId, siteId), + eq(termsTable.locale, locale), + or( + sql`lower(${termsTable.term}) = lower(${wanted})`, + sql`EXISTS (SELECT 1 FROM unnest(${termsTable.aliases}) AS a WHERE lower(a) = lower(${wanted}))` + ) + ) + ) + // -> The term's own name before an alias, should a race ever have let both exist + .orderBy(sql`lower(${termsTable.term}) = lower(${wanted}) DESC`) + .limit(1) + return rows[0]?.id ?? null + } + + /** The locale a term is in, which is what every permission over it is asked about. */ + async localeOf(siteId: string, id: string): Promise { + const rows = await WIKI.db + .select({ locale: termsTable.locale }) + .from(termsTable) + .where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId))) + .limit(1) + return rows[0]?.locale ?? null + } + + /** A term in full, or null when there is none with this id on this site. */ + async get(siteId: string, id: string, tx: Executor = WIKI.db): Promise { + const rows = await tx + .select({ + row: termsTable, + authorName: usersTable.name + }) + .from(termsTable) + .leftJoin(usersTable, eq(usersTable.id, termsTable.authorId)) + .where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId))) + .limit(1) + if (rows.length < 1) { + return null + } + const { row, authorName } = rows[0] + + // -> Both sides of the pair table: a relation is one row whichever term it was saved from + const related = await tx + .select({ id: termsTable.id, term: termsTable.term }) + .from(relationsTable) + .innerJoin( + termsTable, + sql`${termsTable.id} = CASE WHEN ${relationsTable.termId} = ${id} THEN ${relationsTable.relatedId} ELSE ${relationsTable.termId} END` + ) + .where(or(eq(relationsTable.termId, id), eq(relationsTable.relatedId, id))) + .orderBy(sql`lower(${termsTable.term})`) + + let documentation: GlossaryTerm['documentation'] = null + if (row.documentationPath) { + const page = await tx + .select({ title: pagesTable.title }) + .from(pagesTable) + .where( + and( + eq(pagesTable.siteId, siteId), + eq(pagesTable.locale, row.locale), + eq(pagesTable.path, row.documentationPath) + ) + ) + .limit(1) + documentation = { + path: row.documentationPath, + label: row.documentationLabel, + title: page[0]?.title ?? null, + exists: page.length > 0 + } + } + + return { + id: row.id, + siteId: row.siteId, + locale: row.locale, + term: row.term, + expansion: row.expansion, + definition: row.definition, + aliases: row.aliases, + category: row.category, + caseSensitive: row.caseSensitive, + autoLink: row.autoLink, + references: (row.references ?? []) as GlossaryReference[], + relatedTerms: related, + documentation, + author: row.authorId && authorName ? { id: row.authorId, name: authorName } : null, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt) + } + } + + /** + * Create a term. + * + * @param actorId The account creating it, or null for an API key + */ + async create( + siteId: string, + locale: string, + input: GlossaryTermInput, + actorId: string | null + ): Promise { + return WIKI.db.transaction(async (tx) => { + const values = this.#valuesFrom(input) + const refused = await this.#checkWrite(tx, siteId, locale, null, values, input) + if (refused) { + return refused + } + // -> Both dates from JS rather than `now()`, so that what is stored is the millisecond value a + // save will name as `expectedUpdatedAt` — postgres would keep microseconds + const now = new Date() + const [created] = await tx + .insert(termsTable) + .values({ + ...values, + siteId, + locale, + creatorId: actorId, + authorId: actorId, + createdAt: now, + updatedAt: now + }) + .returning({ id: termsTable.id }) + await this.#replaceRelations(tx, created.id, input.relatedTerms ?? []) + const term = (await this.get(siteId, created.id, tx)) as GlossaryTerm + return { ok: true, term, changedFields: [] } + }) + } + + /** + * Replace a term, provided nobody else has saved it since `expectedUpdatedAt` (spec §5.2). + * + * The compare is made by the UPDATE itself, so there is no window between checking and writing. + */ + async update( + siteId: string, + id: string, + input: GlossaryTermInput, + expectedUpdatedAt: string, + actorId: string | null + ): Promise { + return WIKI.db.transaction(async (tx) => { + const before = await this.get(siteId, id, tx) + if (!before) { + return { ok: false, reason: 'notFound' } + } + const values = this.#valuesFrom(input) + const refused = await this.#checkWrite(tx, siteId, before.locale, id, values, input) + if (refused) { + return refused + } + const expected = new Date(expectedUpdatedAt) + const updated = await tx + .update(termsTable) + .set({ ...values, authorId: actorId, updatedAt: new Date() }) + .where( + and( + eq(termsTable.id, id), + eq(termsTable.siteId, siteId), + sql`date_trunc('milliseconds', ${termsTable.updatedAt}) = ${expected.toISOString()}::timestamp` + ) + ) + .returning({ id: termsTable.id }) + if (updated.length < 1) { + return { ok: false, reason: 'stale', current: before } + } + await this.#replaceRelations(tx, id, input.relatedTerms ?? []) + const term = (await this.get(siteId, id, tx)) as GlossaryTerm + return { ok: true, term, changedFields: this.#changedFields(before, term) } + }) + } + + /** Delete a term. Its relations go with it, by cascade. Returns what was deleted, for the log. */ + async remove(siteId: string, id: string): Promise<{ term: string; locale: string } | null> { + const deleted = await WIKI.db + .delete(termsTable) + .where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId))) + .returning({ term: termsTable.term, locale: termsTable.locale }) + return deleted[0] ?? null + } + + /** The columns a create or a save writes, normalized. */ + #valuesFrom(input: GlossaryTermInput) { + const term = normalizeName(input.term) + const documentationPath = normalizePath(input.documentationPath) + return { + term, + expansion: optionalName(input.expansion), + definition: (input.definition ?? '').trim(), + aliases: cleanAliases(input.aliases, term), + documentationPath, + // -> A label for no link would be stored and never shown, then come back on the next link set + documentationLabel: documentationPath ? optionalName(input.documentationLabel) : null, + references: cleanReferences(input.references), + caseSensitive: input.caseSensitive ?? false, + autoLink: input.autoLink ?? true, + category: optionalName(input.category) + } + } + + /** + * Everything that can refuse a write before it is made: the names, then the related terms. + * + * Inside the write's transaction. Two saves racing to claim the same ALIAS can still both pass — + * no index reaches into the arrays — and that is accepted (spec §3.3): the next edit of either term + * is refused, which surfaces it. + */ + async #checkWrite( + tx: Executor, + siteId: string, + locale: string, + selfId: string | null, + values: { term: string; aliases: string[] }, + input: GlossaryTermInput + ): Promise { + const names = [values.term, ...values.aliases] + const clashes = await tx + .select({ id: termsTable.id, term: termsTable.term, aliases: termsTable.aliases }) + .from(termsTable) + .where( + and( + eq(termsTable.siteId, siteId), + eq(termsTable.locale, locale), + selfId ? ne(termsTable.id, selfId) : undefined, + or( + sql`lower(${termsTable.term}) IN (SELECT lower(n) FROM unnest(${sql.param(names)}::text[]) AS n)`, + sql`EXISTS (SELECT 1 FROM unnest(${termsTable.aliases}) AS a WHERE lower(a) IN (SELECT lower(n) FROM unnest(${sql.param(names)}::text[]) AS n))` + ) + ) + ) + .limit(1) + if (clashes.length > 0) { + const holder = clashes[0] + const taken = new Set([holder.term, ...holder.aliases].map((n) => n.toLowerCase())) + // -> Which of the caller's names it was, so the form can mark the right field. The term first: + // a clash on the name itself is the one to fix before any alias + const name = names.find((n) => taken.has(n.toLowerCase())) ?? values.term + return { + ok: false, + reason: 'nameTaken', + field: name === values.term ? 'term' : 'aliases', + name, + holder: { id: holder.id, term: holder.term } + } + } + + const related = [...new Set(input.relatedTerms ?? [])] + if (related.length > 0) { + if (selfId && related.includes(selfId)) { + return { ok: false, reason: 'invalidRelated' } + } + const found = await tx + .select({ id: termsTable.id }) + .from(termsTable) + .where( + and( + inArray(termsTable.id, related), + eq(termsTable.siteId, siteId), + eq(termsTable.locale, locale) + ) + ) + if (found.length !== related.length) { + return { ok: false, reason: 'invalidRelated' } + } + } + return null + } + + /** + * Make a term's related terms exactly these, from both sides. + * + * Every pair touching the term is replaced, so taking B out of A's list takes A out of B's. Each pair + * is written smaller id first, which is what the table's check requires and what makes it one row. + */ + async #replaceRelations(tx: Executor, id: string, relatedIds: string[]): Promise { + await tx + .delete(relationsTable) + .where(or(eq(relationsTable.termId, id), eq(relationsTable.relatedId, id))) + const pairs = [...new Set(relatedIds)] + .filter((other) => other !== id) + .map((other) => + id < other ? { termId: id, relatedId: other } : { termId: other, relatedId: id } + ) + if (pairs.length > 0) { + await tx.insert(relationsTable).values(pairs).onConflictDoNothing() + } + } + + /** Which fields a save changed — names only, never values (spec §8). */ + #changedFields(before: GlossaryTerm, after: GlossaryTerm): string[] { + const shape = (term: GlossaryTerm) => ({ + term: term.term, + expansion: term.expansion, + definition: term.definition, + aliases: term.aliases, + relatedTerms: term.relatedTerms.map((r) => r.id).sort(), + documentationPath: term.documentation?.path ?? null, + documentationLabel: term.documentation?.label ?? null, + references: term.references, + caseSensitive: term.caseSensitive, + autoLink: term.autoLink, + category: term.category + }) + const a = shape(before) + const b = shape(after) + return COMPARED_FIELDS.filter((field) => JSON.stringify(a[field]) !== JSON.stringify(b[field])) + } +} + +export const glossary = new Glossary() diff --git a/backend/models/groups.ts b/backend/models/groups.ts index 028817a0..2e969273 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -3,7 +3,7 @@ import { and, count, eq, ilike, ne, or, sql } from 'drizzle-orm' import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts' import { invalidateAppShellCache } from '../helpers/appShell.ts' import { CustomError } from '../helpers/common.ts' -import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts' +import { resolveLocaleRule, resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts' import type { SystemIds } from './types.ts' import type { FastifyRequest } from 'fastify' @@ -91,9 +91,22 @@ export const PAGE_PERMISSIONS = [ 'read:comments', 'write:comments', 'manage:comments', - 'manage:navigation' + 'manage:navigation', + 'read:glossary', + 'manage:glossary' ] +/** + * The page permissions that are about a site and a locale rather than a page. + * + * Granted through rules like every other page permission, but a rule's `match`, `path` and `tags` say + * nothing about them: a glossary has no path. Every rule naming one that is scoped to the site and the + * locale applies, and the modes rank as they always do — see `resolveLocaleRule` in + * `helpers/pageRules.ts`. `checkAccess` routes them there, so a caller asking about one at a page gets + * the locale's answer rather than one that changes from page to page. + */ +export const LOCALE_PERMISSIONS = ['read:glossary', 'manage:glossary'] + /** Whether a permission list carries any of `ELEVATED_PERMISSIONS`. */ export function isElevated(permissions: readonly string[]): boolean { return permissions.some((permission) => @@ -231,7 +244,8 @@ export const GUEST_ROLES = [ 'read:history', 'read:assets', 'read:comments', - 'write:comments' + 'write:comments', + 'read:glossary' ] /** @@ -333,10 +347,44 @@ class Groups { if (actor.permissions.includes('manage:system')) { return true } + if (LOCALE_PERMISSIONS.includes(permission)) { + return this.checkLocaleAccess(actor, permission, page.siteId, page.locale) + } const rule = resolvePageRule(this.rulesForGroups(actor.groupIds), permission, page) return rule ? rule.mode !== 'DENY' : false } + /** + * Whether this caller holds a locale permission (`LOCALE_PERMISSIONS`) on a site, in a locale. + * + * The question `checkAccess` cannot ask without a page. Every rule naming the permission that is + * scoped to the site and the locale counts, whatever it addresses, and mode alone decides between + * them: a DENY anywhere closes the locale, a FORCE ALLOW anywhere reopens it. + * + * `manage:glossary` implies `read:glossary` in the same locale: editing a glossary one may not read is + * not a state worth supporting, and the editor needs the list to pick related terms from. Each is + * decided on its own rules first, so a DENY on reading does not take managing with it. + * + * @param locale Absent, rules limited to particular locales are not excluded — as for a page + * reference that leaves the locale out + */ + checkLocaleAccess( + actor: AccessActor, + permission: string, + siteId: string, + locale?: string + ): boolean { + if (actor.permissions.includes('manage:system')) { + return true + } + const rules = this.rulesForGroups(actor.groupIds) + const grants = (name: string) => { + const rule = resolveLocaleRule(rules, name, { siteId, locale }) + return rule ? rule.mode !== 'DENY' : false + } + return grants(permission) || (permission === 'read:glossary' && grants('manage:glossary')) + } + /** * Whether any rule this actor holds grants a page permission ANYWHERE. * @@ -381,7 +429,13 @@ class Groups { */ id: uuid(), name: 'Default Rule', - roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'], + roles: [ + 'read:pages', + 'read:assets', + 'read:comments', + 'write:comments', + 'read:glossary' + ], match: 'START', mode: 'ALLOW', path: '', @@ -405,6 +459,10 @@ class Groups { statement about comments: an operator opening the wiki up flips this one rule to ALLOW, and what they get is the set the guests group is allowed to hold (`GUEST_ROLES`) rather than a public wiki whose readers still cannot say anything. + + `read:glossary` is deliberately NOT named here. It ignores the path, so a DENY for it + anywhere outranks every ALLOW for it everywhere: named in this rule, an operator's later + ALLOW for guests would do nothing. Guests are denied it anyway with no rule at all. */ roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'], match: 'START', @@ -462,9 +520,9 @@ class Groups { async createGroup(name: string): Promise { const startingPermissions = ['read:pages', 'read:assets', 'read:comments'] - // -> The rule grants one more than the group-wide list does: see the note on the Users group in + // -> The rule grants more than the group-wide list does: see the note on the Users group in // `init()` for why the two differ - const startingRoles = [...startingPermissions, 'write:comments'] + const startingRoles = [...startingPermissions, 'write:comments', 'read:glossary'] const result = await WIKI.db .insert(groupsTable) .values({ diff --git a/backend/models/index.ts b/backend/models/index.ts index ca0336a1..b97f7f4f 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -9,6 +9,7 @@ import { blogs } from './blogs.ts' import { comments } from './comments.ts' import { extensions } from './extensions.ts' import { flags } from './flags.ts' +import { glossary } from './glossary.ts' import { groups } from './groups.ts' import { hooks } from './hooks.ts' import { icons } from './icons.ts' @@ -52,6 +53,7 @@ export default { comments, extensions, flags, + glossary, groups, hooks, icons, diff --git a/backend/models/pageGraph.ts b/backend/models/pageGraph.ts index 1f492dbd..bf4ca3e6 100644 --- a/backend/models/pageGraph.ts +++ b/backend/models/pageGraph.ts @@ -5,6 +5,7 @@ import { tree as treeTable } from '../db/schema.ts' import { decodeTreePath } from '../helpers/common.ts' +import { liveCondition } from '../helpers/publishing.ts' /** * Page graph model @@ -48,7 +49,8 @@ export interface GraphPageRef { locale: string path: string tags: string[] - publishState: string + /** Whether the page is open to its readers now — see `helpers/publishing.ts`. */ + isLive: boolean } export interface GraphPage { @@ -90,21 +92,24 @@ interface PageRow { path: string title: string tags: string[] | null - publishState: string + isLive: boolean editor: string alias: string | null relations: unknown } -const pageColumns = { - id: pagesTable.id, - locale: pagesTable.locale, - path: pagesTable.path, - title: pagesTable.title, - tags: pagesTable.tags, - publishState: pagesTable.publishState, - editor: pagesTable.editor, - alias: pagesTable.alias +/** A function, not a constant: whether a page is live is a question about the time of the query. */ +function pageColumns() { + return { + id: pagesTable.id, + locale: pagesTable.locale, + path: pagesTable.path, + title: pagesTable.title, + tags: pagesTable.tags, + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean), + editor: pagesTable.editor, + alias: pagesTable.alias + } } /** Precedence when one page reaches another in more than one way: the strongest link is kept. */ @@ -133,7 +138,7 @@ class PageGraphModel { // -> The locale's own pages. Relations are read here and nowhere else: they are what tells a // relation apart from a link in the rows below, and only a source page's matter const pageRows = (await WIKI.db - .select({ ...pageColumns, relations: pagesTable.relations }) + .select({ ...pageColumns(), relations: pagesTable.relations }) .from(pagesTable) .where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.locale, locale))) .orderBy(pagesTable.path) @@ -163,7 +168,7 @@ class PageGraphModel { locale: row.locale, path: row.path, tags: row.tags ?? [], - publishState: row.publishState + isLive: row.isLive }) if (!seen) { refused.add(row.id) @@ -284,7 +289,7 @@ class PageGraphModel { lookups.push(sql`${pagesTable.alias} = ANY(${sql.param(refs)}::text[])`) } const others = (await WIKI.db - .select(pageColumns) + .select(pageColumns()) .from(pagesTable) .where( and(eq(pagesTable.siteId, siteId), sql`(${sql.join(lookups, sql` OR `)})`) diff --git a/backend/models/pages.ts b/backend/models/pages.ts index c7d15fd0..a280e977 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -12,6 +12,7 @@ import { timingSafeCompare } from '../helpers/common.ts' import { invalidateAppShellCache } from '../helpers/appShell.ts' +import { liveCondition, maySeeUnpublished } from '../helpers/publishing.ts' import { rewriteSourceLinks } from '../helpers/linkRewrite.ts' import type { SourceSyntax } from '../helpers/linkRewrite.ts' import { relinkHref, rewriteRenderLinks } from '../helpers/pageLinks.ts' @@ -899,7 +900,7 @@ class Pages { * Answered from the group id the page already carries, so a page that is not part of one costs no * query at all — which is nearly every page on nearly every site. * - * `publicOnly` narrows it the same way it narrows the page itself: a draft translation is not + * `unpublished` narrows it the same way it narrows the page itself: a draft translation is not * something to offer a reader who could not open it. It is what makes the locale selector's list of * languages the list of languages this reader can actually reach. */ @@ -908,8 +909,13 @@ class Pages { { localeGroupId, id, - publicOnly = false - }: { localeGroupId: string | null; id: string; publicOnly?: boolean } + unpublished = true + }: { + localeGroupId: string | null + id: string + /** As for `getPage`. */ + unpublished?: boolean | ((page: RulePageRef) => boolean) + } ): Promise { if (!localeGroupId) { return [] @@ -919,19 +925,28 @@ class Pages { eq(pagesTable.localeGroupId, localeGroupId), ne(pagesTable.id, id) ] - if (publicOnly) { - conditions.push(eq(pagesTable.publishState, 'published')) + if (unpublished === false) { + conditions.push(liveCondition(pagesTable)) } - return await WIKI.db + const rows = await WIKI.db .select({ locale: pagesTable.locale, path: pagesTable.path, title: pagesTable.title, - tags: pagesTable.tags + tags: pagesTable.tags, + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean) }) .from(pagesTable) .where(and(...conditions)) .orderBy(pagesTable.locale) + return rows + .filter( + (row) => + row.isLive || + typeof unpublished !== 'function' || + unpublished({ siteId, locale: row.locale, path: row.path, tags: row.tags ?? [] }) + ) + .map(({ locale, path, title, tags }) => ({ locale, path, title, tags })) } /** @@ -1255,7 +1270,7 @@ class Pages { * through the same check, because the enforcement is this method and not the client. * * **The defaults hand over the whole page**, `unlocked` and `withPassword` included, the way they do - * for `publicOnly` beside them: most callers here are a save, a move, a delete or a re-render, and + * for `unpublished` beside them: most callers here are a save, a move, a delete or a re-render, and * none of those is a reader — a save that got a withheld body back would answer its author with an * empty page, and a re-render would store one. A path that serves a reader has to say so, and there * are exactly two: the `GET` route, and `unlockPage` below. @@ -1377,8 +1392,9 @@ class Pages { * A sitemap is a list handed to search engines, so the question is not what exists but what a * crawler may both reach and index. Four things decide it, three of them in SQL: * - * - **Published**, on the same reading as everywhere else — a `scheduled` page is not published yet - * whatever its dates say, and a draft never was. + * - **Live**, on the same reading as everywhere else (`helpers/publishing.ts`) — a `scheduled` page + * inside its window is listed, one outside it is not, and a draft never is. The list is cached, so a + * window opening or closing reaches it within `SITEMAP_TTL`. * - **Not a redirection**, which has no body to index and sends its reader elsewhere anyway. * - **Not password protected**, since what a crawler would reach there is the lock screen. * - **`isSearchable`**, the page property whose whole purpose is keeping a page out of search @@ -1403,7 +1419,7 @@ class Pages { .where( and( eq(pagesTable.siteId, siteId), - eq(pagesTable.publishState, 'published'), + liveCondition(pagesTable), eq(pagesTable.isSearchable, true), ne(pagesTable.editor, REDIRECT_EDITOR), isNull(pagesTable.password) @@ -1435,14 +1451,14 @@ class Pages { * decide and nothing about the requester does, which is what makes the answer the same for whoever * fetched it and therefore safe to cache and hand on. * - * Published only, as it is everywhere the public is being answered — which is also what lets the app + * Live pages only, as everywhere the public is being answered — which is also what lets the app * shell say 404 rather than 200 at a path with nothing published at it. */ async describePageForPublic( siteId: string, ref: { locale: string; path: string } ): Promise { - return this.describePage(siteId, ref, WIKI.models.groups.actorForPublic(), true) + return this.describePage(siteId, ref, WIKI.models.groups.actorForPublic(), false) } /** @@ -1450,9 +1466,9 @@ class Pages { * * Deliberately the same cut the page route itself makes (`GET /sites/:siteId/pages/:pageIdOrHash`), * so that the title in the document and the page the app then draws can never be about different - * things: its `read:pages` check per path, and its `publicOnly: !actorFrom(req)` — which is to say - * any signed-in session sees an unpublished page, and an API key is answered as the public is, since - * a draft belongs to the people working on it rather than to whatever holds a token. + * things: its `read:pages` check per path, and its `unpublished` — a page that is not live is + * described only to a signed-in session holding `write:pages` or `manage:pages` on it, and an API + * key is answered as the public is (`helpers/publishing.ts`). * * Not for caching across requesters: the answer is one reader's. */ @@ -1462,13 +1478,19 @@ class Pages { req: FastifyRequest ): Promise { const isSession = Boolean(req.session?.authenticated && req.session.user?.id) - return this.describePage(siteId, ref, WIKI.models.groups.actorForRequest(req), !isSession) + const actor = WIKI.models.groups.actorForRequest(req) + return this.describePage( + siteId, + ref, + actor, + isSession ? (page: RulePageRef) => maySeeUnpublished(actor, page) : false + ) } /** * The read behind both, which is the only thing that should call it. * - * `actor` and `publicOnly` are paired by the two methods above rather than left to a caller, because + * `actor` and `unpublished` are paired by the two methods above rather than left to a caller, because * the wrong pairing — the public actor with unpublished pages included — would describe a draft to * whoever asked. */ @@ -1476,7 +1498,7 @@ class Pages { siteId: string, { locale, path }: { locale: string; path: string }, actor: AccessActor, - publicOnly: boolean + unpublished: false | ((page: RulePageRef) => boolean) ): Promise { const conditions = [ eq(pagesTable.siteId, siteId), @@ -1486,12 +1508,13 @@ class Pages { // whoever asked simply need not have — nothing here was handed a hash to look up eq(pagesTable.path, path) ] - if (publicOnly) { - conditions.push(eq(pagesTable.publishState, 'published')) + if (unpublished === false) { + conditions.push(liveCondition(pagesTable)) } const rows = await WIKI.db .select({ + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean), locale: pagesTable.locale, path: pagesTable.path, title: pagesTable.title, @@ -1515,6 +1538,9 @@ class Pages { if (!WIKI.models.groups.checkAccess(actor, 'read:pages', { ...row, siteId })) { return null } + if (!row.isLive && unpublished && !unpublished({ ...row, siteId })) { + return null + } // -> A page and its lock are two things: the title and the description are not what a password // covers (the search index treats them the same way), so they travel and the body does not. @@ -1538,11 +1564,11 @@ class Pages { /** * The locales a page can be read in, for the alternates a document names. * - * Published and readable by this actor, and including the page itself, which is what an `hreflang` + * Live and readable by this actor, and including the page itself, which is what an `hreflang` * set calls for. A translation the asker may not read is not a URL to point them at — for the public * that is the same cut the sitemap makes, which is why the two say the same thing about a page. * - * Published regardless of who is asking, unlike the page itself: a draft translation is not an + * Live regardless of who is asking, unlike the page itself: a draft translation is not an * alternate version of a document, it is one that does not exist yet. */ private async readableAlternates( @@ -1560,7 +1586,7 @@ class Pages { and( eq(pagesTable.siteId, siteId), eq(pagesTable.localeGroupId, localeGroupId), - eq(pagesTable.publishState, 'published') + liveCondition(pagesTable) ) ) .orderBy(pagesTable.locale) @@ -1595,7 +1621,7 @@ class Pages { hash, locale, withContent = false, - publicOnly = false, + unpublished = true, unlocked = true, withPassword = true }: { @@ -1609,17 +1635,21 @@ class Pages { * request addressing a page by hash has in hand before the row is read. */ withContent?: boolean | ((page: RulePageRef) => boolean) - /** Restrict to what a reader with no session may see: published pages. */ - publicOnly?: boolean + /** + * Whether a page that is not live (`helpers/publishing.ts`) may be returned: `false` holds the + * read to live pages, and a predicate is asked of a page that is not live once its row is read. + * Open by default, since most callers are the wiki acting on its own pages; anything answering a + * reader passes one. + */ + unpublished?: boolean | ((page: RulePageRef) => boolean) unlocked?: boolean | ((pageId: string) => boolean) withPassword?: boolean }): Promise { const conditions = [eq(pagesTable.siteId, siteId)] - if (publicOnly) { - // -> Page-level access rules are not implemented, so this is the whole of it: an anonymous - // reader sees published pages, and nothing else. A password does not hide a page from them — - // it withholds the body until they enter it, which is what `locked` below does. - conditions.push(eq(pagesTable.publishState, 'published')) + if (unpublished === false) { + // -> A password does not hide a page from a reader — it withholds the body until they enter + // it, which is what `locked` below does. Being unpublished does. + conditions.push(liveCondition(pagesTable)) } if (id) { conditions.push(eq(pagesTable.id, id)) @@ -1634,6 +1664,7 @@ class Pages { const results = await WIKI.db .select({ page: pagesTable, + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean), authorName: usersTable.name, authorHasAvatar: usersTable.hasAvatar, navigationId: treeTable.navigationId, @@ -1649,16 +1680,18 @@ class Pages { if (!row) { return null } + const ref: RulePageRef = { + path: row.page.path, + locale: row.page.locale, + siteId, + tags: row.page.tags ?? [] + } + // -> Not there, rather than refused: whether a draft exists at a path is the draft's business + if (!row.isLive && typeof unpublished === 'function' && !unpublished(ref)) { + return null + } const isUnlocked = typeof unlocked === 'function' ? unlocked(row.page.id) : unlocked - const includeContent = - typeof withContent === 'function' - ? withContent({ - path: row.page.path, - locale: row.page.locale, - siteId, - tags: row.page.tags ?? [] - }) - : withContent + const includeContent = typeof withContent === 'function' ? withContent(ref) : withContent return this.toPage( { ...row.page, @@ -1670,7 +1703,7 @@ class Pages { localeRelations: await this.localeRelationsFor(siteId, { localeGroupId: row.page.localeGroupId, id: row.page.id, - publicOnly + unpublished }) }, { @@ -1696,14 +1729,15 @@ class Pages { hash, locale, password, - publicOnly = false + unpublished = true }: { siteId: string id?: string hash?: string locale?: string password: string - publicOnly?: boolean + /** As for `getPage`. */ + unpublished?: boolean | ((page: RulePageRef) => boolean) }): Promise { /* Asked for as a reader would see it, for two reasons: a wrong guess must not assemble the body in @@ -1714,7 +1748,7 @@ class Pages { id, hash, locale, - publicOnly, + unpublished, unlocked: false, withPassword: false }) @@ -1735,7 +1769,7 @@ class Pages { return this.getPage({ siteId, id: page.id, - publicOnly, + unpublished, unlocked: true, withPassword: false }) diff --git a/backend/models/search.ts b/backend/models/search.ts index d1210452..a7bd8e1f 100644 --- a/backend/models/search.ts +++ b/backend/models/search.ts @@ -1,4 +1,9 @@ import { sql } from 'drizzle-orm' +import { + liveCondition, + maySeeUnpublished, + maySeeUnpublishedAnywhere +} from '../helpers/publishing.ts' import type { AccessActor } from './groups.ts' /** @@ -117,10 +122,12 @@ export interface SearchPagesParams { orderByDirection?: 'asc' | 'desc' offset?: number limit?: number - /** Restrict to what a reader with no session may see: published pages. */ - publicOnly?: boolean - /** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */ - includeDrafts?: boolean + /** + * Whether a page that is not live (`helpers/publishing.ts`) may be listed, for an `actor` holding + * `write:pages` or `manage:pages` on it. False — a request without a session — holds every result + * to live pages. + */ + unpublished?: boolean /** * Who is searching, so that a result they could not open never reaches them. * @@ -258,8 +265,7 @@ class Search { orderByDirection = 'desc', offset = 0, limit = 25, - publicOnly = false, - includeDrafts = false, + unpublished = false, hideProtectedContent = true, actor }: SearchPagesParams): Promise { @@ -286,12 +292,19 @@ class Search { if (hasQuery) { conditions.push(sql`p.ts @@ ${tsQuery}`) } - if (publicOnly) { - // -> Matches what a page view shows an anonymous reader, so that search cannot surface a page - // that could not then be opened - conditions.push(sql`p."publishState" = 'published'`) - } else if (!includeDrafts) { - conditions.push(sql`p."publishState" <> 'draft'`) + /* + Matches what the page view shows, so that search cannot surface a page that could not then be + opened. Who may see a page that is not live is a page rule's answer, so it is settled per row + below; a searcher who holds neither permission anywhere is held to live pages here instead. + */ + const liveNow = liveCondition({ + publishState: sql`p."publishState"`, + publishStartDate: sql`p."publishStartDate"`, + publishEndDate: sql`p."publishEndDate"` + }) + const checkUnpublished = unpublished && !!actor && maySeeUnpublishedAnywhere(actor) + if (!checkUnpublished) { + conditions.push(liveNow) } if (hideProtectedContent && hasQuery) { /* @@ -392,19 +405,23 @@ class Search { let totalHits: number if (checkRules) { const candidates = await WIKI.db.execute(sql` - SELECT p.id, p.path, p.locale, p.tags, ${relevancy} AS relevancy + SELECT p.id, p.path, p.locale, p.tags, ${liveNow} AS "isLive", ${relevancy} AS relevancy FROM pages p WHERE ${where} ORDER BY ${ordering} `) - const readable = ((candidates.rows ?? candidates) as any[]).filter((row) => - WIKI.models.groups.checkAccess(actor, 'read:pages', { + const readable = ((candidates.rows ?? candidates) as any[]).filter((row) => { + const ref = { siteId, path: row.path as string, locale: row.locale as string, tags: (row.tags ?? []) as string[] - }) - ) + } + return ( + WIKI.models.groups.checkAccess(actor, 'read:pages', ref) && + (row.isLive === true || maySeeUnpublished(actor, ref)) + ) + }) pageIds = readable.slice(offset, offset + limit).map((row) => row.id as string) totalHits = readable.length } else { diff --git a/backend/models/sites.ts b/backend/models/sites.ts index 6cf82e7c..b8f24226 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -139,6 +139,9 @@ class Sites { // been picked. This is the switch that turns them all off without losing that // choice, which is only useful to somebody who has already made it. comments: true, + // -> On: a glossary with no terms costs a reader nothing, and the menu row that opens it is + // also gated on `read:glossary`, which nobody but a fresh install's users holds + glossary: true, lastEditedBy: true, // -> On: a reader who follows a link to a folder rather than a page is better served by // what is in it than by a notice that there is nothing there @@ -468,6 +471,7 @@ class Sites { collaborativeEditing: true, ratingsMode: 'off', comments: true, + glossary: true, lastEditedBy: true, listFolderChildren: true, notifications: true, diff --git a/backend/models/tree.ts b/backend/models/tree.ts index b5fd24d1..c7e8c809 100644 --- a/backend/models/tree.ts +++ b/backend/models/tree.ts @@ -1,5 +1,7 @@ import { and, asc, desc, eq, exists, inArray, ne, not, or, sql, type SQL } from 'drizzle-orm' import { alias, type PgColumn } from 'drizzle-orm/pg-core' +import { liveCondition } from '../helpers/publishing.ts' +import type { RulePageRef } from '../helpers/pageRules.ts' import { pages as pagesTable, tree as treeTable } from '../db/schema.ts' import { CustomError, @@ -217,10 +219,12 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem { } /** - * What a page has to be for a reader to be shown that it exists. + * What a page has to be for a reader to be shown that it exists, as far as SQL can say. * - * Deliberately the same rule the page view itself applies (see `pages.getPage`'s `publicOnly`), so - * that a menu never offers a page that would answer 404 — nor hides one that would open. + * Deliberately the same rule the page view itself applies (see `pages.getPage`'s `unpublished`), so + * that a menu never offers a page that would answer 404 — nor hides one that would open. With + * `unpublished` false a page has to be live; with a predicate every state comes back and the rows are + * held to it by `isListable`, since who may see a page that is not live is a page rule's answer. * * A password-protected page is listed. It is not hidden but locked: opening it puts the reader in * front of the unlock prompt, which is exactly where someone who has the password wants to end up, @@ -229,17 +233,27 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem { * The columns come in one by one rather than as a table, because this is applied both to `pages` and * to an alias of it, and an alias is a different type. * - * @param publicOnly Restrict to what a reader with no session may see. `isBrowsable` applies either - * way: it is the author saying "not in the tree", not an access rule. + * @param unpublished As for `pages.getPage`. `isBrowsable` applies either way: it is the author saying + * "not in the tree", not an access rule. */ function pageIsVisible( - columns: { isBrowsable: PgColumn; publishState: PgColumn }, - publicOnly: boolean + columns: { + isBrowsable: PgColumn + publishState: PgColumn + publishStartDate: PgColumn + publishEndDate: PgColumn + }, + unpublished: Unpublished ): (SQL | undefined)[] { - return [ - eq(columns.isBrowsable, true), - ...(publicOnly ? [eq(columns.publishState, 'published')] : []) - ] + return [eq(columns.isBrowsable, true), ...(unpublished ? [] : [liveCondition(columns)])] +} + +/** Whether a page that is not live may be listed: `getPage`'s `unpublished`, minus the `true`. */ +type Unpublished = false | ((page: RulePageRef) => boolean) + +/** The half of `pageIsVisible` that has to be asked of each row once it is read. */ +function isListable(unpublished: Unpublished, isLive: boolean, page: RulePageRef): boolean { + return isLive || (unpublished !== false && unpublished(page)) } /** @@ -373,7 +387,7 @@ class Tree { * @param path Slash-separated path to list. The site root when empty. * @param depth How many folders below the path to include. 0, the default, is the path itself. * @param tags Only pages carrying every one of these tags. - * @param publicOnly Restrict to what a reader with no session may see. See `pageIsVisible`. + * @param unpublished Whether a page that is not live may be listed. See `pageIsVisible`. */ async listPages({ siteId, @@ -384,7 +398,7 @@ class Tree { orderBy = 'title', orderByDirection = 'asc', depth = 0, - publicOnly = true + unpublished = false }: { siteId: string path?: string | null @@ -394,7 +408,7 @@ class Tree { orderBy?: TreeOrderBy orderByDirection?: 'asc' | 'desc' depth?: number - publicOnly?: boolean + unpublished?: Unpublished }): Promise { if (limit < 1 || limit > MAX_LIMIT) { throw new CustomError('treeInvalidLimit', `The limit must be between 1 and ${MAX_LIMIT}.`) @@ -416,7 +430,8 @@ class Tree { title: treeTable.title, tags: treeTable.tags, description: pagesTable.description, - icon: pagesTable.icon + icon: pagesTable.icon, + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean) }) .from(treeTable) .innerJoin(pagesTable, eq(pagesTable.id, treeTable.id)) @@ -427,15 +442,15 @@ class Tree { eq(treeTable.type, 'page'), sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`, ...(tags && tags.length > 0 ? [sql`${treeTable.tags} @> ${sql.param(tags)}`] : []), - ...pageIsVisible(pagesTable, publicOnly) + ...pageIsVisible(pagesTable, unpublished) ) ) .orderBy(direction(treeTable[orderBy])) .limit(limit) - return rows.map((row) => { + return rows.flatMap((row) => { const folderPath = decodeTreePath(row.folderPath ?? '') ?? '' - return { + const page = { id: row.id, path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName, title: row.title, @@ -443,6 +458,14 @@ class Tree { icon: row.icon ?? '', tags: row.tags ?? [] } + return isListable(unpublished, row.isLive, { + siteId, + locale, + path: page.path, + tags: page.tags + }) + ? [page] + : [] }) } @@ -456,19 +479,19 @@ class Tree { * invisible is a dead end rather than something to offer. * * @param path Slash-separated path of the folder to list. The site root when empty. - * @param publicOnly Restrict pages to what a reader with no session may see. See `pageIsVisible`. + * @param unpublished Whether a page that is not live may be listed. See `pageIsVisible`. * @returns The level, or null when there is no such folder */ async browse({ siteId, path, locale, - publicOnly = true + unpublished = false }: { siteId: string path?: string | null locale: string - publicOnly?: boolean + unpublished?: Unpublished }): Promise { const encodedPath = encodeTreePath(path) const basePath = decodeTreePath(encodedPath) ?? '' @@ -509,6 +532,10 @@ class Tree { A folder is created for whatever is put in it, so it can end up holding only assets, only drafts, or nothing at all — descending into any of those lands on an empty menu. `EXISTS` stops at the first hit, so this costs an index lookup per folder in the level rather than a count. + + Approximate for a reader who may see SOME unpublished pages: whether they may see a particular + one below is a page rule's answer, which SQL cannot ask, so for them any state counts here. The + same is already true of `read:pages`, which is not asked of a folder's contents either. */ const holdsVisiblePages = exists( WIKI.db @@ -521,7 +548,7 @@ class Tree { eq(descendant.locale, treeTable.locale), eq(descendant.type, 'page'), sql`${descendant.folderPath} <@ (${childPathPrefix}::text || ${treeTable.fileName})::ltree`, - ...pageIsVisible(descendantPage, publicOnly) + ...pageIsVisible(descendantPage, unpublished) ) ) ) @@ -538,6 +565,7 @@ class Tree { title: treeTable.title, tags: treeTable.tags, icon: pagesTable.icon, + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean), holdsVisiblePages: sql`${holdsVisiblePages}`.mapWith(Boolean) }) .from(treeTable) @@ -549,7 +577,7 @@ class Tree { eq(treeTable.folderPath, encodedPath), or( eq(treeTable.type, 'folder'), - and(eq(treeTable.type, 'page'), ...pageIsVisible(pagesTable, publicOnly)) + and(eq(treeTable.type, 'page'), ...pageIsVisible(pagesTable, unpublished)) ) ) ) @@ -561,8 +589,20 @@ class Tree { if (row.type === 'folder' && !row.holdsVisiblePages) { continue } + const rowPath = basePath ? `${basePath}/${row.fileName}` : row.fileName + if ( + row.type === 'page' && + !isListable(unpublished, row.isLive, { + siteId, + locale, + path: rowPath, + tags: row.tags ?? [] + }) + ) { + continue + } const entry = merged.get(row.fileName) ?? { - path: basePath ? `${basePath}/${row.fileName}` : row.fileName, + path: rowPath, fileName: row.fileName, title: row.title, icon: null, diff --git a/dev/specs/glossary.md b/dev/specs/glossary.md new file mode 100644 index 00000000..2db966a7 --- /dev/null +++ b/dev/specs/glossary.md @@ -0,0 +1,510 @@ +# Glossary + +**Status:** phases 1 to 3 of [§11](#11-suggested-order) implemented — storage, permissions, the API +and the overlay. URLs (§7) and the page-move rewrite (§5.3) are still to come. Where building it +changed the design, this document was changed with it. +**Covers:** what a glossary term is, how it is stored, who may read and edit it, the overlay it is read +and edited in, its URL, and what the first version does to leave room for linking terms automatically +in page text later. + +A wiki accumulates vocabulary: product names, acronyms, words that mean something narrower here than +they do anywhere else. A glossary gives each of them one place to be defined, one page that documents +it properly, and a way of saying which terms belong together. + +The constraints this is written against: + +- **One site, one locale, one glossary.** A term belongs to a site and to a locale, the same way a page + does. Nothing is shared between sites, and a term is not translated by being edited in another + locale. +- **A name means one term.** A term's name and every one of its aliases are unique within its site and + locale, compared case-insensitively. That is what lets a URL, a related-terms list and, later, a + word in a paragraph each resolve to exactly one entry. +- **The glossary is read over the page, not instead of it.** It is an overlay, fetched the first time + somebody opens it, so a reader who never does pays nothing for it. + +--- + +## 1. Goals and non-goals + +**Goals** + +- A **Glossary overlay**, opened from the header's **Library** menu: terms listed in a sidebar, the + selected term in the main panel, and a **New Term** button at the top of the sidebar that opens the + form in the main panel. +- The fields in [§3.1](#31-glossaryterms): term, expansion, definition, aliases, related terms, + documentation page and its label, external references, case sensitivity, category, and auto-link. +- **Two-way related terms.** Relating A to B relates B to A. +- **A URL per term**, so a term can be linked to from a chat message or another page + ([§7](#7-urls)). +- **Two new permissions** ([§4](#4-permissions)): `read:glossary` for reading a site's glossary in a + locale, and `manage:glossary` for creating, editing and deleting its terms. Both are page-rule + permissions whose rule's path is ignored. +- **Moving a page with `updateLinks` updates every term documented by it** ([§5.3](#53-moving-a-page)). +- **Lost updates are refused, not silent**: a save against a term somebody else changed in the meantime + answers 409 ([§5.2](#52-concurrent-edits)). +- **A per-site switch**, `features.glossary`. + +**Non-goals for the first version** + +- **Linking terms in page text automatically.** Planned; the `autoLink` column ships now and its + toggle is shown disabled. [§9](#9-auto-linking-later) is the design the first version is built to + accommodate. +- **A `block-glossary` content block**, listing terms (or one category's) inside a page, and an inline + block for marking a term explicitly. +- **Glossary terms in site search results.** +- **Linking equivalent terms across locales**, the way pages are grouped into locale groups. +- **Import and export** (CSV, JSON). +- **Revision history of a term.** The audit log records who changed what and which fields; it does not + keep the previous text ([§8](#8-audit-log)). +- **Real-time collaborative editing.** [§5.2](#52-concurrent-edits) is the whole of the concurrency + story. + +--- + +## 2. Architecture + +| Piece | Where | +| --- | --- | +| Tables | `glossaryTerms`, `glossaryTermRelations` in `backend/db/schema.ts`, one generated migration (`--name=glossary`) | +| Model | `backend/models/glossary.ts`, exposed as `WIKI.models.glossary` | +| API | `backend/api/glossary.ts`, schemas in `backend/api/schemas/` | +| Permissions | `read:glossary` and `manage:glossary` in `PAGE_PERMISSIONS` and `LOCALE_PERMISSIONS` (`models/groups.ts`); the group editor's rule list (`GroupEditOverlay.vue`); the lists in CLAUDE.md | +| Overlay | `frontend/src/components/GlossaryOverlay.vue`, registered in `MainOverlayDialog.vue`, opened by `siteStore.openGlossary()` | +| Definition renderer | `frontend/src/renderers/glossary.js`, built on `createProseMarkdown` from `renderers/comment.js` ([§6.4](#64-the-definition)) | +| Access | `frontend/src/stores/glossary.js`, from `GET /glossary/access` ([§4.5](#45-on-the-frontend)) | +| Entry point | The Glossary row of `HeaderLibraryMenu.vue` | +| Strings | `glossary.*` in `backend/locales/en.json` | + +--- + +## 3. Storage + +### 3.1 `glossaryTerms` + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | uuid pk | | +| `siteId` | uuid, → `sites`, on delete cascade | | +| `locale` | varchar(255), not null | Same shape as `pages.locale` | +| `term` | varchar(255), not null | Trimmed, inner whitespace collapsed, NFC | +| `expansion` | varchar(255), nullable | The full form, when the term is an abbreviation | +| `definition` | text, not null, default `''` | Basic markdown source, never HTML ([§6.4](#64-the-definition)) | +| `aliases` | text[], not null, default `{}` | Alternate forms and inflections; normalized like `term` | +| `documentationPath` | varchar(255), nullable | A page path in the term's own locale, no locale prefix | +| `documentationLabel` | varchar(255), nullable | Null means the default, "Read more", which comes from the locale strings and is not stored | +| `references` | jsonb, not null, default `[]` | `[{ url, label }]`, in the order given | +| `caseSensitive` | boolean, not null, default false | How the term is *matched in text*; never how it is compared for uniqueness ([§3.3](#33-uniqueness)) | +| `autoLink` | boolean, not null, default true | Whether the term is linked automatically in page text; no effect until [§9](#9-auto-linking-later) ships | +| `category` | varchar(255), nullable | One per term, free text, used to group terms in the sidebar | +| `creatorId` | uuid, → `users`, on delete set null | | +| `authorId` | uuid, → `users`, on delete set null | Who saved it last | +| `createdAt` | timestamp, default now | | +| `updatedAt` | timestamp, default now | Also the concurrency token ([§5.2](#52-concurrent-edits)) | + +Indexes: + +- `(siteId, locale)` for the list. +- **Unique `(siteId, locale, lower(term))`**: the database's half of [§3.3](#33-uniqueness). +- `(siteId, locale, documentationPath)` where `documentationPath` is not null, for + [§5.3](#53-moving-a-page). + +**Category is a column, not the site `tags` table.** That table counts how many pages use each tag and +is what `/_tags` is drawn from; glossary categories in it would appear on the Tags page as page tags +that no page carries. The category suggestions in the form come from +`SELECT DISTINCT category` over the locale's terms. + +### 3.2 `glossaryTermRelations` + +| Column | Type | +| --- | --- | +| `termId` | uuid, → `glossaryTerms`, on delete cascade | +| `relatedId` | uuid, → `glossaryTerms`, on delete cascade | + +Primary key `(termId, relatedId)`, a `CHECK (termId < relatedId)`, and an index on `relatedId`. + +**One row per pair, read from both sides.** The check stores each relation exactly once with the +smaller id first, so "B is related to A" and "A is related to B" are the same row and cannot drift +apart. A term's related terms are the union of `relatedId where termId = $1` and +`termId where relatedId = $1`. Saving a term's `relatedTerms` replaces the rows touching that term, in +the same transaction as the term itself, so removing B from A's list removes A from B's. + +A join table rather than a uuid array on the term, so that deleting a term takes it out of every other +term's list through the cascade alone, with no cleanup code to forget. + +Related terms must be in the same site and locale; the model refuses any other id with 400. + +### 3.3 Uniqueness + +**A term's name and its aliases share one namespace with every other term's name and aliases** in the +same site and locale, compared with `lower()` after normalization. So: + +- `REST` and `rest` cannot be two terms. `caseSensitive` on `REST` says that the word `rest` in a + paragraph is not a mention of it; it does not make room for a second entry spelled `rest`. +- An alias may not equal another term's name, nor one of its aliases. +- An alias equal to the term's own name, or repeated, is dropped silently on save rather than refused. + +The unique index covers term against term. Everything involving an alias is checked in the model, +inside the save's transaction, and refused with **409** naming the term that already holds the name, so +the form can say which one. Two saves racing to claim the same alias are not caught by the index; that +is accepted, since the outcome is two terms sharing an alias, which a later edit of either one refuses +and so surfaces. + +Comparison uses Postgres `lower()` on the server and `toLocaleLowerCase(locale)` in the browser. The two +agree for every case a glossary is likely to meet; the server is the one that decides. + +### 3.4 Limits + +| Field | Limit | +| --- | --- | +| `term`, `expansion`, `category`, `documentationLabel`, each alias | 255 characters | +| `aliases` | 50 | +| `definition` | 10,000 characters | +| `references` | 20; `url` is `http:` or `https:` only, `label` up to 255 characters and optional (the URL stands in) | +| `relatedTerms` | 50 | + +--- + +## 4. Permissions + +Two permissions of the same kind: page-rule permissions whose rule's path is ignored, so that both are +granted per site and per locale. A site may open its glossary to the readers of one locale and not +another, and hand the maintenance of each locale's glossary to the people who write in it. + +### 4.1 The rule + +Both are **page-rule permissions**, added to `PAGE_PERMISSIONS` and granted through a group's rules like +`read:pages`. They are the page permissions whose rule's **path is ignored** (`LOCALE_PERMISSIONS`): the +question they answer is about a site and a locale, and a glossary has no path. + +**Every rule naming the permission that is scoped to the site and the locale applies, whatever its +`match`, `path` or `tags`, and the modes rank as they do for any other rule:** + + ALLOW < DENY < FORCE ALLOW + +So: no such rule, denied; only ALLOWs, granted; any DENY overrides every ALLOW; any FORCE ALLOW +overrides every DENY. This is step 4 of the ordering in +[`helpers/pageRules.ts`](../../backend/helpers/pageRules.ts) on its own (`resolveLocaleRule`). Steps 1 +to 3, which rank rules by how precisely they address a page, have nothing to rank when no page is being +asked about, so every rule counts as equally specific and mode alone decides. + +What follows from ignoring the path, and what the permissions' hints in the group editor have to say: + +- **A rule anywhere speaks for the whole locale.** An ALLOW written for `/docs` opens the entire + glossary, the same as one for the site root. +- **A DENY anywhere closes the whole locale**, and a more specific ALLOW does not reopen it the way it + would for pages. Only a FORCE ALLOW does. A DENY rule for `/confidential` that also names + `read:glossary` therefore hides the glossary from that group entirely. + +Both are asked by `groups.checkLocaleAccess(actor, permission, siteId, locale)`, and `checkAccess` routes +them there, so asking about one at a page gets the locale's answer. The worker-side `rulesAllow` is not +involved: nothing in a worker reads the glossary. `manage:system` implies both, as it implies +everything. No route declares either in `config.permissions` (the hook reads the global list only); +every glossary route checks in the handler, with a `No route-level permissions:` comment as +`api/pages.ts` does. + +### 4.2 `read:glossary` + +- **`manage:glossary` in a locale implies it there.** Editing a glossary one may not read is not a state + worth supporting, and the editor has to see the list to pick related terms. Each is decided on its own + rules first, so a DENY on reading does not take managing with it. +- **Guests may hold it**: it joins `GUEST_ROLES` in `models/groups.ts` (and the copy in + `GroupEditOverlay.vue`), which is what lets a public wiki open its glossary to readers without an + account. +- **Seeding**: `read:glossary` is added beside `read:pages` in the Users group's seeded ALLOW rule + (`groups.init()`) and in the starting rule of a newly created group (`createGroup`). It is + deliberately **not** added to the Guests group's seeded DENY rule. Guests are denied anyway with no + rule at all, and a DENY there would mean an administrator's later ALLOW for guests did nothing (the + DENY outranks it, path or no path), leaving FORCE ALLOW or editing the default rule as the only ways + in. Without it, a fresh install's guests cannot read the glossary until an administrator writes an + ALLOW rule for it, as with pages. + +### 4.3 `manage:glossary` + +Create, edit and delete terms in the rule's locales. A write is asked about in the locale of the term it +touches: the body's `locale` on a create, the stored term's on a save or a delete. A term the caller may +not even read answers 404 there, as it does on the read route; one they may read but not manage, 403. + +- **Not seeded anywhere** and not in `GUEST_ROLES`: maintaining the glossary is something an + administrator hands out, and a guest has no session to attribute an edit to. + +### 4.4 What a reader sees + +**The documentation link is shown to every reader of the term**, whether or not they may read the page +it points at; following it answers as the page route always does. Hiding it would need a page-rule check +per term in the list, for no information the reader does not already have from the term itself. + +### 4.5 On the frontend + +`userStore.pagePermissions` cannot answer `read:glossary`: it is what the session holds at the current +PATH, it now arrives with the page payload rather than from `pages/userPermissions`, and it is empty on +every route that is not a page — `/_tags` included, where the Library menu is still drawn. So the +answer has an endpoint of its own, `GET /sites/:siteId/glossary/access`, which reports the site switch, +the active locales the session may read the glossary in, and the ones it may edit it in. +`stores/glossary.js` asks it once per site and session (again after a login or a logout), since none +of it changes from page to page. + +- The Library menu's Glossary row shows when `features.glossary` is on and at least one locale is + readable. The overlay opens in the interface's locale when that one is readable, and in the first + readable one otherwise. +- New Term, Edit and Delete show where the locale on screen is one of the editable ones. +- The overlay's locale picker offers every active locale. One the session may not read the glossary in + answers the list with 403, and the sidebar says so in place of the list. + +--- + +## 5. Backend + +### 5.1 API + +All under `/_api/sites/:siteId/glossary`, tagged `Glossary`. + +| Method and path | Permission | What it does | +| --- | --- | --- | +| `GET /access` | none (it reports the permissions) | `{ enabled, readableLocales, manageableLocales }` for the caller ([§4.5](#45-on-the-frontend)) | +| `GET /?locale=` | `read:glossary` in the locale | The list: `id`, `term`, `expansion`, `aliases`, `category`, `caseSensitive`, `autoLink`. Everything the sidebar draws and everything [§9](#9-auto-linking-later) will need, and nothing else | +| `GET /categories?locale=` | `read:glossary` in the locale | Distinct categories, for the form's suggestions | +| `GET /:termId` | `read:glossary` in the term's locale | The whole term, with `relatedTerms` as `[{ id, term }]` and `documentation` as `{ path, label, title, exists }` | +| `GET /lookup?locale=&name=` | `read:glossary` in the locale | Resolves a name or alias to a term id, for [§7](#7-urls); 404 when nothing matches | +| `POST /` | `manage:glossary` in the body's locale | Creates a term; answers it in full | +| `PUT /:termId` | `manage:glossary` in the term's locale | Replaces a term; carries `expectedUpdatedAt`; answers it in full | +| `DELETE /:termId` | `manage:glossary` in the term's locale | Deletes a term and, by cascade, its relations | + +`relatedTerms` is written as an array of ids. `locale` is set on create and cannot be changed by `PUT`: +moving a term to another locale would orphan its relations and its documentation path at once, and a +term in another locale is a different term. + +Every write fails with 404 when `features.glossary` is off for the site, the two lists answer empty, and +a single term and a lookup answer 404, so that turning the switch off hides the glossary without +deleting it. + +### 5.2 Concurrent edits + +`PUT` carries `expectedUpdatedAt`, the `updatedAt` the form was opened with. The model compares it inside +the save's transaction (`UPDATE … WHERE id = $1 AND "updatedAt" = $2`), and when no row matches because +the term has changed since, answers **409** with the term as it now stands. The form then says that +somebody else changed it, shows who and when, and offers to reload it (losing the draft) or to keep the +draft and save over the newer version, which re-sends with the new `updatedAt`. + +A term deleted while it was open answers 404, and the form says so. + +`updatedAt` is compared at millisecond precision, as written by the rest of the codebase. + +### 5.3 Moving a page + +`pages.relinkMovedPage` is what `PUT /pages/:id/move` calls when `updateLinks` is set. It also rewrites +`documentationPath` on every term in the page's site and old locale that points at the old path, in the +same call: + +- **Same locale**: the path is replaced. +- **Another locale**: the term's path is cleared, since a documentation page must be in the term's own + locale. The move's result reports the terms affected, so the dialog can say so. + +These are not edits to somebody else's work in the way relinking a page is, so they are not asked about +per term: the person moving the page with `updateLinks` has asked for what pointed at it to follow. +`updatedAt` is bumped on each term touched, so a form open on one of them gets the 409 above. + +**Not covered**: a move without `updateLinks`, a deletion, and a **folder rename**, which moves the pages +beneath it without relinking anything today. Folder renames are deliberately out of scope here; they +will be handled, for links and documentation paths together, as a separate improvement. A term then +points at a path with no page, and the term view says the page was not found rather than offering a +dead link. + +### 5.4 Site switch + +`features.glossary`, on by default, in the site defaults in `models/sites.ts` and under **General → +Features**. Off, the Library menu has no Glossary row and the API behaves as in +[§5.1](#51-api). Nothing is deleted. + +--- + +## 6. The overlay + +### 6.1 Layout + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Glossary [ en ▾ ] [×] │ +├──────────────────────┬───────────────────────────────────────────────┤ +│ [ + New Term ] │ │ +│ [ Filter… ] │ TERM │ +│ [Category ▾] │ Expansion │ +│ │ │ +│ A │ Definition… │ +│ API │ │ +│ Application Prog… │ Also: aliases │ +│ ▸ ACL │ Related: [chip] [chip] │ +│ B │ Read more → /docs/api │ +│ … │ References: … │ +│ │ Category: Networking │ +│ │ [Edit] [Delete] │ +└──────────────────────┴───────────────────────────────────────────────┘ +``` + +- **Header**: title, the locale picker (only where the site has more than one active locale), close. +- **Sidebar**: New Term (for `manage:glossary`), a filter field matching names and aliases, an + **A–Z / Category** switch, then the terms, each with its expansion as a second line. + - **A–Z**, the default: under letter headings. Letters are the term's first character, upper-cased + in the locale; everything that is not a letter goes under `#`. + - **Category**: under one heading per category, sorted by name, A–Z within each, with the terms that + have none last under "Uncategorized". + + The switch is remembered per browser (`localStorage`, as a convenience that may come back empty). The + whole list for the locale is fetched once when the overlay opens, which is comfortable to a few + thousand terms; beyond that the filter would move to the server. +- **Main panel**: one of three states, below. + +Below 900px the two columns become a list → detail flow: the list fills the overlay, a term replaces it, +and a back button returns. The same breakpoint the header's action buttons collapse at. + +### 6.2 Viewing a term + +Name, expansion, the rendered definition, aliases, related terms as chips (clicking one moves the panel to +it and updates the URL), the documentation link (its label, or "Read more"; "page not found" where the +path no longer resolves), references as a list of external links, and the category (clicking it switches the +sidebar to Category and scrolls to that heading). Edit and Delete for `manage:glossary`; Delete confirms first. + +### 6.3 The form + +Shown by New Term and by Edit, in the main panel. + +| Field | Control | +| --- | --- | +| Term | Text input, required | +| Expansion | Text input | +| Definition | Textarea with a Write / Preview toggle | +| Aliases | Chip input | +| Related terms | Autocomplete over the locale's terms, excluding this one | +| Documentation page | `LinkPickerDialog` with `pagesOnly` (a new prop hiding the URL tab), locked to the term's locale; a clear button | +| Documentation label | Text input, placeholder "Read more" | +| References | Repeatable rows of URL + label, with add and remove | +| Case sensitive | Toggle | +| Auto-link | Toggle, shown on and **disabled**, with a "Coming soon" hint ([§9](#9-auto-linking-later)) | +| Category | Select with suggestions from `GET /categories`; Enter on a new name uses it; shown as a removable chip | + +**Renaming.** When the Term field of an existing term is changed, a checkbox appears under it: *Keep +"‹old name›" as an alias*, **unchecked by default**. Checked, the form adds the old name to `aliases` +before saving, so that `?glossary=` links to the old name keep resolving ([§7](#7-urls)). It is the +form's doing rather than the API's: the server sees an ordinary save, and the uniqueness check runs +against the term's new name and aliases as usual, which the old name, now freed by the rename, passes. + +A name collision answers 409 from the server and is shown against the field that caused it, naming the +term that holds the name. The sidebar's list is also checked as the user types, which catches most +collisions before a save; the server's answer is the one that counts. + +**Unsaved changes**: choosing another term, New Term, switching locale or closing the overlay with a +changed form asks before discarding it. + +### 6.4 The definition + +**Basic markdown, stored as source and rendered in the browser at display time**, which is the bargain +the built-in comments already make. `html: false` is the security boundary: nothing stored is ever HTML, +so there is nothing to sanitize and nothing sanitized by older rules to serve back. + +The comment renderer (`renderers/comment.js`) is the right feature set (paragraphs, emphasis, inline +code, code blocks, lists, links; no headings, images or tables) with two things the glossary does not +want: `@handle` mentions, and `rel="nofollow ugc"` on links that a holder of `manage:glossary`, not an +anonymous commenter, wrote. So `comment.js` exports `createProseMarkdown({ rel })`, the configuration +both use, and `renderers/glossary.js` builds its own instance from it with `rel="noopener"` and no +mention pass. + +--- + +## 7. URLs + +**`?glossary=` on any path the main layout draws** opens the overlay on that term, in the locale of +the path. `` is the term's name, URL-encoded, resolved with `GET /lookup`, which matches aliases +too, so a term renamed with its old name kept as an alias keeps its old links working. + +- Opening the overlay from the menu adds `?glossary` with no value (the list, nothing selected). +- Selecting a term replaces the value; closing the overlay removes the parameter. Both use + `router.replace`, so browsing the glossary does not fill the history with one entry per term. +- A name that resolves to nothing opens the list with a notice, rather than an error page. +- Switching locale in the overlay adds `&glossaryLocale=` when it differs from the path's locale, + and removes it when it does not. + +A query parameter rather than a route of its own, so a shared link opens the term **over the page it was +shared from**, which is usually the context it was being discussed in. The overlay is only mounted by +`MainLayout`, so the admin area and the profile pages ignore the parameter. + +--- + +## 8. Audit log + +A new kind, `glossary`, with three actions: `createGlossaryTerm`, `updateGlossaryTerm`, +`deleteGlossaryTerm`. Each records `siteId`, `locale`, `termId` and `term`; an update also records which +fields changed, not their values, as configuration routes do. The terms rewritten by a page move are +recorded in that move's own entry (`meta.glossaryRelinked: [termId…]`) rather than as entries of their +own. + +Strings: `admin.audit.kinds.glossary` and `admin.audit.actions.`. + +--- + +## 9. Auto-linking (later) + +Recognising terms in page text and linking them to their definitions. Not built in the first version, +but the first version is shaped for it: the `autoLink` and `caseSensitive` columns, aliases, the list +endpoint carrying exactly what a matcher needs, and the uniqueness rule that makes every match resolve to +a single term. + +**It happens in the reader's browser, when the page is drawn.** A page's render is produced once at save +time; linking terms into it then would mean re-rendering every page whenever any term changes, and would +freeze a link to whatever a word meant on the day the page was saved. Drawn at display time, a new term +appears in every page at once and a deleted one disappears. + +Sketch, to be settled when it is built: + +- **The list**, `GET /?locale=` filtered to `autoLink`, fetched once per locale per session and + revalidated with an ETag derived from the locale's newest `updatedAt` and term count. +- **The matcher** is compiled from every name and alias, longest first, so `REST API` wins over `REST`. + Case-insensitive terms compare with `toLocaleLowerCase(locale)`. Word boundaries come from + `Intl.Segmenter(locale, { granularity: 'word' })`, which is what makes it work in languages written + without spaces. +- **Where it looks**: text in the article body only. Not in headings, links, `code`, `pre`, `kbd`, blocks, + or the table of contents. +- **How often**: the first occurrence of each term per page, so a paragraph about REST is not a wall of + underlines. +- **What a match looks like**: an underline, a hover card (expansion and the first lines of the + definition), and a click that opens the overlay on the term. It has to be focusable and reachable by + keyboard. +- **Switches**: the term's `autoLink`, a site setting, and a page property to opt a page out. +- **Crawlers** never see it. The app shell's prerendered body is the stored render, unchanged. + +**Open design point: spans or highlights.** Wrapping matches in elements is the simplest way to get +hover, focus and keyboard access, but changes the DOM of the article. Annotations deliberately insert +nothing and use the CSS Custom Highlight API with click hit-testing (`PageAnnotationsLayer.vue`); the two +features have to coexist on one article, and annotations find their passages again by text, which +wrapped matches must not disturb. + +--- + +## 10. Behaviour worth pinning down + +- **Deleting a term** removes it from every related list (cascade) and breaks any `?glossary=` link to it, + which then opens the list with a notice. +- **Renaming a term** breaks `?glossary=` links to the old name, unless the editor ticks the option to + keep it as an alias ([§6.3](#63-the-form)). +- **A related term in the list but deleted before the save** is refused with 400, and the form reloads the + list. +- **A term's documentation page deleted** leaves the path in place, shown as "page not found", so that + restoring the page restores the link. +- **Turning `features.glossary` off** hides the glossary everywhere and keeps every term. +- **Deleting a site** deletes its terms (cascade on `siteId`). + +--- + +## 11. Suggested order + +1. Schema and migration; `read:glossary` and `manage:glossary` (model lists, `checkLocaleAccess`, the + seeded default rule, the group editor, CLAUDE.md); the site switch. +2. Model and API, including uniqueness, two-way relations, the `updatedAt` check and the audit entries. +3. The overlay: list, view, form, and the Library menu row. +4. URLs. +5. `relinkMovedPage` rewriting documentation paths. +6. Later: auto-linking ([§9](#9-auto-linking-later)), then the other non-goals as they are wanted. + +--- + +## 12. Open questions + +None at the moment. diff --git a/frontend/public/_assets/icons/ultraviolet-live-folder.svg b/frontend/public/_assets/icons/ultraviolet-live-folder.svg new file mode 100644 index 00000000..560ba985 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-live-folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/ultraviolet-parchment.svg b/frontend/public/_assets/icons/ultraviolet-parchment.svg new file mode 100644 index 00000000..b4827813 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-parchment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/ultraviolet-tags.svg b/frontend/public/_assets/icons/ultraviolet-tags.svg new file mode 100644 index 00000000..31538c10 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-tags.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 1529011a..f2bb69f3 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 308 icons. + 311 icons. */ export const BUNDLED_ICONS = { "la:angle-down": {"body":"","width":32,"height":32}, @@ -23,6 +23,7 @@ export const BUNDLED_ICONS = { "la:bell": {"body":"","width":32,"height":32}, "la:bolt": {"body":"","width":32,"height":32}, "la:book": {"body":"","width":32,"height":32}, + "la:book-open": {"body":"","width":32,"height":32}, "la:broadcast-tower": {"body":"","width":32,"height":32}, "la:broom": {"body":"","width":32,"height":32}, "la:calendar": {"body":"","width":32,"height":32}, @@ -155,6 +156,7 @@ export const BUNDLED_ICONS = { "la:stream": {"body":"","width":32,"height":32}, "la:sun": {"body":"","width":32,"height":32}, "la:sync-alt": {"body":"","width":32,"height":32}, + "la:tag": {"body":"","width":32,"height":32}, "la:tags": {"body":"","width":32,"height":32}, "la:tasks": {"body":"","width":32,"height":32}, "la:th-list": {"body":"","width":32,"height":32}, @@ -282,6 +284,7 @@ export const BUNDLED_ICONS = { "mdi:logout": {"body":"","width":24,"height":24}, "mdi:menu-down": {"body":"","width":24,"height":24}, "mdi:message-alert": {"body":"","width":24,"height":24}, + "mdi:museum-outline": {"body":"","width":24,"height":24}, "mdi:palette": {"body":"","width":24,"height":24}, "mdi:percent-outline": {"body":"","width":24,"height":24}, "mdi:play": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/GlossaryOverlay.vue b/frontend/src/components/GlossaryOverlay.vue new file mode 100644 index 00000000..b972b915 --- /dev/null +++ b/frontend/src/components/GlossaryOverlay.vue @@ -0,0 +1,1619 @@ + + + + + diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index 59de9bca..4810d33a 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -235,90 +235,81 @@
-
- -
+ +
-
- {{ - getRuleModeName(rule.mode) - }} -
+ + + + {{ t('admin.groups.ruleDelete') }} +
- - - - + +
+ + {{ perm.title }} + + + {{ t('admin.groups.rulePermissionsNone') }} + +
+ class="admin-groups-rule-select acrylic-btn ml-4 shrink-0" + flat + no-caps + :color="getRuleModeBgColor(rule.mode)" + icon="la:list-alt" + padding="xs md" + :label="t(`admin.groups.rulePermissionsSelect`)" + @click="selectRulePermissions(rule)" />
@@ -336,28 +327,11 @@ option-value="id" option-label="title" multiple - behavior="dialog" :display-value=" t(`admin.groups.selectedSites`, rule.sites.length, { count: rule.sites.length }) "> - - @@ -794,6 +751,8 @@ import { useUserStore } from '@/stores/user' import { v4 as uuid } from 'uuid' import { fileOpen, fileSave } from 'browser-fs-access' +import RuleModeMenu from '@/components/RuleModeMenu.vue' +import RulePermissionsDialog from '@/components/RulePermissionsDialog.vue' import UserSearchDialog from '@/components/UserSearchDialog.vue' import { apiErrorMessage } from '@/helpers/apiError' @@ -1028,7 +987,8 @@ const GUEST_ROLES = [ 'read:history', 'read:assets', 'read:comments', - 'write:comments' + 'write:comments', + 'read:glossary' ] const rules = [ @@ -1167,6 +1127,32 @@ const rules = [ warning: false, restrictedForSystem: true, disabled: false + }, + /* + The two rule permissions that ignore the path: a glossary belongs to a site and a locale, so every + rule naming one that is scoped to them applies, and mode alone decides -- see `resolveLocaleRule` + in `helpers/pageRules.ts`. The hints say so, because a DENY written for one folder closing the + whole glossary is not what anybody would guess. `scope: 'locale'` is what puts them in the + picker's second column (`RulePermissionsDialog`), whose heading carries that warning for both, + and has to match `LOCALE_PERMISSIONS` in `models/groups.ts`. + */ + { + permission: 'read:glossary', + scope: 'locale', + title: 'Read Glossary', + hint: "Can view the site's glossary.", + warning: false, + restrictedForSystem: false, + disabled: false + }, + { + permission: 'manage:glossary', + scope: 'locale', + title: 'Manage Glossary', + hint: 'Can create, edit and delete glossary terms, and view them.', + warning: false, + restrictedForSystem: true, + disabled: false } ] @@ -1299,16 +1285,6 @@ function getRuleModeIcon(mode) { ) } -function getNextRuleMode(mode) { - return ( - { - DENY: 'FORCEALLOW', - ALLOW: 'DENY', - FORCEALLOW: 'ALLOW' - }[mode] || 'ALLOW' - ) -} - function getRuleModeName(mode) { switch (mode) { case 'ALLOW': @@ -1435,15 +1411,39 @@ function newRule() { } /* - Cycle a rule between ALLOW, DENY and FORCEALLOW. Guarded here rather than in the template, because - the control is an icon with a click handler rather than a form control there is a `disable` to set - -- a reader holding `read:groups` may look at a rule, not re-point it. + Set what a rule does. Guarded here as well as by the disabled triggers, since the menu is the only + thing between a reader holding `read:groups` and re-pointing a rule. */ -function cycleRuleMode(rule) { +function setRuleMode(rule, mode) { if (!canManage.value) { return } - rule.mode = getNextRuleMode(rule.mode) + rule.mode = mode +} + +/** + * A rule's permissions as the bar draws them: in the order the picker offers them, with anything the + * rule names that is not on offer (an imported rule may) shown under its raw name at the end. + */ +function ruleRoleChips(rule) { + const roles = new Set(rule.roles) + const known = rules.filter((opt) => roles.has(opt.permission)) + const unknown = rule.roles + .filter((role) => !rules.some((opt) => opt.permission === role)) + .map((role) => ({ permission: role, title: role })) + return [...known, ...unknown] +} + +function selectRulePermissions(rule) { + dialog({ + component: RulePermissionsDialog, + componentProps: { + options: ruleOptions.value, + selected: rule.roles + } + }).onOk((roles) => { + rule.roles = roles + }) } function deleteRule(id) { @@ -1628,22 +1628,14 @@ onMounted(() => { position: relative; padding: 10px 0 24px 40px; + /* -> Drawing only, under the mode button's disc: it must never take the click meant for it */ &-icon { position: absolute; top: 0; left: 0; bottom: 0; width: 31px; - - &::before { - position: absolute; - content: ''; - border-radius: 100%; - width: 31px; - height: 31px; - background-color: currentColor; - top: 4px; - } + pointer-events: none; &::after { position: absolute; @@ -1656,25 +1648,65 @@ onMounted(() => { background-color: currentColor; display: block; } + } - /* - Sized and placed to the disc `::before` draws, with the glyph inset by the padding: an inline - scales its viewBox to whatever box it is given, so the old `width: 100%; height: 38px` - -- metrics for the icon FONT this replaced, where `font-size` did the sizing -- stretched the - mark across the whole circle. + /* + The mode: disc, name and caret as one button, in the rule's colour (`currentColor` throughout, from + the `text-*` class the template sets). - The box stays the full 31px even though the glyph is 15px, so the click target is the disc a - reader is aiming at rather than the mark inside it. - */ - .w-icon { - position: absolute; - top: 4px; - left: 0; - box-sizing: border-box; + It sits in the title row but has to put its disc where the timeline starts, at the rule's left + edge and 4px down: hence the negative left margin over the row's 40px of padding, and the negative + vertical margins that let a 31px disc ride a 12px row without making it taller. The 9px gap is what + leaves the name where it always was, 40px in. + */ + &-mode { + position: relative; + flex: 0 0 auto; + align-self: center; + display: inline-flex; + align-items: center; + gap: 9px; + height: 31px; + margin: -9.5px 0 -9.5px -40px; + padding-right: 10px; + border-radius: 999px; + white-space: nowrap; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover:not(:disabled), + &:focus-visible { + background-color: color-mix(in srgb, currentColor 12%, transparent); + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + + &:disabled { + cursor: default; + } + + &-disc { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; width: 31px; height: 31px; - padding: 8px; - cursor: pointer; + border-radius: 100%; + background-color: currentColor; + + .w-icon { + width: 15px; + height: 15px; + color: #fff; + } + } + + > .w-icon { + margin-left: -5px; } } @@ -1691,11 +1723,6 @@ onMounted(() => { align-items: baseline; padding-top: 4px; - &-text { - flex: 0 0; - white-space: nowrap; - } - input { font-weight: 700; color: $grey-6; @@ -1722,6 +1749,23 @@ onMounted(() => { } } + /* -> At the end of the title row, level with the mode name and the rule's title */ + &-delete { + align-self: center; + margin: -6px 0 -6px 0.5rem; + } + + /* -> The chips wrap onto as many lines as the rule needs; the picker button keeps its place */ + &-roles { + display: flex; + flex: 1; + flex-wrap: wrap; + align-items: center; + gap: 4px; + min-width: 0; + min-height: 32px; + } + &-card { background-color: $grey-2 !important; @@ -1735,10 +1779,6 @@ onMounted(() => { display: flex; align-items: center; - .w-select { - flex-basis: 100%; - } - &.is-allow { background-color: rgba($positive, 0.1); border-bottom: 1px solid rgba($positive, 0.3); @@ -1751,6 +1791,25 @@ onMounted(() => { background-color: rgba($blue, 0.1); border-bottom: 1px solid rgba($blue, 0.3); } + + /* + -> The mode's colour on its own pale bar is too light to read as text, in light mode: each one + darkened by the same amount rather than swapped for a deeper Material shade, which would be + another hue. `!important` because `WBtn` writes its colour as an inline style, which + nothing else outranks. + */ + @at-root .body--light & .admin-groups-rule-select { + color: color-mix(in srgb, var(--rule-select-color) 65%, black) !important; + } + @at-root .body--light &.is-allow { + --rule-select-color: var(--color-positive); + } + @at-root .body--light &.is-deny { + --rule-select-color: var(--color-negative); + } + @at-root .body--light &.is-forceallow { + --rule-select-color: var(--color-blue); + } } &-filters { diff --git a/frontend/src/components/HeaderActionsMenu.vue b/frontend/src/components/HeaderActionsMenu.vue index 4c3f8c36..a9e839ad 100644 --- a/frontend/src/components/HeaderActionsMenu.vue +++ b/frontend/src/components/HeaderActionsMenu.vue @@ -42,9 +42,9 @@ choice, from the same component, as the header button on a wide screen. It anchors to this row because `WMenu` takes the nearest `.w-item` as its trigger. - `hide-asset-btn` because this menu has a File Manager row of its own directly below, and - `@new-page` closes this menu once the submenu has acted: the editor opens behind it otherwise, - with the menu still floating over it. + `hide-asset-btn` because the Library submenu directly below has a File Manager row of its own, + and `@new-page` closes this menu once the submenu has acted: the editor opens behind it + otherwise, with the menu still floating over it. --> @@ -56,17 +56,17 @@ - - + + - + - File Manager + {{ t('common.header.library') }} + + + + @@ -83,10 +83,7 @@ {{ t('common.header.admin') }} - - + + + + {{ t('fileman.title') }} + + + + + {{ t('common.header.glossary') }} + + + + {{ t('tags.title') }} + + + + + + diff --git a/frontend/src/components/HeaderNav.vue b/frontend/src/components/HeaderNav.vue index 6c835332..94e063ad 100644 --- a/frontend/src/components/HeaderNav.vue +++ b/frontend/src/components/HeaderNav.vue @@ -57,24 +57,18 @@ Create New Page - + - File Manager + :aria-label="t(`common.header.library`)"> + {{ t('common.header.library') }} + + + + + + {{ mode.label }} + {{ mode.hint }} + + + + + + + + + diff --git a/frontend/src/components/RulePermissionsDialog.vue b/frontend/src/components/RulePermissionsDialog.vue new file mode 100644 index 00000000..bf3d9097 --- /dev/null +++ b/frontend/src/components/RulePermissionsDialog.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/frontend/src/pages/AdminGeneral.vue b/frontend/src/pages/AdminGeneral.vue index 48630435..393a536a 100644 --- a/frontend/src/pages/AdminGeneral.vue +++ b/frontend/src/pages/AdminGeneral.vue @@ -279,6 +279,19 @@ + + + + {{ t(`admin.general.allowGlossary`) }} + {{ t(`admin.general.allowGlossaryHint`) }} + + + + + +