diff --git a/apps/api/.env.example b/apps/api/.env.example index af3fdeccf..1b0847218 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,17 +9,18 @@ NEXT_PUBLIC_API_URL=http://localhost:8080 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key -# === Content Preview Secret === -# Signs the preview links that let a reviewer read an unpublished record -# without an account. The signature is the *only* access control on those -# links, so this is required whenever a content type has `editorial.preview` -# enabled: at least 32 random bytes, or the API refuses to boot in production -# and preview stays switched off everywhere else. +# === Content Preview Secret (optional override) === +# Signs the preview links that let a reviewer read an unpublished record without +# an account. Nothing to set: the install generates 32 random bytes on first use +# and stores them in `core_secrets`, so preview works out of the box. +# +# Set this only for the two things a generated key cannot do - revoking every +# outstanding link at once, and sharing a key between deployments that do not +# share a database. Anything under 32 bytes is ignored with a warning. # # openssl rand -base64 32 # -# Rotating this value revokes every outstanding preview link at once. -CONTENT_PREVIEW_SECRET= +# CONTENT_PREVIEW_SECRET= # === AI (Vercel AI SDK) === # Gateway (default): one key for Anthropic, OpenAI, Google, etc. via `provider/model` diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 726860ed7..3bfef1532 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -7,17 +7,18 @@ NEXT_PUBLIC_WEB_URL=http://localhost:3000 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key -# === Content Preview Secret === -# Signs the preview links that let a reviewer read an unpublished record -# without an account. The signature is the *only* access control on those -# links, so this is required whenever a content type has `editorial.preview` -# enabled: at least 32 random bytes, or the API refuses to boot in production -# and preview stays switched off everywhere else. +# === Content Preview Secret (optional override) === +# Signs the preview links that let a reviewer read an unpublished record without +# an account. Nothing to set: the install generates 32 random bytes on first use +# and stores them in `core_secrets`, so preview works out of the box. +# +# Set this only for the two things a generated key cannot do - revoking every +# outstanding link at once, and sharing a key between deployments that do not +# share a database. Anything under 32 bytes is ignored with a warning. # # openssl rand -base64 32 # -# Rotating this value revokes every outstanding preview link at once. -CONTENT_PREVIEW_SECRET= +# CONTENT_PREVIEW_SECRET= # === Docker Database Postgres === POSTGRES_USER=root diff --git a/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx new file mode 100644 index 000000000..c76d88871 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx @@ -0,0 +1,285 @@ +--- +title: Dialog or page, and custom layouts +description: Choose how a content type's create and edit forms appear - and rearrange them without giving up a single line of the generated behaviour. +icon: LayoutPanelLeft +--- + +The generated create and edit forms open in a dialog. That is right for most +records and wrong for the ones people spend an hour inside, so a content type can +say which it wants - and, separately, a plugin can decide where the fields go. + +The two are independent. Page mode with no layout is a perfectly good screen; a +custom layout inside a dialog works too. + +## Dialog or page + +```ts title="src/content/article.ts" +admin: { + label: { plural: "Articles", singular: "Article" }, + + create: { mode: "page" }, + edit: { mode: "page" }, +} +``` + +`"dialog"` and `"page"`, and **`"dialog"` is the default** - a content type +written before this existed behaves exactly as it did, and nothing about it +changes until somebody adds those two lines. Each action is independent: a +content type can create on a page and edit in a dialog. + +```ts +// @ts-expect-error - only "dialog" and "page" are presentation modes +create: { mode: "drawer" } +``` + +### The URLs + +Page mode is served by the **same** catch-all route as the list. There is no +second router, and no file to add: + +```text +/admin/content/blog/post list +/admin/content/blog/post/create create page +/admin/content/blog/post/42/edit edit page +``` + +The Create button becomes a link rather than a dialog trigger - none of the +form's JavaScript is downloaded until the page it points at is requested - and +the pencil in each table row becomes a link too. Typing either URL works, which +is the point of checking permissions on the server rather than on the button. + + + The slug resolves to a content type id first, and only then as a form URL. So + an id that ends in `.create` keeps its own list screen, and the create page of + its neighbour is unreachable - a name clash its author can see, rather than a + screen that silently disappeared. + + +### Permissions + +Page mode weakens nothing. The create page checks `can_view` **and** +`can_create`; the edit page checks `can_view` and then `can_edit`, localized or +not - the same pair the edit dialog opens for. A missing permission is a 404, +whether the button was rendered or not, and the generated route behind the form +checks again. + +### After a successful save + +| Situation | What happens | +| --- | --- | +| Create, and edit is also `page` | Goes to the new record's edit page, using the id the mutation returned | +| Create, and edit is a dialog | Goes back to the list | +| Edit | Stays on the page with fresh server data | + +Everything else is unchanged: validation errors stay in the form, structured +backend errors read as sentences, success raises a `sonner` toast with a +description, version conflicts show the banner with your typing intact, and the +submit button is disabled while the write is in flight. + + + The form shows the publication state read-only, on a page exactly as in a + dialog. `status` and `publishedAt` are not in the form schema, and the publish + action on the list is the one thing that moves them - two mutation paths in one + screen is how a form ends up fighting its own state. + + +## Custom layouts + +A layout decides **where the fields are**. It does not decide what happens when +you press Save. + +The Content Engine keeps the form schema, the validation, the default values, +the field overrides, the AutoForm integration, the mutation, the version +precondition, the structured errors, the publication state, the editorial state, +the translations, the permissions, the toast, the cache invalidation, the events, +the search write and the delivery effects. All of them. A layout that called an +API directly would be doing something the engine already did, twice. + +### Registering one + +Layouts live in `buildPlugin`, next to the field and column overrides - never on +the definition, which `src/database/*.ts` imports and Drizzle Kit executes. + +```tsx title="src/config.tsx" +contentTypeAdmin({ + definition: blogPostContentType, + + fields: { + content: { component: BlogArticleEditorField }, + }, + + forms: { + layout: BlogArticleFormLayout, + }, +}); +``` + +`layout` covers both actions. Override one when they genuinely differ: + +```tsx +forms: { + layout: SharedLayout, + create: { layout: FirstDraftLayout }, +} +``` + +### Writing one + +```tsx title="src/views/admin/article/form-layout.tsx" +"use client"; + +import { + ContentFormActions, + ContentFormField, + ContentFormLayoutGrid, + ContentFormMain, + ContentFormSection, + ContentFormSidebar, + ContentFormStatus, +} from "@vitnode/core/content/admin-form"; + +export const BlogArticleFormLayout = () => ( + + + + + + + + + + + + + + + + + + + + + +); +``` + +There is one `
`, one schema and one submit path. `ContentFormField` +renders the element the engine already built - **including its field override**, +so overrides and layouts compose - and an error stays attached to the input it +belongs to wherever that input ended up. + +### The primitives + +| Primitive | What it does | +| --- | --- | +| `ContentFormField` | One field, by name. Nothing if the form has no such field | +| `ContentFormRemainingFields` | Everything the layout did not name | +| `ContentFormActions` | The submit row, with an optional `cancelHref` | +| `ContentFormStatus` | The read-only publication line | +| `ContentFormLayoutGrid` / `Main` / `Sidebar` / `Section` | AdminCP chrome: two columns above `lg`, one below | +| `useContentForm()` | `mode`, `fieldNames`, `localizedFieldNames`, `publication` | + +### Localized content types get one layout, and one form + +A layout places every field of the content type in one screen, localized or not: + +```tsx + {/* translation table */} + {/* translation table */} + {/* translation table */} + {/* base table */} + {/* base table */} +``` + +Nothing here says which is which, and nothing needs to: **a localized field +renders its own language control automatically**, and a shared one does not. The +layout decides where a field appears; the Content Engine decides where its value +goes. + +`useContentForm().localizedFieldNames` is there for a layout that wants to group +or annotate them - it is never needed to *place* one. + +### The server/client boundary + +`config.tsx` is a **server** module, so a layout referenced from it is a client +reference crossing an RSC boundary. That decides the shape of the whole API: + +- The layout receives only **serialisable** props: `mode`, `contentTypeId`, + `pluginId`, `itemId`, `singular`, `publication`, `title`. +- Field elements, the form instance and the submit action arrive through + **client context** instead. A `renderField(name)` callback prop would read + well and would be a server closure, which cannot cross the boundary at all. + +So: `"use client"` at the top of the layout file, and no inline arrow in +`config.tsx`. + + + In development, a layout that never renders one of the form's fields logs + which ones - a field silently missing from the payload is the one failure mode + this API has that the generated form does not. + + +## Field and column overrides + +Both are unchanged, and both compose with everything above - see +[Overriding the AdminCP](/docs/dev/content-engine/overriding-admincp). + +The blog is the worked example of all three. A **simple** record, with a colour +picker and a colour cell: + +```tsx title="src/config.tsx" +contentTypeAdmin({ + definition: blogCategoryContentType, + fields: { color: { component: BlogCategoryColorField } }, + columns: { color: { cell: BlogCategoryColorCell } }, +}); +``` + +```tsx title="src/views/admin/category/color-cell.tsx" +"use client"; + +export const BlogCategoryColorCell = ({ row }) => + row.color ? ( +
+ + + {row.color} + +
+ ) : ( + No color + ); +``` + +The swatch is `aria-hidden` and the value beside it is real text: a cell that +communicated the colour only visually would be unreadable to a screen reader and +ambiguous to anyone who cannot tell two blues apart. + +And a **rich** one, with the editor: + +```tsx title="src/views/admin/article/editor-field.tsx" +"use client"; + +const AutoFormEditor = dynamic( + async () => + await import("@vitnode/core/components/form/fields/editor").then(mod => ({ + default: mod.AutoFormEditor, + })), + { loading: () => , ssr: false }, +); + +export const BlogArticleEditorField = (props: ItemAutoFormComponentProps) => ( + }> + + +); +``` + +`field.value` and `field.onChange` are the whole integration. The editor is one +input in the same `react-hook-form` instance as the title and the category, so +dirty state and validation work without a single line about them - and the Tiptap +bundle arrives with the editor rather than with the page. diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index f006e0ab7..ffc4b6508 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -29,9 +29,14 @@ You get a nav item, a breadcrumb, and a screen at: - **Sorting** - `admin.list.orderableFields`, plus the system columns and the publication ones ([below](#what-is-sortable)) - **Pagination** - the standard cursor pagination, capped at 100 per page -- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open -- **Delete** - a confirmation dialog +- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open - or full pages, + with `admin.create.mode` / `admin.edit.mode` + ([below](#dialog-or-page)) +- **Delete** - a confirmation dialog, in the ⋯ menu ([below](#the-actions-cell)) - **History** - with [`editorial`](#editorial): every version, a diff, and restore +- **Languages** - with [`localization`](/docs/dev/content-engine/translation-editorial): + the list and the form open in *your* VitNode language, and each translated + field carries its own switcher ([below](#localized-content-types)) - **Empty, loading and error states** - out of the box ## What "lazy-loaded on open" actually means @@ -55,6 +60,22 @@ chunks, so it is downloaded once: milliseconds of theatre either way. +## Dialog or page + +The forms open in a dialog by default. A content type people spend an hour +inside can ask for a page instead, and a plugin can rearrange either without +giving up any of the generated behaviour: + +```ts +admin: { + create: { mode: "page" }, + edit: { mode: "page" }, +} +``` + +`"dialog"` is the default and stays the default. See +[Dialog or page, and custom layouts](/docs/dev/content-engine/admin-form-layouts). + ## What is sortable The table header offers a sort control for every column the generated route @@ -133,8 +154,9 @@ its table with a **status** column, rendered as a badge rather than raw text: ### The publish action -Each row gains a third icon button, before Edit and Delete. It flips with the -row's state rather than showing two buttons with a dead one: +Each row gains an icon button of its own, leading the +[actions cell](#the-actions-cell). It flips with the row's state rather than +showing two buttons with a dead one: | Row | Icon | Action | | --- | --- | --- | @@ -162,22 +184,68 @@ publication date. It has no publish control of its own: `status` and `publishedAt` are not in the form schema, and two competing mutation paths in one dialog is how a form ends up fighting its own state. -## Editorial +## The actions cell -A content type with [`editorial`](/docs/dev/content-engine/editorial) gains a -**clock** row action, an **eye** one when preview is on, and changes how the -edit dialog handles a failed save. The cell reads left to right: +However much a content type opts into, the row holds at most **three** buttons, +in this order: ```text -Preview · Schedule · History · Publish/Unpublish · Edit · Delete +Publish/Unpublish · Edit · ⋯ ``` +Everything else is listed by name behind the **⋯** button, which is always last: + +```text +Preview · Schedule · History · Languages · Delivery +───────────────────────────────────────────────────── +Delete +``` + +Publish and edit are what people click all day, so they stay in the row. The rest +are capability actions somebody reaches for occasionally, and a name says which is +which - *Languages of this Post* and *Delivery for this Post* were two adjacent +icons before, in a cell that ran to eight of them. + +**Delete** is in the menu too, last and under a rule, in the destructive colour. +It is the one action here that cannot be undone, and it has no business one pixel +from the pencil. Nothing else about it changed: it still asks, still names the +record, and still refuses to guess about a version nobody has seen. + +An item appears only when the content type has that capability **and** the role +holds the permission behind it, so a role allowed none of them gets no ⋯ button at +all rather than an empty menu. One panel is mounted at a time, and its body is +fetched when it opens - a 25-row table is 25 buttons, not 150 dialogs. + + + `can_view` covers preview, history, languages and delivery - reading what + changed is part of seeing the record. Scheduling is `can_publish`, because + booking a publication is publishing, just later. Delete is `can_delete`. Every + route answers 403 whether or not its item was ever rendered. + + +## Editorial + +A content type with [`editorial`](/docs/dev/content-engine/editorial) gains a +**History** item, a **Preview** one when preview is on, and changes how the edit +dialog handles a failed save. + ### History The dialog body is lazy-loaded exactly like the form, and for the same reason. -It lists one line per version - the operation as a badge, the author or -**System**, a localised date, and which fields moved - with a **Current** badge -on the newest. +It reads as a timeline: one entry per version on a rail, each with a dot in its +operation's colour - green for a create, blue for a publish, amber for an edit, +violet for a restore, grey for an unpublish, red for a delete - then the +operation as a badge, the author or **System**, and a localised date. The newest +carries a **Current** badge and a ring on its dot. + +The author's name is coloured by their role and links to their admin page - +except when there is nobody to link to: a system mutation, or an account deleted +since. Both read **System**, because a link that answers 404 is worse than plain +text. + +A version that moved no field value - a publish, an unpublish, a delete - says +**No field values changed.** on the entry itself. There is nothing behind a +toggle for those, so none is offered, and nothing is fetched to find that out. Twenty-five at a time, with **Load older versions** underneath when there are more. It appends rather than replaces, so scrolling back through a long history @@ -191,7 +259,8 @@ and adopts the new current version, so a second restore in the same sitting does not conflict with the first. The list carries metadata only. Expanding a version fetches that one snapshot -and renders a field-level diff against the version before it: +and renders a field-level diff against the version before it, in a panel headed +by the paths the revision recorded as changed: | Kind | Rendered as | | --- | --- | @@ -204,6 +273,11 @@ and renders a field-level diff against the version before it: | `relation`, `user` | the stored id, as `#3` | | `null` | the same em-dash the table cells use | +Each row is the old value, an arrow, the new one. Short values - ids, numbers, +dates, booleans - are set in a box so the pair either side of the arrow is easy +to tell apart at a glance; prose is not, because a paragraph in a box is just a +paragraph in a box. + No raw JSON anywhere. Somebody comparing two versions of an article is looking for the sentence that changed, and `{"title":"..."}` makes them find it themselves. @@ -221,31 +295,37 @@ publication state does not move. The button is absent without `can_restore`. ### Preview -With [`editorial.preview`](/docs/dev/content-engine/preview), an eye icon leads -the actions cell - present only for a content type that can be previewed, absent +With [`editorial.preview`](/docs/dev/content-engine/preview), **Preview link** +leads the ⋯ menu - present only for a content type that can be previewed, absent rather than disabled for anything else. -The link is minted **when the popover opens**, never with the table payload. A +The link is minted **when the panel opens**, never with the table payload. A page of 25 rows must not be 25 live bearer credentials for unpublished records -sitting in a browser, most of them never used. Closing the popover throws the +sitting in a browser, most of them never used. Closing the panel throws the link away, so opening it again mints a fresh one instead of showing you one that may already have expired. The URL is absolute - it is going on a clipboard and into somebody else's chat -window - and it points at the web app when `preview.pathTemplate` is set, or at -the API's JSON endpoint when it is not. The popover also says when the link -expires, that it is pinned to one version, and, for a record with no history -yet, that it reads live rather than frozen. - -Without a usable `CONTENT_PREVIEW_SECRET` the server answers 503 and the toast -names the variable, because the person clicking the button is usually the person -who can set it. +window - and it points at a **page** wherever there is one to point at: the +dedicated preview page when `preview.pathTemplate` names one, the record's own +canonical page carrying `?preview=` when the content type has `delivery`, and +the API's JSON endpoint only when it has neither. See [Where a preview link +points](/docs/dev/content-engine/preview#where-a-preview-link-points). On a +localized content type the link previews the default language. The panel also +says when the link expires, that it is pinned to one version, and, for a record +with no history yet, that it reads live rather than frozen. + +Nothing has to be configured for the button to work - the install signs with a key +it generated for itself. If it cannot build an absolute URL because +`NEXT_PUBLIC_WEB_URL` or `NEXT_PUBLIC_API_URL` is not a URL, the server answers +503 and the toast names the variable, because the person clicking the button is +usually the person who can set it. ### Scheduling -With [`editorial.scheduling`](/docs/dev/content-engine/scheduling), a calendar -icon opens a dialog showing what is booked, what already ran and who booked it, -above a two-field form: what should happen, and when. +With [`editorial.scheduling`](/docs/dev/content-engine/scheduling), a +**Schedule** item opens a dialog showing what is booked, what already ran and who +booked it, above a two-field form: what should happen, and when. - The date field names the timezone it is reading, because "9am" is a question otherwise. @@ -341,3 +421,32 @@ type's own `can_view`. The page checks `can_view` server-side and 404s without it. The create, edit and delete controls check their own permissions client-side - and the routes behind them check again, which is the check that actually matters. + +## Localized content types + +A localized content type gets **no extra screen and no extra control**. There is +no `Shared | English | Polish` strip and no locale in the URL: + +- the **list** shows each record in the language you are reading VitNode in, and + `Missing` where a translation does not exist yet; +- the **form** shows every field at once, and each localized one carries its own + small language switcher: + +```text +Title [ Tytuł artykułu ] [ PL ▾ ] +Content [ Treść… ] [ PL ▾ ] +Friendly URL [ tytul-artykulu ] [ PL ▾ ] +Category Aktualności +Author Maciej +``` + +Switching `Title` to English leaves the others in Polish - there is no +form-global language. One Save writes the base row and every changed language in +one transaction. + +Per-language status, publication, history and delete live in their own +**Languages** panel in the ⋯ menu, because the language is part of *that* decision +rather than a mode the whole screen is in. + +The whole thing is described in [Localized +editing](/docs/dev/content-engine/translation-editorial#the-admincp). diff --git a/apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx b/apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx index 8a5e5e0c5..796c28353 100644 --- a/apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx @@ -38,9 +38,8 @@ seo: field.group({ ``` Localization is a property of the whole group. Half a logical value on each -table would mean two revision histories and two permissions for one box an -editor sees as one thing - somebody with `can_translate` could rewrite a leaf -only `can_edit` should touch. +table would mean two revision histories for one box an editor sees as one thing - +restoring a language would rewrite half of it and leave the other half alone. **Instead:** two groups, which also makes which-is-which a fact about the declaration: diff --git a/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx index dc93da8cb..f4f64d8b9 100644 --- a/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx @@ -33,7 +33,7 @@ what stops a new endpoint joining the set without one. | `GET /{id}/schedules` | `can_view` | | `POST /{id}/schedule` · `/{scheduleId}/cancel` | `can_publish` | | `GET /{id}/translations` · `/{locale}` · `/public-locales` | `can_view` | -| `POST` · `PUT /{id}/translations/{locale}` | `can_translate` | +| `POST` · `PUT /{id}/translations/{locale}` | `can_edit` | | `DELETE /{id}/translations/{locale}` | `can_delete` | | `POST /{id}/translations/{locale}/publish` · `/unpublish` | `can_publish` | | `GET /{id}/translations/{locale}/revisions` · `/{revisionId}` | `can_view` | @@ -48,26 +48,27 @@ Two choices in there are worth the sentence they take: from a source the editor did not type, so somebody who may not edit must not reach the same outcome through the history. -## The translator +## No translation permission -`can_translate` depends on `can_view` and deliberately **not** on `can_edit`, -which is what makes "writes Polish and nothing else" expressible. Give a role -`can_view + can_translate` and it can: +Writing a locale is editing the record in that language, so there is no +`can_translate`: `can_edit` covers a shared field and a translation alike, and +`can_create` covers a new record together with whatever languages it is born +with. One Save button writes one record, and splitting the permission would mean +a form that is half allowed. -- read the record, every locale tab and every locale's history; -- create and edit a translation in any enabled locale. +`can_edit` still stops where editing stops. A role with `can_view + can_edit`: + +- reads the record, every language and every language's history; +- writes a shared field, and creates or edits a translation in any enabled + locale. It cannot: -- edit a shared field (`PUT /{id}` is `can_edit`); - publish or unpublish anything, record or translation (`can_publish`); - restore a shared revision *or* a locale's own (`can_restore`, which needs `can_edit`); - delete the record or a translation (`can_delete`). -Existing roles simply do not have `can_translate` - permissions are stored as -JSON per role, so a new one denies by default and needs no migration. - ## Advanced collections have no second door There is no per-relation or per-repeatable endpoint. A collection is written @@ -129,11 +130,11 @@ part of it is written to fail rather than to be helpful. - **No fallback.** A `pl` link opened on the English URL is a 404, not the English copy. Falling back would hand a reviewer a different language from the one they were sent. -- **A weak secret disables it entirely.** An install whose `CONTENT_PREVIEW_SECRET` - is missing, too short, or still the published placeholder can have its tokens - forged by anyone, so no token is honoured - and the answer is the same 404, - because "preview is misconfigured here" is not something an anonymous request - needs to learn. +- **The signing key cannot be guessed or forgotten.** It is 32 random bytes the + install generates for itself and stores in `core_secrets`, not a value someone + has to remember to set - there is no published placeholder to forge against. A + `CONTENT_PREVIEW_SECRET` override shorter than that is ignored rather than + honoured, because a password is not a signing key. - **Nothing caches it.** `Cache-Control: private, no-store` and `X-Robots-Tag: noindex, nofollow`, and the response carries no cache tag at all. - **The projection is the public one.** The same function the detail route uses, diff --git a/apps/docs/content/docs/dev/content-engine/editorial.mdx b/apps/docs/content/docs/dev/content-engine/editorial.mdx index 5626c4fac..baa1fa6c9 100644 --- a/apps/docs/content/docs/dev/content-engine/editorial.mdx +++ b/apps/docs/content/docs/dev/content-engine/editorial.mdx @@ -90,11 +90,12 @@ editorial: { - [`scheduling`](/docs/dev/content-engine/scheduling) - publish or unpublish at a set time, on a one-minute tick. - - At least 32 random bytes - `openssl rand -base64 32`. The signature is the - only access control a preview link has, so without one the API refuses to - start in production, and preview fails closed everywhere else. See - [Secure preview](/docs/dev/content-engine/preview#content_preview_secret-is-required). + + The signature is the only access control a preview link has, and the install + generates the key that produces it - 32 random bytes, stored in `core_secrets` + the first time anything signs a link. There is no environment variable to set. + See + [The signing key configures itself](/docs/dev/content-engine/preview#the-signing-key-configures-itself). ## `version` is generated, so you cannot declare it diff --git a/apps/docs/content/docs/dev/content-engine/fields.mdx b/apps/docs/content/docs/dev/content-engine/fields.mdx index bf5be419f..e9b016add 100644 --- a/apps/docs/content/docs/dev/content-engine/fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/fields.mdx @@ -16,7 +16,7 @@ things: a column, a Zod rule, an AdminCP input and a table cell. | `boolean` | `boolean` | `boolean` | `AutoFormSwitch` | ✓ | ✓ | ✗ | | `enum` | `varchar(length ?? 64)` | literal union | select or radio | ✓ | ✓ | ✗ | | `dateTime` | `timestamp` | ISO string | `AutoFormDateTime` | ✓ | ✗ | ✗ | -| `user` | `integer` → `core_users.id` | `number` | async combobox | ✓ | ✓ | ✗ | +| `user` | `integer` → `core_users.id` | `number` | [people picker](/docs/ui/user) | ✓ | ✓ | ✗ | | `relation` | `integer` → target `id` | `number` | async combobox | ✓ | ✓ | ✗ | | `relation` (`multiple`) | junction table | `number[]` | multi picker | ✗ | membership | ✗ | | `group` | one column per leaf | nested object | labelled section | per leaf | per leaf | per leaf | diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index da8342567..bfedfd491 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -17,7 +17,7 @@ other 20%, so you find out here rather than halfway through building. | Rich text, media and file fields | Hand-build the field, or store an id and resolve it yourself | | To-the-second [scheduling](/docs/dev/content-engine/scheduling) | The queue drains on a one-minute tick, so a schedule fires within about a minute | | Scheduling a field edit, or a recurring schedule | Only `status` is scheduled. One row, one time, one action | -| Revoking a single [preview link](/docs/dev/content-engine/preview) | Tokens are stateless. Rotate `CONTENT_PREVIEW_SECRET`, or wait out the expiry | +| Revoking a single [preview link](/docs/dev/content-engine/preview) | Tokens are stateless. Rotate the install's signing key - which revokes all of them - or wait out the expiry | | Keeping a previewed revision from being pruned | A link is pinned to one revision, and retention can remove it before the link expires. The TTL is a maximum, not a promise | | A preview **list** of drafts | Only one record at a time, by signed link. There is no list route and there will not be one | | Approval workflows, reviewer assignment, per-locale revisions | [`editorial`](/docs/dev/content-engine/editorial) records what happened; it does not gate who may do it beyond the staff permissions | @@ -47,8 +47,8 @@ generated one without friction. [`localization`](/docs/dev/content-engine/localization) generates the tables, the types, the schemas, the services, the per-locale lifecycle, the per-locale history -and the AdminCP locale tabs. What it deliberately refuses is every combination -whose *reading* half is not built yet: +and the AdminCP's per-field language switchers. What it deliberately refuses is +every combination whose *reading* half is not built yet: Nothing is refused any more. [Locale-aware public reads](/docs/dev/content-engine/localized-public-api) landed @@ -75,17 +75,21 @@ positions depending on the language. Order by a column the record has one of. `filterableFields` and `searchableFields` *may* name a localized field: both are evaluated against the single translation the reader is being served. -## Localized field names cannot appear on base-table surfaces +## A localized field can be shown, but not queried A localized field has no column on the base table, so it cannot be an -`admin.list` column, an `orderableFields` or `searchableFields` entry, a -`form.fields` entry, `admin.titleField`, or part of an `indexes` declaration. All -six are compile errors and runtime errors. - -`admin.titleField` therefore falls back to `null` on a content type whose only -text fields are localized. The locale tabs show the localized title inside each -tab, and the list's language selector adds a column showing each record's title -in the language being viewed. +`orderableFields` or `searchableFields` entry, `admin.list.defaultOrderBy`, part +of an `indexes` declaration, or a key in `schemas.create`/`update`/`select`. All +of those are SQL over the base row, and every one of them is a compile error and +a runtime error. + +It *may* be an `admin.list` column, `admin.titleField` and an `admin.form.fields` +entry, because those are presentation: the AdminCP resolves the value from the +one translation it already loaded for the reader's own language. + +The practical consequence is that a localized column is **displayed but not +sortable**. Nothing about the base-table ordering guarantees changes; there is +simply no header control on that column. ## Foreign key names on a long translation table are truncated by Postgres diff --git a/apps/docs/content/docs/dev/content-engine/localization.mdx b/apps/docs/content/docs/dev/content-engine/localization.mdx index 53e99abeb..24e5e7660 100644 --- a/apps/docs/content/docs/dev/content-engine/localization.mdx +++ b/apps/docs/content/docs/dev/content-engine/localization.mdx @@ -144,20 +144,27 @@ What lands where: | Every shared field | Every localized field | | | `version`, `createdAt`, `updatedAt` | -A localized field is **not** a column on the base table, which has consequences -worth knowing up front: +A localized field is **not** a column on the base table, and the engine draws a +line between *showing* one and *querying* one. -- it cannot appear in `admin.list.columns`, `orderableFields`, `searchableFields` - or `form.fields`, -- it cannot be `admin.titleField`, -- it cannot appear in `indexes`, -- it is absent from `schemas.create`, `schemas.update` and `schemas.select`. +**Showing is fine.** A localized field may appear in `admin.list.columns`, may be +`admin.titleField`, and always appears in `admin.form.fields`. The AdminCP +resolves it in the language the reader is already using VitNode in, and its form +input carries its own language switcher - see +[Localized editing](/docs/dev/content-engine/translation-editorial). -All five are compile errors *and* runtime errors: there is nowhere on the base -form or in a base-table query for them to go, and a silently-dropped title is -worse than a refused definition. Localized values have their own AdminCP surface - -the [locale tabs](/docs/dev/content-engine/translation-editorial) in the edit -dialog, and the language selector on the list. +**Querying is not.** A localized field cannot appear in: + +- `admin.list.orderableFields` or `admin.list.searchableFields`, +- `admin.list.defaultOrderBy`, +- `indexes`, +- `schemas.create`, `schemas.update` or `schemas.select`. + +Those are all SQL over the base table, and the value is not there. A list ordered +by a per-language title would reshuffle itself for every reader and make one +cursor mean two positions at once. Each of them is a compile error *and* a +runtime error, because a silently-dropped ordering is worse than a refused +definition. ## Optimistic locking per locale @@ -199,9 +206,14 @@ const { row, translation } = await localizedService.create({ ``` Either both exist or neither does. That invariant is what every later stage leans -on - a record always resolves in at least one language, so a locale tab strip -always has something to show and a public read always has something to fall back -to. +on - a record always resolves in at least one language, so the AdminCP always has +something to show and a public read always has something to fall back to. + +`localization.defaultLocale` is a **storage and fallback** rule, not a display +one. It decides which translation must exist, and which one a public reader falls +back to. It does not decide which language an editor sees first: that is their +own VitNode language. See [Localized +editing](/docs/dev/content-engine/translation-editorial#two-different-languages). Two rules protect it: @@ -313,9 +325,9 @@ answer to the same slug. | Stage | What it adds | | --- | --- | | **5A** | Tables, types, schemas, language resolution, translation service, per-locale locking, atomic create, routes, migrations | -| **5B** | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, `can_translate`, AdminCP locale tabs | +| **5B** | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, per-field language switchers in the AdminCP | | **5C** | Locale-aware public API, locale precedence, fallback resolution, strict-locale slugs, locale-aware cache tags, locale preview links | -| **5D** (this one) | Per-locale search documents, the localized rebuild, per-language diagnostics, the AdminCP list language selector | +| **5D** (this one) | Per-locale search documents, the localized rebuild, per-language diagnostics, the AdminCP list in the reader's own language | Explicitly outside all four: locale-specific relations, localized media, AI translation, translation memory, external TMS integration, `hreflang` and sitemap @@ -327,7 +339,7 @@ Content Engine. - [Localized fields](/docs/dev/content-engine/localized-fields) - which kinds, and why the others are refused - [Translation tables](/docs/dev/content-engine/translation-tables) - the generated schema, keys and indexes - [Translation service](/docs/dev/content-engine/translation-service) - every method, and every conflict it can raise -- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - per-locale publish, the subordination rule, permissions and the locale tabs +- [Localized editing](/docs/dev/content-engine/translation-editorial) - the AdminCP's per-field language switchers, per-locale publish, the subordination rule and permissions - [Translation revisions](/docs/dev/content-engine/translation-revisions) - one history per language, and what a restore may not cross - [Locale preview](/docs/dev/content-engine/translation-preview) - freezing one language, both halves of it - [Localization migrations](/docs/dev/content-engine/localization-migrations) - the generated migration, and how to localize an existing content type safely diff --git a/apps/docs/content/docs/dev/content-engine/localized-fields.mdx b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx index 39ff0d978..519859f34 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx @@ -164,28 +164,33 @@ and `ContentLocalizedValues` is `{}` - which is what makes a `translation:` key impossible to fill in by accident on a Stage 1-4 definition, and what keeps every existing type exactly as it was. -## Where a localized field may not appear +## Where a localized field may appear -Everything on this list addresses a column on the *base* table: +The engine draws one line, and it is between **showing** a value and **querying** +one. ```ts admin: { list: { - columns: ["title"], // ✗ - orderableFields: ["title"], // ✗ - searchableFields: ["title"], // ✗ + columns: ["title"], // ✓ shown in the reader's own language + orderableFields: ["title"], // ✗ ORDER BY on the base table + searchableFields: ["title"], // ✗ a predicate on the base row }, - form: { fields: ["title"] }, // ✗ - titleField: "title", // ✗ + form: { fields: ["title"] }, // ✓ one form, with its own language switcher + titleField: "title", // ✓ resolved per reader }, -indexes: [{ on: ["title"] }], // ✗ +indexes: [{ on: ["title"] }], // ✗ no such column to index ``` -All six are compile errors, and all six are runtime errors as well. The defaults -skip localized fields automatically, so a localized content type that says nothing -about `admin.list` gets a sensible shared-only list without having to opt out of -anything. +The refusals are compile errors *and* runtime errors. They are not squeamishness: +a list ordered by a per-language title would reshuffle itself for every reader, +and one cursor would mean two positions at once. -`admin.titleField` falls back to `null` when every text field is localized. A -toast whose wording depended on the reading admin's locale would be worse than no -title at all; Stage 5B gives the AdminCP a locale-aware one. +The **defaults** stay shared-only. A localized content type that says nothing +about `admin.list` gets a shared-only list of columns without opting out of +anything; naming a localized column is a decision you make. + +`admin.titleField` does fall back to a localized field when there is no shared +one, because the alternative was `#123`. The AdminCP resolves it from the +translation it already loaded for the reader's language - see [Localized +editing](/docs/dev/content-engine/translation-editorial#the-admincp). diff --git a/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx b/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx index 9831ed686..f6013d50d 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx @@ -113,7 +113,8 @@ cache for free. Fallback is deliberately narrow, and these are the places it does **not** apply: - **Slug lookup.** See below. -- **The AdminCP.** A locale tab shows that locale, or shows `Missing`. +- **The AdminCP.** A localized field shows the language its switcher is on, or + shows nothing; a list cell shows `Missing`. - **Preview.** A [locale preview](/docs/dev/content-engine/translation-preview) is bound to one language and refuses every other. - **History and mutations.** A revision belongs to a locale; a write names one. diff --git a/apps/docs/content/docs/dev/content-engine/localized-search.mdx b/apps/docs/content/docs/dev/content-engine/localized-search.mdx index fbef1d0fa..200a05617 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-search.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-search.mdx @@ -162,15 +162,14 @@ Stage 5D adds is content that actually has languages to filter on. ## The AdminCP list -A localized content type's list gets a language selector. It is a **view control, -not a filter**: picking Polish adds a column showing each record's Polish title -and status - including `Missing`, which is the row most worth finding. Hiding -untranslated records would be the opposite of what somebody choosing a language is -looking for. - -The choice lives in the URL, so it survives a reload, paginates with the table and -can be sent to whoever is doing the translating. Changing it resets the cursor: -page three of one ordering is not page three of another. +A localized content type's list is shown in the language you are already reading +VitNode in. There is no selector above the table and nothing in the URL: the +AdminCP resolves your locale server-side and asks the list route for it. + +It is a **view, not a filter**. Every record is listed, and one with no +translation in your language shows `Missing` rather than being hidden - that is +the row most worth finding. Sorting and searching still address the base table, +so a localized column is displayed without a sort control. ## No migration diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index 71e167fdc..209a3a733 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -49,6 +49,7 @@ "permissions", "events", "overriding-admincp", + "admin-form-layouts", "production-hardening", "concurrency", "failure-and-retries", diff --git a/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx b/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx index 7b78e7811..b7bd27d5a 100644 --- a/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx @@ -62,6 +62,14 @@ contentTypeAdmin({ The override receives the same props the generated input would, so the field stays wired into `AutoForm`'s validation and error display. + + `config.tsx` is loaded by the `vitnode` CLI to enumerate a plugin's routes and + messages, so **anything `server-only` reachable from it breaks `vitnode init`** + - including a `"use server"` module a field override imports. Server data + reaches an override the way the generated inputs get it: through the props the + Content Engine already passes, such as `ContentField`'s `loadOptions`. + + `config.tsx` is a server module, so an inline arrow written there is a server closure and cannot be handed to the client form. Put the component in its own diff --git a/apps/docs/content/docs/dev/content-engine/preview.mdx b/apps/docs/content/docs/dev/content-engine/preview.mdx index eb387bbc9..a781bb96a 100644 --- a/apps/docs/content/docs/dev/content-engine/preview.mdx +++ b/apps/docs/content/docs/dev/content-engine/preview.mdx @@ -47,9 +47,9 @@ editorial: { | `expiresInMinutes` | `number`, 1–1440 | `15` | How long a link stays valid | | `pathTemplate` | `string` | `null` | A page in your app. Exactly one `{token}`, leading `/`, no `..` | -Without `pathTemplate` the AdminCP links straight at the JSON endpoint. That is -the honest default: linking to a page nobody has written yet would just be a -404 with extra steps. +`pathTemplate` is for a page written *specifically* to render previews. Most +content types do not need one - see [Where a preview link +points](#where-a-preview-link-points) for what happens without it. `editorial.preview` needs @@ -60,58 +60,59 @@ the honest default: linking to a page nobody has written yet would just be a you cast past it. -## `CONTENT_PREVIEW_SECRET` is required +## The signing key configures itself -**Preview does not work without one.** This single value is the entire -authorization story - there is no session to fall back on - so a missing or -guessable secret is not a warning, it is every draft on the site readable by -anyone who has read the VitNode source. +**There is no environment variable to set.** The first process that needs to sign +a link generates 32 random bytes and stores them in `core_secrets`, keyed by +`content_preview`. Every other process reads the same row, so a link minted by one +API instance verifies on all of them, and it survives restarts and redeploys the +way an environment variable would. -```bash -openssl rand -base64 32 -``` - -```bash title=".env" -CONTENT_PREVIEW_SECRET=Q0hBTkdFLU1FLXRoaXMtaXMtYW4tZXhhbXBsZS12YWx1ZQ== -``` - -A secret is acceptable when all three are true: +Setting one used to be a prerequisite for the feature, and a deployment that +forgot got a 503 from a button somebody clicked three days later. Now `preview` +is a property of the *content type* and nothing else: turn it on in `editorial` +and it works. ```text -CONTENT_PREVIEW_SECRET is set -it is not the built-in placeholder -it is at least 32 bytes +core_secrets + name "content_preview" + value 32 random bytes, base64 ``` -Anything else and preview **fails closed**, everywhere: +The key is read once per process, on the first request, and only on installs where +some content type has `editorial.preview` enabled. An install with nothing to +preview never touches the table. -| Where | What happens | -| --- | --- | -| Boot, in production | The API refuses to start, naming the content types that made it mandatory | -| Boot, in development | A warning on stdout. The app starts; preview does not | -| `POST /{id}/preview` | **503**, with a message that names the variable | -| `GET /content/{path}/preview/{token}` | **404** - the same answer a forged token gets, so an anonymous request learns nothing about the deployment | -| AdminCP → System → Integrations | `contentPreview.secure: false`, next to the same flag for `CRON_SECRET` | - - - Refusing to start `pnpm dev` over a missing secret would be rude. Serving - drafts to anyone who guesses a URL would be worse. So a development install - boots and preview simply does not work until you set one - and the 503 says - exactly that, rather than failing somewhere unhelpful. + + There is no revocation list, and no per-link kill switch. Deleting the + `content_preview` row invalidates every outstanding link at once - the next + process to need a key generates a fresh one - which is the blunt instrument you + want when a link leaks. Short expiries are what keep you from needing it. - - Next imports every route module while collecting page data, so the API's boot - check runs on the build machine too - which has no business holding a runtime - signing key. The build logs the warning and carries on; the process that - actually serves requests still refuses to start. - +### `CONTENT_PREVIEW_SECRET`, if you want it - - There is no revocation list, and no per-link kill switch. Changing the secret - invalidates every outstanding link at once, which is the blunt instrument you - want when one leaks. Short expiries are what keep you from needing it. - +The variable still works, as an **override**. It is worth keeping for the two +things a generated key cannot do: + +- **Rotating on a schedule**, without a database write. +- **Sharing a key** between deployments that do not share a database, so a link + minted in one verifies in the other. + +```bash +openssl rand -base64 32 +``` + +```bash title=".env" +CONTENT_PREVIEW_SECRET=Q0hBTkdFLU1FLXRoaXMtaXMtYW4tZXhhbXBsZS12YWx1ZQ== +``` + +An override under 32 bytes is **ignored**, with a warning, and the generated key +is used instead. A signature is the entire authorization story for these links - +there is no session to fall back on - so signing with a password is not a +degraded mode worth offering. Ignoring it is loud rather than silent, because +silently substituting a different key would make a rotation that did not happen +look like one that did. ## The routes @@ -138,26 +139,49 @@ and it is asserted by a test rather than left to review. } ``` -### `url` is always absolute +### Where a preview link points -It goes on somebody's clipboard and into somebody else's chat window, so a path -would be useless - and in a split deployment it would resolve against the wrong -host. Which origin it resolves against depends on where the link actually points: +A reviewer should land on a **page**. Three branches, in order, and the JSON +endpoint is what is left when there is no page to land on: -| `preview.pathTemplate` | Resolved against | Example | +| # | When | Where the link goes | | --- | --- | --- | -| set | `NEXT_PUBLIC_WEB_URL` - your app renders the page | `https://example.com/articles/preview/{token}` | -| unset | `NEXT_PUBLIC_API_URL` - the generated JSON endpoint | `https://api.example.com/api/@vitnode/example/content/articles/preview/{token}` | +| 1 | `preview.pathTemplate` is set | That page, on `NEXT_PUBLIC_WEB_URL`: `https://example.com/articles/preview/{token}` | +| 2 | The content type has [`delivery`](/docs/dev/content-engine/delivery) | **The record's own canonical page**, carrying `?preview=`: `https://example.com/en/blog/my-post?preview={token}` | +| 3 | Neither | The generated JSON endpoint, on `NEXT_PUBLIC_API_URL`: `https://api.example.com/api/@vitnode/example/content/articles/preview/{token}` | + +**Branch 2 is the one most content types take.** A record with a delivery layer +already has a public URL, and the page that renders the published record is the +right place to render the draft: the reviewer sees the real layout rather than a +preview-shaped copy of it, and nobody has to write a second page to get there. + + + Branch 2 hands your delivery page a token and expects it to notice. A page that + ignores `?preview=` renders the *published* record - or 404s, for a draft that + has never been published - which looks like a broken link rather than a page + that was never wired up. See [Rendering a preview + page](#rendering-a-preview-page). + -Two different origins, deliberately: the page is served by the web app and the +Two different origins, deliberately: a page is served by the web app and the endpoint by the API, and assuming they share a host is exactly the assumption a -split deployment breaks. Both are validated at boot when preview is enabled, so -a malformed `NEXT_PUBLIC_WEB_URL` is a startup error rather than a broken link -handed to a reviewer. - -`url` is built on the server, because only the definition knows whether this -install has a preview page or should link at the JSON endpoint. The token is -percent-encoded into the path. +split deployment breaks. Both are checked at boot when preview is enabled, so a +malformed `NEXT_PUBLIC_WEB_URL` is a startup warning and a 503 rather than a +broken link handed to a reviewer. + +`url` is absolute in every branch - it goes on somebody's clipboard and into +somebody else's chat window, so a path would be useless. It is built on the +server, because only the server knows which branch applies *and* holds the +record's slug. + + + On a localized content type the AdminCP button previews the **default locale**, + and the token is bound to it. That is not a detail: the public preview route + resolves a locale for every localized read and refuses a token that names a + different one, so a locale-less token would 404 wherever it pointed. The + language is in the path already (`/en/blog/...`), so the page reads it from its + own route parameter rather than a query string. + `/preview/{token}` is two path segments and `/{slug}` is one, so they can never @@ -206,8 +230,68 @@ is opened until it expires. It is a link, not a nonce. ## Rendering a preview page -The API returns JSON. Turning that into a page is your app's job, and it is -about fifteen lines: +The API returns JSON. Turning that into a page is your app's job. + +### On the page you already have + +The default. Your delivery page reads `?preview=` and, when it is there, fetches +the draft instead of the published record - same component, same layout, one +branch: + +```tsx title="src/app/[locale]/blog/[slug]/page.tsx" +import { postContentType } from "@/content/post"; +import { CONTENT_PREVIEW_QUERY_PARAM } from "@vitnode/core/content"; +import { + contentPreviewFetch, + contentPublicFetch, +} from "@vitnode/core/content/next"; +import { notFound } from "next/navigation"; + +export default async function PostPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string; slug: string }>; + searchParams: Promise>; +}) { + const { locale, slug } = await params; + const token = (await searchParams)[CONTENT_PREVIEW_QUERY_PARAM]; + + // The locale comes off the route, not the query string: a preview URL carries + // its language in the path, and it has to match the token. + const { data } = + typeof token === "string" + ? await contentPreviewFetch({ + definition: postContentType, + locale, + pluginId: "@vitnode/blog", + token, + }) + : await contentPublicFetch({ + definition: postContentType, + locale, + pluginId: "@vitnode/blog", + slug, + }); + + if (!data) notFound(); + + return
{/* one component, both states */}
; +} +``` + + + Nothing in the response says so - it is the same shape the public route + returns, which is exactly what makes reusing the page work. Render a banner + when the token is present, or somebody will forward a screenshot of what they + think is the live page. + + +### Or a page of its own + +When a preview should look different from the published page - a review banner, +an annotation sidebar, a diff - give it `pathTemplate` and a route of its own. +It takes the token from the path rather than the query string: ```tsx title="src/app/articles/preview/[token]/page.tsx" import { articleContentType } from "@/content/article"; @@ -243,42 +327,38 @@ export default async function ArticlePreviewPage({ a draft readable after its token expired, and would hand one reviewer's link to the next visitor. -Then point the AdminCP at it: +Then point the AdminCP at it, which is what takes preview off branch 2 and onto +branch 1: ```ts preview: { enabled: true, pathTemplate: "/articles/preview/{token}" } ``` - - Nothing in the response says so - it is the same shape the public route - returns. Say it in your own markup, or somebody will forward a screenshot of - what they think is the live page. - - ## What the AdminCP does -An eye icon on every row, for a content type with preview and for nobody else - -absent rather than disabled, because there is no way to enable it from the UI. +**Preview link** leads the ⋯ menu on every row, for a content type with preview +and for nobody else - absent rather than disabled, because there is no way to +enable it from the UI. -Clicking it mints the link **then**, not before. A table of 25 rows must not be +Opening it mints the link **then**, not before. A table of 25 rows must not be 25 live bearer credentials for unpublished records sitting in a browser, most of -them never used. The popover shows the link, a copy button, an Open link and +them never used. The panel shows the link, a copy button, an Open link and when it expires. -Closing the popover throws the link away, so opening it again mints a fresh one +Closing the panel throws the link away, so opening it again mints a fresh one rather than showing you one that may already have expired. ## What it does not do | Not supported | Why | | --- | --- | -| Revoking one link | No table, no state. Rotate the secret, or wait out the expiry | +| Revoking one link | No table, no state. Rotate the signing key - which revokes all of them - or wait out the expiry | | Previewing a **list** of drafts | There is no preview list route, and there will not be one | | Seeing who opened a link | It is an anonymous read. Nothing is logged per view | | Previewing a record with no public API | There would be nothing safe to render | | Keeping a previewed revision from being pruned | That needs persistent preview records. Retention wins, and the link 404s | | Comments or annotations on a preview | Read-only. A reviewer replies wherever they already talk to you | -| Working without `CONTENT_PREVIEW_SECRET` | The signature *is* the access control. Missing means off, in every environment | +| Signing with a short `CONTENT_PREVIEW_SECRET` | The signature *is* the access control. An override under 32 bytes is ignored for the generated key | ## Why it is safe @@ -308,7 +388,7 @@ record in the signed token and still projected through the public allowlist. Only the frozen-snapshot guarantee is missing, because there is nothing to guarantee: such a link follows any edit made before the reviewer opens it. -The AdminCP says so in the popover rather than letting "preview" imply something +The AdminCP says so in the panel rather than letting "preview" imply something this one link cannot deliver, and the first edit fixes it permanently. Fabricating a snapshot at mint time was the alternative, and it was rejected: @@ -328,7 +408,7 @@ That is accepted behaviour, not a bug to route around: pruned - so it takes many saves and a slow reviewer. - Raise `revisions.retention` if your editors work in bursts, or shorten `expiresInMinutes` so the two windows match. -- The popover says the link is pinned to a version and can age out. +- The panel says the link is pinned to a version and can age out. "Expires in 30 minutes" means *no later than* 30 minutes. Protecting the diff --git a/apps/docs/content/docs/dev/content-engine/production-hardening.mdx b/apps/docs/content/docs/dev/content-engine/production-hardening.mdx index ea0e42967..b214d20eb 100644 --- a/apps/docs/content/docs/dev/content-engine/production-hardening.mdx +++ b/apps/docs/content/docs/dev/content-engine/production-hardening.mdx @@ -59,7 +59,7 @@ listener that must act once can key off it. See - **No automatic schema migration.** Migrations are generated by `drizzle-kit` and committed. The engine creates no schema at runtime. - **No field-level permissions.** Permissions are per content type and per - operation. A translator is limited by *locale*, not by field. + operation, and never per field or per locale. - **No repair of a search index that is merely stale.** Drift is *detected* by counting; correcting it is a rebuild, which is a decision an operator makes. diff --git a/apps/docs/content/docs/dev/content-engine/revisions.mdx b/apps/docs/content/docs/dev/content-engine/revisions.mdx index 53a38d3d3..b29279cd3 100644 --- a/apps/docs/content/docs/dev/content-engine/revisions.mdx +++ b/apps/docs/content/docs/dev/content-engine/revisions.mdx @@ -412,12 +412,14 @@ survives is an editorial judgement, and guessing at it is worse than asking. ### The history dialog -A clock icon on every row, lazy-loaded like the edit form. One line per version -with the operation as a badge, the author (or **System**), a localised date and -the changed fields. Expanding one renders a field-level diff against the version -before it - a badge for an enum, a formatted date for a `dateTime`, long -`textarea` values collapsed, `null` as the same em-dash the table cells use, and -no raw JSON anywhere. A relation shows the id the snapshot stores; see +A **History** item in the row's ⋯ menu, lazy-loaded like the edit form. A +timeline, newest first: one entry per version with a dot in the operation's colour, the operation +as a badge, the author (or **System**) and a localised date. A version that +moved no field - a publish, an unpublish, a delete - says so on the entry and +offers nothing to open. Expanding any other renders a field-level diff against +the version before it - a badge for an enum, a formatted date for a `dateTime`, +long `textarea` values collapsed, `null` as the same em-dash the table cells +use, and no raw JSON anywhere. A relation shows the id the snapshot stores; see [the AdminCP page](/docs/dev/content-engine/admincp#history). Restore asks for confirmation and states all four facts: which version, that it diff --git a/apps/docs/content/docs/dev/content-engine/scheduling.mdx b/apps/docs/content/docs/dev/content-engine/scheduling.mdx index e64278cd1..390fab579 100644 --- a/apps/docs/content/docs/dev/content-engine/scheduling.mdx +++ b/apps/docs/content/docs/dev/content-engine/scheduling.mdx @@ -358,7 +358,7 @@ Nothing about the existing Server Action paths changed. ## The AdminCP -A calendar icon leads the actions cell for a schedulable content type. The +A **Schedule** item in the row's ⋯ menu, for a schedulable content type. The dialog shows what is booked, what already ran and who booked it, plus a form with two fields: what should happen, and when. diff --git a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx index 48c7bae39..1f652de0a 100644 --- a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx +++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx @@ -410,8 +410,8 @@ nobody notices from the inside. ## AdminCP -Every delivery-enabled content type gets a read-only delivery panel on its row -action: +Every delivery-enabled content type gets a read-only **Delivery** panel in the +row's ⋯ menu: ```text Delivery diff --git a/apps/docs/content/docs/dev/content-engine/structured-fields.mdx b/apps/docs/content/docs/dev/content-engine/structured-fields.mdx index d06a2cba5..bcb4dfa95 100644 --- a/apps/docs/content/docs/dev/content-engine/structured-fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/structured-fields.mdx @@ -141,9 +141,9 @@ become columns there, one row per language - and the value stays nested in every language. A `localized: true` leaf inside a group is a definition-time error. Half a -logical value on each table would mean two revision histories and two -permissions for one box an editor sees as one thing: somebody with -`can_translate` could rewrite a leaf only `can_edit` should touch. +logical value on each table would mean two revision histories for one box an +editor sees as one thing: restoring a language would rewrite half of it and +leave the other half alone. If some of the values are per-language and some are not, that is two groups: diff --git a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx index 5881aadff..fdfc96bc2 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx @@ -1,12 +1,12 @@ --- -title: Translation lifecycle -description: Each language publishes on its own schedule - and a translation is never public before the record is. +title: Localized editing +description: One form, a language switcher inside each translated field, and a lifecycle each language runs on its own. icon: Languages --- [Localization](/docs/dev/content-engine/localization) gave a record one row per language. This page is about what those rows *do*: a status of their own, a -publish button of their own, and a rule about how the two levels relate. +history of their own, and a rule about how the two levels relate. ```ts title="src/content/article.ts" export const articleContentType = defineContentType({ @@ -51,11 +51,20 @@ That is the whole model. A translation's status is **subordinate**: publishing the Polish copy of a draft article puts nothing on the internet, and unpublishing the article takes every language down at once. - - A record going live exposes the languages that were *already* marked published, - and no others. This is the difference between "we are ready to launch" and "the - Polish copy is finished", and they are rarely the same day. It also means - nobody can ship a half-finished translation by pressing one button. + + Publishing a record moves every translation it has with it, in the record's own + transaction - each through this service, so each takes its delivery address and + records the publish in its own history. Unpublishing takes them all back down. + + Publication is a decision about the *record*, and there is one control for it. + Before this, a record's publish moved only the base row, so a localized article + read as `published` in the AdminCP while every language of it was still a draft: + no canonical URL, no search document, nothing public. Both rows were telling the + truth, about different things, and nobody could see which. + + A language added to an already-published record is published as it is created, + for the same reason - otherwise it would be a language with nothing left to + publish it. ## The states @@ -63,9 +72,14 @@ the article takes every language down at once. | | What it means | | --- | --- | | **Missing** | No translation row for this language. Nothing to publish. | -| **Draft** | A translation exists and is not public. Where every one starts. | +| **Draft** | A translation exists and is not public. Where a language of a draft record starts. | | **Published** | Public - if the base record is published too. | +The per-locale `publish` and `unpublish` below still exist, and are how a single +language is held back from a record that is otherwise live. They are an override, +not the ordinary route: the AdminCP's language dialog reports each language's +status and offers no publish button of its own. + There is deliberately no **Outdated**. Its honest definition is "the source language changed after this translation did", and comparing two `updatedAt` timestamps does not mean that: a typo fix in English would mark every language @@ -172,20 +186,18 @@ if (outcome) { | Action | Permission | | --- | --- | | Read a translation, read its history | `can_view` | -| Create or update a translation | `can_translate` | +| Create or update a translation | `can_edit` | | Publish or unpublish a translation | `can_publish` | | Restore a translation revision | `can_restore` | | Delete a non-default translation | `can_delete` | -`can_translate` depends on `can_view` and **not** on `can_edit`, which is the -whole point of having it: a translator gets every locale tab without gaining the -ability to touch a shared field, move the record's global publication state or -delete it. +There is deliberately no translation permission. Writing a locale is editing the +record in that language, so `can_edit` covers a shared field and a translation +alike - and the AdminCP's single Save button, which posts one composite request +for both halves, needs exactly one grant to answer for all of it. - - Staff permissions are stored as JSON per role, so a new one simply is not on - any existing role. Grant it in AdminCP → Staff. - +Publishing, restoring and deleting keep their own gates, so `can_edit` still +cannot put an unfinished language on the internet or make one disappear. The default-locale translation stays undeletable **even with `can_delete`**. It is created with the record and is what makes "a record always resolves in some @@ -204,7 +216,8 @@ a revision, without it they simply do not. Both take `{ "expectedVersion": 3 }` and answer `{ "changed": true, "row": { … } }`. A stale version comes back as the same structured 409 every translation route uses, with `locale` in every arm - which -is what lets a tab strip point at the right tab rather than at the record. +is what lets the AdminCP say *which language* moved rather than just "the record +changed". Locales are canonical strings on the outside and numeric `core_languages.id` values on the inside. A client never sends an id, so it can never point one at a @@ -212,37 +225,122 @@ language it was not shown. ## The AdminCP -The edit dialog of a localized content type opens on a tab strip: +There is **one form**. No `Shared | English | Polski` strip, no locale in the URL, +and no form-global language state: + +```text +Title [ Tytuł artykułu ] [ PL ▾ ] +Content [ Treść… ] [ PL ▾ ] +Friendly URL [ tytul-artykulu ] [ PL ▾ ] +Category Aktualności +Author Maciej +``` + +Each **localized field** carries its own small language switcher - the same +`multiLang` behaviour VitNode has always used for language-aware inputs. Shared +fields sit beside them with no switcher, because there is nothing to switch. + +### Two different languages + +Two things are called "the language" and they are not the same thing: + +| | What it decides | +| --- | --- | +| **Your VitNode language** | What the AdminCP *shows you first*: the list's titles, and the language every localized input opens in | +| **`localization.defaultLocale`** | Which translation a record cannot exist without, and what a public reader falls back to | + +Reading the AdminCP in Polish opens every localized field on Polish, whatever +`defaultLocale` says. It is the language you are already in; being asked to pick +it again would be a control with one sensible answer. + +If your language is not one the install serves, the field falls back to the first +enabled one rather than writing into a language nothing renders. On a +one-language install no switcher is rendered at all. + +### Switching one field, not the screen + +Switching `Title` to English leaves the body and the URL in Polish. That is +deliberate: comparing one heading against another should not move the whole page. + +```text +Title [ Article title ] [ EN ▾ ] ← switched +Content [ Treść… ] [ PL ▾ ] ← unchanged +Friendly URL [ tytul-artykulu ] [ PL ▾ ] ← unchanged +``` + +Selecting a language whose translation does not exist shows an **empty box**, and +saving writes nothing for it. Looking at a language is not a decision to create a +translation in it. + +### One Save, one transaction + +The form holds every language at once - read in one request when it opens, not +one request per language - and one Save writes all of it: ```text -Shared | English ✓ | Polski ● | Deutsch ○ +BEGIN + update the base row with its own expectedVersion + update the EN translation with its own expectedVersion + create the PL translation +COMMIT ``` -- **Shared** holds the fields that are not per-language, plus the record's global - publication, history and scheduling. -- **Each locale tab** holds that language's fields, its status, its version, its - publish button, its history and - for anything but the default - its delete - button. +Only what actually changed is sent. A Polish-only edit sends no shared values and +no English entry, so the base version, the English version, the English revision +history and the English cache are all left exactly where they were. -The strip loads metadata only, in one request. A language's values are fetched -when its tab is opened, so opening the dialog on a record with nine languages -costs one query rather than nine. +If any part is refused - somebody saved the English copy while you were typing - +**nothing commits**, and the error names the language. -Only languages the app actually serves get a tab: they come from the app config, -already filtered to the enabled ones. And **opening a tab never creates a -translation** - a missing language shows `Missing` and an explicit create button, -because looking is not a decision to publish an empty page. +### Per-language lifecycle -### When somebody else got there first +Status, publication, history, restore and delete are genuinely per-language and +genuinely not fields, so they live in their own **Languages** panel - reached from +the row's ⋯ menu - rather than around the form: the language is a parameter of +*that* decision, not a mode the whole screen is in. -A stale save keeps the form exactly as you left it and shows a banner naming the -language that moved, with a **Reload this language** button. Nothing is retried -and nothing is merged: reloading is a decision, and so is saving over what the -reload reveals. +Only languages the app actually serves appear: they come from the app config, +already filtered to the enabled ones. The default-locale translation has no +delete button. + +### Field-local languages are not JSON storage + +Worth stating plainly, because the form makes it look otherwise: a field-local +language switcher is a **UI** decision. Nothing about the storage model changed. + +```text +Admin form Storage + +Title [PL ▾] blog_posts +Content [EN ▾] id, categoryId, authorId, status, version +Friendly URL[PL ▾] ──▶ +Category (shared) blog_posts_translations +Author (shared) itemId, languageId, title, friendlyUrl, + content, status, version +``` + +One base row, one translation row per `(itemId, languageId)`, each with its own +`version`, its own `status`, its own `publishedAt` and its own revision history. +The form holds `[{ languageCode, value }]` per field only while you are editing; +the save takes it apart again and writes rows. + +There is no JSON column, and the old `MultiLangValue` persistence model has not +come back. + +### The list + +A localized list shows the record in the language you are reading, with nothing +above the table to choose: + +```text +Name Color +Aktualności ● #3260c0 +Poradniki ● #23a06b +``` -English and Polish edits are two different rows with two different version -counters, so they never conflict with each other - only with another edit of the -*same* language. +A record with no translation in your language shows `Missing` rather than a +blank - that is the row worth spotting. Sorting and searching still address the +base table, so a localized column is displayed but not sortable. ## Stage 5B boundaries @@ -261,5 +359,5 @@ both frozen revisions - see route that mints one landed with Stage 5C. Locale-specific *scheduling* stays outside Stage 5 entirely. A scheduled global -publish exposes the languages already marked published and publishes no drafts; a +publish moves every language the record has with it; a scheduled global unpublish hides every language at once. diff --git a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx index 5cfa3741c..f0dbaca82 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx @@ -119,9 +119,9 @@ AdminCP is already allowed to see it. The link is the credential from there on. It freezes the record's newest **shared** revision and that locale's newest **translation** revision, and returns both ids alongside the link. A locale with -no translation is a 404 rather than a link to the fallback - the button is on a -language tab, and a link that quietly previewed a different language would be -worse than no link. +no translation is a 404 rather than a link to the fallback - the link names one +language, and one that quietly previewed a different one would be worse than no +link. `?locale=` is a query parameter rather than a second placeholder in `editorial.preview.pathTemplate`, and that is deliberate: a new placeholder would diff --git a/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx index 0e5ec8a85..e54bba237 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx @@ -64,9 +64,9 @@ distinct. **Localized fields only.** What is absent is the point: - **No shared fields.** They live on the base row and have their own history. - This is a security boundary as much as a modelling one: a translation snapshot - that carried shared values would let a restore performed with `can_translate` - rewrite fields only `can_edit` may touch. + This is a containment boundary as much as a modelling one: a translation + snapshot that carried shared values would rewrite the whole record on a restore + that asked for one language. - **No other locale's values.** Restoring Polish must not touch English. - **Nothing derived** - no public response object, no search document, no relation labels. All three are shaped by configuration that may since have @@ -196,14 +196,16 @@ demand, one at a time. ## In the AdminCP -Each locale tab has its own **Show this language's history** section, loaded when -it is opened rather than with the tab - a language's history can be long, and -nobody who only wanted to fix a typo should pay for it. Restore is offered on -every version but the current one, and only with `can_restore`. +History is per-language, so it lives in the row's **Languages** action rather than +in the form: each language gets its own **Show this language's history** section, +loaded when it is opened rather than with the dialog - a language's history can be +long, and nobody who only wanted to fix a typo should pay for it. Restore is +offered on every version but the current one, and only with `can_restore`. ## Related -- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - the - per-locale publish/unpublish these revisions record +- [Localized editing](/docs/dev/content-engine/translation-editorial) - the + per-locale publish/unpublish these revisions record, and the form they are + reached from - [Revisions](/docs/dev/content-engine/revisions) - the shared history the base row keeps, and the retention rules both share diff --git a/apps/docs/content/docs/dev/content-engine/translation-service.mdx b/apps/docs/content/docs/dev/content-engine/translation-service.mdx index 24bf54446..daa54fb5c 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-service.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-service.mdx @@ -214,8 +214,8 @@ Five distinct outcomes, because a client that cannot tell them apart can only sh | Deleting the default translation | `409` | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | | A localized slug is taken **in this language** | `409` | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | -The version conflict names the locale, which is the one thing a locale tab strip -has to know to reload the right tab: +The version conflict names the locale, which is the one thing the AdminCP needs to +say *which language* somebody else saved: ```json { @@ -269,10 +269,10 @@ DELETE /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} | `PUT /{id}/translations/{locale}` | `can_edit` | `{ expectedVersion, values }` | | `DELETE /{id}/translations/{locale}` | `can_delete` | `{ expectedVersion }` | -Existing permissions on purpose. A dedicated `can_translate` means a migration for -every role in every install, and doing that before the AdminCP has a translation -screen to gate would ship a checkbox that governs nothing anybody can see. It -arrives in Stage 5B, with the UI it belongs to. +Existing permissions on purpose, and permanently: writing a locale is editing the +record in that language, so `can_edit` is the whole answer. A dedicated +translation permission would mean a migration for every role in every install, and +a Save button that is half allowed. `PUT`, not `PATCH`: the Next.js API route handler exports no `PATCH`. diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx index 522971ecd..a41726738 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -18,10 +18,10 @@ the emitting plugin are needed, the event map is global. | `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP | | `blog.post.created` | `{ postId, categoryId }` | A blog post is created | | `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited | -| `blog.post.deleted` | `{ postId, categoryId }` | A blog post is deleted | +| `blog.post.deleted` | `{ postId }` | A blog post is deleted | | `blog.category.created` | `{ categoryId }` | A blog category is created | | `blog.category.updated` | `{ categoryId }` | A blog category is edited | -| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category (and its posts, via cascade) is deleted | +| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category is deleted (`postIds` is always empty) | ## Core @@ -166,10 +166,19 @@ themselves, and core will emit it once deletion lands. ## Blog (`@vitnode/blog`) + + The blog runs on the [Content + Engine](/docs/dev/content-engine), so the events that describe what actually + happened are `content.blog.post.*` and `content.blog.category.*` - they carry + changed fields, revision ids, publication transitions, per-locale translation + events and slug history. The four names below are re-emitted from those by + listeners in the plugin, so existing consumers keep working. Prefer the + `content.*` ones for anything new. + + ### blog.category.created / blog.category.updated -Emitted after a category (and its translated titles) is created or edited in -the AdminCP. +Re-emitted after `content.blog.category.created` / `.updated`. + + + The row is gone by the time this is emitted, so there is nothing left to read + it from - and inventing one would put a wrong id into an audit trail. A + listener that needs the category should watch `content.blog.post.deleted` and + keep its own index. + + ### blog.category.deleted -Emitted after a category is deleted. Deleting a category cascade-deletes its -posts at the database level, so the payload carries the ids of the posts that -were removed with it. +Re-emitted after `content.blog.category.deleted`. -**Use cases:** the blog plugin itself ships a listener on this event -(`cleanup-category-search`) that removes the cascade-deleted posts from the -search index - a good template for cleaning up any data your plugin keys by -post id. - ## Content Engine events Every content type declared with the diff --git a/apps/docs/content/docs/dev/index.mdx b/apps/docs/content/docs/dev/index.mdx index f04aff5ce..96cdad078 100644 --- a/apps/docs/content/docs/dev/index.mdx +++ b/apps/docs/content/docs/dev/index.mdx @@ -8,9 +8,29 @@ icon: Power We're working hard to bring you the best documentation experience. +## Support + +- [Postgres 18-19](https://www.postgresql.org/) (min: v18, recommended: v19) - database support. + +### Supported Package Managers + +- [bun](https://bun.com/) (min: v1.1, recommended: v1.3) +- [pnpm](https://pnpm.io/) (min: v10, recommended: v11) +- [node.js](https://nodejs.org/) (min: v22, recommended: v24) + +### Optional Support + +- [Redis](https://redis.io/) (min: v7, recommended: v8) - caching and session management. +- [Docker](https://www.docker.com/) (min: v24, recommended: v25) - containerization and deployment. +- [ElasticSearch](https://www.elastic.co/elasticsearch/) (min: v8, recommended: v9) - advanced search capabilities. +- [NodeMailer](https://nodemailer.com/about/) - email sending capabilities. +- [Resend](https://resend.com/) - email sending capabilities. +- [S3](https://aws.amazon.com/s3/) - file storage. +- [Supabase](https://supabase.com/) - database management and file storage. + ## Get started -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 86cbf5f65..c1e79d1ea 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -85,3 +85,160 @@ npm run dev + +## How it is built + +The blog is the Content Engine's reference implementation. Two content types, +three component overrides and one layout - and no CRUD of its own: + +```text +plugins/blog/src/ +├── content/category.ts Blog Category (blog.category) +├── content/post.ts Blog Article (blog.post) +├── database/{categories,posts}.ts createContentModel(...) +├── config.tsx contentTypeAdmin(...) x2 +└── views/admin/ + ├── category/color-field.tsx AutoFormColor override + ├── category/color-cell.tsx table cell override + ├── article/editor-field.tsx AutoFormEditor override + └── article/form-layout.tsx the editor screen +``` + +There is no `api/modules/admin/**`, no create/edit dialog, no manual validation, +no manual search sync and no hand-written slug uniqueness check. The generated +routes, forms, permissions, events, search documents and canonical URLs come +from the two definitions. + +### Categories - the simple example + +Dialog create and edit, because a name and a colour do not need a page: + +```ts title="src/content/category.ts" +export const blogCategoryContentType = defineContentType({ + id: "blog.category", + tableName: "blog_categories", + + localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + + fields: { + color: field.text({ maxLength: 50, nullable: true }), + name: field.text({ localized: true, required: true, maxLength: 100 }), + }, + + admin: { + label: { plural: "Categories", singular: "Category" }, + permissionModule: "categories", + titleField: "name", + create: { mode: "dialog" }, + edit: { mode: "dialog" }, + list: { columns: ["name", "color", "updatedAt"] }, + }, +}); +``` + +The colour is the AdminCP's own picker through a +[field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), +and the colour column is a swatch **plus** the value in words. + +`name` is localized and is still the list's first column and the content type's +`titleField`. That is the split the engine draws: showing a localized value is a +projection the AdminCP resolves in *your* language, while ordering and filtering +stay on the base table. The list reads: + +```text +Name Color Updated +Aktualności ● #3260c0 2 days ago +Poradniki ● #23a06b a week ago +``` + +and the dialog is one form, with the switcher inside the field that needs one: + +```text +Name [ Aktualności ] [ PL ▾ ] + +Color [ ● #3260c0 ] + + Cancel Save +``` + + + `titleField: "name"` fixes the list, the toasts and the page headings, because + the AdminCP resolves a localized title from the translation it already loaded. + The **relation picker** is a different query: a relation label is resolved from + a shared column on the target with a SQL join, and a localized content type has + none - so the article's category picker labels its options `#3` rather than + "Aktualności". Resolving one from the translation table is a Content Engine + change, not something a plugin should paper over. + + +### Articles - the rich example + +Page create and edit, a custom layout, `AutoFormEditor` for the body, a native +relation to the category, an author, publication, editorial history, search and +delivery: + +```text +/admin/content/blog/post list +/admin/content/blog/post/create create +/admin/content/blog/post/42/edit edit +``` + +```text +┌───────────────────────────────────────────────────────┐ +│ Title │ Publish │ +│ [............................] │ Status: Draft │ +│ │ [ Save ] │ +│ Content ├──────────────────────┤ +│ ┌────────────────────────────┐ │ Article settings │ +│ │ AutoFormEditor │ │ Friendly URL │ +│ └────────────────────────────┘ │ Category │ +│ │ Author │ +└───────────────────────────────────────────────────────┘ +``` + +Below `lg` it is a single column: body first, then metadata, then the actions. + +Articles are localized, and the layout does not know it. `title`, `content` and +`friendlyUrl` are stored per language and `categoryId` and `authorId` are not - +so the first three render their own small language switchers and the last two do +not, from one `ContentFormField` call each: + +```text +Title [ Tytuł artykułu ] [ PL ▾ ] +Content [ AutoFormEditor ] [ PL ▾ ] +Friendly URL [ tytul-artykulu ] [ PL ▾ ] +Category Aktualności +Author Maciej +``` + +Everything opens in the language you are reading VitNode in - not in +`defaultLocale` - and switching `Title` to English leaves the editor in Polish. +One Save writes the base row and every changed language in one transaction. +Per-language publish, history and delete live in the list's **Languages** row +action. + +### Upgrading from an older blog + +Migration `0035_migrate_blog_to_content_engine.sql` is additive. No table is +dropped and no record moves: + +- `blog_categories` and `blog_posts` keep their names, ids, colours, categories, + authors and timestamps. +- The text moves out of `core_languages_words` and into + `blog_categories_translations` / `blog_posts_translations`, one row per + language that actually had a translation. +- Every existing article becomes `published` with `publishedAt = createdAt` - + they were all publicly readable before, and that is the one publication fact + the old schema can prove. `version` starts at 1 and no revision history is + invented. +- A record with no default-locale translation gets one built from a name it + already has, rather than being left unreadable. + +Two things do change, deliberately: + +| Was | Now | +| --- | --- | +| `GET /api/@vitnode/blog/posts` | `GET /api/@vitnode/blog/content/blog` | +| `GET /api/@vitnode/blog/categories` | removed - categories have no public URL | +| `/admin/blog/posts`, `/admin/blog/categories` | redirect to the generated screens | +| `blog.post.deleted` carried `categoryId` | it does not; see [Built-in events](/docs/dev/events/built-in-events#blogpostdeleted) | diff --git a/apps/docs/content/docs/ui/meta.json b/apps/docs/content/docs/ui/meta.json index e9356625b..71129e393 100644 --- a/apps/docs/content/docs/ui/meta.json +++ b/apps/docs/content/docs/ui/meta.json @@ -22,9 +22,11 @@ "input-group", "nullable-number", "radio-group", + "roles", "select", "switch", "textarea", + "user", "---UI---", "..." ] diff --git a/apps/docs/content/docs/ui/roles.mdx b/apps/docs/content/docs/ui/roles.mdx new file mode 100644 index 000000000..fcfc5fcf1 --- /dev/null +++ b/apps/docs/content/docs/ui/roles.mdx @@ -0,0 +1,150 @@ +--- +title: Roles +description: Search and pick roles for an Auto Form field - one, or as many as you like. +--- + +## Preview + + + +## Usage + +```ts +import { z } from "zod"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormRoles } from "@vitnode/core/components/form/fields/input-roles"; +``` + +One component covers both shapes, because the difference is the value and +nothing else - the search, the colour, the language resolution and the empty +state are identical, and two copies is how they drift. + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + + +```ts +const formSchema = z.object({ + roleId: z.number(), +}); +``` + +```tsx + ( + + ), + }, + ]} +/> +``` + +The value is a single id, and picking again replaces it. + + + + + +```ts +const formSchema = z.object({ + roleIds: z.array(z.number()).min(1), +}); +``` + +```tsx + ( + + ), + }, + ]} +/> +``` + +The value is an array of ids. Chosen roles appear above the picker as removable +chips, and the picker **appends** rather than replaces - picking one that is +already chosen removes it, which is what the tick beside it in the list means. + + + + +Out of the box it searches the AdminCP roles list. The **guest** role is never +offered: it is the role a request has when it has no account, so it is not +something to assign to anybody. + +## Editing an existing record + +Same rule as [User](/docs/ui/user): the picker can only name ids it has seen, so +an edit form passes the roles it already knows about. + +```tsx + +``` + +## Keeping a role out of the list + +`excludeIds` drops options another field already owns - a primary-role picker +and a secondary-role picker should not both offer the same one: + +```tsx + +``` + +## Names are resolved per reader + +A role carries one name per language. The field renders the active locale's, and +falls back to the first translation rather than to the id - a role with no +translation in your language is still a role somebody named: + +```ts +import { roleOptionName } from "@vitnode/core/components/form/fields/input-roles"; + +roleOptionName(role, "pl"); // "Administrator PL", or the first name it has +``` + +## Props + +| Prop | Type | Default | What it does | +| --- | --- | --- | --- | +| `multiple` | `boolean` | `false` | `number[]` instead of `number`, with chips | +| `label` | `ReactNode` | — | Field label | +| `description` | `ReactNode` | — | Help text under the control | +| `placeholder` | `string` | `Select an option` | Shown while nothing is chosen | +| `searchPlaceholder` | `string` | `Search...` | Placeholder inside the search box | +| `selected` | `RoleOption[]` | `[]` | Roles the field opens on | +| `excludeIds` | `number[]` | `[]` | Roles the picker must not offer | +| `search` | `(value: string) => Promise` | AdminCP roles list | Replaces the lookup | +| `disabled` | `boolean` | `false` | Blocks opening the picker and removing chips | + +`RoleOption` is `{ id, color, name }`, where `name` is the raw +`{ languageCode, name }[]` - the server has no business deciding which language +the person clicking reads in. + +## See also + +- [User](/docs/ui/user) - the same idea for people. +- [Combobox](/docs/ui/combobox) - when the options are strings rather than records. diff --git a/apps/docs/content/docs/ui/user.mdx b/apps/docs/content/docs/ui/user.mdx new file mode 100644 index 000000000..4de9cad5e --- /dev/null +++ b/apps/docs/content/docs/ui/user.mdx @@ -0,0 +1,128 @@ +--- +title: User +description: Pick a person by name, with their avatar, for an Auto Form field. +--- + +## Preview + + + +## Usage + +```ts +import { z } from "zod"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormUser } from "@vitnode/core/components/form/fields/input-users"; +``` + +The value is the **user id**, so the schema is a plain number and the payload +needs no unwrapping: + +```ts +const formSchema = z.object({ + authorId: z.number(), +}); +``` + +```tsx + ( + + ), + }, + ]} +/> +``` + +Out of the box it searches the AdminCP users list, which means it answers with +whatever that route lets the signed-in admin see - the permission check lives +there and is not repeated in the component. + +## Editing an existing record + +A picker cannot show a name it has never fetched. An edit form starts holding an +id, so pass the person it already knows about as `selected`: + +```tsx + +``` + +Without it the field opens on the placeholder, as though nobody were chosen. +Whatever the search returns afterwards is remembered on top of that, so a person +picked a moment ago still reads as their name. + + + A nullable author is `z.number().nullable()`. The field renders the placeholder + for `null` and never invents a value - clearing one is up to your own control, + because "no author" and "author not chosen yet" are the same state here. + + +## Searching somewhere else + +`search` replaces the lookup entirely - a plugin scoping to its own members, a +different endpoint, or fixtures in a test: + +```tsx + await searchReviewers(value)} +/> +``` + +It runs on every open with an empty string, and again - debounced - as the +person types. That is deliberate: the list is a live view of who exists, and a +cached one offers somebody who was deleted since. + +## Props + +| Prop | Type | Default | What it does | +| --- | --- | --- | --- | +| `label` | `ReactNode` | — | Field label | +| `description` | `ReactNode` | — | Help text under the control | +| `placeholder` | `string` | `Select an option` | Shown while nothing is chosen | +| `searchPlaceholder` | `string` | `Search...` | Placeholder inside the search box | +| `selected` | `PartialUserOption \| null` | — | The person the field opens on | +| `search` | `(value: string) => Promise` | AdminCP users list | Replaces the lookup | +| `clearable` | `boolean` | `false` | Adds a button that sets the field back to nobody | +| `disabled` | `boolean` | `false` | Blocks opening the picker | + +`UserOption` is `{ id, name, nameCode, avatarColor }` - the columns it takes to +recognise a person on sight. `selected` accepts a **partial** one, because the +caller often knows only an id and a name. + +A generated avatar needs a colour, and that is the column a caller who resolved +only a *name* does not have. Rather than invent one - the wrong colour reads as a +different person - the field draws a neutral placeholder in the same box, so the +name stays where it is when a search fills the real avatar in. + +## In the Content Engine + +A [`field.user()`](/docs/dev/content-engine/fields) renders this field +automatically - the author picker on a blog post is this component. Its options +come from the content type's own picker route rather than from the users list, +so an editor who may write articles can choose an author without also being +trusted to browse the member list. + +## See also + +- [Roles](/docs/ui/roles) - the same idea for roles, single or multiple. +- [Combobox](/docs/ui/combobox) - when the options are strings rather than people. diff --git a/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql b/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql new file mode 100644 index 000000000..cd66b71e3 --- /dev/null +++ b/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql @@ -0,0 +1,192 @@ +CREATE TABLE "blog_categories_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "name" varchar(100) NOT NULL, + CONSTRAINT "blog_categories_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "blog_posts_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "title" varchar(255) NOT NULL, + "friendlyUrl" varchar(255) NOT NULL, + "content" text NOT NULL, + CONSTRAINT "blog_posts_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "blog_posts" DROP CONSTRAINT "blog_posts_categoryId_blog_categories_id_fk"; +--> statement-breakpoint +ALTER TABLE "blog_categories" ALTER COLUMN "updatedAt" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "blog_posts" ALTER COLUMN "updatedAt" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ADD CONSTRAINT "blog_categories_translations_itemId_blog_categories_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."blog_categories"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ADD CONSTRAINT "blog_categories_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ADD CONSTRAINT "blog_posts_translations_itemId_blog_posts_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."blog_posts"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ADD CONSTRAINT "blog_posts_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "blog_categories_translations_language_id_idx" ON "blog_categories_translations" USING btree ("languageId");--> statement-breakpoint +CREATE INDEX "blog_posts_translations_language_id_status_idx" ON "blog_posts_translations" USING btree ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "blog_posts_translations_language_id_friendly_url_key" ON "blog_posts_translations" USING btree ("languageId","friendlyUrl");--> statement-breakpoint +ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_categoryId_blog_categories_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."blog_categories"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "blog_categories_created_at_idx" ON "blog_categories" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "blog_categories_updated_at_idx" ON "blog_categories" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "blog_posts_status_created_at_idx" ON "blog_posts" USING btree ("status","createdAt");--> statement-breakpoint +CREATE INDEX "blog_posts_category_id_idx" ON "blog_posts" USING btree ("categoryId");--> statement-breakpoint +CREATE INDEX "blog_posts_author_id_idx" ON "blog_posts" USING btree ("authorId");--> statement-breakpoint +CREATE INDEX "blog_posts_created_at_idx" ON "blog_posts" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "blog_posts_updated_at_idx" ON "blog_posts" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "blog_posts_status_published_at_idx" ON "blog_posts" USING btree ("status","publishedAt");--> statement-breakpoint +-- +-- Data migration: the blog's own storage -> the Content Engine's. +-- +-- Nothing above this line dropped a table or a column, and nothing below moves a +-- record: ids, categories, authors and timestamps stay exactly where they are. +-- What moves is the *text*, out of `core_languages_words` and into the two +-- translation tables the engine reads. +-- + +-- 1. Publication. Every article that exists today is publicly readable - the old +-- public route returned every row and every search document was written +-- `isPublic: true` - so they all migrate as published. `publishedAt` is +-- `createdAt`, which is the only publication date the old schema can prove; +-- no revision history is fabricated, so `version` stays at its default of 1. +UPDATE "blog_posts" +SET "status" = 'published', "publishedAt" = "createdAt" +WHERE "status" = 'draft' AND "publishedAt" IS NULL;--> statement-breakpoint + +-- 2. Category names. One row per (category, language) that actually had a title, +-- so a language nobody translated into stays untranslated rather than being +-- invented. A stored empty title would break `name`'s minimum length, so it +-- falls back to a unique placeholder an editor can see and fix. +INSERT INTO "blog_categories_translations" + ("itemId", "languageId", "version", "createdAt", "updatedAt", "name") +SELECT + c."id", + l."id", + 1, + c."createdAt", + c."updatedAt", + LEFT(COALESCE(NULLIF(w."value", ''), 'category-' || c."id"), 100) +FROM "core_languages_words" w +JOIN "blog_categories" c ON c."id" = w."itemId" +JOIN "core_languages" l ON l."code" = w."languageCode" +WHERE w."pluginCode" = '@vitnode/blog' + AND w."tableName" = 'blog_categories' + AND w."variable" = 'title' +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 3. Article text. The three variables the plugin kept side by side become one +-- row, for each (article, language) pair that had any of them. A missing +-- friendly URL falls back to something unique rather than to an empty string, +-- which the new UNIQUE (languageId, friendlyUrl) index would reject on the +-- second article. +INSERT INTO "blog_posts_translations" ( + "itemId", "languageId", "version", "createdAt", "updatedAt", + "publishedAt", "status", "title", "friendlyUrl", "content" +) +SELECT + p."id", + l."id", + 1, + p."createdAt", + p."updatedAt", + p."createdAt", + 'published', + LEFT(COALESCE(w."title", ''), 255), + LEFT( + COALESCE(NULLIF(w."friendlyUrl", ''), 'post-' || p."id" || '-' || l."code"), + 255 + ), + COALESCE(w."content", '') +FROM ( + SELECT + "itemId", + "languageCode", + MAX("value") FILTER (WHERE "variable" = 'title') AS "title", + MAX("value") FILTER (WHERE "variable" = 'content') AS "content", + MAX("value") FILTER (WHERE "variable" = 'friendlyUrl') AS "friendlyUrl" + FROM "core_languages_words" + WHERE "pluginCode" = '@vitnode/blog' + AND "tableName" = 'blog_posts' + AND "variable" IN ('title', 'content', 'friendlyUrl') + GROUP BY "itemId", "languageCode" +) w +JOIN "blog_posts" p ON p."id" = w."itemId" +JOIN "core_languages" l ON l."code" = w."languageCode" +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 4. The default locale. A localized content type refuses to leave a record +-- without a translation in its `defaultLocale`, so a record that was only ever +-- written in another language gets an English row built from the name it +-- already has in whichever language it does have. Nothing is invented: the +-- value is one the record genuinely carries. +INSERT INTO "blog_categories_translations" + ("itemId", "languageId", "version", "createdAt", "updatedAt", "name") +SELECT + c."id", + l."id", + 1, + c."createdAt", + c."updatedAt", + LEFT( + COALESCE( + ( + SELECT NULLIF(t."name", '') + FROM "blog_categories_translations" t + WHERE t."itemId" = c."id" + ORDER BY t."languageId" + LIMIT 1 + ), + 'category-' || c."id" + ), + 100 + ) +FROM "blog_categories" c +JOIN "core_languages" l ON l."code" = 'en' +ON CONFLICT DO NOTHING;--> statement-breakpoint + +INSERT INTO "blog_posts_translations" ( + "itemId", "languageId", "version", "createdAt", "updatedAt", + "publishedAt", "status", "title", "friendlyUrl", "content" +) +SELECT + p."id", + l."id", + 1, + p."createdAt", + p."updatedAt", + p."createdAt", + 'published', + LEFT(COALESCE(NULLIF(source."title", ''), 'post-' || p."id"), 255), + LEFT('post-' || p."id" || '-en', 255), + COALESCE(source."content", '') +FROM "blog_posts" p +JOIN "core_languages" l ON l."code" = 'en' +LEFT JOIN LATERAL ( + SELECT t."title", t."content" + FROM "blog_posts_translations" t + WHERE t."itemId" = p."id" + ORDER BY t."languageId" + LIMIT 1 +) source ON TRUE +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 5. The old storage, now that everything in it has a new home. Scoped to rows +-- that were genuinely migrated: a word in a language the install does not have +-- could not be copied, so it is left where it is rather than deleted. +DELETE FROM "core_languages_words" w +USING "core_languages" l +WHERE w."pluginCode" = '@vitnode/blog' + AND w."tableName" IN ('blog_categories', 'blog_posts') + AND l."code" = w."languageCode"; diff --git a/apps/docs/migrations/0036_add_core_secrets.sql b/apps/docs/migrations/0036_add_core_secrets.sql new file mode 100644 index 000000000..ffa881d19 --- /dev/null +++ b/apps/docs/migrations/0036_add_core_secrets.sql @@ -0,0 +1,7 @@ +CREATE TABLE "core_secrets" ( + "name" varchar(100) PRIMARY KEY NOT NULL, + "value" text NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_secrets" ENABLE ROW LEVEL SECURITY; \ No newline at end of file diff --git a/apps/docs/migrations/meta/0035_snapshot.json b/apps/docs/migrations/meta/0035_snapshot.json new file mode 100644 index 000000000..4b679e430 --- /dev/null +++ b/apps/docs/migrations/meta/0035_snapshot.json @@ -0,0 +1,4456 @@ +{ + "id": "42b7098a-c42b-4c70-8673-087b8ff56ce4", + "prevId": "0f660415-9144-44ed-9d96-78cd76711ebf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_slug_history": { + "name": "core_content_slug_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retiredAt": { + "name": "retiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_slug_history_shared_unique": { + "name": "core_content_slug_history_shared_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_locale_unique": { + "name": "core_content_slug_history_locale_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_item_idx": { + "name": "core_content_slug_history_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_plugin_id_idx": { + "name": "core_content_slug_history_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_categories_created_at_idx": { + "name": "blog_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_categories_updated_at_idx": { + "name": "blog_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories_translations": { + "name": "blog_categories_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_categories_translations_language_id_idx": { + "name": "blog_categories_translations_language_id_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_categories_translations_itemId_blog_categories_id_fk": { + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "tableFrom": "blog_categories_translations", + "tableTo": "blog_categories", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "blog_categories_translations_languageId_core_languages_id_fk": { + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "tableFrom": "blog_categories_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "blog_categories_translations_item_id_language_id_pk": { + "name": "blog_categories_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_posts_status_created_at_idx": { + "name": "blog_posts_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_category_id_idx": { + "name": "blog_posts_category_id_idx", + "columns": [ + { + "expression": "categoryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_author_id_idx": { + "name": "blog_posts_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_created_at_idx": { + "name": "blog_posts_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_updated_at_idx": { + "name": "blog_posts_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_status_published_at_idx": { + "name": "blog_posts_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts_translations": { + "name": "blog_posts_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "friendlyUrl": { + "name": "friendlyUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_posts_translations_language_id_status_idx": { + "name": "blog_posts_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_translations_language_id_friendly_url_key": { + "name": "blog_posts_translations_language_id_friendly_url_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "friendlyUrl", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_translations_itemId_blog_posts_id_fk": { + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "tableFrom": "blog_posts_translations", + "tableTo": "blog_posts", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "blog_posts_translations_languageId_core_languages_id_fk": { + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "tableFrom": "blog_posts_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "blog_posts_translations_item_id_language_id_pk": { + "name": "blog_posts_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles": { + "name": "example_advanced_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationNoIndex": { + "name": "syndicationNoIndex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "noIndex": { + "name": "noIndex", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles": { + "name": "example_localized_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/0036_snapshot.json b/apps/docs/migrations/meta/0036_snapshot.json new file mode 100644 index 000000000..cf83f00f2 --- /dev/null +++ b/apps/docs/migrations/meta/0036_snapshot.json @@ -0,0 +1,4488 @@ +{ + "id": "73f97c77-4699-41af-83fc-fe63a1f6196a", + "prevId": "42b7098a-c42b-4c70-8673-087b8ff56ce4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_slug_history": { + "name": "core_content_slug_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retiredAt": { + "name": "retiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_slug_history_shared_unique": { + "name": "core_content_slug_history_shared_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_locale_unique": { + "name": "core_content_slug_history_locale_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_item_idx": { + "name": "core_content_slug_history_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_plugin_id_idx": { + "name": "core_content_slug_history_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_secrets": { + "name": "core_secrets", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_categories_created_at_idx": { + "name": "blog_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_categories_updated_at_idx": { + "name": "blog_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories_translations": { + "name": "blog_categories_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_categories_translations_language_id_idx": { + "name": "blog_categories_translations_language_id_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_categories_translations_itemId_blog_categories_id_fk": { + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "tableFrom": "blog_categories_translations", + "tableTo": "blog_categories", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "blog_categories_translations_languageId_core_languages_id_fk": { + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "tableFrom": "blog_categories_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "blog_categories_translations_item_id_language_id_pk": { + "name": "blog_categories_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_posts_status_created_at_idx": { + "name": "blog_posts_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_category_id_idx": { + "name": "blog_posts_category_id_idx", + "columns": [ + { + "expression": "categoryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_author_id_idx": { + "name": "blog_posts_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_created_at_idx": { + "name": "blog_posts_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_updated_at_idx": { + "name": "blog_posts_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_status_published_at_idx": { + "name": "blog_posts_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts_translations": { + "name": "blog_posts_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "friendlyUrl": { + "name": "friendlyUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_posts_translations_language_id_status_idx": { + "name": "blog_posts_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_translations_language_id_friendly_url_key": { + "name": "blog_posts_translations_language_id_friendly_url_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "friendlyUrl", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_translations_itemId_blog_posts_id_fk": { + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "tableFrom": "blog_posts_translations", + "tableTo": "blog_posts", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "blog_posts_translations_languageId_core_languages_id_fk": { + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "tableFrom": "blog_posts_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "blog_posts_translations_item_id_language_id_pk": { + "name": "blog_posts_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles": { + "name": "example_advanced_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationNoIndex": { + "name": "syndicationNoIndex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "noIndex": { + "name": "noIndex", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles": { + "name": "example_localized_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index 2ba6eff0f..ed43117c1 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -246,6 +246,20 @@ "when": 1786292946013, "tag": "0034_add_example_article_no_index_flag", "breakpoints": true + }, + { + "idx": 35, + "version": "7", + "when": 1786350996229, + "tag": "0035_migrate_blog_to_content_engine", + "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1786562844917, + "tag": "0036_add_core_secrets", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx index 3dfeea398..87b011c5c 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx @@ -1,64 +1,9 @@ -import type { Metadata } from "next"; +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; +import { blogCategoryContentType } from "@vitnode/blog/content/category"; -import { CONFIG_PLUGIN } from "@vitnode/blog/const"; -import { ActionsCategoriesAdmin } from "@vitnode/blog/views/admin/categories/actions/actions"; - -const CategoriesAdminView = dynamic(async () => - import("@vitnode/blog/views/admin/categories/table/categories-admin-view").then(mod => ({ - default: mod.CategoriesAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("categories"), - }; -}; - -export default async function CategoriesPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.categories"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
- - {canCreate && } - - - }> - - -
-
- ); +/** The address categories used to live at. See the posts page next door. */ +export default async function LegacyCategoriesPage() { + await redirect(contentAdminHref(blogCategoryContentType.id)); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx index 99d58036d..148140681 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx @@ -1,64 +1,16 @@ -import type { Metadata } from "next"; - -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@vitnode/blog/const"; -import { ActionsPostsAdmin } from "@vitnode/blog/views/admin/posts/actions/actions"; - -const PostsAdminView = dynamic(async () => - import("@vitnode/blog/views/admin/posts/table/posts-admin-view").then(mod => ({ - default: mod.PostsAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("posts"), - }; -}; - -export default async function PostsPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.posts"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
- - {canCreate && } - - - }> - - -
-
- ); +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; + +import { blogPostContentType } from "@vitnode/blog/content/post"; + +/** + * The address articles used to live at. + * + * A redirect rather than a second list screen: the AdminCP linked here for + * several releases, so the URL is in bookmarks and in muscle memory - but the + * page behind it is now generated, and keeping a duplicate of it would mean two + * tables to fix every time one of them was wrong. + */ +export default async function LegacyPostsPage() { + await redirect(contentAdminHref(blogPostContentType.id)); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx deleted file mode 100644 index b4680017f..000000000 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx deleted file mode 100644 index 6aad0fb44..000000000 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx index 23c72b508..ef6a13465 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx @@ -1,22 +1,68 @@ +import { getTranslations } from "next-intl/server"; + +import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, +} from "@vitnode/core/content/const"; +import { contentAdminHref, contentTypeToPath } from "@vitnode/core/content/registry"; import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; import { getContentLabels, - resolveContentType, + resolveContentRoute, } from "@vitnode/core/views/admin/views/content/content-admin-view"; +/** + * The breadcrumb of every generated Content Engine screen. + * + * The list keeps the trail it always had. A create or an edit **page** appends + * one more crumb, labelled from `core.content` with the content type's own + * singular - so it reads "Blog / Articles / Create article" in whatever language + * the AdminCP is in, and "Articles" becomes a link back to the list. + * + * The record id is deliberately **not** a crumb of its own: `/42/` would render + * as a dead "42" between two words, and the page it would point at is the one + * being read. + */ export default async function BreadcrumbSlot({ params, }: { params: Promise<{ slug: string[] }>; }) { const { slug } = await params; - const entry = await resolveContentType(params); - const labels = entry ? await getContentLabels(entry) : undefined; + const route = await resolveContentRoute(params); + const labels = route ? await getContentLabels(route.entry) : undefined; + + if (!route || route.action === "list") { + return ( + + ); + } + + const t = await getTranslations("core.content"); + const { definition } = route.entry; return ( ); } diff --git a/apps/docs/src/examples/roles.tsx b/apps/docs/src/examples/roles.tsx new file mode 100644 index 000000000..2df8d2aba --- /dev/null +++ b/apps/docs/src/examples/roles.tsx @@ -0,0 +1,68 @@ +"use client"; + +import type { RoleOption } from "@vitnode/core/components/form/fields/search-roles.action.server"; + +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormRoles } from "@vitnode/core/components/form/fields/input-roles"; +import { z } from "zod"; + +const formSchema = z.object({ + roleId: z.number(), + roleIds: z.array(z.number()), +}); + +/** + * The docs preview has no admin session, so the default lookup would answer with + * an empty list. A `search` of our own keeps the example clickable. + */ +const ROLES: RoleOption[] = [ + { + color: "#ef4444", + id: 1, + name: [{ languageCode: "en", name: "Administrator" }], + }, + { color: "#3b82f6", id: 2, name: [{ languageCode: "en", name: "Editor" }] }, + { color: null, id: 3, name: [{ languageCode: "en", name: "Member" }] }, +]; + +const search = async (value: string) => + Promise.resolve( + ROLES.filter(role => + role.name[0].name.toLowerCase().includes(value.toLowerCase()), + ), + ); + +export default function RolesExample() { + return ( + ( + + ), + }, + { + id: "roleIds", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + /> + ); +} diff --git a/apps/docs/src/examples/user.tsx b/apps/docs/src/examples/user.tsx new file mode 100644 index 000000000..05df5ae18 --- /dev/null +++ b/apps/docs/src/examples/user.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { + AutoFormUser, + type UserOption, +} from "@vitnode/core/components/form/fields/input-users"; +import { z } from "zod"; + +const formSchema = z.object({ + authorId: z.number(), +}); + +/** + * The docs preview has no admin session, so the default lookup would answer with + * an empty list. A `search` of our own keeps the example clickable. + */ +const PEOPLE: UserOption[] = [ + { avatarColor: "3b82f6", id: 1, name: "Ada Lovelace", nameCode: "ada" }, + { avatarColor: "ef4444", id: 2, name: "Grace Hopper", nameCode: "grace" }, + { avatarColor: "22c55e", id: 3, name: "Alan Turing", nameCode: "alan" }, +]; + +export default function UserExample() { + return ( + ( + + Promise.resolve( + PEOPLE.filter(person => + person.name.toLowerCase().includes(value.toLowerCase()), + ), + ) + } + /> + ), + }, + ]} + formSchema={formSchema} + /> + ); +} diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index c4dd50874..2eadc25f1 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -1,89 +1,59 @@ { "@vitnode/blog": { "title": "Blog", - "admin": { - "nav": { - "posts": "Wpisy", - "categories": "Kategorie" - }, - "categories": { - "desc": "Zarządzaj kategoriami wpisów na blogu.", - "table": { + "content": { + "post": { + "title": "Artykuły", + "desc": "Pisz artykuły na blogu i zarządzaj nimi.", + "fields": { "title": "Tytuł", + "friendlyUrl": "Przyjazny adres URL", + "content": "Treść", + "categoryId": "Kategoria", + "authorId": "Autor", + "status": "Status", + "publishedAt": "Opublikowano", + "updatedAt": "Zaktualizowano" + } + }, + "category": { + "title": "Kategorie", + "desc": "Grupuj artykuły razem.", + "fields": { + "name": "Nazwa", "color": "Kolor", - "updated_at": "Zaktualizowano" - }, - "delete": { - "title": "Usuń kategorię", - "desc": "Czy na pewno chcesz usunąć kategorię ? Tej akcji nie można cofnąć.", - "confirm": "Tak, usuń tę kategorię", - "success": "Kategoria została pomyślnie usunięta." - }, - "create": { - "title": "Utwórz kategorię", - "desc": "Nowa kategoria dla wpisów na blogu.", - "form": { - "title": { - "label": "Tytuł", - "already_exists": "Kategoria o tym tytule już istnieje." - }, - "color": "Kolor" - }, - "submit": "Utwórz", - "success": "Kategoria została pomyślnie utworzona." + "updatedAt": "Zaktualizowano" + } + } + }, + "admin": { + "article": { + "content": { + "label": "Treść" }, - "edit": { - "title": "Edytuj kategorię", - "submit": "Zapisz zmiany", - "success": "Kategoria została pomyślnie zaktualizowana." + "form": { + "publish": "Publikacja", + "settings": { + "title": "Ustawienia artykułu" + } } }, - "posts": { - "desc": "Twórz wpisy na blogu i zarządzaj nimi.", - "table": { - "title": "Tytuł", - "category": "Kategoria", - "author": "Autor", - "updated_at": "Zaktualizowano" - }, - "create": { - "title": "Utwórz wpis", - "desc": "Napisz nowy artykuł na swój blog.", - "form": { - "title": { - "label": "Tytuł", - "already_exists": "Wpis o tym tytule już istnieje." - }, - "friendly_url": { - "label": "Przyjazny adres URL", - "desc": "Używany w adresie wpisu. Wypełniany automatycznie na podstawie tytułu.", - "already_exists": "Taki przyjazny adres URL już istnieje." - }, - "content": "Treść", - "category": "Kategoria" - }, - "submit": "Utwórz wpis", - "success": "Wpis został pomyślnie utworzony." - }, - "edit": { - "title": "Edytuj wpis", - "submit": "Zapisz zmiany", - "success": "Wpis został pomyślnie zaktualizowany." - }, - "delete": { - "title": "Usuń wpis", - "desc": "Czy na pewno chcesz usunąć wpis ? Tej akcji nie można cofnąć.", - "confirm": "Tak, usuń ten wpis", - "success": "Wpis został pomyślnie usunięty." + "category": { + "color": { + "label": "Kolor", + "desc": "Wyświetlany obok kategorii na listach.", + "none": "Brak koloru" } } } }, - "@vitnode/blog:posts": "Wpisy", - "@vitnode/blog:posts:can_view": "Wyświetlanie listy wpisów", - "@vitnode/blog:posts:can_create": "Tworzenie wpisów", - "@vitnode/blog:posts:can_edit": "Edytowanie wpisów", - "@vitnode/blog:posts:can_delete": "Usuwanie wpisów", + "@vitnode/blog:posts": "Artykuły", + "@vitnode/blog:posts:can_view": "Wyświetlanie listy artykułów", + "@vitnode/blog:posts:can_create": "Tworzenie artykułów", + "@vitnode/blog:posts:can_edit": "Edytowanie artykułów", + "@vitnode/blog:posts:can_delete": "Usuwanie artykułów", + "@vitnode/blog:posts:can_publish": "Publikowanie i cofanie publikacji artykułów", + "@vitnode/blog:posts:can_restore": "Przywracanie wcześniejszej wersji artykułu", "@vitnode/blog:categories": "Kategorie", "@vitnode/blog:categories:can_view": "Wyświetlanie listy kategorii", "@vitnode/blog:categories:can_create": "Tworzenie kategorii", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index a2dfe6a95..045807316 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -101,6 +101,11 @@ "types": "./dist/src/content/next/revalidate-route.server.d.ts", "default": "./dist/src/content/next/revalidate-route.server.js" }, + "./content/admin-form": { + "import": "./dist/src/views/admin/views/content/form/index.js", + "types": "./dist/src/views/admin/views/content/form/index.d.ts", + "default": "./dist/src/views/admin/views/content/form/index.js" + }, "./api/config": { "import": "./dist/src/api/config.js", "types": "./dist/src/api/config.d.ts", diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index e46235cad..5d841298a 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -27,7 +27,8 @@ import { SessionAdminModel } from "@/api/models/session-admin"; import { StorageModel } from "@/api/models/storage"; import { validateContentTypes } from "@/content/registry"; import { ensureContentLocalizationLanguages } from "@/content/server/language-resolver"; -import { assertContentPreviewConfig } from "@/content/server/preview-config"; +import { warnAboutContentPreviewConfig } from "@/content/server/preview-config"; +import { ensureContentPreviewSecret } from "@/content/server/preview-secret"; import { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; import { buildApiMessagesSources } from "@/lib/i18n/sources"; @@ -100,8 +101,13 @@ export interface EnvVariablesVitNode { * id to table, service and owner has to live somewhere it can reach. */ contentModels: RegisteredContentModel[]; - /** Signs content preview links. Flagged in the admin integrations panel - * while it is still the well-known default. */ + /** + * Signs content preview links. + * + * Generated by the install and stored in `core_secrets`, unless + * `CONTENT_PREVIEW_SECRET` overrides it. Absent only when no content type + * has `editorial.preview` enabled, i.e. when there is nothing to sign. + */ contentPreviewSecret?: string; /** Web origins the background cache bridge posts to. */ contentRevalidateOrigins?: string[]; @@ -260,12 +266,17 @@ export const globalMiddleware = ({ ); // Once, here, because "does anything have preview enabled" is only answerable - // after every plugin's content types are in. Throws in production rather than - // booting an install whose preview links anyone could forge. - assertContentPreviewConfig({ - contentTypes: contentTypesMetadata, - secret: process.env.CONTENT_PREVIEW_SECRET, - }); + // after every plugin's content types are in. A warning, never a boot failure: + // preview is one content type's opt-in feature, not a prerequisite for the + // API. + warnAboutContentPreviewConfig({ contentTypes: contentTypesMetadata }); + + // Whether anything can mint a preview link at all - and so whether this + // install has any reason to hold a signing key. An install with no previewable + // content type never touches `core_secrets` because of this. + const hasPreviewableContentTypes = contentTypesMetadata.some( + entry => entry.definition.editorial.preview.enabled, + ); // Not validated: a model carries the definition that `contentTypesMetadata` // already checked, so a second pass would only repeat the same errors. @@ -355,6 +366,13 @@ export const globalMiddleware = ({ c.set("storage", new StorageModel(c)); c.set("realtime", realtime); + // Resolved before `core` is set rather than per mint, so the integrations + // panel and the routes read the same value. Memoised, so this is one query + // on the first request of the process and nothing afterwards. + const contentPreviewSecret = hasPreviewableContentTypes + ? await ensureContentPreviewSecret(dbProvider) + : undefined; + c.set("core", { ai, i18n: i18nMetadata, @@ -381,7 +399,7 @@ export const globalMiddleware = ({ cookieSecure: authorization?.cookieSecure ?? true, }, captcha, - contentPreviewSecret: CONFIG.contentPreviewSecret, + contentPreviewSecret, cronSecret: CONFIG.cronJobSecret, hasCronAdapter: !!cron, plugins: pluginsMetadata, diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts index bb760c958..30d8dfb14 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts @@ -6,10 +6,7 @@ import { core_cron } from "@/database/cron"; import { core_queue } from "@/database/queue"; import { getQueueStatus } from "@/lib/api/get-queue-status"; import { isCronStale } from "@/lib/api/is-cron-stale"; -import { - INSECURE_DEFAULT_CRON_SECRET, - isSecureContentPreviewSecret, -} from "@/lib/config"; +import { INSECURE_DEFAULT_CRON_SECRET } from "@/lib/config"; import { isRealtimePubSubEnabled, isWebSocketEnabled } from "@/ws/registry"; import { buildRoute } from "../../../../lib/route"; @@ -47,11 +44,6 @@ export const integrationsDebugAdminRoute = buildRoute({ active: z.boolean(), // How many content types can mint preview links. contentTypes: z.number(), - // `false` when `CONTENT_PREVIEW_SECRET` is missing, left at its - // well-known default, or too short to be a signing key. Preview - // does not merely warn in that state - it refuses to serve, and - // a production boot fails outright. - secure: z.boolean(), }), cron: z.object({ // `true` when a cron adapter is configured, i.e. an in-process @@ -173,9 +165,6 @@ export const integrationsDebugAdminRoute = buildRoute({ contentPreview: { active: previewContentTypes > 0, contentTypes: previewContentTypes, - // The same predicate the routes fail closed on, so the panel and the - // behaviour cannot disagree about what "secure" means. - secure: isSecureContentPreviewSecret(core.contentPreviewSecret), }, cron: { active: cronActive, diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx index eb6a2e2cd..1cee0c08a 100644 --- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx +++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx @@ -24,23 +24,33 @@ export const ConfirmActionAlertDialog = ({ children, title, description, + finalFocus, submitVariant, textSubmit, onSubmit, ...props }: Omit, "children"> & React.ComponentProps & { - children: React.ReactElement; + /** + * The element that opens the dialog. + * + * Optional, for a caller that owns `open` itself - a confirmation opened from + * a menu item has no trigger to render, because the item is gone by the time + * the dialog is on screen. + */ + children?: React.ReactElement; description?: React.ReactNode; + /** Where focus goes on close, for a dialog whose trigger no longer exists. */ + finalFocus?: React.ComponentProps["finalFocus"]; title?: React.ReactNode; }) => { const t = useTranslations("core.global.confirm_action"); return ( - + {children ? : null} - + {title ?? t("title")} diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 79eb3a58d..aca27e168 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -10,7 +10,9 @@ import { type FieldValues, type Mode, useForm, + useFormContext, type UseFormReturn, + useFormState, } from "react-hook-form"; import z from "zod"; @@ -63,6 +65,15 @@ export interface ItemAutoFormComponentProps { itemParams?: InputParams; label?: React.ReactNode; labelRight?: React.ReactNode; + /** + * Whether this field holds one value per language. + * + * Set by whoever builds the field list - the Content Engine reads it off + * `localized: true` - so a custom component can pass it straight through to + * `AutoFormInput`, `AutoFormTextarea` or `AutoFormEditor` and get the language + * switcher without knowing why the field has one. + */ + multiLang?: boolean; otherProps: { ["aria-invalid"]?: boolean; enum?: string[]; @@ -98,6 +109,40 @@ function AutoFormField({ return ; } +/** + * The submit button of the surrounding `AutoForm`, for a `layout` that has to + * place it itself. + * + * Reads the form through context rather than taking props, so it stays in step + * with validity and submission exactly like the built-in one - and so a layout + * cannot wire up a button that submits a different form. + */ +export const AutoFormSubmitButton = ({ + children, + className, + variant, +}: { + children?: React.ReactNode; + className?: string; + variant?: React.ComponentProps["variant"]; +}) => { + const t = useTranslations("core.global"); + const { control } = useFormContext(); + const { isSubmitting, isValid } = useFormState({ control }); + + return ( + + ); +}; + export type AutoFormOnSubmit< T extends z.ZodObject, TContext = unknown, @@ -118,6 +163,7 @@ export function AutoForm< onSubmit: onSubmitProp, captcha, fields, + layout, tabs, submitButtonProps, children, @@ -126,6 +172,19 @@ export function AutoForm< captcha?: z.infer["captcha"]; fields: ItemAutoFormProps[]; formSchema: T; + /** + * Places the fields yourself instead of stacking them in declaration order. + * + * Called with every field already rendered and keyed by its `id`, so a layout + * puts an element where it wants it and each one stays wired into this form's + * validation, dirty state and error display. One ``, one schema, one + * submit - a layout cannot accidentally create a second of any of them. + * + * The automatic submit button is **not** rendered in this mode: a layout that + * decides where the fields go has to decide where the button goes too. + * Mutually exclusive with `tabs`. + */ + layout?: (renderedFields: Record) => React.ReactNode; mode?: Mode; onSubmit?: AutoFormOnSubmit; submitButtonProps?: Omit< @@ -272,6 +331,24 @@ export function AutoForm< ); + if (layout) { + return ( + + {layout( + Object.fromEntries( + fields + .filter(isFieldVisible) + .map(item => [item.id, renderField(item)]), + ), + )} + + {children} + + {captcha &&
} + + ); + } + return (
{tabs?.length ? ( diff --git a/packages/vitnode/src/components/form/common/async-picker.tsx b/packages/vitnode/src/components/form/common/async-picker.tsx new file mode 100644 index 000000000..c8bb88343 --- /dev/null +++ b/packages/vitnode/src/components/form/common/async-picker.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { useDebouncedCallback } from "use-debounce"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; + +/** Anything this picker can offer: an identity and whatever renders it. */ +export interface AsyncPickerOption { + id: number; +} + +/** + * A search-as-you-type picker over a server-side list. + * + * The shape every "choose a user", "choose a role" control in the AdminCP had + * grown its own copy of. Extracted so the ones that matter - the debounce, the + * spinner that only shows while there is nothing to show, and re-running the + * empty search each time it opens so the list is never yesterday's - are + * decided once. + * + * **Not** built on `Combobox`: an option here is a row with an avatar or a + * colour swatch in it, and the combobox's async mode renders `{ label, value }` + * strings. Rendering is the caller's job through `renderOption`. + * + * Deliberately uncontrolled about *selection*: it reports what was picked and + * nothing else, so the same component serves a single-value field and a + * multi-value one without knowing which it is in. + */ +export function AsyncPicker({ + className, + disabled, + emptyLabel, + invalid, + onSelect, + renderOption, + search, + searchPlaceholder, + selectedIds = [], + trigger, +}: { + className?: string; + disabled?: boolean; + /** Shown when a search comes back with nothing. Defaults to the core string. */ + emptyLabel?: string; + invalid?: boolean; + onSelect: (option: TOption) => void; + renderOption: (option: TOption) => React.ReactNode; + search: (value: string) => Promise; + searchPlaceholder?: string; + /** Ticked in the list, so picking again to remove reads as a toggle. */ + selectedIds?: number[]; + trigger: React.ReactNode; +}) { + const t = useTranslations("core.global"); + const [open, setOpen] = React.useState(false); + const [options, setOptions] = React.useState([]); + const [isSearching, setIsSearching] = React.useState(false); + + const runSearch = React.useCallback( + async (value: string) => { + setIsSearching(true); + try { + setOptions(await search(value)); + } catch (error) { + // A search that fails is an empty list plus a console line, never a + // thrown error: this sits inside a form, and taking the page down + // because a lookup timed out loses whatever else was typed. + // eslint-disable-next-line no-console + console.error(error); + setOptions([]); + } finally { + setIsSearching(false); + } + }, + [search], + ); + const debouncedSearch = useDebouncedCallback(runSearch, 400); + const selected = new Set(selectedIds); + + return ( + { + setOpen(next); + // Cleared and re-run on every open rather than cached: the list is a + // live view of who exists, and a stale one offers somebody who was + // deleted since. + if (next) { + setOptions([]); + void runSearch(""); + } + }} + open={open} + > + + } + > + {trigger} + + + + + + + + {isSearching && options.length === 0 ? ( +
+ +
+ ) : ( + <> + + {emptyLabel ?? t("results_not_found")} + + + {options.map(option => ( + { + onSelect(option); + setOpen(false); + }} + value={String(option.id)} + > + {renderOption(option)} + {selected.has(option.id) && ( + + )} + + ))} + + + )} +
+
+
+
+ ); +} diff --git a/packages/vitnode/src/components/form/fields/checkbox.tsx b/packages/vitnode/src/components/form/fields/checkbox.tsx index ef1980333..939e40e9f 100644 --- a/packages/vitnode/src/components/form/fields/checkbox.tsx +++ b/packages/vitnode/src/components/form/fields/checkbox.tsx @@ -16,6 +16,10 @@ export const AutoFormCheckbox = ({ className, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + // Only the language-aware inputs implement this - dropped here so it never + // lands on the DOM element the rest props spread into. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, ...props }: ItemAutoFormComponentProps & Omit, "checked">) => { diff --git a/packages/vitnode/src/components/form/fields/color.tsx b/packages/vitnode/src/components/form/fields/color.tsx index 360a35938..d57a9d3d7 100644 --- a/packages/vitnode/src/components/form/fields/color.tsx +++ b/packages/vitnode/src/components/form/fields/color.tsx @@ -16,6 +16,10 @@ export const AutoFormColor = ({ field, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + // Only the language-aware inputs implement this - dropped here so it never + // lands on the DOM element the rest props spread into. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, ...props }: ItemAutoFormComponentProps & Omit, "onChange" | "value">) => { diff --git a/packages/vitnode/src/components/form/fields/combobox.tsx b/packages/vitnode/src/components/form/fields/combobox.tsx index 1295ef1d2..f03ed3f02 100644 --- a/packages/vitnode/src/components/form/fields/combobox.tsx +++ b/packages/vitnode/src/components/form/fields/combobox.tsx @@ -56,6 +56,10 @@ export const AutoFormCombobox = ({ field, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + // Only the language-aware inputs implement this - dropped here so it never + // lands on the DOM element the rest props spread into. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, description, placeholder, otherProps, diff --git a/packages/vitnode/src/components/form/fields/date-time.tsx b/packages/vitnode/src/components/form/fields/date-time.tsx index c6cdb4023..026cec39c 100644 --- a/packages/vitnode/src/components/form/fields/date-time.tsx +++ b/packages/vitnode/src/components/form/fields/date-time.tsx @@ -33,6 +33,10 @@ export const AutoFormDateTime = ({ field, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + // Only the language-aware inputs implement this - dropped here so it never + // lands on the DOM element the rest props spread into. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, otherProps: { isOptional }, ...props }: ItemAutoFormComponentProps & diff --git a/packages/vitnode/src/components/form/fields/input-roles.test.tsx b/packages/vitnode/src/components/form/fields/input-roles.test.tsx new file mode 100644 index 000000000..905ea9305 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/input-roles.test.tsx @@ -0,0 +1,241 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { describe, expect, it, vi } from "vitest"; + +import { Form, FormField } from "@/components/ui/form"; + +import type { RoleOption } from "./search-roles.action.server"; + +import { AutoFormRoles, roleOptionName } from "./input-roles"; + +vi.mock("next-intl", () => ({ + useLocale: () => "pl", + useTranslations: () => (key: string) => key, +})); +vi.mock("./search-roles.action.server", () => ({ searchRoles: vi.fn() })); + +/** `cmdk` measures its list and scrolls the active row into view. */ +vi.stubGlobal( + "ResizeObserver", + class { + disconnect() {} + observe() {} + unobserve() {} + }, +); +Element.prototype.scrollIntoView = vi.fn(); + +const ADMIN: RoleOption = { + color: "#ff0000", + id: 1, + name: [ + { languageCode: "en", name: "Administrator" }, + { languageCode: "pl", name: "Administrator PL" }, + ], +}; +const EDITOR: RoleOption = { + color: null, + id: 2, + name: [{ languageCode: "en", name: "Editor" }], +}; + +const Harness = ({ + defaultValue, + excludeIds, + multiple = false, + onValue, + search = async () => Promise.resolve([ADMIN, EDITOR]), + selected = [], +}: { + defaultValue?: unknown; + excludeIds?: number[]; + multiple?: boolean; + onValue?: (value: unknown) => void; + search?: (value: string) => Promise; + selected?: RoleOption[]; +}) => { + const form = useForm({ + defaultValues: { + roles: defaultValue ?? (multiple ? [] : null), + } as FieldValues, + }); + onValue?.(form.watch("roles")); + + return ( + + ( + + )} + /> + + ); +}; + +const openPicker = () => { + fireEvent.click(screen.getByRole("button", { name: /pick a role/i })); +}; + +describe("roleOptionName", () => { + it("prefers the reader's language", () => { + expect(roleOptionName(ADMIN, "pl")).toBe("Administrator PL"); + }); + + it("falls back to the first translation, never to the id", () => { + // A role with no Polish name is still a role somebody named. + expect(roleOptionName(EDITOR, "pl")).toBe("Editor"); + }); + + it("falls back to the id only when there is no name at all", () => { + expect(roleOptionName({ color: null, id: 7, name: [] }, "pl")).toBe("7"); + }); +}); + +describe("AutoFormRoles, single", () => { + it("stores one id", async () => { + const values: unknown[] = []; + render( + { + values.push(value); + }} + />, + ); + + openPicker(); + fireEvent.click(await screen.findByText("Editor")); + + await waitFor(() => { + expect(values.at(-1)).toBe(2); + }); + }); + + it("replaces rather than appends", async () => { + const values: unknown[] = []; + render( + { + values.push(value); + }} + />, + ); + + openPicker(); + fireEvent.click(await screen.findByText("Editor")); + await waitFor(() => { + expect(values.at(-1)).toBe(2); + }); + + fireEvent.click(screen.getByRole("button", { name: /editor/i })); + fireEvent.click(await screen.findByText("Administrator PL")); + + await waitFor(() => { + expect(values.at(-1)).toBe(1); + }); + }); + + it("names the role it opens on in the reader's language", () => { + render(); + + expect( + screen.getByRole("button", { name: /administrator pl/i }), + ).toBeDefined(); + }); +}); + +describe("AutoFormRoles, multiple", () => { + it("collects ids into an array", async () => { + const values: unknown[] = []; + render( + { + values.push(value); + }} + />, + ); + + openPicker(); + fireEvent.click(await screen.findByText("Administrator PL")); + await waitFor(() => { + expect(values.at(-1)).toEqual([1]); + }); + + openPicker(); + fireEvent.click(await screen.findByText("Editor")); + + await waitFor(() => { + expect(values.at(-1)).toEqual([1, 2]); + }); + }); + + it("toggles a role that is already chosen back off", async () => { + // What the tick beside an option in the list is promising. + const values: unknown[] = []; + render( + { + values.push(value); + }} + selected={[ADMIN]} + />, + ); + + openPicker(); + // By role, not by text: the chip above the picker carries the same name, and + // clicking that would prove nothing about the list. + fireEvent.click( + await screen.findByRole("option", { name: /administrator pl/i }), + ); + + await waitFor(() => { + expect(values.at(-1)).toEqual([]); + }); + }); + + it("lists what is chosen as removable chips", async () => { + const values: unknown[] = []; + render( + { + values.push(value); + }} + selected={[ADMIN, EDITOR]} + />, + ); + + expect(screen.getByText("Administrator PL")).toBeDefined(); + expect(screen.getByText("Editor")).toBeDefined(); + + fireEvent.click(screen.getAllByRole("button", { name: "remove" })[0]); + + await waitFor(() => { + expect(values.at(-1)).toEqual([2]); + }); + }); + + it("never offers an excluded role", async () => { + const search = vi.fn(async () => Promise.resolve([ADMIN, EDITOR])); + render(); + + openPicker(); + + expect(await screen.findByText("Editor")).toBeDefined(); + expect(screen.queryByText("Administrator PL")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/input-roles.tsx b/packages/vitnode/src/components/form/fields/input-roles.tsx new file mode 100644 index 000000000..fc369a907 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/input-roles.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { XIcon } from "lucide-react"; +import { useLocale, useTranslations } from "next-intl"; +import React from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FormMessage } from "@/components/ui/form"; + +import type { ItemAutoFormComponentProps } from "../auto-form"; +import type { RoleOption } from "./search-roles.action.server"; + +import { AsyncPicker } from "../common/async-picker"; +import { AutoFormDesc } from "../common/desc"; +import { AutoFormLabel } from "../common/label"; +import { searchRoles } from "./search-roles.action.server"; + +export type { RoleOption }; + +/** + * A role's name in the reader's language. + * + * Falls back to the first translation rather than to the id: a role with no + * English name is still a role somebody named, and showing `4` helps nobody. + */ +export const roleOptionName = (role: RoleOption, locale: string): string => + role.name.find(item => item.languageCode === locale)?.name ?? + role.name[0]?.name ?? + String(role.id); + +/** + * Picks one role, or several, for an `AutoForm` field. + * + * One component rather than two, because the difference is the *shape of the + * value* and nothing else - the search, the colour, the language resolution and + * the empty state are identical, and two copies is how they drift: + * + * ```ts + * z.object({ roleId: z.number() }) // multiple omitted + * z.object({ roleIds: z.array(z.number()).min(1) }) // multiple + * ``` + * + * With `multiple` the chosen roles are listed as removable chips and the picker + * stays open for business - it appends rather than replaces, and picking one + * that is already there removes it, which is what the tick in the list means. + * + * `selected` seeds the names for ids the field starts with, exactly as + * `AutoFormUser` does and for the same reason: an edit form knows its roles + * before the picker has searched for anything. + */ +export const AutoFormRoles = ({ + description, + disabled, + excludeIds = [], + field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, + label, + labelRight, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, + multiple = false, + otherProps, + placeholder, + search = searchRoles, + searchPlaceholder, + selected = [], +}: ItemAutoFormComponentProps & { + disabled?: boolean; + /** Roles the picker must not offer - ones another field already owns. */ + excludeIds?: number[]; + /** `number[]` instead of `number`, and a chip list instead of one label. */ + multiple?: boolean; + placeholder?: string; + search?: (value: string) => Promise; + searchPlaceholder?: string; + /** Roles the field opens on, for an edit form that already has some. */ + selected?: RoleOption[]; +}) => { + const t = useTranslations("core.global"); + const locale = useLocale(); + const [known, setKnown] = React.useState>(() => + Object.fromEntries(selected.map(role => [role.id, role])), + ); + + const ids: number[] = multiple + ? Array.isArray(field.value) + ? (field.value as number[]) + : [] + : typeof field.value === "number" + ? [field.value] + : []; + + const nameOf = (id: number): string => { + const role = known[id]; + + return role ? roleOptionName(role, locale) : String(id); + }; + const colorOf = (id: number): string | undefined => + known[id]?.color ?? undefined; + + const remove = (id: number) => { + field.onChange(multiple ? ids.filter(item => item !== id) : null); + }; + + const label_ = !!label && ( + + {label} + + ); + + const picker = ( + + disabled={disabled} + invalid={otherProps["aria-invalid"]} + onSelect={option => { + setKnown(seen => ({ ...seen, [option.id]: option })); + + if (!multiple) { + field.onChange(option.id); + + return; + } + + // A second pick of the same role removes it, which is what the tick + // beside it in the list is promising. + field.onChange( + ids.includes(option.id) + ? ids.filter(item => item !== option.id) + : [...ids, option.id], + ); + }} + renderOption={option => ( + + {roleOptionName(option, locale)} + + )} + search={async value => + (await search(value)).filter(role => !excludeIds.includes(role.id)) + } + searchPlaceholder={searchPlaceholder} + selectedIds={ids} + trigger={ + !multiple && ids.length > 0 ? ( + + {nameOf(ids[0])} + + ) : ( + + {placeholder ?? t("select_option")} + + ) + } + /> + ); + + if (!multiple) { + return ( + <> + {label_} + {picker} + {!!description && {description}} + + + ); + } + + return ( + <> + {label_} + + {ids.length > 0 && ( +
    + {ids.map(id => ( +
  • + + + {nameOf(id)} + + + +
  • + ))} +
+ )} + + {picker} + {!!description && {description}} + + + ); +}; diff --git a/packages/vitnode/src/components/form/fields/input-users.test.tsx b/packages/vitnode/src/components/form/fields/input-users.test.tsx new file mode 100644 index 000000000..00373e932 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/input-users.test.tsx @@ -0,0 +1,256 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { describe, expect, it, vi } from "vitest"; + +import { Form, FormField } from "@/components/ui/form"; + +import type { UserOption } from "./search-users.action.server"; + +import { AutoFormUser, type PartialUserOption } from "./input-users"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); +vi.mock("./search-users.action.server", () => ({ searchUsers: vi.fn() })); + +/** `cmdk` measures its list and scrolls the active row into view. */ +vi.stubGlobal( + "ResizeObserver", + class { + disconnect() {} + observe() {} + unobserve() {} + }, +); +Element.prototype.scrollIntoView = vi.fn(); + +const ADA: UserOption = { + avatarColor: "aabbcc", + id: 4, + name: "Ada Lovelace", + nameCode: "ada", +}; +const GRACE: UserOption = { + avatarColor: "ddeeff", + id: 9, + name: "Grace Hopper", + nameCode: "grace", +}; + +const Harness = ({ + clearable = false, + defaultValue, + onValue, + search = async () => Promise.resolve([ADA, GRACE]), + selected, +}: { + clearable?: boolean; + defaultValue?: null | number; + onValue?: (value: unknown) => void; + search?: (value: string) => Promise; + selected?: PartialUserOption; +}) => { + const form = useForm({ + defaultValues: { authorId: defaultValue ?? null } as FieldValues, + }); + onValue?.(form.watch("authorId")); + + return ( +
+ ( + + )} + /> + + ); +}; + +const openPicker = () => { + fireEvent.click(screen.getByRole("button", { name: /pick an author/i })); +}; + +describe("AutoFormUser", () => { + it("shows the placeholder until somebody is chosen", () => { + render(); + + expect( + screen.getByRole("button", { name: /pick an author/i }), + ).toBeDefined(); + }); + + it("stores the user id, not the whole person", async () => { + // What makes the field usable from a schema: `z.object({ authorId: z.number() })`. + const values: unknown[] = []; + render( + { + values.push(value); + }} + />, + ); + + openPicker(); + fireEvent.click(await screen.findByText("Ada Lovelace")); + + await waitFor(() => { + expect(values.at(-1)).toBe(4); + }); + }); + + it("labels the id it holds with the name it just learned", async () => { + render(); + + openPicker(); + fireEvent.click(await screen.findByText("Grace Hopper")); + + // The trigger has to read as a person, not as `9` - and nothing re-fetches + // between choosing and rendering. + await waitFor(() => { + expect( + screen.getByRole("button", { name: /grace hopper/i }), + ).toBeDefined(); + }); + }); + + it("opens on the person an edit form already knows about", () => { + // Without `selected` the field holds an id it has never seen a name for, so + // it would open on the placeholder as though nothing were chosen. + render(); + + expect(screen.getByRole("button", { name: /ada lovelace/i })).toBeDefined(); + }); + + describe("the avatar", () => { + const avatars = () => + screen + .getAllByRole("button", { name: /lovelace/i })[0] + .querySelectorAll("img"); + + it("is drawn from the colour when there is one", () => { + render(); + + expect(avatars()).toHaveLength(1); + }); + + it("falls back to a placeholder when the caller knows only a name", () => { + // The Content Engine case: a record resolves its author's *label* and + // carries no colour, and a name sitting alone reads as a broken row. + render( + , + ); + + const trigger = screen.getByRole("button", { name: /ada lovelace/i }); + // No generated avatar - inventing a colour would show a different person - + // but the box is still there, so the name does not move when a search + // fills the real one in. + expect(trigger.querySelectorAll("img")).toHaveLength(0); + expect(trigger.querySelector("svg.lucide-user")).not.toBeNull(); + }); + + it("becomes the real one once a search has run", async () => { + render( + , + ); + + // The trigger already reads as the person, so it is what opens the list. + fireEvent.click(screen.getByRole("button", { name: /ada lovelace/i })); + fireEvent.click(await screen.findByRole("option", { name: /lovelace/i })); + + await waitFor(() => { + expect(avatars()).toHaveLength(1); + }); + }); + }); + + describe("the clear button", () => { + const clear = () => screen.queryByRole("button", { name: "remove" }); + + it("sits inside the control rather than beside it", () => { + // It used to be a flex sibling of a `w-full` trigger, which pushed it out + // of the card the field lives in. + render(); + + const trigger = screen.getByRole("button", { name: /ada lovelace/i }); + expect(clear()?.parentElement).toBe(trigger.parentElement); + expect(clear()?.className).toContain("absolute"); + }); + + it("is absent while there is nothing to clear", () => { + render(); + + expect(clear()).toBeNull(); + }); + + it("is absent on a field that does not allow one", () => { + // A required author with a clear button is a button whose only outcome is + // a validation error. + render(); + + expect(clear()).toBeNull(); + }); + + it("puts the field back to nobody", async () => { + const values: unknown[] = []; + render( + { + values.push(value); + }} + selected={ADA} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "remove" })); + + await waitFor(() => { + expect(values.at(-1)).toBeNull(); + }); + expect( + screen.getByRole("button", { name: /pick an author/i }), + ).toBeDefined(); + }); + }); + + it("searches again on every open, with what was typed", async () => { + const search = vi.fn(async () => Promise.resolve([ADA])); + render(); + + openPicker(); + + // The empty search runs on open: the list is a live view of who exists, and + // a cached one offers somebody who was deleted since. + await waitFor(() => { + expect(search).toHaveBeenCalledWith(""); + }); + }); + + it("survives a search that throws", async () => { + // A lookup that fails must not take down the form around it. + const search = vi.fn(async () => Promise.reject(new Error("offline"))); + vi.spyOn(console, "error").mockImplementation(() => undefined); + render(); + + openPicker(); + + await waitFor(() => { + expect(search).toHaveBeenCalled(); + }); + expect( + screen.getByRole("button", { name: /pick an author/i }), + ).toBeDefined(); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/input-users.tsx b/packages/vitnode/src/components/form/fields/input-users.tsx new file mode 100644 index 000000000..06b274317 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/input-users.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { UserIcon, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { Avatar } from "@/components/avatar"; +import { Button } from "@/components/ui/button"; +import { FormMessage } from "@/components/ui/form"; + +import type { ItemAutoFormComponentProps } from "../auto-form"; +import type { UserOption } from "./search-users.action.server"; + +import { AsyncPicker } from "../common/async-picker"; +import { AutoFormDesc } from "../common/desc"; +import { AutoFormLabel } from "../common/label"; +import { searchUsers } from "./search-users.action.server"; + +export type { UserOption }; + +/** + * A person the field can label but has not necessarily fetched. + * + * `avatarColor` is optional because the caller often knows only a name and an + * id - the Content Engine resolves a `user` field's label alongside the record + * and never carries a colour with it. + */ +export type PartialUserOption = Omit & + Partial>; + +/** + * A person's face, or the space where it will be. + * + * A generated avatar needs a colour, and a colour is the one column a caller + * that only resolved a *name* does not have. Inventing one is not an option - + * the wrong colour reads as a different person - so the gap is filled with a + * neutral placeholder rather than left empty. + * + * Same box either way, which is the point: the name sits in the same place + * before and after the real avatar arrives, so nothing jumps sideways when a + * search fills the colour in. + */ +const UserAvatar = ({ + size, + user, +}: { + size: number; + user: PartialUserOption; +}) => + user.avatarColor ? ( + + ) : ( + + + + ); + +/** + * Picks one person, by name, for an `AutoForm` field. + * + * The author selector, the "assign this to" selector, and every other place a + * form needs a person rather than a string. The value is the **user id**, so a + * schema is `z.number()` and the payload needs no unwrapping: + * + * ```ts + * const formSchema = z.object({ authorId: z.number() }); + * ``` + * + * A picker cannot show a name it has never fetched, so an *edit* form passes the + * person it already knows about as `selected`. Without it the field would open + * showing a bare id, or - worse - showing the placeholder as though nothing were + * chosen. Whatever the search returns is remembered on top of that, so a person + * picked a moment ago still reads as their name. + */ +export const AutoFormUser = ({ + clearable = false, + description, + disabled, + field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, + label, + labelRight, + // Language-aware inputs only - dropped so it never reaches the DOM. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, + otherProps, + placeholder, + search = searchUsers, + searchPlaceholder, + selected, +}: ItemAutoFormComponentProps & { + className?: string; + /** + * Offers a way back to *nobody*, for a field that allows it. + * + * Off by default: on a required field a clear button is a button whose only + * outcome is a validation error. + */ + clearable?: boolean; + disabled?: boolean; + placeholder?: string; + /** Swap the lookup - a plugin scoping to its own members, or a test. */ + search?: (value: string) => Promise; + searchPlaceholder?: string; + /** The person the field opens on, for an edit form that already has one. */ + selected?: null | PartialUserOption; +}) => { + const t = useTranslations("core.global"); + // Everyone this field has *learned* about, from its own searches. + const [known, setKnown] = React.useState>( + {}, + ); + + const value = typeof field.value === "number" ? field.value : null; + // Only where there is something to clear: on an empty field the button would + // be an affordance for a state it is already in. + const clearButton = clearable && value !== null; + // A search wins over `selected`, because it carries the colour a caller that + // only resolved a name does not have. `selected` is read on every render + // rather than seeded into state once: a caller may resolve the person + // *after* the first paint - the Content Engine does exactly that - and a + // one-time seed would leave the field showing a placeholder for good. + const current = + value === null + ? null + : (known[value] ?? (selected?.id === value ? selected : null)); + + return ( + <> + {!!label && ( + + {label} + + )} + + {/* `relative`, because the clear button sits *inside* the control. It + cannot be a child of the trigger - that is a ` + )} +
+ + {!!description && {description}} + + + ); +}; diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx index 5185c6e55..1ac88c27e 100644 --- a/packages/vitnode/src/components/form/fields/input.tsx +++ b/packages/vitnode/src/components/form/fields/input.tsx @@ -43,8 +43,11 @@ const MultiLangInput = ({ )} - - + {/* `FormControl` on the input itself, not on the group: it is what hands + the field its id, and a label pointing at the wrapping div labels + nothing a screen reader can use. */} + + - {languages.length > 1 && ( - - - - )} - - + + {languages.length > 1 && ( + + + + )} + {!!description && {description}} diff --git a/packages/vitnode/src/components/form/fields/nullable-number.tsx b/packages/vitnode/src/components/form/fields/nullable-number.tsx index 8048a192f..4aa74caf6 100644 --- a/packages/vitnode/src/components/form/fields/nullable-number.tsx +++ b/packages/vitnode/src/components/form/fields/nullable-number.tsx @@ -52,6 +52,10 @@ export const AutoFormNullableNumber = ({ otherProps: { isOptional }, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + // Only the language-aware inputs implement this - dropped here so it never + // lands on the DOM element the rest props spread into. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, className, unitLabel, orLabel, diff --git a/packages/vitnode/src/components/form/fields/radio-group.tsx b/packages/vitnode/src/components/form/fields/radio-group.tsx index aa4051a20..559f7b1b9 100644 --- a/packages/vitnode/src/components/form/fields/radio-group.tsx +++ b/packages/vitnode/src/components/form/fields/radio-group.tsx @@ -28,6 +28,10 @@ export const AutoFormRadioGroup = ({ field, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + // Only the language-aware inputs implement this - dropped here so it never + // lands on the DOM element the rest props spread into. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, description, otherProps: { enum: enumValues = [], isOptional }, labels = [], diff --git a/packages/vitnode/src/components/form/fields/search-roles.action.server.ts b/packages/vitnode/src/components/form/fields/search-roles.action.server.ts new file mode 100644 index 000000000..37e242fdc --- /dev/null +++ b/packages/vitnode/src/components/form/fields/search-roles.action.server.ts @@ -0,0 +1,51 @@ +"use server"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +/** + * One role, as a picker needs it. + * + * `name` is the raw per-language list rather than a resolved string: the server + * has no business deciding which language the person clicking reads in, so the + * component resolves it against the active locale. + */ +export interface RoleOption { + color: null | string; + id: number; + name: { languageCode: string; name: string }[]; +} + +/** + * The default search behind {@link AutoFormRoles}. + * + * The guest role is filtered out, and that is not cosmetic: it is the role a + * request has when it has no account, so it is never something to *assign* to + * anybody. Every consumer of this list wanted it gone, so it is gone here rather + * than in each of them. + */ +export const searchRoles = async (search: string): Promise => { + const res = await fetcher(adminModule, { + path: "/list", + method: "get", + module: "admin/roles", + args: { + query: { search, first: "20" }, + }, + withPagination: true, + }); + + if (res.status !== 200) { + return []; + } + + const data = await res.json(); + + return data.edges + .filter(role => !role.guest) + .map(role => ({ + id: role.id, + color: role.color, + name: role.name, + })); +}; diff --git a/packages/vitnode/src/views/admin/views/core/staff/create/search.action.server.ts b/packages/vitnode/src/components/form/fields/search-users.action.server.ts similarity index 52% rename from packages/vitnode/src/views/admin/views/core/staff/create/search.action.server.ts rename to packages/vitnode/src/components/form/fields/search-users.action.server.ts index 7257ce7db..96c6f5f6a 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/create/search.action.server.ts +++ b/packages/vitnode/src/components/form/fields/search-users.action.server.ts @@ -3,16 +3,23 @@ import { adminModule } from "@/api/modules/admin/admin.module"; import { fetcher } from "@/lib/fetcher"; -export interface StaffUserOption { +/** The columns a user picker needs: enough to identify a person on sight. */ +export interface UserOption { avatarColor: string; id: number; name: string; nameCode: string; } -export const searchUsersForStaff = async ( - search: string, -): Promise => { +/** + * The default search behind {@link AutoFormUser}. + * + * Reads the AdminCP users list, so it answers with whatever that route lets the + * calling admin see - the permission check lives there and is not repeated here. + * A non-200 is an empty list rather than a throw: a picker that cannot reach the + * server should offer nothing, not take the form down with it. + */ +export const searchUsers = async (search: string): Promise => { const res = await fetcher(adminModule, { path: "/list", method: "get", diff --git a/packages/vitnode/src/components/form/fields/select.tsx b/packages/vitnode/src/components/form/fields/select.tsx index 4b9c35408..bdc227a04 100644 --- a/packages/vitnode/src/components/form/fields/select.tsx +++ b/packages/vitnode/src/components/form/fields/select.tsx @@ -21,6 +21,10 @@ export const AutoFormSelect = ({ field, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + // Only the language-aware inputs implement this - dropped here so it never + // lands on the DOM element the rest props spread into. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, description, otherProps: { enum: enumValues = [], isOptional }, placeholder, diff --git a/packages/vitnode/src/components/form/fields/shared-props.test.tsx b/packages/vitnode/src/components/form/fields/shared-props.test.tsx new file mode 100644 index 000000000..d1d2fe0ac --- /dev/null +++ b/packages/vitnode/src/components/form/fields/shared-props.test.tsx @@ -0,0 +1,112 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import React from "react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Form, FormField } from "@/components/ui/form"; + +import type { ItemAutoFormComponentProps } from "../auto-form"; + +import { AutoFormCheckbox } from "./checkbox"; +import { AutoFormColor } from "./color"; +import { AutoFormCombobox } from "./combobox"; +import { AutoFormDateTime } from "./date-time"; +import { AutoFormNullableNumber } from "./nullable-number"; +import { AutoFormRadioGroup } from "./radio-group"; +import { AutoFormSelect } from "./select"; +import { AutoFormSwitch } from "./switch"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +const FIELDS: { + name: string; + render: (props: ItemAutoFormComponentProps) => React.ReactNode; +}[] = [ + { + name: "AutoFormCheckbox", + render: props => , + }, + { name: "AutoFormColor", render: props => }, + { + name: "AutoFormCombobox", + render: props => ( + await Promise.resolve([])} + id="combobox" + {...props} + /> + ), + }, + { + name: "AutoFormDateTime", + render: props => , + }, + { + name: "AutoFormNullableNumber", + render: props => , + }, + { + name: "AutoFormRadioGroup", + render: props => , + }, + { name: "AutoFormSelect", render: props => }, + { name: "AutoFormSwitch", render: props => }, +]; + +const Harness = ({ + component, +}: { + component: (props: ItemAutoFormComponentProps) => React.ReactNode; +}) => { + const form = useForm({ defaultValues: { value: undefined } as FieldValues }); + // The async combobox reads its options through react-query. + const [queryClient] = React.useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + + return ( + +
+ ( + <> + {component({ + field, + label: "Field", + multiLang: false, + otherProps: { enum: ["one", "two"], isOptional: false }, + })} + + )} + /> + +
+ ); +}; + +describe("AutoForm fields that are not language-aware", () => { + let errors: string[] = []; + + beforeEach(() => { + errors = []; + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each(FIELDS)("$name keeps multiLang off the DOM", ({ render: field }) => { + render(); + + expect(errors.filter(error => error.includes("multiLang"))).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/switch.tsx b/packages/vitnode/src/components/form/fields/switch.tsx index 1ee053341..cdb918d46 100644 --- a/packages/vitnode/src/components/form/fields/switch.tsx +++ b/packages/vitnode/src/components/form/fields/switch.tsx @@ -12,6 +12,10 @@ export const AutoFormSwitch = ({ field, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + // Only the language-aware inputs implement this - dropped here so it never + // lands on the DOM element the rest props spread into. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiLang, labelRight, otherProps: { isOptional }, className, diff --git a/packages/vitnode/src/components/form/fields/textarea.test.tsx b/packages/vitnode/src/components/form/fields/textarea.test.tsx new file mode 100644 index 000000000..f08be6feb --- /dev/null +++ b/packages/vitnode/src/components/form/fields/textarea.test.tsx @@ -0,0 +1,194 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { InputParams } from "@/lib/helpers/auto-form"; + +import { LanguagesProvider } from "@/components/languages-provider"; +import { Form, FormField } from "@/components/ui/form"; + +import { AutoFormTextarea } from "./textarea"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +const LANGUAGES = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +const Harness = ({ + onSubmit = vi.fn(), + defaultValue, + languages = LANGUAGES, + itemParams, + multiLang = true, +}: { + defaultValue?: unknown; + itemParams?: InputParams; + languages?: { code: string; enabled?: boolean; name: string }[]; + multiLang?: boolean; + onSubmit?: (values: FieldValues) => void; +}) => { + const form = useForm({ + defaultValues: { body: defaultValue } as FieldValues, + }); + + return ( + +
+ ( + + )} + /> + + +
+ ); +}; + +describe("AutoFormTextarea multiLang", () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the language select when more than one language is enabled", () => { + render(); + + expect(screen.getByRole("combobox")).toBeDefined(); + }); + + it("shows no selector on a one-language install", () => { + // A switcher with one option is a control that cannot do anything. + render(); + + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("shows none for a shared field either", () => { + render(); + + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("starts on the reader's own language", () => { + render( + , + ); + + // `useLocale()` is `en`, and `en` is second in the stored array - so this is + // the reader's language rather than whatever happened to be written first. + expect(screen.getByRole("textbox").value).toBe( + "Hello", + ); + }); + + it("writes the typed value as a { languageCode, value }[] array", async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Hello" }, + }); + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { body: [{ languageCode: "en", value: "Hello" }] }, + expect.anything(), + ); + }); + }); + + it("keeps a value per language, and restores it on the way back", async () => { + const onSubmit = vi.fn(); + render( + , + ); + + const switchTo = async (name: string) => { + fireEvent.click(screen.getByRole("combobox")); + const option = await screen.findByRole("option", { name }); + fireEvent.pointerDown(option); + fireEvent.click(option); + }; + + await switchTo("Polski"); + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe( + "Cześć", + ); + }); + + await switchTo("English"); + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe( + "Hello", + ); + }); + }); + + it("shows an empty box for a language with no translation, and writes nothing", async () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("combobox")); + const option = await screen.findByRole("option", { name: "Polski" }); + fireEvent.pointerDown(option); + fireEvent.click(option); + + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe(""); + }); + + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + // Looking at a language is not a decision to create a translation in it. + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { body: [{ languageCode: "en", value: "Hello" }] }, + expect.anything(), + ); + }); + }); + + it("applies the value maxLength from itemParams to the textarea", () => { + render(); + + expect(screen.getByRole("textbox").getAttribute("maxLength")).toBe("12"); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx index 4f0026834..fcfa27552 100644 --- a/packages/vitnode/src/components/form/fields/textarea.tsx +++ b/packages/vitnode/src/components/form/fields/textarea.tsx @@ -3,27 +3,110 @@ import type React from "react"; import { FormControl, FormMessage } from "@/components/ui/form"; import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"; import { Textarea } from "@/components/ui/textarea"; +import { getMultiLangConstraints } from "@/lib/helpers/multi-lang"; import type { ItemAutoFormComponentProps } from "../auto-form"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; +import { MultiLangSelect, useMultiLangField } from "./multi-lang"; + +type AutoFormTextareaProps = ItemAutoFormComponentProps & + Omit, "value"> & { + description?: React.ReactNode; + label?: React.ReactNode; + multiLang?: boolean; + }; + +/** + * The same textarea, holding one value per language. + * + * The switcher sits beside the label rather than inside the box, which is where + * `AutoFormEditor` puts it too: a textarea is resizable and multi-line, so an + * inline addon would end up floating in the middle of the control. + */ +const MultiLangTextarea = ({ + label, + labelRight, + description, + isOptional, + field, + itemParams, + ...props +}: Omit & { + isOptional?: boolean; +}) => { + const { languages, selected, setSelected, currentValue, setValue } = + useMultiLangField(field); + const { maxLength, minLength } = getMultiLangConstraints(itemParams); + + return ( + <> +
+ {!!label && ( + + {label} + + )} + {languages.length > 1 && ( + + )} +
+ + +