diff --git a/apps/api/.env.example b/apps/api/.env.example index af3fdeccf..9ab8297a6 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,12 +9,12 @@ NEXT_PUBLIC_API_URL=http://localhost:8080 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key -# === Content Preview Secret === +# === Content Preview Secret (optional) === # 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. +# without an account. Optional: leave it unset and the API boots normally with +# preview switched off - minting a link answers 503 naming this variable, and +# opening one answers 404. Set at least 32 random bytes to switch it on, because +# the signature is the *only* access control those links have. # # openssl rand -base64 32 # diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 726860ed7..7e09103d8 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -7,12 +7,12 @@ NEXT_PUBLIC_WEB_URL=http://localhost:3000 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key -# === Content Preview Secret === +# === Content Preview Secret (optional) === # 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. +# without an account. Optional: leave it unset and the API boots normally with +# preview switched off - minting a link answers 503 naming this variable, and +# opening one answers 404. Set at least 32 random bytes to switch it on, because +# the signature is the *only* access control those links have. # # openssl rand -base64 32 # 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..19f28d053 --- /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`, or +`can_translate` on a localized content type - 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..db1719bb9 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 +- **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 - **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 @@ -341,3 +362,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 a separate +**Languages** row action, 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/content-engine-security.mdx b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx index dc93da8cb..a76f3f306 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 @@ -54,12 +54,14 @@ Two choices in there are worth the sentence they take: which is what makes "writes Polish and nothing else" expressible. Give a role `can_view + can_translate` and it can: -- read the record, every locale tab and every locale's history; +- read the record, every language and every language's history; - create and edit a translation in any enabled locale. It cannot: -- edit a shared field (`PUT /{id}` is `can_edit`); +- edit a shared field - `PUT /{id}` is `can_edit`, and the AdminCP's composite + `PUT /{id}/localized` re-checks `can_edit` in the handler the moment its payload + carries one, so reaching the form grants nothing; - publish or unpublish anything, record or translation (`can_publish`); - restore a shared revision *or* a locale's own (`can_restore`, which needs `can_edit`); diff --git a/apps/docs/content/docs/dev/content-engine/editorial.mdx b/apps/docs/content/docs/dev/content-engine/editorial.mdx index 5626c4fac..4c3542f55 100644 --- a/apps/docs/content/docs/dev/content-engine/editorial.mdx +++ b/apps/docs/content/docs/dev/content-engine/editorial.mdx @@ -91,10 +91,11 @@ editorial: { 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). + At least 32 random bytes - `openssl rand -base64 32`. The variable itself is + optional and the API boots without it, but the signature is the only access + control a preview link has, so until you set one preview fails closed: a + warning at boot, a 503 from the button. See + [Secure preview](/docs/dev/content-engine/preview#content_preview_secret-is-optional). ## `version` is generated, so you cannot declare it diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index da8342567..746745660 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -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..9739d4790 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, `can_translate`, 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..491dd8aca 100644 --- a/apps/docs/content/docs/dev/content-engine/preview.mdx +++ b/apps/docs/content/docs/dev/content-engine/preview.mdx @@ -60,12 +60,15 @@ the honest default: linking to a page nobody has written yet would just be a you cast past it. -## `CONTENT_PREVIEW_SECRET` is required +## `CONTENT_PREVIEW_SECRET` is optional -**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. +**Nothing requires it except preview.** Leave it unset and the API boots exactly +as it would otherwise - an install that never sends anyone a draft link has no +reason to hold a signing key, so this is not a deployment prerequisite. Set one +when you want the feature, and set a real one: this single value is the entire +authorization story - there is no session to fall back on - so a guessable secret +is not a warning, it is every draft on the site readable by anyone who has read +the VitNode source. ```bash openssl rand -base64 32 @@ -87,24 +90,19 @@ Anything else and preview **fails closed**, everywhere: | 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 | +| Boot, in every environment | A warning on stdout naming the content types that wanted it. 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. - - - - 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. + + A missing secret switches preview *off*; it never switches it to unsigned. + Refusing to boot would turn one content type's opt-in feature into a + prerequisite for the entire API - and for `next build`, which imports every + route module and so runs this check on a machine that has no business holding a + runtime signing key. Honouring unsigned tokens would serve drafts to anyone who + guesses a URL. So the process starts, the warning says what is missing, and the + 503 says it again to whoever clicks the button. @@ -151,9 +149,9 @@ host. Which origin it resolves against depends on where the link actually points Two different origins, deliberately: the 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. +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 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 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..77acee752 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 @@ -178,10 +192,15 @@ if (outcome) { | 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 +whole point of having it: a translator can write any language without gaining the ability to touch a shared field, move the record's global publication state or delete it. +The AdminCP's single Save button posts one composite request, and the split is +enforced on the *server*: the route needs `can_translate`, and it additionally +checks `can_edit` the moment the payload carries a shared field. Nothing is +inferred from whether the browser disabled an input. + Staff permissions are stored as JSON per role, so a new one simply is not on any existing role. Grant it in AdminCP → Staff. @@ -204,7 +223,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 +232,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 row action 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 +366,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..f5121974d 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx @@ -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..34669fc42 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 { 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/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/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/_journal.json b/apps/docs/migrations/meta/_journal.json index 2ba6eff0f..72819b08a 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -246,6 +246,13 @@ "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 } ] } \ 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/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index c4dd50874..15a622106 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -1,92 +1,64 @@ { "@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:posts:can_translate": "Pisanie tłumaczeń artykułów", "@vitnode/blog:categories": "Kategorie", "@vitnode/blog:categories:can_view": "Wyświetlanie listy kategorii", "@vitnode/blog:categories:can_create": "Tworzenie kategorii", "@vitnode/blog:categories:can_edit": "Edytowanie kategorii", - "@vitnode/blog:categories:can_delete": "Usuwanie kategorii" + "@vitnode/blog:categories:can_delete": "Usuwanie kategorii", + "@vitnode/blog:categories:can_translate": "Pisanie tłumaczeń 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..cdd278db0 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -27,7 +27,7 @@ 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 { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; import { buildApiMessagesSources } from "@/lib/i18n/sources"; @@ -260,9 +260,10 @@ 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({ + // after every plugin's content types are in. A warning, never a boot failure: + // `CONTENT_PREVIEW_SECRET` is optional and preview is what fails closed + // without it. + warnAboutContentPreviewConfig({ contentTypes: contentTypesMetadata, secret: process.env.CONTENT_PREVIEW_SECRET, }); 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..f896b0d37 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 @@ -48,9 +48,9 @@ export const integrationsDebugAdminRoute = buildRoute({ // 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. + // well-known default, or too short to be a signing key. The + // variable is optional and the API boots without it, but preview + // does not merely warn in that state - it refuses to serve. secure: z.boolean(), }), cron: z.object({ 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/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/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 && ( + + )} +
+ + +