From 218721451202eaee46c27c0553e5b44c84bc87bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:11:23 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(moderation):=20=E2=9C=A8=20warnings,?= =?UTF-8?q?=20warning=20thresholds=20and=20account=20suspensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moderators can warn members from a profile, a comment, a ModCP report or the ModCP member page with one shared form that previews the consequences. Administrators configure warning reasons and point thresholds in the AdminCP; crossing a threshold suspends the account temporarily or indefinitely. - Warnings, reasons, thresholds, sanctions and an audit trail in new core tables, with reason and content snapshots so history never changes. - Active points and suspension state derive from timestamps on every read. - Issuance is serialized per member with an advisory lock and made idempotent per moderator; the warning, threshold decision, sanction, audit entries, optional report resolution and notifications commit together. - Report targets gain optional `resolveAuthors`; content-linked warnings check the scoped `can_warn` permission and resolve author and scope on the server. Content Engine types get it automatically and `canWarn` on /permissions. - Suspended sessions are treated as guests on every API path, refused by the AdminCP and disconnected from WebSockets; /account-status explains why. - ModCP warnings log and member standing, AdminCP reason and threshold screens, comment "Warn author", WarnDialog and WarnAuthorButton. - Docs for administrators, moderators, members and plugin developers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY --- .../docs/content-engine/comments-api.mdx | 2 +- .../content-engine/public-permissions-api.mdx | 1 + .../docs/content-engine/public-write-api.mdx | 2 +- .../docs/dev/events/built-in-events.mdx | 15 +- .../dev/moderation/configure-warnings.mdx | 80 ++ .../web/content/docs/dev/moderation/index.mdx | 3 +- .../docs/dev/moderation/issue-warnings.mdx | 104 ++ .../web/content/docs/dev/moderation/meta.json | 6 +- .../content/docs/dev/moderation/reference.mdx | 57 ++ .../docs/dev/moderation/warn-content.mdx | 90 ++ .../docs/dev/moderation/warning-form.webp | Bin 0 -> 74576 bytes .../docs/dev/moderation/warning-member.webp | Bin 0 -> 80526 bytes .../docs/dev/moderation/warning-reasons.webp | Bin 0 -> 41202 bytes .../dev/moderation/warning-thresholds.webp | Bin 0 -> 42626 bytes .../content/docs/dev/moderation/warnings.mdx | 53 + apps/web/src/page-cache.gen.ts | 5 + .../src/api/lib/moderation/registry.ts | 8 +- .../vitnode/src/api/lib/moderation/targets.ts | 19 + .../src/api/lib/moderation/warnings/access.ts | 181 ++++ .../api/lib/moderation/warnings/reasons.ts | 119 +++ .../api/lib/moderation/warnings/suspension.ts | 114 +++ .../src/api/lib/notifications/core-types.ts | 128 ++- .../src/api/middlewares/global.middleware.ts | 28 +- packages/vitnode/src/api/models/events.ts | 46 + packages/vitnode/src/api/moderation.ts | 1 + .../src/api/modules/admin/admin.module.ts | 2 + .../warnings/routes/warning-reasons.routes.ts | 282 ++++++ .../routes/warning-thresholds.routes.ts | 279 ++++++ .../admin/warnings/warnings.admin.module.ts | 28 + .../moderation/lib/warnings-service.ts | 915 ++++++++++++++++++ .../modules/moderation/lib/warnings-view.ts | 223 +++++ .../modules/moderation/moderation.module.ts | 20 + .../moderation/routes/account-status.route.ts | 126 +++ .../modules/moderation/routes/show.route.ts | 6 + .../moderation/routes/warnings.routes.ts | 633 ++++++++++++ .../api/modules/users/routes/session.route.ts | 16 +- packages/vitnode/src/api/plugin.ts | 17 + .../src/content/server/public-permissions.ts | 32 +- .../src/content/server/report-target.ts | 32 + packages/vitnode/src/database/warnings.ts | 218 +++++ packages/vitnode/src/lib/moderation/const.ts | 2 + .../vitnode/src/lib/moderation/warnings.ts | 193 ++++ packages/vitnode/src/locales/api/en.json | 25 + packages/vitnode/src/locales/en.json | 350 ++++++- packages/vitnode/src/pages/account-status.tsx | 22 + .../admin/moderation/warning-reasons.tsx | 25 + .../admin/moderation/warning-thresholds.tsx | 25 + packages/vitnode/src/pages/modcp/member.tsx | 24 + packages/vitnode/src/pages/modcp/warnings.tsx | 34 + packages/vitnode/src/routes.tsx | 36 + .../src/tanstack/account-status/route.ts | 33 + .../src/tanstack/account-status/screen.tsx | 18 + .../src/tanstack/admin/warnings/route.ts | 62 ++ .../src/tanstack/admin/warnings/screen.tsx | 188 ++++ .../vitnode/src/tanstack/comments/author.ts | 21 + .../vitnode/src/tanstack/comments/item.tsx | 51 +- .../vitnode/src/tanstack/comments/root.tsx | 32 +- .../tanstack/comments/use-content-comments.ts | 3 + .../src/tanstack/content/permissions.ts | 12 +- .../src/tanstack/layout/main-header.tsx | 13 +- .../src/tanstack/moderation/breadcrumb.tsx | 16 + .../vitnode/src/tanstack/moderation/index.ts | 6 + .../vitnode/src/tanstack/moderation/route.ts | 6 +- .../tanstack/moderation/suspension-notice.tsx | 61 ++ .../src/tanstack/moderation/warn-button.tsx | 155 +++ .../src/tanstack/moderation/warnings-route.ts | 154 +++ .../tanstack/moderation/warnings-screen.tsx | 62 ++ .../vitnode/src/tanstack/profile/screen.tsx | 51 +- .../admin/layouts/sidebar/nav/nav-model.tsx | 10 + .../warnings/warning-reason-form.tsx | 205 ++++ .../warnings/warning-reasons-content.tsx | 216 +++++ .../warnings/warning-threshold-form.tsx | 166 ++++ .../warnings/warning-thresholds-content.tsx | 241 +++++ .../moderation/warnings/warnings-admin-api.ts | 191 ++++ .../views/core/shared/admin-permissions.ts | 5 + .../admin/views/core/shared/admin-scope.ts | 2 + .../src/views/auth/settings/nav-content.tsx | 2 + .../src/views/auth/settings/settings-nav.ts | 4 +- .../src/views/moderation/modcp/modcp-nav.tsx | 5 +- .../moderation/modcp/report-activity.tsx | 7 + .../views/moderation/modcp/report-sidebar.tsx | 16 + .../warnings/account-status-content.tsx | 268 +++++ .../warnings/modcp-member-content.tsx | 138 +++ .../warnings/modcp-warnings-content.tsx | 257 +++++ .../warnings/warn-dialog-content.tsx | 706 ++++++++++++++ .../moderation/warnings/warning-format.tsx | 30 + .../moderation/warnings/warning-list.tsx | 438 +++++++++ .../views/moderation/warnings/warnings-api.ts | 348 +++++++ 88 files changed, 8771 insertions(+), 55 deletions(-) create mode 100644 apps/web/content/docs/dev/moderation/configure-warnings.mdx create mode 100644 apps/web/content/docs/dev/moderation/issue-warnings.mdx create mode 100644 apps/web/content/docs/dev/moderation/warn-content.mdx create mode 100644 apps/web/content/docs/dev/moderation/warning-form.webp create mode 100644 apps/web/content/docs/dev/moderation/warning-member.webp create mode 100644 apps/web/content/docs/dev/moderation/warning-reasons.webp create mode 100644 apps/web/content/docs/dev/moderation/warning-thresholds.webp create mode 100644 apps/web/content/docs/dev/moderation/warnings.mdx create mode 100644 packages/vitnode/src/api/lib/moderation/warnings/access.ts create mode 100644 packages/vitnode/src/api/lib/moderation/warnings/reasons.ts create mode 100644 packages/vitnode/src/api/lib/moderation/warnings/suspension.ts create mode 100644 packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts create mode 100644 packages/vitnode/src/api/modules/admin/warnings/routes/warning-thresholds.routes.ts create mode 100644 packages/vitnode/src/api/modules/admin/warnings/warnings.admin.module.ts create mode 100644 packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts create mode 100644 packages/vitnode/src/api/modules/moderation/lib/warnings-view.ts create mode 100644 packages/vitnode/src/api/modules/moderation/routes/account-status.route.ts create mode 100644 packages/vitnode/src/api/modules/moderation/routes/warnings.routes.ts create mode 100644 packages/vitnode/src/database/warnings.ts create mode 100644 packages/vitnode/src/lib/moderation/warnings.ts create mode 100644 packages/vitnode/src/pages/account-status.tsx create mode 100644 packages/vitnode/src/pages/admin/moderation/warning-reasons.tsx create mode 100644 packages/vitnode/src/pages/admin/moderation/warning-thresholds.tsx create mode 100644 packages/vitnode/src/pages/modcp/member.tsx create mode 100644 packages/vitnode/src/pages/modcp/warnings.tsx create mode 100644 packages/vitnode/src/tanstack/account-status/route.ts create mode 100644 packages/vitnode/src/tanstack/account-status/screen.tsx create mode 100644 packages/vitnode/src/tanstack/admin/warnings/route.ts create mode 100644 packages/vitnode/src/tanstack/admin/warnings/screen.tsx create mode 100644 packages/vitnode/src/tanstack/comments/author.ts create mode 100644 packages/vitnode/src/tanstack/moderation/suspension-notice.tsx create mode 100644 packages/vitnode/src/tanstack/moderation/warn-button.tsx create mode 100644 packages/vitnode/src/tanstack/moderation/warnings-route.ts create mode 100644 packages/vitnode/src/tanstack/moderation/warnings-screen.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-reason-form.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-reasons-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-threshold-form.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-thresholds-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/moderation/warnings/warnings-admin-api.ts create mode 100644 packages/vitnode/src/views/moderation/warnings/account-status-content.tsx create mode 100644 packages/vitnode/src/views/moderation/warnings/modcp-member-content.tsx create mode 100644 packages/vitnode/src/views/moderation/warnings/modcp-warnings-content.tsx create mode 100644 packages/vitnode/src/views/moderation/warnings/warn-dialog-content.tsx create mode 100644 packages/vitnode/src/views/moderation/warnings/warning-format.tsx create mode 100644 packages/vitnode/src/views/moderation/warnings/warning-list.tsx create mode 100644 packages/vitnode/src/views/moderation/warnings/warnings-api.ts diff --git a/apps/web/content/docs/content-engine/comments-api.mdx b/apps/web/content/docs/content-engine/comments-api.mdx index 77137ca5e2..a885e0d7bc 100644 --- a/apps/web/content/docs/content-engine/comments-api.mdx +++ b/apps/web/content/docs/content-engine/comments-api.mdx @@ -58,7 +58,7 @@ const comments = useContentComments({ queryKey, thread, ...actions }) | `liveStatus` | `"off"` (no `live`, or no open WebSocket), `"joining"`, `"live"`, or `"denied"` when the server refused or revoked the subscription | | `trackDraft(id, open)` | Call with `true` while an edit or reply form is open on `id` and `false` when it closes. A live update that removes that comment keeps it on screen with `removed: true` and its record until then. `useContentCommentItem` calls it for you | | `replyCount`, `truncated` | Every public comment at any depth, and whether a page held more replies than one answer carries | -| `permissionsOf(id)` | `{ canDelete, canEdit, canHide, canReport }`, all `false` until known or after a failed batch | +| `permissionsOf(id)` | `{ canDelete, canEdit, canHide, canReport, canWarn }`, all `false` until known or after a failed batch | | `canComment`, `canReply(node)` | Whether the composer and **Reply** show: signed in, `create` given, under `maxDepth`, not hidden | | `features` | Which actions you wired up | | `busy` | The action running right now, `{ action, id }` or `null`. A second action waits for it | diff --git a/apps/web/content/docs/content-engine/public-permissions-api.mdx b/apps/web/content/docs/content-engine/public-permissions-api.mdx index d7e6e3a5ff..9b907f21e1 100644 --- a/apps/web/content/docs/content-engine/public-permissions-api.mdx +++ b/apps/web/content/docs/content-engine/public-permissions-api.mdx @@ -37,6 +37,7 @@ Cookie: vitnode_auth= | --- | --- | --- | | `canEdit` | `GET /{id}/edit` and `PATCH /{id}` would accept this visitor: a moderator with `can_edit` in the record's container, or the owner while the record is not hidden, `update.own` is on and the container's `authorEdit` policy still allows it | Always `false` | | `canReport` | The content type has `reports`, the record is public, and the visitor did not write it | `true` on public records. The report dialog asks them to sign in | +| `canWarn` | The content type has `reports`, the visitor is a moderator with `can_warn` for the record's scope, and did not write it. See [Warn content authors](/docs/dev/moderation/warn-content) | `false` | A record is **left out of `items`** when the visitor can neither read it publicly nor edit it: a missing id, someone else's draft, or a hidden record for anyone but a moderator with `can_edit`. The route answers the same way for all of them, so it never confirms that a draft exists. Your own draft comes back with `canEdit: true` and `canReport: false`. diff --git a/apps/web/content/docs/content-engine/public-write-api.mdx b/apps/web/content/docs/content-engine/public-write-api.mdx index c35f0fb3a0..8cc9665719 100644 --- a/apps/web/content/docs/content-engine/public-write-api.mdx +++ b/apps/web/content/docs/content-engine/public-write-api.mdx @@ -74,7 +74,7 @@ The routes live in the public module, under `/api/{pluginId}/content/{publicApi. | `POST /` | `create` | `{ values }` | `201` with the record | | `GET /{id}/edit` | `update` | | `200` with the record, drafts included, `Cache-Control: private, no-store` | | `PATCH /{id}` | `update` | `{ values, expectedVersion }` | `200` with the record | -| `GET /permissions?ids=1,2` | `publicWrite`, `reports` or `visibility` | | `200` with `{ items: [{ id, canDelete, canEdit, canHide, canReport }] }`, `Cache-Control: private, no-store` | +| `GET /permissions?ids=1,2` | `publicWrite`, `reports` or `visibility` | | `200` with `{ items: [{ id, canDelete, canEdit, canHide, canReport, canWarn }] }`, `Cache-Control: private, no-store` | `GET /{id}/edit` and `PATCH /{id}` answer only for the owner and for moderators. There is no public delete, publish or hide route. diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 90e2989a14..831a80726d 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -564,11 +564,13 @@ member is notified. ## Moderation events -Eleven names, declared in `VitNodeEvents` in +Twenty names, declared in `VitNodeEvents` in `packages/vitnode/src/api/models/events.ts` and emitted by core with `pluginId: "@vitnode/core"`. Each fires **after** its database write commits. The envelope's `actor` is the reporter for `created`, the -administrator for `report_reason.*` and the moderator for the rest. Adding an internal note emits nothing. See +administrator for `report_reason.*`, `warning_reason.*` and +`warning_threshold.*`, and the moderator for the rest. No payload carries a +staff note. Adding an internal note emits nothing. See [Moderation](/docs/dev/moderation). | Event | Payload | Fires when | @@ -584,6 +586,15 @@ administrator for `report_reason.*` and the moderator for the rest. Adding an in | `moderation.report_reason.updated` | `{ reasonId, code }` | An admin edits a report reason | | `moderation.report_reason.deleted` | `{ reasonId, code }` | An admin deletes a report reason. Its reports stay | | `moderation.report_reason.reordered` | `{ reasonIds }` | An admin saves a new order. `reasonIds` lists every reason in that order | +| `moderation.warning.issued` | `{ warningId, userId, issuedById, points, reasonId, reportId, pluginId, targetType, targetId }` | A moderator issues a [warning](/docs/dev/moderation/warnings). Not emitted again for a retried request | +| `moderation.warning.revoked` | `{ warningId, userId, revokedById }` | A moderator revokes a warning | +| `moderation.sanction.applied` | `{ sanctionId, userId, type, source, endsAt, thresholdId, warningId }` | A threshold suspends an account. `endsAt` is `null` for an indefinite suspension | +| `moderation.sanction.revoked` | `{ sanctionId, userId, revokedById }` | A moderator lifts a suspension | +| `moderation.warning_reason.created` | `{ reasonId }` | An admin adds a warning reason | +| `moderation.warning_reason.updated` | `{ reasonId }` | An admin edits or disables a warning reason | +| `moderation.warning_threshold.created` | `{ thresholdId }` | An admin adds a warning threshold | +| `moderation.warning_threshold.updated` | `{ thresholdId }` | An admin edits, enables or disables a threshold | +| `moderation.warning_threshold.deleted` | `{ thresholdId }` | An admin deletes a threshold | A hide or restore from the ModCP also emits the content type's own `content.{id}.hidden` or `unhidden` event, because it goes through the same diff --git a/apps/web/content/docs/dev/moderation/configure-warnings.mdx b/apps/web/content/docs/dev/moderation/configure-warnings.mdx new file mode 100644 index 0000000000..21a429e79e --- /dev/null +++ b/apps/web/content/docs/dev/moderation/configure-warnings.mdx @@ -0,0 +1,80 @@ +--- +title: Configure warnings +description: Set up VitNode warning reasons with default points and expiry, and warning thresholds that suspend an account automatically, in the AdminCP. +icon: Settings2 +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import reasons from "./warning-reasons.webp"; +import thresholds from "./warning-thresholds.webp"; + +Warning reasons give moderators consistent points and messages, and thresholds decide when points turn into a suspension. This guide sets up the example from [Warnings](/docs/dev/moderation/warnings): a 2-point **Spam** reason and suspensions at 8 and 10 points. + +## Before you begin + +You need an AdminCP account with the **Warning settings** permissions: **View warning reasons and thresholds**, and **Manage warning reasons and thresholds** to change them. Admins with a root role have both. + + + + + +### Add a warning reason + +Open **AdminCP → Moderation → Warning reasons** and choose **Add reason**. Fill in: + +| Field | Example | What it does | +| --- | --- | --- | +| Title | Spam | What moderators pick and what the member reads | +| Description | Ads or repeated junk | Helps moderators choose; members never see it | +| Points | 2 | Added to the member's active points. `0` makes an official warning without points | +| Points expire after | 30 days | After this the points stop counting. Check **Never expire** for permanent points | +| Default message | Please stop posting spam. | Prefilled in the warn form; moderators can edit it | +| Enabled | On | Disabled reasons can't be picked for new warnings | + +Title, description and message are translated per language, like report reasons. Drag rows to set the order moderators see. + + + +Reasons are never deleted, only disabled. Every warning keeps a copy of its reason's title and the points it was issued with, so editing or disabling a reason never changes a warning already issued. + + + + + +### Add thresholds + +Open **AdminCP → Moderation → Warning thresholds** and choose **Add threshold** twice: + +| Active points | Action | Duration | +| --- | --- | --- | +| 8 | Temporary suspension | 24 hours | +| 10 | Indefinite suspension | - | + + + +Only one enabled threshold can use each number of points; saving a second one answers **Another enabled threshold already uses this number of points**. Disable or delete a threshold to stop it. Suspensions it already created keep a copy of the rule and stay until they end or are lifted. + + + + + +## Check the result + +Warn a test member twice with **Spam** (4 points), then preview a third time in the warn form: **Automatic action** says nothing. Change the points to 4 (with an explanation) and the preview shows **Account suspended for 1 day**. See [Issue warnings](/docs/dev/moderation/issue-warnings). + +Changes apply to warnings issued from now on. Saving a lower threshold does not suspend members who are already above it, and raising one does not lift anything. + +## Related + +- [Warnings](/docs/dev/moderation/warnings) +- [Configure moderators](/docs/dev/moderation/configure-moderators) diff --git a/apps/web/content/docs/dev/moderation/index.mdx b/apps/web/content/docs/dev/moderation/index.mdx index adbc7ad926..0424b45cb6 100644 --- a/apps/web/content/docs/dev/moderation/index.mdx +++ b/apps/web/content/docs/dev/moderation/index.mdx @@ -24,6 +24,7 @@ A moderator signs in with their normal user session. The ModCP never asks for an | Give a user or role moderator permissions in the AdminCP | [Configure moderators](/docs/dev/moderation/configure-moderators) | | Review, assign, resolve and act on reports | [Process reports](/docs/dev/moderation/process-reports) | | Approve or reject new content and edits before they go live | [Review approvals](/docs/dev/moderation/review-approvals) | +| Warn members and suspend accounts automatically at a points limit | [Warnings](/docs/dev/moderation/warnings) | | Look up routes, options, events and limits | [Moderation reference](/docs/dev/moderation/reference) | ## How the parts fit together @@ -37,4 +38,4 @@ Reports belong to core, not to the plugin. They keep their snapshot after the ar ## What is not included yet -This version covers reports, hiding and restoring content, and [reviewing approvals](/docs/dev/moderation/review-approvals). It has no warnings, bans, automatic moderation, anonymous reports, report notifications or permanent deletion from the ModCP. Moderators still have to read things themselves, sorry. +This version covers reports, hiding and restoring content, [reviewing approvals](/docs/dev/moderation/review-approvals) and [warnings with automatic suspensions](/docs/dev/moderation/warnings). It has no manual bans, appeals, automatic content moderation, anonymous reports, report notifications or permanent deletion from the ModCP. Moderators still have to read things themselves, sorry. diff --git a/apps/web/content/docs/dev/moderation/issue-warnings.mdx b/apps/web/content/docs/dev/moderation/issue-warnings.mdx new file mode 100644 index 0000000000..5ac4ed8563 --- /dev/null +++ b/apps/web/content/docs/dev/moderation/issue-warnings.mdx @@ -0,0 +1,104 @@ +--- +title: Issue warnings +description: Warn a VitNode member from their profile, a comment, a report or the Moderator CP, preview the consequences, and revoke warnings or lift suspensions. +icon: TriangleAlert +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import form from "./warning-form.webp"; +import member from "./warning-member.webp"; + +Moderators warn members with one shared form, opened from wherever the problem is. This guide warns a member for a blog comment, then revokes the warning and lifts the suspension it caused. + +## Before you begin + +Warnings need [warning reasons](/docs/dev/moderation/configure-warnings) and a moderator entry with the right permissions. Grant them in **AdminCP → Staff → Moderators**: + +| Permission | Where | Lets the moderator | +| --- | --- | --- | +| **Warn authors of this content** (`can_warn`) | The content type's module, for example **Comment** in the blog. Can be [scoped](/docs/dev/moderation/scoped-permissions) | Warn the author of that content | +| **Warn members directly, without content** | Core → **Warnings** | Warn from a profile or the ModCP without content | +| **Change a warning's points** / **Change when a warning's points expire** | Core → **Warnings** | Override the reason's defaults, with an explanation | +| **View every member's warnings** | Core → **Warnings** | See all warnings, not only those in their scope | +| **Read staff notes on warnings** | Core → **Warnings** | Read private notes and override explanations | +| **Revoke warnings** | Core → **Warnings** | Revoke a warning | +| **View suspensions** / **Lift suspensions** | Core → **Warnings** | See and lift suspensions | + +A moderator limited to one forum section can warn for content in that section only. Without **Warn members directly** they cannot warn from a profile, and without **View every member's warnings** they see only the warnings they issued or that belong to their scope. The member's total of active points is always shown, because a warning anywhere counts toward account-wide thresholds. + + + + + +### Open the warn form + +Pick the entry point that matches what you are looking at: + +| From | How | +| --- | --- | +| A comment | **More actions** (`…`) → **Warn author** | +| A report | **Warn author** under the status button in the ModCP report | +| A profile | **Warn** next to **Warnings** on the member's profile | +| The ModCP | **Moderator CP → Warnings → Find a member**, then **Warn member** | + +Each button appears only when the server says you may use it. + + + + + +### Fill in the warning + +Pick a reason. The points, expiry and message follow the reason; change the points or expiry only if you may, and say why in **Why the change?**. + +- **Message to the member** is what the member reads in the notification and on their account status page. +- **Staff note** is private. The member never sees it, and only staff who can read notes do. + +**What happens** shows the member's active points now, this warning's points, the total after and the automatic action. It is a preview: the server calculates everything again when you submit, in case someone else warned the member meanwhile. + + + +From a report, **Also close the report as action taken** closes it in the same step. If either part fails, neither happens. Without it, the report stays open and its timeline records the warning. + + + + + +### Submit + +Choose **Issue warning**. The toast says how many active points the member has now and whether their account was suspended, from the server's actual result. If that differs from the preview, the toast says so. Sending the same form twice, for example after a network error, issues it once. + + + + + +### Revoke a warning or lift a suspension + +Open the member in **Moderator CP → Warnings** (or **Warnings** on their profile). **Revoke** on a warning asks for a reason; its points stop counting at once and the member is notified. The warning stays in the history, marked **Revoked**. + +Revoking a warning never lifts the suspension it caused. Lift it separately with **Lift** on the suspension. Other active suspensions keep applying. + + + + + + + +## Check the result + +Sign in as the warned member. A notification links to **Account status**, which lists the warning with your message and its points. If the warning crossed a threshold, every page shows the suspension notice and actions such as commenting answer like a signed-out visitor's. + +## Related + +- [Warnings](/docs/dev/moderation/warnings) +- [Process reports](/docs/dev/moderation/process-reports) diff --git a/apps/web/content/docs/dev/moderation/meta.json b/apps/web/content/docs/dev/moderation/meta.json index fa3c4e79af..3e93372d21 100644 --- a/apps/web/content/docs/dev/moderation/meta.json +++ b/apps/web/content/docs/dev/moderation/meta.json @@ -1,6 +1,6 @@ { "title": "Moderation", - "description": "Reports, the Moderator CP and scoped moderator permissions", + "description": "Reports, warnings, the Moderator CP and scoped moderator permissions", "icon": "ShieldAlert", "pages": [ "index", @@ -10,6 +10,10 @@ "configure-moderators", "process-reports", "review-approvals", + "warnings", + "configure-warnings", + "issue-warnings", + "warn-content", "reference" ] } diff --git a/apps/web/content/docs/dev/moderation/reference.mdx b/apps/web/content/docs/dev/moderation/reference.mdx index c31ff51a26..6105a79110 100644 --- a/apps/web/content/docs/dev/moderation/reference.mdx +++ b/apps/web/content/docs/dev/moderation/reference.mdx @@ -223,12 +223,67 @@ Statuses are `pending`, `approved`, `rejected`, `superseded` (replaced by the au | `moderation.report_reason.updated` | `{ reasonId, code }` | | `moderation.report_reason.deleted` | `{ reasonId, code }` | | `moderation.report_reason.reordered` | `{ reasonIds }` in the new order | +| `moderation.warning.issued` | `{ warningId, userId, issuedById, points, reasonId, reportId, pluginId, targetType, targetId }` | +| `moderation.warning.revoked` | `{ warningId, userId, revokedById }` | +| `moderation.sanction.applied` | `{ sanctionId, userId, type, source, endsAt, thresholdId, warningId }` | +| `moderation.sanction.revoked` | `{ sanctionId, userId, revokedById }` | +| `moderation.warning_reason.created` / `.updated` | `{ reasonId }` | +| `moderation.warning_threshold.created` / `.updated` / `.deleted` | `{ thresholdId }` | | `content.approval.submitted` | `{ submissionId, replacedSubmissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId }` | | `content.approval.approved` | `{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId }` | | `content.approval.rejected` | `{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId, reason }` | Adding a note emits no event. Hiding through a report also emits the content type's own `hidden` or `unhidden` event. See [Built-in events](/docs/dev/events/built-in-events#moderation-events). +## Warnings + +Concepts and flows are in [Warnings](/docs/dev/moderation/warnings). Everything below is core's. + +### Warning permissions + +| Type | Module | Permission | Scoped | +| --- | --- | --- | --- | +| Moderator | A report target's `permissionModule` | `can_warn`: warn the content's authors. Added only for targets with `resolveAuthors` | With the target's `scope` | +| Moderator | `warnings` | `can_view`, `can_warn` (direct), `can_override_points`, `can_override_expiration`, `can_view_notes`, `can_revoke`, `can_view_sanctions`, `can_manage_sanctions` | No | +| Admin | `warning_settings` | `can_view`, `can_manage` | No | + +### Warning API routes + +Under `/api/@vitnode/core/moderation`, using the user session. + +| Method and path | Who | Body or query | +| --- | --- | --- | +| `GET /warnings/reasons` | Anyone who can warn | Enabled reasons in display order | +| `POST /warnings/preview` | Anyone who can warn | `{ userId, reasonId, points?, expiration?, content?, reportId? }`. Runs every check and answers points before and after and the projected `sanction` | +| `POST /warnings` | Anyone who can warn | The preview body plus `{ message, staffNote?, overrideNote?, resolveReport?, idempotencyKey }` | +| `POST /warnings/{id}/revoke` | `can_revoke` | `{ reason }` | +| `GET /warnings` | Moderators | Query `userId`, `reasonId`, `issuedById`, `status`, `pluginId`, `targetType`, `from`, `to`, `page`, `pageSize` | +| `GET /members?search=` | `can_view` or `can_warn` | Up to 10 members | +| `GET /members/{id}` | `can_view` or anyone who can warn | Active points, visible warnings, `hiddenWarnings`, sanctions with `can_view_sanctions` | +| `POST /sanctions/{id}/revoke` | `can_manage_sanctions` | `{ reason }` | +| `GET /account-status` | The member, also while suspended | Own suspension, active points, warnings and suspensions, never staff notes | + +`expiration` is `{ mode: "default" }`, `{ mode: "never" }`, `{ mode: "hours", hours }` or `{ mode: "at", at }` (an ISO date in the future). `content` is `{ pluginId, targetType, targetId }`; with `reportId`, the content comes from the report. A retry with the same `idempotencyKey` from the same moderator answers the first result with `duplicate: true`. + +Refusals answer `{ error }`: `self` and `protected` (`403`), `forbidden` (`403`), `points_override_forbidden` and `expiration_override_forbidden` (`403`), `override_note_required`, `invalid_points`, `invalid_expiration`, `author_mismatch` and `report_mismatch` (`400`), `user_not_found`, `reason_not_found` and `content_not_found` (`404`), `reason_disabled`, `already_revoked` and `report_closed` (`409`). + +The AdminCP routes are under `/api/@vitnode/core/admin/warnings`: `GET`/`POST /reasons`, `PATCH /reasons/{id}`, `PUT /reasons/reorder`, `GET`/`POST /thresholds`, `PUT`/`DELETE /thresholds/{id}`. A second enabled threshold at the same points answers `409 duplicate_points`. + +### Warning limits + +| Limit | Value | +| --- | --- | +| Points per warning or reason | 0 to 100 | +| Threshold points | 1 to 1000 | +| Expiry and suspension length | 1 hour to 5 years | +| Message to the member | 3 to 2000 characters | +| Staff note, explanations, revocation reasons | Up to 2000 characters; explanations and reasons at least 3 | +| Warning reasons / thresholds | 50 / 20 | + +### Warning notifications + +`core.warning_issued`, `core.warning_revoked`, `core.account_suspended` and `core.suspension_revoked`, in the **Account** category. They are mandatory in the inbox, link to `/account-status`, and are published inside the warning's transaction, so a rolled-back warning notifies nobody. They carry the member's message and points, never staff notes or the moderator's name. + ## Snapshot format Stored in `core_moderation_reports.snapshot` as JSON, `version: 1`: @@ -274,4 +329,6 @@ The migration `20261011193321_add_moderation_reports` adds `core_moderation_repo The migration `20261019161102_add_moderation_report_reasons` adds `core_moderation_report_reasons` with a unique `code`, `requiresDetails` and `position`. Titles and descriptions live in `core_languages_words`. `core_moderation_reports.reason` holds the reason's `code` without a foreign key, so deleting a reason keeps its reports. `db:prepare` and `db:migrate` add the four starter reasons only while the table is empty. +The migration `20261028212551_add_moderation_warnings` adds `core_moderation_warning_reasons`, `core_moderation_warning_thresholds` (a partial unique index allows one enabled threshold per number of points), `core_moderation_warnings`, `core_moderation_sanctions` and `core_moderation_audit`. A warning keeps a copy of its reason's title, the points it was issued with and a short content snapshot; `(issuedById, idempotencyKey)` is unique, and so is a sanction's `warningId`. Warned users' rows cascade on account deletion; actors are `ON DELETE SET NULL`. + Approvals live in `core_content_submissions`, one row per submission. Rows are never deleted with the content, author and reviewer are `ON DELETE SET NULL`, and a partial unique index allows one `pending` submission per record. Container policy overrides are stored in `core_content_containers.policies`. diff --git a/apps/web/content/docs/dev/moderation/warn-content.mdx b/apps/web/content/docs/dev/moderation/warn-content.mdx new file mode 100644 index 0000000000..e998e17fb3 --- /dev/null +++ b/apps/web/content/docs/dev/moderation/warn-content.mdx @@ -0,0 +1,90 @@ +--- +title: Warn content authors +description: Let VitNode moderators warn the author of your plugin's content, with the server resolving authorship and moderation scope, and add the WarnAuthorButton. +icon: UserX +--- + +A content-linked warning names a piece of content, so moderators limited to one section can warn for that section only. This guide adds **Warn author** to your plugin's content. + +## How the server decides + +Warnings reuse your content's **report target**. The browser sends only the content's `pluginId`, `targetType`, `targetId` and the member's id. The server then: + +1. Finds the report target, and refuses content without one. +2. Resolves the content's moderation scope with the target's `resolveScope`. +3. Checks the moderator's `can_warn` in the target's `permissionModule` for that scope. +4. Reads the current authors with the target's `resolveAuthors`, and refuses the warning unless the member is one of them. + +A client-sent author or scope is never trusted. The warning keeps a short title and URL of the content, so it stays readable after the content is deleted or its plugin is removed. + +## Content Engine types + +A content type with `reports` is warnable with no extra code. Core adds `resolveAuthors`, reading the same author field search and reports use, and adds `can_warn` to the type's moderator permissions next to `can_view_reports`. + +The permissions route answers `canWarn` for each record: + +```json title="GET /api/@vitnode/blog/content/comments/permissions?ids=1" +{ + "items": [ + { + "id": 1, + "canDelete": false, + "canEdit": false, + "canHide": false, + "canReport": true, + "canWarn": true + } + ] +} +``` + +Comments rendered with [`ContentCommentActions`](/docs/content-engine/comments-api) show **Warn author** in the **More actions** menu whenever `canWarn` is true and the thread has `report`. The blog's comments get it this way. + +## Add the button to your own page + +For other content, render `WarnAuthorButton` when the permissions route answers `canWarn`: + +```tsx title="src/pages/topic-page.tsx" +import { contentTarget } from "@vitnode/core/content"; +import { WarnAuthorButton } from "@vitnode/core/tanstack/moderation"; + +import { topicContentType } from "@/content/topic"; +import { manifest } from "@/manifest.gen"; + +{permissions.canWarn && topic.author ? ( + +) : null} +``` + +`WarnDialog` takes the same props plus `open` and `onOpenChange`, for a menu item or a trigger you already have. Both accept `reportId` to link the warning to a report, and `onIssued` to refresh your own data with the server's result. + +## Custom report targets + +For a target built with `defineReportTarget`, add `resolveAuthors`. It runs on the server as the moderator, so hidden content still resolves; return `null` once the content is deleted: + +```ts title="src/moderation/topic-target.ts" +import { defineReportTarget } from "@vitnode/core/api/moderation"; + +export const topicTarget = defineReportTarget({ + // ...type, label, permissionModule, parseId, capture, current + resolveAuthors: async (c, { targetId }) => { + const topic = await findTopic(c, targetId); + + return topic ? { authorIds: [topic.authorId], title: topic.title } : null; + }, +}); +``` + +With `resolveAuthors`, the target's module gets a `can_warn` permission. Without it, the target stays reportable but not warnable. + +## Check the result + +Sign in as a moderator with **Warn authors of this content** for your module and open a record written by someone else: **Warn author** appears. Sign in as a moderator without it: the button is gone, and `POST /moderation/warnings` with that content answers `403`. + +## Related + +- [Issue warnings](/docs/dev/moderation/issue-warnings) +- [Custom report targets](/docs/dev/moderation/reference#custom-report-targets) diff --git a/apps/web/content/docs/dev/moderation/warning-form.webp b/apps/web/content/docs/dev/moderation/warning-form.webp new file mode 100644 index 0000000000000000000000000000000000000000..5092065f4c82b6981e4ccc9c207c9bc06c3d797d GIT binary patch literal 74576 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 T)1EED$+SB@Bu5JR-~a#saimlL literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/moderation/warning-reasons.webp b/apps/web/content/docs/dev/moderation/warning-reasons.webp new file mode 100644 index 0000000000000000000000000000000000000000..d9c1186e698e03dcdc2564e31facff2ada1f8c8b GIT binary patch literal 41202 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 YQk}jyONCw*g7uft7zO}fc>w_b1G0sm&Hw-a literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/moderation/warning-thresholds.webp b/apps/web/content/docs/dev/moderation/warning-thresholds.webp new file mode 100644 index 0000000000000000000000000000000000000000..dafbfe102e0c39d9962bb2805bb675cbae6d6f4a GIT binary patch literal 42626 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 literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/moderation/warnings.mdx b/apps/web/content/docs/dev/moderation/warnings.mdx new file mode 100644 index 0000000000..6384b95078 --- /dev/null +++ b/apps/web/content/docs/dev/moderation/warnings.mdx @@ -0,0 +1,53 @@ +--- +title: Warnings +description: How VitNode warnings, active points, thresholds and account suspensions fit together, who can do what, and what a suspended member sees. +icon: TriangleAlert +--- + +A **warning** records a moderator's decision about a member. It can carry **points**, and when a warning brings a member's **active points** up to an administrator's **threshold**, VitNode suspends the account automatically. Warnings, points and suspensions are separate records, so revoking one never silently rewrites another. + +| Concept | What it is | +| --- | --- | +| Warning | Who was warned, by whom, why, how many points, the message the member reads, a private staff note, and optionally the content or report it is about | +| Active points | The sum of points on warnings that are not revoked and whose expiry is still in the future, computed from timestamps on every read | +| Threshold | An administrator's rule, like "at 8 active points, suspend for 24 hours" | +| Sanction | A consequence on the account. This version has temporary and indefinite **suspensions** | + +## Choose a guide + +| You want to | Read | +| --- | --- | +| Set up warning reasons and automatic suspensions | [Configure warnings](/docs/dev/moderation/configure-warnings) | +| Warn a member, revoke a warning or lift a suspension | [Issue warnings](/docs/dev/moderation/issue-warnings) | +| Add **Warn author** to your plugin's content | [Warn content authors](/docs/dev/moderation/warn-content) | +| Look up routes, permissions and events | [Moderation reference](/docs/dev/moderation/reference#warnings) | + +## How a warning becomes a suspension + +1. A moderator opens the warn form. It previews the member's active points before and after, and the suspension a threshold would apply. +2. On submit, the server checks every permission again and locks that member's warnings for the length of one transaction. Two moderators warning the same member at once are handled one after the other, so the points add up and each threshold fires once. +3. A threshold applies when the warning moves active points from below its number to its number or above. When one warning crosses several, only the highest applies: 6 → 11 points with thresholds at 8 and 10 gives only the 10-point suspension. +4. The warning, the threshold decision, the suspension, the audit entries and the member's notifications commit together or not at all. + +A temporary suspension ends at its own end time, even when the member still has the points. Expired points or a changed threshold never create a new suspension on their own; only a new warning does. Points that later fall below a threshold let it fire again on a future crossing. + +## What a suspended member sees + +The server checks suspensions when it reads the session, for every request: core routes, plugin routes, server functions and WebSocket handshakes. A suspended account is treated as a guest everywhere, so it can browse public pages but cannot post, comment, report, moderate or open the AdminCP. Open WebSocket connections are closed when the suspension is applied. + +The member keeps their session. A strip under the header says the account is suspended and links to **Account status** at `/account-status`, which shows the suspension end time, active points and the warning history with each message. Members reach the same page from **Settings → Account status** at any time. It never shows staff notes or which moderator issued a warning. + +## Who is protected + +Nobody can warn themselves. A member who holds a root role cannot be warned at all, and other staff (admins and moderators) can only be warned by someone who holds a root role. These checks run before a threshold is evaluated, so a threshold can never suspend a protected account. + +## Limits of this version + +- Suspensions are the only sanction. There is no manual "suspend without a warning" action, no appeal workflow and no posting restriction per plugin yet; the data model leaves room for them. +- Suspension state is cached for up to a minute per member. Applying or lifting a suspension clears that cache, and with Redis every instance sees it at once. Without Redis, other instances may take up to a minute. +- The audit trail is stored in `core_moderation_audit` but has no screen yet. + +## Related + +- [Scoped permissions](/docs/dev/moderation/scoped-permissions) +- [Configure moderators](/docs/dev/moderation/configure-moderators) diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index 8e11435303..2ccdea3b66 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -36,6 +36,8 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/admin/core/debug/services' }, { path: '/admin/core/debug/vitnode' }, { path: '/admin/core/moderation/report-reasons' }, + { path: '/admin/core/moderation/warning-reasons' }, + { path: '/admin/core/moderation/warning-thresholds' }, { path: '/admin/core/staff/admins' }, { path: '/admin/core/staff/moderators' }, { path: '/admin/core/system/files' }, @@ -57,6 +59,7 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/forum/topics/:slug', maxAge: 60, staleWhileRevalidate: 30 }, { path: '/login/sso/:providerId' }, { path: '/modcp/approvals/:id' }, + { path: '/modcp/members/:id' }, { path: '/modcp/reports/:id' }, { path: '/news/comments/:slug' }, { path: '/newsroom/guide/:topic' }, @@ -69,6 +72,7 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/login/verify-email' }, { path: '/modcp/approvals' }, { path: '/modcp/reports' }, + { path: '/modcp/warnings' }, { path: '/news/new' }, { path: '/newsroom/browse' }, { path: '/newsroom/embed' }, @@ -96,6 +100,7 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/stories/:slug' }, { path: '/users/:nameCode' }, { path: '/solutions/:slug' }, + { path: '/account-status' }, { path: '/admin' }, { path: '/blog', maxAge: 300, staleWhileRevalidate: 60 }, { path: '/desks' }, diff --git a/packages/vitnode/src/api/lib/moderation/registry.ts b/packages/vitnode/src/api/lib/moderation/registry.ts index 389fdf3a1d..b67779c4b4 100644 --- a/packages/vitnode/src/api/lib/moderation/registry.ts +++ b/packages/vitnode/src/api/lib/moderation/registry.ts @@ -30,7 +30,10 @@ export interface ModerationRegistry { } export const moderationPermissionEntries = ( - target: Pick, + target: Pick< + ModerationReportTargetDefinition, + "actions" | "resolveAuthors" | "scope" + >, ): PermissionStaffEntryInput[] => { const scope = target.scope ? { scope: target.scope } : {}; const hideable = Boolean(target.actions?.hide ?? target.actions?.unhide); @@ -52,6 +55,9 @@ export const moderationPermissionEntries = ( permission: MODERATION_PERMISSIONS.deleteNotes, ...scope, }, + ...(target.resolveAuthors + ? [{ permission: MODERATION_PERMISSIONS.warn, ...scope }] + : []), ...(hideable ? [ { permission: MODERATION_PERMISSIONS.viewHidden, ...scope }, diff --git a/packages/vitnode/src/api/lib/moderation/targets.ts b/packages/vitnode/src/api/lib/moderation/targets.ts index f9ce8dab38..aec4d7a7e3 100644 --- a/packages/vitnode/src/api/lib/moderation/targets.ts +++ b/packages/vitnode/src/api/lib/moderation/targets.ts @@ -14,6 +14,14 @@ export interface ModerationTargetState { url: null | string; } +/** What {@link ModerationReportTargetDefinition.resolveAuthors} returns. */ +export interface ModerationTargetAuthors { + /** Every user who wrote the target; a warning can only name one of them. */ + authorIds: number[]; + /** A short plain-text title kept with the warning, or `null`. */ + title: null | string; +} + export interface ModerationCaptureArgs { locale: null | string; targetId: string; @@ -84,6 +92,17 @@ export interface ModerationReportTargetDefinition { parseId: (value: string) => null | string; /** Moderator permission module the report permissions are added to. */ permissionModule: string; + /** + * The target's current authors and a short plain-text title, or `null` once + * it is deleted. Server-only; runs as the moderator, so hidden content still + * resolves. Setting it makes the target warnable: moderators with + * `can_warn` in `permissionModule` (limited by `scope`) can warn its + * authors. Content Engine types with `reports` provide it automatically. + */ + resolveAuthors?: ( + c: Context, + args: { targetId: string }, + ) => Promise; /** * The target's resource id in the `scope` family: `""` for none, * `undefined` once the target is deleted. Required with `scope`. diff --git a/packages/vitnode/src/api/lib/moderation/warnings/access.ts b/packages/vitnode/src/api/lib/moderation/warnings/access.ts new file mode 100644 index 0000000000..ff50c1b435 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/warnings/access.ts @@ -0,0 +1,181 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import { and, eq, inArray, isNotNull, or } from "drizzle-orm"; + +import { core_moderation_warnings } from "@/database/warnings"; +import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; +import { + WARNING_PERMISSION_MODULE, + WARNING_PERMISSIONS, +} from "@/lib/moderation/warnings"; +import { manifest } from "@/manifest"; + +import type { ResolvedStaffPermissionSet } from "../../permission-staff"; +import type { ModeratorContext } from "../access"; + +import { + getStaffFlags, + resolveStaffPermissions, +} from "../../check-staff-permission"; +import { + hasStaffPermission, + staffPermissionResources, +} from "../../staff-permission"; +import { canOnReport } from "../access"; + +export type WarningPermission = + (typeof WARNING_PERMISSIONS)[keyof typeof WARNING_PERMISSIONS]; + +/** Whether the moderator holds one of core's unscoped `warnings` permissions. */ +export const hasWarningPermission = ( + set: ResolvedStaffPermissionSet, + permission: WarningPermission, +): boolean => + hasStaffPermission(set, { + module: WARNING_PERMISSION_MODULE, + permission, + plugin: manifest.id, + }); + +/** The content a warning points at, as stored on the warning. */ +export interface WarningContentRef { + pluginId: null | string; + scopeId: null | string; + targetType: null | string; +} + +/** Whether the moderator may warn the authors of content in this scope. */ +export const canWarnForContent = ( + c: Context, + set: ResolvedStaffPermissionSet, + ref: { pluginId: string; scopeId: null | string; targetType: string }, +): boolean => canOnReport(c, set, ref, MODERATION_PERMISSIONS.warn); + +/** + * Whether the moderator may read one warning's details: with `can_view`, any + * warning; otherwise the warnings they issued and those linked to content + * they may warn for. + */ +export const canSeeWarning = ( + c: Context, + moderator: ModeratorContext, + warning: WarningContentRef & { issuedById: null | number }, +): boolean => { + if (hasWarningPermission(moderator.set, WARNING_PERMISSIONS.view)) { + return true; + } + if (warning.issuedById === moderator.user.id) return true; + if (!warning.pluginId || !warning.targetType) return false; + + return canWarnForContent(c, moderator.set, { + pluginId: warning.pluginId, + scopeId: warning.scopeId, + targetType: warning.targetType, + }); +}; + +/** Staff notes and override explanations: `can_view_notes`, or the issuer's own. */ +export const canSeeWarningNotes = ( + moderator: ModeratorContext, + warning: { issuedById: null | number }, +): boolean => + hasWarningPermission(moderator.set, WARNING_PERMISSIONS.viewNotes) || + warning.issuedById === moderator.user.id; + +/** {@link canSeeWarning} as SQL, for listing. `undefined` means every warning. */ +export const viewableWarningsCondition = ( + c: Context, + moderator: ModeratorContext, +): SQL | undefined => { + if (hasWarningPermission(moderator.set, WARNING_PERMISSIONS.view)) { + return undefined; + } + + const table = core_moderation_warnings; + const conditions: (SQL | undefined)[] = [ + eq(table.issuedById, moderator.user.id), + ]; + for (const target of c.get("core").moderation?.targets ?? []) { + if (!target.definition.resolveAuthors) continue; + const family = target.definition.scope; + const resources = staffPermissionResources( + moderator.set, + { + module: target.definition.permissionModule, + permission: MODERATION_PERMISSIONS.warn, + plugin: target.pluginId, + }, + family ?? "", + ); + if (resources === null) continue; + + const ofTarget = and( + eq(table.pluginId, target.pluginId), + eq(table.targetType, target.definition.type), + ); + conditions.push( + resources === "all" || !family + ? ofTarget + : and( + ofTarget, + isNotNull(table.scopeId), + inArray(table.scopeId, resources), + ), + ); + } + + return or(...conditions); +}; + +/** + * Whether the moderator can warn anyone at all: directly, or for content of + * at least one registered target. + */ +export const canWarnAnywhere = ( + c: Context, + set: ResolvedStaffPermissionSet, +): boolean => { + if (hasWarningPermission(set, WARNING_PERMISSIONS.warn)) return true; + + return (c.get("core").moderation?.targets ?? []).some( + target => + target.definition.resolveAuthors !== undefined && + staffPermissionResources( + set, + { + module: target.definition.permissionModule, + permission: MODERATION_PERMISSIONS.warn, + plugin: target.pluginId, + }, + target.definition.scope ?? "", + ) !== null, + ); +}; + +export type WarningTargetRefusal = "protected" | "self"; + +/** + * Who may not be warned by this moderator: themselves, anyone holding a root + * role, and - unless the moderator holds a root role - other staff. Checked + * before threshold evaluation, so a threshold can never suspend a protected + * account. + */ +export const warningTargetRefusal = async ( + c: Context, + moderator: ModeratorContext, + target: { id: number; roleId: number }, +): Promise => { + if (target.id === moderator.user.id) return "self"; + + const targetSet = await resolveStaffPermissions(c, { + type: "moderator", + user: target, + }); + if (targetSet.root) return "protected"; + if (moderator.set.root) return null; + + const flags = await getStaffFlags(c, target); + + return flags.isAdmin || flags.isModerator ? "protected" : null; +}; diff --git a/packages/vitnode/src/api/lib/moderation/warnings/reasons.ts b/packages/vitnode/src/api/lib/moderation/warnings/reasons.ts new file mode 100644 index 0000000000..62e9d28878 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/warnings/reasons.ts @@ -0,0 +1,119 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { and, asc, eq, inArray } from "drizzle-orm"; + +import type { ContentDatabase } from "@/content/server/service"; +import type { ModerationAuditType } from "@/lib/moderation/warnings"; + +import { core_languages_words } from "@/database/languages"; +import { + core_moderation_audit, + core_moderation_warning_reasons, +} from "@/database/warnings"; + +export const WARNING_REASON_WORDS = { + pluginCode: "core", + tableName: "core_moderation_warning_reasons", +} as const; + +export type WarningReasonWordVariable = "description" | "message" | "title"; + +const multiLangSchema = z.array( + z.object({ languageCode: z.string(), value: z.string() }), +); + +export const warningReasonSchema = z.object({ + id: z.number(), + points: z.number(), + expiresAfterHours: z.number().nullable(), + enabled: z.boolean(), + position: z.number(), + title: multiLangSchema, + description: multiLangSchema, + message: multiLangSchema, +}); + +export type WarningReason = z.infer; + +/** Every warning reason in display order, with its words; `enabledOnly` for new warnings. */ +export const listWarningReasons = async ( + c: Context, + { enabledOnly = false, ids }: { enabledOnly?: boolean; ids?: number[] } = {}, +): Promise => { + const table = core_moderation_warning_reasons; + const rows = await c + .get("db") + .select({ + enabled: table.enabled, + expiresAfterHours: table.expiresAfterHours, + id: table.id, + points: table.points, + position: table.position, + }) + .from(table) + .where( + and( + enabledOnly ? eq(table.enabled, true) : undefined, + ids ? inArray(table.id, ids.length > 0 ? ids : [0]) : undefined, + ), + ) + .orderBy(asc(table.position), asc(table.id)); + if (rows.length === 0) return []; + + const words = await c + .get("db") + .select({ + itemId: core_languages_words.itemId, + languageCode: core_languages_words.languageCode, + value: core_languages_words.value, + variable: core_languages_words.variable, + }) + .from(core_languages_words) + .where( + and( + eq(core_languages_words.pluginCode, WARNING_REASON_WORDS.pluginCode), + eq(core_languages_words.tableName, WARNING_REASON_WORDS.tableName), + inArray( + core_languages_words.itemId, + rows.map(row => row.id), + ), + ), + ); + const pick = (id: number, variable: WarningReasonWordVariable) => + words + .filter(word => word.itemId === id && word.variable === variable) + .map(word => ({ languageCode: word.languageCode, value: word.value })); + + return rows.map(row => ({ + ...row, + description: pick(row.id, "description"), + message: pick(row.id, "message"), + title: pick(row.id, "title"), + })); +}; + +/** + * Appends one entry to the warning system's audit trail. Never pass staff + * notes or override explanations in `data`. + */ +export const appendModerationAudit = async ( + db: ContentDatabase, + entry: { + actorId: null | number; + data?: Record; + sanctionId?: null | number; + type: ModerationAuditType; + userId?: null | number; + warningId?: null | number; + }, +): Promise => { + await db.insert(core_moderation_audit).values({ + actorId: entry.actorId, + data: entry.data ?? {}, + sanctionId: entry.sanctionId ?? null, + type: entry.type, + userId: entry.userId ?? null, + warningId: entry.warningId ?? null, + }); +}; diff --git a/packages/vitnode/src/api/lib/moderation/warnings/suspension.ts b/packages/vitnode/src/api/lib/moderation/warnings/suspension.ts new file mode 100644 index 0000000000..fb18b1211d --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/warnings/suspension.ts @@ -0,0 +1,114 @@ +import type { Context } from "hono"; + +import { and, eq, gt, isNull, or } from "drizzle-orm"; + +import { core_moderation_sanctions } from "@/database/warnings"; +import { isSanctionActiveAt } from "@/lib/moderation/warnings"; + +/** The suspension that keeps an account restricted right now. */ +export interface ActiveSuspension { + /** The latest end among active suspensions; `null` while any is indefinite. */ + endsAt: Date | null; + sanctionIds: number[]; +} + +interface CachedSanction { + endsAt: null | string; + id: number; + startsAt: string; +} + +/** Positive and negative answers both live this long, unless revoked sooner. */ +const SUSPENSION_CACHE_TTL_SECONDS = 60; + +const suspensionCacheKey = (userId: number): string => + `moderation:suspension:${userId}`; + +const loadSuspensions = async ( + c: Context, + userId: number, + now: Date, +): Promise => { + const table = core_moderation_sanctions; + const rows = await c + .get("db") + .select({ + endsAt: table.endsAt, + id: table.id, + startsAt: table.startsAt, + }) + .from(table) + .where( + and( + eq(table.userId, userId), + eq(table.type, "suspension"), + isNull(table.revokedAt), + or(isNull(table.endsAt), gt(table.endsAt, now)), + ), + ); + + return rows.map(row => ({ + endsAt: row.endsAt?.toISOString() ?? null, + id: row.id, + startsAt: row.startsAt.toISOString(), + })); +}; + +/** + * The account's active suspension at server time, or `null`. Each sanction is + * re-checked against its own timestamps on every call, so a temporary one + * stops applying the moment it ends - no scheduled job involved. Cached per + * user for a minute and dropped by {@link forgetSuspension} whenever a + * sanction is applied or revoked. Server-only. + */ +export const findActiveSuspension = async ( + c: Context, + userId: number, + now: Date = new Date(), +): Promise => { + const cache = c.get("cache"); + const key = suspensionCacheKey(userId); + let sanctions = await cache.getSystem(key); + if (!sanctions) { + sanctions = await loadSuspensions(c, userId, now); + await cache.setSystem(key, sanctions, SUSPENSION_CACHE_TTL_SECONDS); + } + + const active = sanctions.filter(sanction => + isSanctionActiveAt( + { + endsAt: sanction.endsAt ? new Date(sanction.endsAt) : null, + revokedAt: null, + startsAt: new Date(sanction.startsAt), + }, + now, + ), + ); + if (active.length === 0) return null; + + const indefinite = active.some(sanction => sanction.endsAt === null); + const latest = active + .map(sanction => sanction.endsAt) + .filter((value): value is string => value !== null) + .sort() + .at(-1); + + return { + endsAt: indefinite || !latest ? null : new Date(latest), + sanctionIds: active.map(sanction => sanction.id), + }; +}; + +/** + * Drops the cached suspension state of a user and closes their open + * WebSocket connections, so a new or revoked sanction applies on the next + * request on every instance that shares the cache. + */ +export const forgetSuspension = async ( + c: Context, + userId: number, + { disconnect }: { disconnect: boolean }, +): Promise => { + await c.get("cache").deleteSystem(suspensionCacheKey(userId)); + if (disconnect) c.get("realtime")?.closeUserConnections(userId); +}; diff --git a/packages/vitnode/src/api/lib/notifications/core-types.ts b/packages/vitnode/src/api/lib/notifications/core-types.ts index 1fd6ee2490..7a6c5937f0 100644 --- a/packages/vitnode/src/api/lib/notifications/core-types.ts +++ b/packages/vitnode/src/api/lib/notifications/core-types.ts @@ -16,4 +16,130 @@ export const adminMessageNotification = buildNotificationType({ present: ({ data }) => ({ body: data.description, title: data.title }), }); -export const coreNotificationTypes = [adminMessageNotification]; +const reasonTextSchema = z + .array(z.object({ languageCode: z.string(), value: z.string() })) + .nullable(); + +const pickText = ( + values: null | { languageCode: string; value: string }[], + locale: string, +): string => + values?.find(item => item.languageCode === locale)?.value ?? + values?.find(item => item.languageCode === "en")?.value ?? + values?.[0]?.value ?? + ""; + +const ACCOUNT_STATUS_PATH = "/account-status"; + +/** Sent to a member when a moderator warns them. Never carries staff notes. */ +export const warningIssuedNotification = buildNotificationType({ + id: "core.warning_issued", + version: 1, + schema: z.object({ + message: z.string().max(2000), + points: z.number().int().min(0), + reason: reasonTextSchema, + warningId: z.number().int(), + }), + category: "account", + label: "core.notifications.types.warning_issued.label", + description: "core.notifications.types.warning_issued.description", + defaults: { email: "immediate", inApp: true }, + email: true, + mandatory: true, + present: ({ data, locale, t }) => { + const reason = pickText(data.reason, locale); + + return { + body: data.message, + target: ACCOUNT_STATUS_PATH, + title: reason + ? t("core.notifications.types.warning_issued.title_reason", { + points: data.points, + reason, + }) + : t("core.notifications.types.warning_issued.title", { + points: data.points, + }), + }; + }, +}); + +/** Sent to a member when a warning of theirs is revoked. */ +export const warningRevokedNotification = buildNotificationType({ + id: "core.warning_revoked", + version: 1, + schema: z.object({ + points: z.number().int().min(0), + reason: reasonTextSchema, + warningId: z.number().int(), + }), + category: "account", + label: "core.notifications.types.warning_revoked.label", + description: "core.notifications.types.warning_revoked.description", + defaults: { email: "none", inApp: true }, + email: true, + mandatory: true, + present: ({ data, t }) => ({ + body: t("core.notifications.types.warning_revoked.body", { + points: data.points, + }), + target: ACCOUNT_STATUS_PATH, + title: t("core.notifications.types.warning_revoked.title"), + }), +}); + +/** Sent to a member whose account a threshold suspended. */ +export const accountSuspendedNotification = buildNotificationType({ + id: "core.account_suspended", + version: 1, + schema: z.object({ + endsAt: z.string().nullable(), + sanctionId: z.number().int(), + }), + category: "account", + label: "core.notifications.types.account_suspended.label", + description: "core.notifications.types.account_suspended.description", + defaults: { email: "immediate", inApp: true }, + email: true, + mandatory: true, + present: ({ data, locale, t }) => ({ + body: data.endsAt + ? t("core.notifications.types.account_suspended.body_until", { + endsAt: new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(data.endsAt)), + }) + : t("core.notifications.types.account_suspended.body_indefinite"), + target: ACCOUNT_STATUS_PATH, + title: t("core.notifications.types.account_suspended.title"), + }), +}); + +/** Sent to a member when staff lift one of their suspensions. */ +export const suspensionRevokedNotification = buildNotificationType({ + id: "core.suspension_revoked", + version: 1, + schema: z.object({ sanctionId: z.number().int() }), + category: "account", + label: "core.notifications.types.suspension_revoked.label", + description: "core.notifications.types.suspension_revoked.description", + defaults: { email: "immediate", inApp: true }, + email: true, + mandatory: true, + present: ({ t }) => ({ + body: t("core.notifications.types.suspension_revoked.body"), + target: ACCOUNT_STATUS_PATH, + title: t("core.notifications.types.suspension_revoked.title"), + }), +}); + +export const coreNotificationTypes = [ + adminMessageNotification, + warningIssuedNotification, + warningRevokedNotification, + accountSuspendedNotification, + suspensionRevokedNotification, +]; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 21271b598f..88905bf3b1 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -19,6 +19,7 @@ import { runAfterResponse } from "@/api/lib/after-response"; import { DEFAULT_SESSION_COOKIE_NAME } from "@/api/lib/auth-cookie"; import { CacheModel } from "@/api/lib/cache"; import { MemoryCacheStore } from "@/api/lib/cache-memory"; +import { findActiveSuspension } from "@/api/lib/moderation/warnings/suspension"; import { AIModel } from "@/api/models/ai"; import { AuditModel } from "@/api/models/audit"; import { EmailModel } from "@/api/models/email"; @@ -295,6 +296,17 @@ export interface EnvVariablesVitNode { search: SearchModel; /** Uploads, reads and deletes files through the storage adapter: see {@link StorageModel}. */ storage: StorageModel; + /** + * Set when the session belongs to a suspended account. `user` is then + * `null`, so every route - core's and every plugin's - treats the request as + * a guest's. Only routes built for the suspended experience (the account + * status page, sign-out) read this. + */ + suspension: null | { + /** The latest end among active suspensions; `null` while any is indefinite. */ + endsAt: Date | null; + userId: number; + }; /** The signed-in user from the session cookie, or `null` for a guest. */ user: null | { avatarColor: string; @@ -739,8 +751,17 @@ export const globalMiddleware = ({ await ensureContentLocalizationLanguages(c, contentTypesMetadata); } - const user = await new SessionModel(c).getUser(); - c.set("user", user); + const sessionUser = await new SessionModel(c).getUser(); + const suspension = sessionUser + ? await findActiveSuspension(c, sessionUser.id) + : null; + c.set("user", suspension ? null : sessionUser); + c.set( + "suspension", + sessionUser && suspension + ? { endsAt: suspension.endsAt, userId: sessionUser.id } + : null, + ); c.set("admin", null); c.set("adminSessionExpiresAt", null); c.set("log", loggerMiddleware(c)); @@ -775,6 +796,9 @@ export const globalAdminMiddleware = () => { extend: c.req.query("passive") !== "true", }); if (!session) throw new HTTPException(403); + if (await findActiveSuspension(c, session.user.id)) { + throw new HTTPException(403); + } c.set("admin", { user: session.user, }); diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 40d54d76ce..efa4a86ced 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -149,6 +149,52 @@ export interface VitNodeEvents { code: string; reasonId: number; }; + "moderation.sanction.applied": { + /** `null` for an indefinite sanction. */ + endsAt: Date | null; + sanctionId: number; + source: "manual" | "threshold"; + thresholdId: null | number; + type: "suspension"; + userId: number; + warningId: null | number; + }; + "moderation.sanction.revoked": { + revokedById: number; + sanctionId: number; + userId: number; + }; + "moderation.warning.issued": { + issuedById: number; + pluginId: null | string; + points: number; + reasonId: null | number; + reportId: null | number; + targetId: null | string; + targetType: null | string; + userId: number; + warningId: number; + }; + "moderation.warning.revoked": { + revokedById: number; + userId: number; + warningId: number; + }; + "moderation.warning_reason.created": { + reasonId: number; + }; + "moderation.warning_reason.updated": { + reasonId: number; + }; + "moderation.warning_threshold.created": { + thresholdId: number; + }; + "moderation.warning_threshold.deleted": { + thresholdId: number; + }; + "moderation.warning_threshold.updated": { + thresholdId: number; + }; "notifications.deleted_all": { items: number; }; diff --git a/packages/vitnode/src/api/moderation.ts b/packages/vitnode/src/api/moderation.ts index 797d8ff71e..5cce7251de 100644 --- a/packages/vitnode/src/api/moderation.ts +++ b/packages/vitnode/src/api/moderation.ts @@ -15,5 +15,6 @@ export { type ModerationCaptureResult, type ModerationReportTargetDefinition, type ModerationTargetActionDefinition, + type ModerationTargetAuthors, type ModerationTargetState, } from "./lib/moderation/targets"; diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index 0c25a69cdd..ef750d1c87 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -14,6 +14,7 @@ import { sendNotificationRoute } from "./routes/notifications.route"; import { sessionAdminRoute } from "./routes/session.route"; import { staffAdminModule } from "./staff/staff.admin.module"; import { usersAdminModule } from "./users/users.admin.module"; +import { warningsAdminModule } from "./warnings/warnings.admin.module"; export const adminModule = buildModule({ name: "admin", @@ -31,6 +32,7 @@ export const adminModule = buildModule({ notificationsAdminModule, pluginsAdminModule, reportReasonsAdminModule, + warningsAdminModule, ], cronJobs: [], }); diff --git a/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts new file mode 100644 index 0000000000..10f41be05d --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts @@ -0,0 +1,282 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { count, eq, inArray, max, sql } from "drizzle-orm"; + +import { buildRoute } from "@/api/builders"; +import { + appendModerationAudit, + listWarningReasons, + WARNING_REASON_WORDS, + warningReasonSchema, +} from "@/api/lib/moderation/warnings/reasons"; +import { saveLanguageWords } from "@/api/lib/save-language-words"; +import { errorSchema, json } from "@/api/modules/moderation/lib/schema"; +import { core_moderation_warning_reasons } from "@/database/warnings"; +import { WARNING_LIMITS } from "@/lib/moderation/warnings"; + +const wordsSchema = (maxLength: number) => + z.array( + z.object({ + languageCode: z.string(), + value: z.string().trim().max(maxLength), + }), + ); + +const reasonBody = z.object({ + title: z + .array( + z.object({ + languageCode: z.string(), + value: z + .string() + .trim() + .min(1) + .max(WARNING_LIMITS.reasonTitleMaxLength), + }), + ) + .min(1), + description: wordsSchema(WARNING_LIMITS.reasonDescriptionMaxLength), + message: wordsSchema(WARNING_LIMITS.reasonMessageMaxLength), + points: z.number().int().min(0).max(WARNING_LIMITS.maxPoints), + expiresAfterHours: z + .number() + .int() + .min(1) + .max(WARNING_LIMITS.maxDurationHours) + .nullable(), + enabled: z.boolean(), +}); + +const idParams = z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), +}); + +const LOCK_REASONS = sql`lock table ${core_moderation_warning_reasons} in share row exclusive mode`; + +const saveReasonWords = async ( + c: Context, + reasonId: number, + body: Partial>, +) => { + for (const variable of ["title", "description", "message"] as const) { + const values = body[variable]; + if (values === undefined) continue; + await saveLanguageWords(c, { + ...WARNING_REASON_WORDS, + itemId: reasonId, + values: values.filter(item => item.value.length > 0), + variable, + }); + } +}; + +/** What an audit entry keeps of a reason: settings, never the words. */ +const auditedSettings = (body: Partial>) => ({ + enabled: body.enabled, + expiresAfterHours: body.expiresAfterHours, + points: body.points, +}); + +export const listWarningReasonsAdminRoute = buildRoute({ + adminStaffPermission: { module: "warning_settings", permission: "can_view" }, + route: { + method: "get", + path: "/reasons", + description: + "Every warning reason in display order, disabled ones too (Admin only)", + responses: { + 200: json( + z.object({ reasons: z.array(warningReasonSchema) }), + "Warning reasons", + ), + 403: { description: "Access Denied" }, + }, + }, + handler: async c => c.json({ reasons: await listWarningReasons(c) }, 200), +}); + +export const createWarningReasonAdminRoute = buildRoute({ + adminStaffPermission: { + module: "warning_settings", + permission: "can_manage", + }, + route: { + method: "post", + path: "/reasons", + description: `Add a warning reason at the end of the list (Admin only). There can be at most ${WARNING_LIMITS.maxReasons}`, + request: { + body: { + required: true, + content: { "application/json": { schema: reasonBody } }, + }, + }, + responses: { + 201: json(z.object({ id: z.number() }), "Warning reason created"), + 403: { description: "Access Denied" }, + 409: json(errorSchema, "The list already holds the maximum number"), + }, + }, + handler: async c => { + const body = c.req.valid("json"); + const created = await c.get("db").transaction(async tx => { + await tx.execute(LOCK_REASONS); + const [{ position: lastPosition, total }] = await tx + .select({ + position: max(core_moderation_warning_reasons.position), + total: count(), + }) + .from(core_moderation_warning_reasons); + if (total >= WARNING_LIMITS.maxReasons) return null; + + const [row] = await tx + .insert(core_moderation_warning_reasons) + .values({ + enabled: body.enabled, + expiresAfterHours: body.expiresAfterHours, + points: body.points, + position: (lastPosition ?? -1) + 1, + }) + .returning({ id: core_moderation_warning_reasons.id }); + await appendModerationAudit(tx, { + actorId: c.get("admin")?.user.id ?? null, + data: { reasonId: row.id, ...auditedSettings(body) }, + type: "reason_created", + }); + + return row; + }); + if (!created) return c.json({ error: "limit_reached" }, 409); + + await saveReasonWords(c, created.id, body); + await c + .get("events") + .emit("moderation.warning_reason.created", { reasonId: created.id }); + + return c.json({ id: created.id }, 201); + }, +}); + +export const updateWarningReasonAdminRoute = buildRoute({ + adminStaffPermission: { + module: "warning_settings", + permission: "can_manage", + }, + route: { + method: "patch", + path: "/reasons/{id}", + description: + "Edit or disable a warning reason (Admin only). Warnings already issued keep the points, expiry and title they were issued with", + request: { + params: idParams, + body: { + required: true, + content: { "application/json": { schema: reasonBody.partial() } }, + }, + }, + responses: { + 200: json(z.object({ id: z.number() }), "Warning reason updated"), + 403: { description: "Access Denied" }, + 404: json(errorSchema, "Warning reason not found"), + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const body = c.req.valid("json"); + const settings = auditedSettings(body); + const patch = Object.fromEntries( + Object.entries(settings).filter(([, value]) => value !== undefined), + ); + + const found = await c.get("db").transaction(async tx => { + const [row] = await tx + .select({ id: core_moderation_warning_reasons.id }) + .from(core_moderation_warning_reasons) + .where(eq(core_moderation_warning_reasons.id, id)) + .limit(1); + if (!row) return false; + if (Object.keys(patch).length > 0) { + await tx + .update(core_moderation_warning_reasons) + .set(patch) + .where(eq(core_moderation_warning_reasons.id, id)); + } + await appendModerationAudit(tx, { + actorId: c.get("admin")?.user.id ?? null, + data: { + reasonId: id, + ...patch, + wordsChanged: body.title !== undefined, + }, + type: "reason_updated", + }); + + return true; + }); + if (!found) return c.json({ error: "not_found" }, 404); + + await saveReasonWords(c, id, body); + await c + .get("events") + .emit("moderation.warning_reason.updated", { reasonId: id }); + + return c.json({ id }, 200); + }, +}); + +export const reorderWarningReasonsAdminRoute = buildRoute({ + adminStaffPermission: { + module: "warning_settings", + permission: "can_manage", + }, + route: { + method: "put", + path: "/reasons/reorder", + description: + "Set the order of every warning reason at once (Admin only). `ids` must list each reason exactly once", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ ids: z.array(z.number().int()).min(1) }), + }, + }, + }, + }, + responses: { + 200: { description: "Order saved" }, + 400: json(errorSchema, "`ids` does not match the current reasons"), + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + const { ids } = c.req.valid("json"); + const db = c.get("db"); + const current = await db + .select({ id: core_moderation_warning_reasons.id }) + .from(core_moderation_warning_reasons); + const known = new Set(current.map(row => row.id)); + if ( + ids.length !== known.size || + new Set(ids).size !== ids.length || + ids.some(id => !known.has(id)) + ) { + return c.json({ error: "mismatch" }, 400); + } + + const positionById = sql.join( + ids.map( + (id, position) => + sql`when ${core_moderation_warning_reasons.id} = ${id} then ${position}::integer`, + ), + sql` `, + ); + await db + .update(core_moderation_warning_reasons) + .set({ position: sql`case ${positionById} end` }) + .where(inArray(core_moderation_warning_reasons.id, ids)); + + return c.body(null, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/warnings/routes/warning-thresholds.routes.ts b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-thresholds.routes.ts new file mode 100644 index 0000000000..92ec137bea --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-thresholds.routes.ts @@ -0,0 +1,279 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { and, asc, count, eq, ne, sql } from "drizzle-orm"; + +import type { ContentDatabase } from "@/content/server/service"; + +import { buildRoute } from "@/api/builders"; +import { appendModerationAudit } from "@/api/lib/moderation/warnings/reasons"; +import { errorSchema, json } from "@/api/modules/moderation/lib/schema"; +import { core_moderation_warning_thresholds } from "@/database/warnings"; +import { + WARNING_LIMITS, + WARNING_THRESHOLD_ACTIONS, +} from "@/lib/moderation/warnings"; + +const thresholdSchema = z.object({ + id: z.number(), + points: z.number(), + action: z.enum(WARNING_THRESHOLD_ACTIONS), + durationHours: z.number().nullable(), + enabled: z.boolean(), + updatedAt: z.date(), +}); + +const thresholdFields = z.object({ + points: z.number().int().min(1).max(WARNING_LIMITS.maxThresholdPoints), + action: z.enum(WARNING_THRESHOLD_ACTIONS), + durationHours: z + .number() + .int() + .min(1) + .max(WARNING_LIMITS.maxDurationHours) + .nullable(), + enabled: z.boolean(), +}); + +/** A temporary suspension needs its length; an indefinite one has none. */ +const thresholdBody = thresholdFields.refine( + value => + value.action === "suspend_temporary" + ? value.durationHours !== null + : value.durationHours === null, + { message: "duration_mismatch", path: ["durationHours"] }, +); + +const idParams = z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), +}); + +const LOCK_THRESHOLDS = sql`lock table ${core_moderation_warning_thresholds} in share row exclusive mode`; + +/** Whether another enabled rule already sits at `points`; rejected as ambiguous. */ +const pointsTaken = async ( + tx: ContentDatabase, + points: number, + exceptId?: number, +): Promise => { + const table = core_moderation_warning_thresholds; + const [row] = await tx + .select({ id: table.id }) + .from(table) + .where( + and( + eq(table.points, points), + eq(table.enabled, true), + exceptId === undefined ? undefined : ne(table.id, exceptId), + ), + ) + .limit(1); + + return row !== undefined; +}; + +const actorOf = (c: Context) => c.get("admin")?.user.id ?? null; + +const responses = { + 400: json(errorSchema, "A temporary suspension needs a duration"), + 403: { description: "Access Denied" }, + 409: json( + errorSchema, + "Another enabled threshold uses the same points, or the limit is reached", + ), +}; + +export const listWarningThresholdsAdminRoute = buildRoute({ + adminStaffPermission: { module: "warning_settings", permission: "can_view" }, + route: { + method: "get", + path: "/thresholds", + description: "Every warning threshold, lowest points first (Admin only)", + responses: { + 200: json( + z.object({ thresholds: z.array(thresholdSchema) }), + "Warning thresholds", + ), + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + const table = core_moderation_warning_thresholds; + const thresholds = await c + .get("db") + .select({ + action: table.action, + durationHours: table.durationHours, + enabled: table.enabled, + id: table.id, + points: table.points, + updatedAt: table.updatedAt, + }) + .from(table) + .orderBy(asc(table.points), asc(table.id)); + + return c.json({ thresholds }, 200); + }, +}); + +export const createWarningThresholdAdminRoute = buildRoute({ + adminStaffPermission: { + module: "warning_settings", + permission: "can_manage", + }, + route: { + method: "post", + path: "/thresholds", + description: + "Add a threshold (Admin only). It applies to warnings issued from now on; members already above it are not suspended", + request: { + body: { + required: true, + content: { "application/json": { schema: thresholdBody } }, + }, + }, + responses: { + 201: json(z.object({ id: z.number() }), "Threshold created"), + ...responses, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + const table = core_moderation_warning_thresholds; + const result = await c.get("db").transaction(async tx => { + await tx.execute(LOCK_THRESHOLDS); + const [{ total }] = await tx.select({ total: count() }).from(table); + if (total >= WARNING_LIMITS.maxThresholds) { + return { error: "limit_reached" } as const; + } + if (body.enabled && (await pointsTaken(tx, body.points))) { + return { error: "duplicate_points" } as const; + } + + const [row] = await tx + .insert(table) + .values(body) + .returning({ id: table.id }); + await appendModerationAudit(tx, { + actorId: actorOf(c), + data: { thresholdId: row.id, ...body }, + type: "threshold_created", + }); + + return { id: row.id }; + }); + if ("error" in result) return c.json({ error: result.error }, 409); + + await c + .get("events") + .emit("moderation.warning_threshold.created", { thresholdId: result.id }); + + return c.json({ id: result.id }, 201); + }, +}); + +export const updateWarningThresholdAdminRoute = buildRoute({ + adminStaffPermission: { + module: "warning_settings", + permission: "can_manage", + }, + route: { + method: "put", + path: "/thresholds/{id}", + description: + "Edit, enable or disable a threshold (Admin only). Changes apply to future warnings only - nobody is suspended or unsuspended because of them", + request: { + params: idParams, + body: { + required: true, + content: { "application/json": { schema: thresholdBody } }, + }, + }, + responses: { + 200: json(z.object({ id: z.number() }), "Threshold updated"), + 404: json(errorSchema, "Threshold not found"), + ...responses, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const body = c.req.valid("json"); + const table = core_moderation_warning_thresholds; + const result = await c.get("db").transaction(async tx => { + await tx.execute(LOCK_THRESHOLDS); + const [existing] = await tx + .select({ id: table.id }) + .from(table) + .where(eq(table.id, id)) + .limit(1); + if (!existing) return { error: "not_found" } as const; + if (body.enabled && (await pointsTaken(tx, body.points, id))) { + return { error: "duplicate_points" } as const; + } + + await tx.update(table).set(body).where(eq(table.id, id)); + await appendModerationAudit(tx, { + actorId: actorOf(c), + data: { thresholdId: id, ...body }, + type: "threshold_updated", + }); + + return { id }; + }); + if ("error" in result) { + return result.error === "not_found" + ? c.json({ error: result.error }, 404) + : c.json({ error: result.error }, 409); + } + + await c + .get("events") + .emit("moderation.warning_threshold.updated", { thresholdId: id }); + + return c.json({ id }, 200); + }, +}); + +export const deleteWarningThresholdAdminRoute = buildRoute({ + adminStaffPermission: { + module: "warning_settings", + permission: "can_manage", + }, + route: { + method: "delete", + path: "/thresholds/{id}", + description: + "Delete a threshold (Admin only). Suspensions it created stay and keep a copy of the rule", + request: { params: idParams }, + responses: { + 200: { description: "Threshold deleted" }, + 403: { description: "Access Denied" }, + 404: json(errorSchema, "Threshold not found"), + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const table = core_moderation_warning_thresholds; + const deleted = await c.get("db").transaction(async tx => { + const [row] = await tx + .delete(table) + .where(eq(table.id, id)) + .returning({ id: table.id, points: table.points }); + if (!row) return false; + await appendModerationAudit(tx, { + actorId: actorOf(c), + data: { points: row.points, thresholdId: id }, + type: "threshold_deleted", + }); + + return true; + }); + if (!deleted) return c.json({ error: "not_found" }, 404); + + await c + .get("events") + .emit("moderation.warning_threshold.deleted", { thresholdId: id }); + + return c.body(null, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/warnings/warnings.admin.module.ts b/packages/vitnode/src/api/modules/admin/warnings/warnings.admin.module.ts new file mode 100644 index 0000000000..70a5b012a2 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/warnings/warnings.admin.module.ts @@ -0,0 +1,28 @@ +import { buildModule } from "@/api/builders"; + +import { + createWarningReasonAdminRoute, + listWarningReasonsAdminRoute, + reorderWarningReasonsAdminRoute, + updateWarningReasonAdminRoute, +} from "./routes/warning-reasons.routes"; +import { + createWarningThresholdAdminRoute, + deleteWarningThresholdAdminRoute, + listWarningThresholdsAdminRoute, + updateWarningThresholdAdminRoute, +} from "./routes/warning-thresholds.routes"; + +export const warningsAdminModule = buildModule({ + name: "warnings", + routes: [ + listWarningReasonsAdminRoute, + createWarningReasonAdminRoute, + reorderWarningReasonsAdminRoute, + updateWarningReasonAdminRoute, + listWarningThresholdsAdminRoute, + createWarningThresholdAdminRoute, + updateWarningThresholdAdminRoute, + deleteWarningThresholdAdminRoute, + ], +}); diff --git a/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts b/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts new file mode 100644 index 0000000000..b25b1697ed --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts @@ -0,0 +1,915 @@ +import type { Context } from "hono"; + +import { and, eq, isNull, sql } from "drizzle-orm"; + +import type { ModeratorContext } from "@/api/lib/moderation/access"; +import type { RegisteredReportTarget } from "@/api/lib/moderation/targets"; +import type { WarningReason } from "@/api/lib/moderation/warnings/reasons"; +import type { ContentDatabase } from "@/content/server/service"; +import type { + WarningContentSnapshot, + WarningThresholdAction, +} from "@/lib/moderation/warnings"; + +import { canOnReport } from "@/api/lib/moderation/access"; +import { findReportTarget } from "@/api/lib/moderation/registry"; +import { + canSeeWarning, + canWarnForContent, + hasWarningPermission, + warningTargetRefusal, +} from "@/api/lib/moderation/warnings/access"; +import { + appendModerationAudit, + listWarningReasons, +} from "@/api/lib/moderation/warnings/reasons"; +import { forgetSuspension } from "@/api/lib/moderation/warnings/suspension"; +import { + accountSuspendedNotification, + suspensionRevokedNotification, + warningIssuedNotification, + warningRevokedNotification, +} from "@/api/lib/notifications/core-types"; +import { core_users } from "@/database/users"; +import { + core_moderation_sanctions, + core_moderation_warning_thresholds, + core_moderation_warnings, +} from "@/database/warnings"; +import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; +import { + activeWarningPoints, + evaluateWarningThresholds, + thresholdSuspensionEnd, + WARNING_LIMITS, + WARNING_PERMISSIONS, +} from "@/lib/moderation/warnings"; + +import type { ReportRow } from "./reports"; + +import { + appendReportEvent, + authorizeReport, + reportConflict, + reportError, + updateReport, +} from "./reports"; + +const HOUR_MS = 60 * 60 * 1000; + +export const warningError = reportError; + +/** How the moderator wants the points to expire. */ +export type WarningExpirationInput = + | { at: Date; mode: "at" } + | { hours: number; mode: "hours" } + | { mode: "default" } + | { mode: "never" }; + +export interface WarningContentInput { + pluginId: string; + targetId: string; + targetType: string; +} + +/** What preview and issue share: who, why, how many points, and what for. */ +export interface WarningDraftInput { + content?: null | WarningContentInput; + expiration: WarningExpirationInput; + points?: null | number; + reasonId: null | number; + reportId?: null | number; + userId: number; +} + +export interface IssueWarningInput extends WarningDraftInput { + idempotencyKey: string; + message: string; + overrideNote?: null | string; + resolveReport?: boolean; + staffNote?: null | string; +} + +export interface WarningTargetUser { + id: number; + name: string; + roleId: number; +} + +export interface ResolvedWarningContent { + pluginId: string; + scopeFamily: null | string; + scopeId: null | string; + snapshot: WarningContentSnapshot; + target: RegisteredReportTarget; + targetId: string; + targetType: string; +} + +/** A draft after every server-side check: what the warning would be. */ +export interface ResolvedWarningDraft { + content: null | ResolvedWarningContent; + /** The reason's expiration in hours, `null` for never. */ + defaultExpiresAfterHours: null | number; + defaultPoints: number; + /** Computes the expiry against the time the warning is written. */ + expiresAt: (now: Date) => Date | null; + overrides: { expiration: boolean; points: boolean }; + points: number; + reason: null | WarningReason; + report: null | ReportRow; + user: WarningTargetUser; +} + +const loadTargetUser = async ( + c: Context, + userId: number, +): Promise => { + const [user] = await c + .get("db") + .select({ + id: core_users.id, + name: core_users.name, + roleId: core_users.roleId, + }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + + return user ?? null; +}; + +const resolveContent = async ( + c: Context, + moderator: ModeratorContext, + content: WarningContentInput, + userId: number, +): Promise => { + const target = findReportTarget(c.get("core").moderation, { + pluginId: content.pluginId, + type: content.targetType, + }); + const resolveAuthors = target?.definition.resolveAuthors; + if (!target || !resolveAuthors) { + throw warningError(404, { error: "content_not_found" }); + } + const targetId = target.definition.parseId(content.targetId); + if (targetId === null) + throw warningError(404, { error: "content_not_found" }); + + const { definition } = target; + let scopeId: null | string = null; + if (definition.scope && definition.resolveScope) { + const resolved = await definition.resolveScope(c, targetId); + if (resolved === undefined) { + throw warningError(404, { error: "content_not_found" }); + } + scopeId = resolved === "" ? null : resolved; + } + + const ref = { + pluginId: target.pluginId, + scopeId, + targetType: definition.type, + }; + if (!canWarnForContent(c, moderator.set, ref)) { + throw warningError(403, { error: "forbidden" }); + } + + const authors = await resolveAuthors(c, { targetId }); + if (!authors) throw warningError(404, { error: "content_not_found" }); + if (!authors.authorIds.includes(userId)) { + throw warningError(400, { error: "author_mismatch" }); + } + const current = await definition.current(c, { locale: null, targetId }); + + return { + pluginId: target.pluginId, + scopeFamily: definition.scope ?? null, + scopeId, + snapshot: { + title: authors.title ? authors.title.slice(0, 500) : null, + url: current?.url ?? null, + }, + target, + targetId, + targetType: definition.type, + }; +}; + +const sameContent = ( + a: WarningContentInput, + b: Pick, +): boolean => + a.pluginId === b.pluginId && + a.targetType === b.targetType && + a.targetId === b.targetId; + +/** + * Runs every check a warning needs - target protection, reason, scope, + * authorship, report match, override permissions - and resolves what the + * warning would hold. Never trusts a client-sent author or scope: both come + * from the content's own report target. + */ +export const resolveWarningDraft = async ( + c: Context, + moderator: ModeratorContext, + input: WarningDraftInput, +): Promise => { + const user = await loadTargetUser(c, input.userId); + if (!user) throw warningError(404, { error: "user_not_found" }); + + const refusal = await warningTargetRefusal(c, moderator, user); + if (refusal) throw warningError(403, { error: refusal }); + + let report: null | ReportRow = null; + let contentInput = input.content ?? null; + if (input.reportId) { + ({ report } = await authorizeReport(c, moderator, input.reportId)); + if (contentInput && !sameContent(contentInput, report)) { + throw warningError(400, { error: "report_mismatch" }); + } + contentInput = { + pluginId: report.pluginId, + targetId: report.targetId, + targetType: report.targetType, + }; + } + + const content = contentInput + ? await resolveContent(c, moderator, contentInput, user.id) + : null; + if ( + !content && + !hasWarningPermission(moderator.set, WARNING_PERMISSIONS.warn) + ) { + throw warningError(403, { error: "forbidden" }); + } + + let reason: null | WarningReason = null; + if (input.reasonId !== null) { + [reason = null] = await listWarningReasons(c, { ids: [input.reasonId] }); + if (!reason) throw warningError(404, { error: "reason_not_found" }); + if (!reason.enabled) throw warningError(409, { error: "reason_disabled" }); + } + + const defaultPoints = reason?.points ?? 0; + const defaultExpiresAfterHours = reason?.expiresAfterHours ?? null; + const points = input.points ?? defaultPoints; + if ( + !Number.isInteger(points) || + points < 0 || + points > WARNING_LIMITS.maxPoints + ) { + throw warningError(400, { error: "invalid_points" }); + } + + const { expiration } = input; + const overrides = { + expiration: + expiration.mode === "at" || + (expiration.mode === "never" && defaultExpiresAfterHours !== null) || + (expiration.mode === "hours" && + expiration.hours !== defaultExpiresAfterHours), + points: points !== defaultPoints, + }; + if ( + overrides.points && + !hasWarningPermission(moderator.set, WARNING_PERMISSIONS.overridePoints) + ) { + throw warningError(403, { error: "points_override_forbidden" }); + } + if ( + overrides.expiration && + !hasWarningPermission(moderator.set, WARNING_PERMISSIONS.overrideExpiration) + ) { + throw warningError(403, { error: "expiration_override_forbidden" }); + } + + const requestTime = Date.now(); + if (expiration.mode === "hours") { + if ( + !Number.isInteger(expiration.hours) || + expiration.hours < 1 || + expiration.hours > WARNING_LIMITS.maxDurationHours + ) { + throw warningError(400, { error: "invalid_expiration" }); + } + } + if (expiration.mode === "at") { + const at = expiration.at.getTime(); + if ( + Number.isNaN(at) || + at <= requestTime || + at > requestTime + WARNING_LIMITS.maxDurationHours * HOUR_MS + ) { + throw warningError(400, { error: "invalid_expiration" }); + } + } + + const expiresAt = (now: Date): Date | null => { + switch (expiration.mode) { + case "at": + return expiration.at; + case "default": + return defaultExpiresAfterHours === null + ? null + : new Date(now.getTime() + defaultExpiresAfterHours * HOUR_MS); + case "hours": + return new Date(now.getTime() + expiration.hours * HOUR_MS); + case "never": + return null; + } + }; + + return { + content, + defaultExpiresAfterHours, + defaultPoints, + expiresAt, + overrides, + points, + reason, + report, + user, + }; +}; + +type Db = ContentDatabase; + +const lockUserWarnings = async (tx: Db, userId: number): Promise => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`core_moderation_warnings:${userId}`}))`, + ); +}; + +/** The user's active points at `now`, read through `db`. */ +export const loadActivePoints = async ( + db: Db, + userId: number, + now: Date, +): Promise => { + const rows = await db + .select({ + expiresAt: core_moderation_warnings.expiresAt, + points: core_moderation_warnings.points, + revokedAt: core_moderation_warnings.revokedAt, + }) + .from(core_moderation_warnings) + .where(eq(core_moderation_warnings.userId, userId)); + + return activeWarningPoints(rows, now); +}; + +export const loadEnabledThresholds = async (db: Db) => + await db + .select({ + action: core_moderation_warning_thresholds.action, + durationHours: core_moderation_warning_thresholds.durationHours, + enabled: core_moderation_warning_thresholds.enabled, + id: core_moderation_warning_thresholds.id, + points: core_moderation_warning_thresholds.points, + }) + .from(core_moderation_warning_thresholds) + .where(eq(core_moderation_warning_thresholds.enabled, true)); + +export interface ProjectedSanction { + action: WarningThresholdAction; + durationHours: null | number; + endsAt: Date | null; + points: number; + thresholdId: number; +} + +/** + * What a warning of `points` expiring at `expiresAt` would do to the user now. + * Advisory: {@link issueWarning} computes it again under the user's lock. + */ +export const projectWarning = async ( + c: Context, + { + expiresAt, + points, + userId, + }: { expiresAt: Date | null; points: number; userId: number }, +) => { + const db = c.get("db"); + const now = new Date(); + const [before, rules] = await Promise.all([ + loadActivePoints(db, userId, now), + loadEnabledThresholds(db), + ]); + const counts = expiresAt === null || expiresAt.getTime() > now.getTime(); + const after = before + (counts ? points : 0); + const { applied, crossed } = evaluateWarningThresholds({ + after, + before, + rules, + }); + + return { + after, + before, + crossed: crossed.map(rule => rule.id), + sanction: applied + ? { + action: applied.action, + durationHours: applied.durationHours, + endsAt: thresholdSuspensionEnd(applied, now), + points: applied.points, + thresholdId: applied.id, + } + : null, + } satisfies { + after: number; + before: number; + crossed: number[]; + sanction: null | ProjectedSanction; + }; +}; + +export interface IssuedWarning { + duplicate: boolean; + points: { after: number; before: number }; + reportResolved: boolean; + sanction: null | { + endsAt: Date | null; + id: number; + thresholdId: null | number; + }; + warningId: number; +} + +const findSanctionOfWarning = async (db: Db, warningId: number) => { + const [sanction] = await db + .select({ + endsAt: core_moderation_sanctions.endsAt, + id: core_moderation_sanctions.id, + thresholdId: core_moderation_sanctions.thresholdId, + }) + .from(core_moderation_sanctions) + .where(eq(core_moderation_sanctions.warningId, warningId)) + .limit(1); + + return sanction ?? null; +}; + +const findIssuedWarning = async ( + db: Db, + moderator: ModeratorContext, + { + idempotencyKey, + userId, + }: Pick, +): Promise => { + const [existing] = await db + .select({ + id: core_moderation_warnings.id, + pointsAfter: core_moderation_warnings.pointsAfter, + pointsBefore: core_moderation_warnings.pointsBefore, + reportId: core_moderation_warnings.reportId, + userId: core_moderation_warnings.userId, + }) + .from(core_moderation_warnings) + .where( + and( + eq(core_moderation_warnings.issuedById, moderator.user.id), + eq(core_moderation_warnings.idempotencyKey, idempotencyKey), + ), + ) + .limit(1); + if (!existing) return null; + if (existing.userId !== userId) { + throw warningError(409, { error: "idempotency_conflict" }); + } + + return { + duplicate: true, + points: { after: existing.pointsAfter, before: existing.pointsBefore }, + reportResolved: false, + sanction: await findSanctionOfWarning(db, existing.id), + warningId: existing.id, + }; +}; + +/** + * Issues a warning. Serialized per warned user with a transaction-scoped + * advisory lock, so concurrent warnings read each other's points and a + * threshold creates at most one sanction. The warning, the threshold decision, + * the sanction, the audit entries, the optional report resolution and the + * notifications commit together or not at all. A retry with the same + * `idempotencyKey` from the same moderator returns the first result. + */ +export const issueWarning = async ( + c: Context, + moderator: ModeratorContext, + input: IssueWarningInput, +): Promise => { + // A retry of a request that already went through answers with its result, + // even when the report it resolved is closed or the content is gone now. + const previous = await findIssuedWarning(c.get("db"), moderator, input); + if (previous) return previous; + + const draft = await resolveWarningDraft(c, moderator, input); + const overridden = draft.overrides.points || draft.overrides.expiration; + const overrideNote = input.overrideNote?.trim() ?? ""; + const staffNote = input.staffNote?.trim() ?? ""; + if (overridden && overrideNote.length < WARNING_LIMITS.explanationMinLength) { + throw warningError(400, { error: "override_note_required" }); + } + + const { report } = draft; + const resolveReport = Boolean(report && input.resolveReport); + if (report && resolveReport) { + const manage = canOnReport( + c, + moderator.set, + { + pluginId: report.pluginId, + scopeId: report.scopeId, + targetType: report.targetType, + }, + MODERATION_PERMISSIONS.manageReports, + ); + if (!manage) throw warningError(403, { error: "forbidden" }); + if (report.status === "done") throw reportConflict(report, "report_closed"); + } + + const result = await c.get("db").transaction(async (tx: Db) => { + await lockUserWarnings(tx, draft.user.id); + + const existing = await findIssuedWarning(tx, moderator, input); + if (existing) return existing; + + const now = new Date(); + const expiresAt = draft.expiresAt(now); + const before = await loadActivePoints(tx, draft.user.id, now); + const counts = expiresAt === null || expiresAt.getTime() > now.getTime(); + const after = before + (counts ? draft.points : 0); + + const [warning] = await tx + .insert(core_moderation_warnings) + .values({ + contentSnapshot: draft.content?.snapshot ?? null, + defaultPoints: draft.defaultPoints, + expiresAt, + idempotencyKey: input.idempotencyKey, + issuedAt: now, + issuedById: moderator.user.id, + message: input.message.trim(), + overrideNote: overridden ? overrideNote : null, + pluginId: draft.content?.pluginId ?? null, + points: draft.points, + pointsAfter: after, + pointsBefore: before, + reasonId: draft.reason?.id ?? null, + reasonSnapshot: draft.reason ? { title: draft.reason.title } : null, + reportId: report?.id ?? null, + scopeFamily: draft.content?.scopeFamily ?? null, + scopeId: draft.content?.scopeId ?? null, + staffNote: staffNote === "" ? null : staffNote, + targetId: draft.content?.targetId ?? null, + targetType: draft.content?.targetType ?? null, + userId: draft.user.id, + }) + .returning({ id: core_moderation_warnings.id }); + + await appendModerationAudit(tx, { + actorId: moderator.user.id, + data: { + content: draft.content + ? { + pluginId: draft.content.pluginId, + targetId: draft.content.targetId, + targetType: draft.content.targetType, + } + : null, + expiresAt: expiresAt?.toISOString() ?? null, + points: draft.points, + reasonId: draft.reason?.id ?? null, + reportId: report?.id ?? null, + }, + type: "warning_issued", + userId: draft.user.id, + warningId: warning.id, + }); + if (overridden) { + await appendModerationAudit(tx, { + actorId: moderator.user.id, + data: { + defaultExpiresAfterHours: draft.defaultExpiresAfterHours, + defaultPoints: draft.defaultPoints, + expiresAt: expiresAt?.toISOString() ?? null, + expiration: draft.overrides.expiration, + points: draft.points, + pointsOverridden: draft.overrides.points, + }, + type: "warning_overridden", + userId: draft.user.id, + warningId: warning.id, + }); + } + + const rules = await loadEnabledThresholds(tx); + const { applied, crossed } = evaluateWarningThresholds({ + after, + before, + rules, + }); + await appendModerationAudit(tx, { + actorId: moderator.user.id, + data: { + after, + appliedThresholdId: applied?.id ?? null, + before, + crossedThresholdIds: crossed.map(rule => rule.id), + }, + type: "threshold_evaluated", + userId: draft.user.id, + warningId: warning.id, + }); + + let sanction: IssuedWarning["sanction"] = null; + if (applied) { + const endsAt = thresholdSuspensionEnd(applied, now); + const [row] = await tx + .insert(core_moderation_sanctions) + .values({ + createdById: moderator.user.id, + endsAt, + source: "threshold", + startsAt: now, + thresholdId: applied.id, + thresholdSnapshot: { + action: applied.action, + durationHours: applied.durationHours, + points: applied.points, + }, + type: "suspension", + userId: draft.user.id, + warningId: warning.id, + }) + .returning({ id: core_moderation_sanctions.id }); + sanction = { endsAt, id: row.id, thresholdId: applied.id }; + await appendModerationAudit(tx, { + actorId: moderator.user.id, + data: { + action: applied.action, + endsAt: endsAt?.toISOString() ?? null, + source: "threshold", + thresholdId: applied.id, + thresholdPoints: applied.points, + }, + sanctionId: row.id, + type: "sanction_applied", + userId: draft.user.id, + warningId: warning.id, + }); + } + + if (report) { + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { + points: draft.points, + userId: draft.user.id, + warningId: warning.id, + }, + reportId: report.id, + type: "warning", + }); + if (resolveReport) { + await updateReport(tx, report, { + openKey: null, + resolution: "action_taken", + resolvedAt: now, + resolvedById: moderator.user.id, + status: "done", + }); + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { resolution: "action_taken", warningId: warning.id }, + reportId: report.id, + type: "resolved", + }); + } + } + + const notifications = c.get("notifications"); + await notifications.publish({ + actorId: null, + data: { + message: input.message.trim(), + points: draft.points, + reason: draft.reason?.title ?? null, + warningId: warning.id, + }, + idempotencyKey: `warning:${warning.id}`, + recipients: [draft.user.id], + tx, + type: warningIssuedNotification, + }); + if (sanction) { + await notifications.publish({ + actorId: null, + data: { + endsAt: sanction.endsAt?.toISOString() ?? null, + sanctionId: sanction.id, + }, + idempotencyKey: `sanction:${sanction.id}`, + recipients: [draft.user.id], + tx, + type: accountSuspendedNotification, + }); + } + + return { + duplicate: false, + points: { after, before }, + reportResolved: resolveReport, + sanction, + warningId: warning.id, + } satisfies IssuedWarning; + }); + + if (result.duplicate) return result; + + if (result.sanction) { + await forgetSuspension(c, draft.user.id, { disconnect: true }); + } + const events = c.get("events"); + await events.emit("moderation.warning.issued", { + issuedById: moderator.user.id, + pluginId: draft.content?.pluginId ?? null, + points: draft.points, + reasonId: draft.reason?.id ?? null, + reportId: report?.id ?? null, + targetId: draft.content?.targetId ?? null, + targetType: draft.content?.targetType ?? null, + userId: draft.user.id, + warningId: result.warningId, + }); + if (result.sanction) { + await events.emit("moderation.sanction.applied", { + endsAt: result.sanction.endsAt, + sanctionId: result.sanction.id, + source: "threshold", + thresholdId: result.sanction.thresholdId, + type: "suspension", + userId: draft.user.id, + warningId: result.warningId, + }); + } + if (report && result.reportResolved) { + await events.emit("moderation.report.resolved", { + reportId: report.id, + resolution: "action_taken", + }); + } + + return result; +}; + +/** + * Revokes a warning: its points stop counting at once, the record stays. + * Sanctions it led to stay too - lifting them is a separate action. + */ +export const revokeWarning = async ( + c: Context, + moderator: ModeratorContext, + { id, reason }: { id: number; reason: string }, +) => { + const db = c.get("db"); + const [warning] = await db + .select() + .from(core_moderation_warnings) + .where(eq(core_moderation_warnings.id, id)) + .limit(1); + if (!warning || !canSeeWarning(c, moderator, warning)) { + throw warningError(404, { error: "not_found" }); + } + if (!hasWarningPermission(moderator.set, WARNING_PERMISSIONS.revoke)) { + throw warningError(403, { error: "forbidden" }); + } + if (warning.revokedAt) throw warningError(409, { error: "already_revoked" }); + + const revoked = await db.transaction(async (tx: Db) => { + const now = new Date(); + const [row] = await tx + .update(core_moderation_warnings) + .set({ + revokedAt: now, + revokedById: moderator.user.id, + revokeReason: reason.trim(), + }) + .where( + and( + eq(core_moderation_warnings.id, id), + isNull(core_moderation_warnings.revokedAt), + ), + ) + .returning({ id: core_moderation_warnings.id }); + if (!row) return false; + + await appendModerationAudit(tx, { + actorId: moderator.user.id, + data: { points: warning.points }, + type: "warning_revoked", + userId: warning.userId, + warningId: id, + }); + await c.get("notifications").publish({ + actorId: null, + data: { + points: warning.points, + reason: warning.reasonSnapshot?.title ?? null, + warningId: id, + }, + idempotencyKey: `warning-revoked:${id}`, + recipients: [warning.userId], + tx, + type: warningRevokedNotification, + }); + + return true; + }); + if (!revoked) throw warningError(409, { error: "already_revoked" }); + + await c.get("events").emit("moderation.warning.revoked", { + revokedById: moderator.user.id, + userId: warning.userId, + warningId: id, + }); + + return { userId: warning.userId, warningId: id }; +}; + +/** + * Lifts one sanction. Other active sanctions of the same user keep applying. + */ +export const revokeSanction = async ( + c: Context, + moderator: ModeratorContext, + { id, reason }: { id: number; reason: string }, +) => { + if ( + !hasWarningPermission(moderator.set, WARNING_PERMISSIONS.manageSanctions) + ) { + throw warningError(403, { error: "forbidden" }); + } + const db = c.get("db"); + const [sanction] = await db + .select() + .from(core_moderation_sanctions) + .where(eq(core_moderation_sanctions.id, id)) + .limit(1); + if (!sanction) throw warningError(404, { error: "not_found" }); + if (sanction.revokedAt) throw warningError(409, { error: "already_revoked" }); + + const revoked = await db.transaction(async (tx: Db) => { + const [row] = await tx + .update(core_moderation_sanctions) + .set({ + revokedAt: new Date(), + revokedById: moderator.user.id, + revokeReason: reason.trim(), + }) + .where( + and( + eq(core_moderation_sanctions.id, id), + isNull(core_moderation_sanctions.revokedAt), + ), + ) + .returning({ id: core_moderation_sanctions.id }); + if (!row) return false; + + await appendModerationAudit(tx, { + actorId: moderator.user.id, + data: { type: sanction.type }, + sanctionId: id, + type: "sanction_revoked", + userId: sanction.userId, + warningId: sanction.warningId, + }); + await c.get("notifications").publish({ + actorId: null, + data: { sanctionId: id }, + idempotencyKey: `sanction-revoked:${id}`, + recipients: [sanction.userId], + tx, + type: suspensionRevokedNotification, + }); + + return true; + }); + if (!revoked) throw warningError(409, { error: "already_revoked" }); + + await forgetSuspension(c, sanction.userId, { disconnect: false }); + await c.get("events").emit("moderation.sanction.revoked", { + revokedById: moderator.user.id, + sanctionId: id, + userId: sanction.userId, + }); + + return { sanctionId: id, userId: sanction.userId }; +}; diff --git a/packages/vitnode/src/api/modules/moderation/lib/warnings-view.ts b/packages/vitnode/src/api/modules/moderation/lib/warnings-view.ts new file mode 100644 index 0000000000..98b5ef64d1 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/warnings-view.ts @@ -0,0 +1,223 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { desc, eq, inArray } from "drizzle-orm"; + +import type { ModeratorContext } from "@/api/lib/moderation/access"; + +import { findReportTarget } from "@/api/lib/moderation/registry"; +import { + canSeeWarning, + canSeeWarningNotes, + hasWarningPermission, +} from "@/api/lib/moderation/warnings/access"; +import { + core_moderation_sanctions, + core_moderation_warnings, +} from "@/database/warnings"; +import { + isSanctionActiveAt, + SANCTION_SOURCES, + SANCTION_TYPES, + WARNING_PERMISSIONS, + WARNING_STATUSES, + WARNING_THRESHOLD_ACTIONS, + warningStatusAt, +} from "@/lib/moderation/warnings"; + +import { loadReportPeople } from "./people"; +import { reportPersonSchema } from "./schema"; + +const textSchema = z.array( + z.object({ languageCode: z.string(), value: z.string() }), +); + +export const warningContentSchema = z.object({ + pluginId: z.string(), + targetType: z.string(), + targetId: z.string(), + targetLabel: z.string().nullable(), + title: z.string().nullable(), + url: z.string().nullable(), +}); + +/** A warning as staff see it; notes are `null` without `can_view_notes`. */ +export const staffWarningSchema = z.object({ + id: z.number(), + userId: z.number(), + status: z.enum(WARNING_STATUSES), + points: z.number(), + defaultPoints: z.number().nullable(), + reasonId: z.number().nullable(), + reason: textSchema.nullable(), + message: z.string(), + staffNote: z.string().nullable(), + overrideNote: z.string().nullable(), + issuedAt: z.date(), + expiresAt: z.date().nullable(), + issuedBy: reportPersonSchema.nullable(), + content: warningContentSchema.nullable(), + reportId: z.number().nullable(), + revokedAt: z.date().nullable(), + revokedBy: reportPersonSchema.nullable(), + revokeReason: z.string().nullable(), + sanctionId: z.number().nullable(), +}); + +export const sanctionSchema = z.object({ + id: z.number(), + type: z.enum(SANCTION_TYPES), + source: z.enum(SANCTION_SOURCES), + active: z.boolean(), + startsAt: z.date(), + endsAt: z.date().nullable(), + warningId: z.number().nullable(), + threshold: z + .object({ + action: z.enum(WARNING_THRESHOLD_ACTIONS), + durationHours: z.number().nullable(), + points: z.number(), + }) + .nullable(), + createdBy: reportPersonSchema.nullable(), + revokedAt: z.date().nullable(), + revokedBy: reportPersonSchema.nullable(), + revokeReason: z.string().nullable(), +}); + +export type WarningRow = typeof core_moderation_warnings.$inferSelect; +export type SanctionRow = typeof core_moderation_sanctions.$inferSelect; + +const contentOf = (c: Context, row: WarningRow) => { + if (!row.pluginId || !row.targetType || !row.targetId) return null; + + return { + pluginId: row.pluginId, + targetType: row.targetType, + targetId: row.targetId, + targetLabel: + findReportTarget(c.get("core").moderation, { + pluginId: row.pluginId, + type: row.targetType, + })?.definition.label ?? null, + title: row.contentSnapshot?.title ?? null, + url: row.contentSnapshot?.url ?? null, + }; +}; + +/** + * Turns warning rows into what one moderator may read: rows they may not + * see are dropped, and staff notes are blanked unless allowed. + */ +export const serializeStaffWarnings = async ( + c: Context, + moderator: ModeratorContext, + rows: WarningRow[], + now: Date = new Date(), +) => { + const visible = rows.filter(row => canSeeWarning(c, moderator, row)); + const canViewSanctions = hasWarningPermission( + moderator.set, + WARNING_PERMISSIONS.viewSanctions, + ); + const [people, sanctions] = await Promise.all([ + loadReportPeople( + c, + visible.flatMap(row => [row.issuedById, row.revokedById]), + ), + canViewSanctions && visible.length > 0 + ? c + .get("db") + .select({ + id: core_moderation_sanctions.id, + warningId: core_moderation_sanctions.warningId, + }) + .from(core_moderation_sanctions) + .where( + inArray( + core_moderation_sanctions.warningId, + visible.map(row => row.id), + ), + ) + : [], + ]); + const person = (id: null | number) => + id === null ? null : (people.get(id) ?? null); + + return visible.map(row => { + const notes = canSeeWarningNotes(moderator, row); + + return { + id: row.id, + userId: row.userId, + status: warningStatusAt(row, now), + points: row.points, + defaultPoints: row.defaultPoints, + reasonId: row.reasonId, + reason: row.reasonSnapshot?.title ?? null, + message: row.message, + staffNote: notes ? row.staffNote : null, + overrideNote: notes ? row.overrideNote : null, + issuedAt: row.issuedAt, + expiresAt: row.expiresAt, + issuedBy: person(row.issuedById), + content: contentOf(c, row), + reportId: row.reportId, + revokedAt: row.revokedAt, + revokedBy: person(row.revokedById), + revokeReason: row.revokeReason, + sanctionId: + sanctions.find(sanction => sanction.warningId === row.id)?.id ?? null, + }; + }); +}; + +export const serializeSanctions = async ( + c: Context, + rows: SanctionRow[], + now: Date = new Date(), +) => { + const people = await loadReportPeople( + c, + rows.flatMap(row => [row.createdById, row.revokedById]), + ); + const person = (id: null | number) => + id === null ? null : (people.get(id) ?? null); + + return rows.map(row => ({ + id: row.id, + type: row.type, + source: row.source, + active: isSanctionActiveAt(row, now), + startsAt: row.startsAt, + endsAt: row.endsAt, + warningId: row.warningId, + threshold: row.thresholdSnapshot, + createdBy: person(row.createdById), + revokedAt: row.revokedAt, + revokedBy: person(row.revokedById), + revokeReason: row.revokeReason, + })); +}; + +export const loadUserWarnings = async (c: Context, userId: number) => + await c + .get("db") + .select() + .from(core_moderation_warnings) + .where(eq(core_moderation_warnings.userId, userId)) + .orderBy( + desc(core_moderation_warnings.issuedAt), + desc(core_moderation_warnings.id), + ); + +export const loadUserSanctions = async (c: Context, userId: number) => + await c + .get("db") + .select() + .from(core_moderation_sanctions) + .where(eq(core_moderation_sanctions.userId, userId)) + .orderBy( + desc(core_moderation_sanctions.startsAt), + desc(core_moderation_sanctions.id), + ); diff --git a/packages/vitnode/src/api/modules/moderation/moderation.module.ts b/packages/vitnode/src/api/modules/moderation/moderation.module.ts index 390c2a50af..afc46dc58f 100644 --- a/packages/vitnode/src/api/modules/moderation/moderation.module.ts +++ b/packages/vitnode/src/api/modules/moderation/moderation.module.ts @@ -1,5 +1,6 @@ import { buildModule } from "@/api/builders"; +import { accountStatusRoute } from "./routes/account-status.route"; import { approvalFiltersRoute, approveApprovalRoute, @@ -16,6 +17,16 @@ import { } from "./routes/notes.routes"; import { listReportReasonsRoute } from "./routes/reasons.route"; import { showReportRoute } from "./routes/show.route"; +import { + issueWarningRoute, + listWarningReasonsRoute, + listWarningsRoute, + memberStandingRoute, + previewWarningRoute, + revokeSanctionRoute, + revokeWarningRoute, + searchMembersRoute, +} from "./routes/warnings.routes"; import { assignReportRoute, claimReportRoute, @@ -48,5 +59,14 @@ export const moderationModule = buildModule({ showApprovalRoute, approveApprovalRoute, rejectApprovalRoute, + listWarningReasonsRoute, + previewWarningRoute, + issueWarningRoute, + listWarningsRoute, + revokeWarningRoute, + revokeSanctionRoute, + searchMembersRoute, + memberStandingRoute, + accountStatusRoute, ], }); diff --git a/packages/vitnode/src/api/modules/moderation/routes/account-status.route.ts b/packages/vitnode/src/api/modules/moderation/routes/account-status.route.ts new file mode 100644 index 0000000000..daf65a9522 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/account-status.route.ts @@ -0,0 +1,126 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; +import { findReportTarget } from "@/api/lib/moderation/registry"; +import { findActiveSuspension } from "@/api/lib/moderation/warnings/suspension"; +import { + isSanctionActiveAt, + WARNING_STATUSES, + warningStatusAt, +} from "@/lib/moderation/warnings"; + +import { json } from "../lib/schema"; +import { loadActivePoints } from "../lib/warnings-service"; +import { loadUserSanctions, loadUserWarnings } from "../lib/warnings-view"; + +const textSchema = z.array( + z.object({ languageCode: z.string(), value: z.string() }), +); + +/** + * What a member may read about their own warnings. Built field by field so + * staff notes, override explanations, revocation reasons and the issuing + * moderator never leave the server. + */ +const ownWarningSchema = z.object({ + id: z.number(), + status: z.enum(WARNING_STATUSES), + points: z.number(), + reason: textSchema.nullable(), + message: z.string(), + issuedAt: z.date(), + expiresAt: z.date().nullable(), + content: z + .object({ title: z.string().nullable(), url: z.string().nullable() }) + .nullable(), +}); + +const ownSanctionSchema = z.object({ + id: z.number(), + active: z.boolean(), + startsAt: z.date(), + endsAt: z.date().nullable(), + revoked: z.boolean(), + warningId: z.number().nullable(), +}); + +export const accountStatusRoute = buildRoute({ + route: { + method: "get", + path: "/account-status", + description: + "The signed-in member's own standing: active warning points, warning history and suspensions. Also answers for a suspended account, whose session is otherwise treated as a guest. Never includes staff notes", + responses: { + 200: json( + z.object({ + suspension: z.object({ endsAt: z.date().nullable() }).nullable(), + activePoints: z.number(), + warnings: z.array(ownWarningSchema), + sanctions: z.array(ownSanctionSchema), + }), + "The account's standing", + ), + 401: { description: "Sign in first" }, + }, + }, + handler: async c => { + const userId = c.get("user")?.id ?? c.get("suspension")?.userId; + if (!userId) return c.json({ error: "unauthenticated" }, 401); + + const now = new Date(); + const [warnings, sanctions, activePoints, suspension] = await Promise.all([ + loadUserWarnings(c, userId), + loadUserSanctions(c, userId), + loadActivePoints(c.get("db"), userId, now), + findActiveSuspension(c, userId, now), + ]); + const registry = c.get("core").moderation; + + const contentOf = async (row: (typeof warnings)[number]) => { + if (!row.pluginId || !row.targetType || !row.targetId) return null; + const target = findReportTarget(registry, { + pluginId: row.pluginId, + type: row.targetType, + }); + // Link only to content that is still there and not hidden. + const current = target + ? await target.definition + .current(c, { locale: null, targetId: row.targetId }) + .catch(() => null) + : null; + + return { + title: row.contentSnapshot?.title ?? null, + url: current && !current.hidden ? current.url : null, + }; + }; + + return c.json( + { + suspension: suspension ? { endsAt: suspension.endsAt } : null, + activePoints, + warnings: await Promise.all( + warnings.map(async row => ({ + id: row.id, + status: warningStatusAt(row, now), + points: row.points, + reason: row.reasonSnapshot?.title ?? null, + message: row.message, + issuedAt: row.issuedAt, + expiresAt: row.expiresAt, + content: await contentOf(row), + })), + ), + sanctions: sanctions.map(row => ({ + id: row.id, + active: isSanctionActiveAt(row, now), + startsAt: row.startsAt, + endsAt: row.endsAt, + revoked: row.revokedAt !== null, + warningId: row.warningId, + })), + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/moderation/routes/show.route.ts b/packages/vitnode/src/api/modules/moderation/routes/show.route.ts index 653e96b4d0..3796bdf37c 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/show.route.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/show.route.ts @@ -3,6 +3,7 @@ import { asc, eq } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; import { canOnReport, requireModerator } from "@/api/lib/moderation/access"; +import { canWarnForContent } from "@/api/lib/moderation/warnings/access"; import { core_moderation_report_events } from "@/database/moderation"; import { MODERATION_PERMISSIONS, @@ -76,6 +77,8 @@ const detailSchema = z.object({ }), permissions: z.object({ manage: z.boolean(), + /** Whether the moderator may warn the content's authors from this report. */ + warn: z.boolean(), actions: z.array( z.object({ action: z.enum(MODERATION_TARGET_ACTIONS), @@ -217,6 +220,9 @@ export const showReportRoute = buildRoute({ }, permissions: { manage, + warn: + target?.definition.resolveAuthors !== undefined && + canWarnForContent(c, moderator.set, ref), actions: MODERATION_TARGET_ACTIONS.flatMap(action => { const definition = target?.definition.actions?.[action]; if (!definition) return []; diff --git a/packages/vitnode/src/api/modules/moderation/routes/warnings.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/warnings.routes.ts new file mode 100644 index 0000000000..a565b927b6 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/warnings.routes.ts @@ -0,0 +1,633 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { + and, + count, + desc, + eq, + gt, + gte, + ilike, + isNotNull, + isNull, + lte, + or, +} from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { ModeratorContext } from "@/api/lib/moderation/access"; + +import { buildRoute } from "@/api/builders"; +import { requireModerator } from "@/api/lib/moderation/access"; +import { + canWarnAnywhere, + hasWarningPermission, + viewableWarningsCondition, +} from "@/api/lib/moderation/warnings/access"; +import { + listWarningReasons, + warningReasonSchema, +} from "@/api/lib/moderation/warnings/reasons"; +import { findActiveSuspension } from "@/api/lib/moderation/warnings/suspension"; +import { core_users } from "@/database/users"; +import { core_moderation_warnings } from "@/database/warnings"; +import { containsLikePattern } from "@/lib/api/like-pattern"; +import { + WARNING_LIMITS, + WARNING_PERMISSIONS, + WARNING_THRESHOLD_ACTIONS, +} from "@/lib/moderation/warnings"; + +import type { WarningDraftInput } from "../lib/warnings-service"; + +import { loadReportPeople } from "../lib/people"; +import { errorSchema, json, reportPersonSchema } from "../lib/schema"; +import { + issueWarning, + loadActivePoints, + projectWarning, + resolveWarningDraft, + revokeSanction, + revokeWarning, +} from "../lib/warnings-service"; +import { + loadUserSanctions, + loadUserWarnings, + sanctionSchema, + serializeSanctions, + serializeStaffWarnings, + staffWarningSchema, +} from "../lib/warnings-view"; + +const body = (schema: T) => ({ + body: { content: { "application/json": { schema } }, required: true }, +}); + +const idParams = z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), +}); + +const expirationSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("default") }), + z.object({ mode: z.literal("never") }), + z.object({ + mode: z.literal("hours"), + hours: z.number().int().min(1).max(WARNING_LIMITS.maxDurationHours), + }), + z.object({ mode: z.literal("at"), at: z.iso.datetime() }), +]); + +const draftSchema = z.object({ + userId: z.number().int().positive(), + reasonId: z.number().int().positive().nullable(), + points: z.number().int().min(0).max(WARNING_LIMITS.maxPoints).nullish(), + expiration: expirationSchema.default({ mode: "default" }), + content: z + .object({ + pluginId: z.string().min(1).max(100), + targetType: z.string().min(1).max(100), + targetId: z.string().min(1).max(64), + }) + .nullish(), + reportId: z.number().int().positive().nullish(), +}); + +const issueSchema = draftSchema.extend({ + message: z + .string() + .trim() + .min(WARNING_LIMITS.messageMinLength) + .max(WARNING_LIMITS.messageMaxLength), + staffNote: z.string().trim().max(WARNING_LIMITS.noteMaxLength).nullish(), + overrideNote: z.string().trim().max(WARNING_LIMITS.noteMaxLength).nullish(), + resolveReport: z.boolean().default(false), + idempotencyKey: z + .string() + .min(8) + .max(64) + .regex(/^[\w-]+$/), +}); + +const revokeSchema = z.object({ + reason: z + .string() + .trim() + .min(WARNING_LIMITS.explanationMinLength) + .max(WARNING_LIMITS.noteMaxLength), +}); + +const toDraft = (input: z.infer): WarningDraftInput => ({ + content: input.content ?? null, + expiration: + input.expiration.mode === "at" + ? { at: new Date(input.expiration.at), mode: "at" } + : input.expiration, + points: input.points ?? null, + reasonId: input.reasonId, + reportId: input.reportId ?? null, + userId: input.userId, +}); + +const commonResponses = { + 400: json(errorSchema, "Invalid input"), + 401: { description: "Sign in first" }, + 403: json(errorSchema, "Not allowed for this moderator or this member"), + 404: json(errorSchema, "No such member, reason, content or report"), + 409: json(errorSchema, "The state changed, or the reason is disabled"), +}; + +const requireWarner = async (c: Context): Promise => { + const moderator = await requireModerator(c); + if (!canWarnAnywhere(c, moderator.set)) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + return moderator; +}; + +export const listWarningReasonsRoute = buildRoute({ + route: { + method: "get", + path: "/warnings/reasons", + description: + "Enabled warning reasons, in display order, for the warn form (moderators who can warn)", + responses: { + 200: json( + z.object({ reasons: z.array(warningReasonSchema) }), + "Warning reasons", + ), + 401: commonResponses[401], + 403: { description: "Cannot warn anyone" }, + }, + }, + handler: async c => { + await requireWarner(c); + + return c.json( + { reasons: await listWarningReasons(c, { enabledOnly: true }) }, + 200, + ); + }, +}); + +const projectedSanctionSchema = z.object({ + action: z.enum(WARNING_THRESHOLD_ACTIONS), + durationHours: z.number().nullable(), + endsAt: z.date().nullable(), + points: z.number(), + thresholdId: z.number(), +}); + +export const previewWarningRoute = buildRoute({ + route: { + method: "post", + path: "/warnings/preview", + description: + "Runs every check of a warning without issuing it and predicts its consequences: the member's active points before and after, and the suspension a threshold would apply. Advisory - issuing recalculates", + request: body(draftSchema), + responses: { + 200: json( + z.object({ + user: reportPersonSchema, + points: z.object({ + current: z.number(), + warning: z.number(), + projected: z.number(), + }), + defaults: z.object({ + points: z.number(), + expiresAfterHours: z.number().nullable(), + }), + expiresAt: z.date().nullable(), + overrides: z.object({ points: z.boolean(), expiration: z.boolean() }), + sanction: projectedSanctionSchema.nullable(), + suspendedUntil: z.object({ endsAt: z.date().nullable() }).nullable(), + permissions: z.object({ + overridePoints: z.boolean(), + overrideExpiration: z.boolean(), + resolveReport: z.boolean(), + viewWarnings: z.boolean(), + }), + related: z.array(staffWarningSchema), + }), + "What the warning would do", + ), + ...commonResponses, + }, + }, + handler: async c => { + const moderator = await requireWarner(c); + const draft = await resolveWarningDraft( + c, + moderator, + toDraft(c.req.valid("json")), + ); + const now = new Date(); + const expiresAt = draft.expiresAt(now); + const projection = await projectWarning(c, { + expiresAt, + points: draft.points, + userId: draft.user.id, + }); + const canViewSanctions = hasWarningPermission( + moderator.set, + WARNING_PERMISSIONS.viewSanctions, + ); + const [people, suspension, relatedRows] = await Promise.all([ + loadReportPeople(c, [draft.user.id]), + canViewSanctions ? findActiveSuspension(c, draft.user.id, now) : null, + draft.content + ? c + .get("db") + .select() + .from(core_moderation_warnings) + .where( + and( + eq(core_moderation_warnings.pluginId, draft.content.pluginId), + eq( + core_moderation_warnings.targetType, + draft.content.targetType, + ), + eq(core_moderation_warnings.targetId, draft.content.targetId), + ), + ) + .orderBy(desc(core_moderation_warnings.issuedAt)) + .limit(10) + : [], + ]); + const user = people.get(draft.user.id); + if (!user) throw new HTTPException(404, { message: "user_not_found" }); + + return c.json( + { + user, + points: { + current: projection.before, + warning: draft.points, + projected: projection.after, + }, + defaults: { + points: draft.defaultPoints, + expiresAfterHours: draft.defaultExpiresAfterHours, + }, + expiresAt, + overrides: draft.overrides, + sanction: projection.sanction, + suspendedUntil: suspension ? { endsAt: suspension.endsAt } : null, + permissions: { + overridePoints: hasWarningPermission( + moderator.set, + WARNING_PERMISSIONS.overridePoints, + ), + overrideExpiration: hasWarningPermission( + moderator.set, + WARNING_PERMISSIONS.overrideExpiration, + ), + resolveReport: + draft.report !== null && draft.report.status !== "done", + viewWarnings: hasWarningPermission( + moderator.set, + WARNING_PERMISSIONS.view, + ), + }, + related: await serializeStaffWarnings(c, moderator, relatedRows, now), + }, + 200, + ); + }, +}); + +export const issueWarningRoute = buildRoute({ + route: { + method: "post", + path: "/warnings", + description: + "Issue a warning. The server resolves the content's author and scope, recalculates points under the member's lock, applies at most one threshold suspension and notifies the member. Retrying with the same `idempotencyKey` returns the first result", + request: body(issueSchema), + responses: { + 200: json( + z.object({ + warningId: z.number(), + duplicate: z.boolean(), + points: z.object({ before: z.number(), after: z.number() }), + reportResolved: z.boolean(), + sanction: z + .object({ + id: z.number(), + endsAt: z.date().nullable(), + thresholdId: z.number().nullable(), + }) + .nullable(), + }), + "The warning as issued", + ), + ...commonResponses, + }, + }, + handler: async c => { + const moderator = await requireWarner(c); + const input = c.req.valid("json"); + const result = await issueWarning(c, moderator, { + ...toDraft(input), + idempotencyKey: input.idempotencyKey, + message: input.message, + overrideNote: input.overrideNote ?? null, + resolveReport: input.resolveReport, + staffNote: input.staffNote ?? null, + }); + + return c.json(result, 200); + }, +}); + +export const revokeWarningRoute = buildRoute({ + route: { + method: "post", + path: "/warnings/{id}/revoke", + description: + "Revoke a warning with a reason. Its points stop counting at once; the record and any suspension it led to stay", + request: { params: idParams, ...body(revokeSchema) }, + responses: { + 200: json( + z.object({ warningId: z.number(), userId: z.number() }), + "Warning revoked", + ), + ...commonResponses, + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { reason } = c.req.valid("json"); + + return c.json(await revokeWarning(c, moderator, { id, reason }), 200); + }, +}); + +export const revokeSanctionRoute = buildRoute({ + route: { + method: "post", + path: "/sanctions/{id}/revoke", + description: + "Lift one sanction with a reason. Other active sanctions of the member keep applying", + request: { params: idParams, ...body(revokeSchema) }, + responses: { + 200: json( + z.object({ sanctionId: z.number(), userId: z.number() }), + "Sanction revoked", + ), + ...commonResponses, + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { reason } = c.req.valid("json"); + + return c.json(await revokeSanction(c, moderator, { id, reason }), 200); + }, +}); + +const statusCondition = ( + status: "active" | "all" | "expired" | "revoked", + now: Date, +) => { + const table = core_moderation_warnings; + switch (status) { + case "active": + return and( + isNull(table.revokedAt), + or(isNull(table.expiresAt), gt(table.expiresAt, now)), + ); + case "all": + return undefined; + case "expired": + return and(isNull(table.revokedAt), lte(table.expiresAt, now)); + case "revoked": + return isNotNull(table.revokedAt); + } +}; + +export const listWarningsRoute = buildRoute({ + route: { + method: "get", + path: "/warnings", + description: + "Warnings the moderator may see, newest first, with filters. Moderators without `can_view` see the warnings they issued and those linked to content in their scope", + request: { + query: z.object({ + userId: z.coerce.number().int().positive().optional(), + reasonId: z.coerce.number().int().positive().optional(), + issuedById: z.coerce.number().int().positive().optional(), + status: z.enum(["all", "active", "expired", "revoked"]).default("all"), + pluginId: z.string().max(100).optional(), + targetType: z.string().max(100).optional(), + from: z.iso.datetime().optional(), + to: z.iso.datetime().optional(), + page: z.coerce.number().int().positive().max(10_000).default(1), + pageSize: z.coerce + .number() + .int() + .positive() + .max(100) + .default(WARNING_LIMITS.pageSize), + }), + }, + responses: { + 200: json( + z.object({ + items: z.array( + staffWarningSchema.extend({ user: reportPersonSchema.nullable() }), + ), + page: z.number(), + pageSize: z.number(), + total: z.number(), + }), + "One page of warnings", + ), + 401: commonResponses[401], + 403: { description: "Not a moderator" }, + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const query = c.req.valid("query"); + const table = core_moderation_warnings; + const now = new Date(); + const where = and( + viewableWarningsCondition(c, moderator), + statusCondition(query.status, now), + query.userId ? eq(table.userId, query.userId) : undefined, + query.reasonId ? eq(table.reasonId, query.reasonId) : undefined, + query.issuedById ? eq(table.issuedById, query.issuedById) : undefined, + query.pluginId ? eq(table.pluginId, query.pluginId) : undefined, + query.targetType ? eq(table.targetType, query.targetType) : undefined, + query.from ? gte(table.issuedAt, new Date(query.from)) : undefined, + query.to ? lte(table.issuedAt, new Date(query.to)) : undefined, + ); + + const [[totalRow], rows] = await Promise.all([ + c.get("db").select({ value: count() }).from(table).where(where), + c + .get("db") + .select() + .from(table) + .where(where) + .orderBy(desc(table.issuedAt), desc(table.id)) + .limit(query.pageSize) + .offset((query.page - 1) * query.pageSize), + ]); + const [items, people] = await Promise.all([ + serializeStaffWarnings(c, moderator, rows, now), + loadReportPeople( + c, + rows.map(row => row.userId), + ), + ]); + + return c.json( + { + items: items.map(item => ({ + ...item, + user: people.get(item.userId) ?? null, + })), + page: query.page, + pageSize: query.pageSize, + total: totalRow?.value ?? 0, + }, + 200, + ); + }, +}); + +export const searchMembersRoute = buildRoute({ + route: { + method: "get", + path: "/members", + description: + "Find members by name for the ModCP member lookup (moderators who can warn or view warnings)", + request: { + query: z.object({ search: z.string().trim().min(1).max(100) }), + }, + responses: { + 200: json( + z.object({ items: z.array(reportPersonSchema) }), + "Up to 10 members", + ), + 401: commonResponses[401], + 403: { description: "Cannot warn or view warnings" }, + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + if ( + !hasWarningPermission(moderator.set, WARNING_PERMISSIONS.view) && + !hasWarningPermission(moderator.set, WARNING_PERMISSIONS.warn) + ) { + throw new HTTPException(403, { message: "Forbidden" }); + } + const { search } = c.req.valid("query"); + const pattern = containsLikePattern(search); + const rows = await c + .get("db") + .select({ id: core_users.id }) + .from(core_users) + .where( + or( + ilike(core_users.name, pattern), + ilike(core_users.nameCode, pattern), + ), + ) + .orderBy(core_users.name) + .limit(10); + const people = await loadReportPeople( + c, + rows.map(row => row.id), + ); + + return c.json( + { + items: rows.flatMap(row => { + const person = people.get(row.id); + + return person ? [person] : []; + }), + }, + 200, + ); + }, +}); + +export const memberStandingRoute = buildRoute({ + route: { + method: "get", + path: "/members/{id}", + description: + "A member's standing for the ModCP: global active points, the warnings this moderator may see and, with `can_view_sanctions`, their sanctions", + request: { params: idParams }, + responses: { + 200: json( + z.object({ + user: reportPersonSchema, + activePoints: z.number(), + hiddenWarnings: z.number(), + warnings: z.array(staffWarningSchema), + sanctions: z.array(sanctionSchema).nullable(), + permissions: z.object({ + warn: z.boolean(), + revoke: z.boolean(), + viewSanctions: z.boolean(), + manageSanctions: z.boolean(), + }), + }), + "The member's standing", + ), + 401: commonResponses[401], + 403: { description: "Not a moderator" }, + 404: json(errorSchema, "No such member"), + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const has = ( + permission: (typeof WARNING_PERMISSIONS)[keyof typeof WARNING_PERMISSIONS], + ) => hasWarningPermission(moderator.set, permission); + if (!has(WARNING_PERMISSIONS.view) && !canWarnAnywhere(c, moderator.set)) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + const people = await loadReportPeople(c, [id]); + const user = people.get(id); + if (!user) return c.json({ error: "not_found" }, 404); + + const now = new Date(); + const viewSanctions = has(WARNING_PERMISSIONS.viewSanctions); + const [rows, sanctionRows, activePoints] = await Promise.all([ + loadUserWarnings(c, id), + viewSanctions ? loadUserSanctions(c, id) : null, + loadActivePoints(c.get("db"), id, now), + ]); + const warnings = await serializeStaffWarnings(c, moderator, rows, now); + + return c.json( + { + user, + activePoints, + hiddenWarnings: rows.length - warnings.length, + warnings, + sanctions: sanctionRows + ? await serializeSanctions(c, sanctionRows, now) + : null, + permissions: { + warn: has(WARNING_PERMISSIONS.warn) && id !== moderator.user.id, + revoke: has(WARNING_PERMISSIONS.revoke), + viewSanctions, + manageSanctions: has(WARNING_PERMISSIONS.manageSanctions), + }, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/routes/session.route.ts b/packages/vitnode/src/api/modules/users/routes/session.route.ts index 7af52f1dac..0675de0413 100644 --- a/packages/vitnode/src/api/modules/users/routes/session.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/session.route.ts @@ -41,6 +41,13 @@ export const sessionRoute = buildRoute({ notifications: zodNotificationState, }) .nullable(), + suspension: z + .object({ endsAt: z.date().nullable() }) + .nullable() + .optional() + .describe( + "Set while the session's account is suspended; `user` is then `null`", + ), }), }, }, @@ -50,7 +57,14 @@ export const sessionRoute = buildRoute({ }, handler: async c => { const user = c.get("user"); - if (!user) return c.json({ user: null }); + if (!user) { + const suspension = c.get("suspension"); + + return c.json({ + user: null, + suspension: suspension ? { endsAt: suspension.endsAt } : null, + }); + } const [{ isAdmin, isModerator }, notifications] = await Promise.all([ getStaffFlags(c, user), diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index c0809290d8..25b63dd698 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -54,6 +54,19 @@ export const newBuildPluginApiCore = buildApiPlugin({ moderator: { users: ["can_edit", "can_view_hidden_presence"], widgets: ["can_edit"], + warnings: [ + "can_view", + "can_warn", + "can_override_points", + "can_override_expiration", + { permission: "can_view_notes", dependsOn: ["can_view"] }, + { permission: "can_revoke", dependsOn: ["can_view"] }, + "can_view_sanctions", + { + permission: "can_manage_sanctions", + dependsOn: ["can_view_sanctions"], + }, + ], }, admin: { dashboard: [ @@ -117,6 +130,10 @@ export const newBuildPluginApiCore = buildApiPlugin({ { permission: "can_edit", dependsOn: ["can_view"] }, { permission: "can_delete", dependsOn: ["can_view"] }, ], + warning_settings: [ + "can_view", + { permission: "can_manage", dependsOn: ["can_view"] }, + ], staff_moderators: [ "can_view", { permission: "can_create", dependsOn: ["can_view"] }, diff --git a/packages/vitnode/src/content/server/public-permissions.ts b/packages/vitnode/src/content/server/public-permissions.ts index e0e4ff1446..98ddb76d37 100644 --- a/packages/vitnode/src/content/server/public-permissions.ts +++ b/packages/vitnode/src/content/server/public-permissions.ts @@ -8,6 +8,8 @@ import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentModel } from "./model"; +import { resolveStaffPermissions } from "../../api/lib/check-staff-permission"; +import { canWarnForContent } from "../../api/lib/moderation/warnings/access"; import { buildRoute } from "../../api/lib/route"; import { contentTypeName } from "../admin/labels"; import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const"; @@ -53,6 +55,13 @@ export interface ContentViewerPermissions { * to sign in. */ canReport: boolean; + /** + * A **Warn author** action makes sense: the type has `reports`, the + * visitor is a moderator with `can_warn` for the record's scope and did not + * write it. `POST /moderation/warnings` re-checks all of it, with the + * author resolved on the server. + */ + canWarn: boolean; } const parseIds = ( @@ -90,6 +99,25 @@ export const resolveContentViewerPermissions = async ( .from(table) .where(inArray(columns.id, ids))) as Record[]; const user = c.get("user"); + const moderatorSet = + user && definition.reports.enabled + ? await resolveStaffPermissions(c, { type: "moderator", user }) + : null; + const scopeColumn = model.moderationScope?.column; + const canWarnOn = (row: Record, ownerId: unknown) => { + if (!moderatorSet?.staff || typeof ownerId !== "number") return false; + if (ownerId === user?.id) return false; + const scope = scopeColumn ? row[scopeColumn] : null; + + return canWarnForContent(c, moderatorSet, { + pluginId, + scopeId: + typeof scope === "number" || typeof scope === "string" + ? String(scope) + : null, + targetType: definition.id, + }); + }; const decided = await Promise.all( rows.map(async row => { @@ -138,6 +166,7 @@ export const resolveContentViewerPermissions = async ( definition.reports.enabled && isPublic && (!isOwner || definition.reports.selfReport), + canWarn: definition.reports.enabled && canWarnOn(row, ownerId), id, }; }), @@ -176,7 +205,7 @@ export const contentPermissionsRoute = < route: { method: "get", path: "/permissions", - description: `What the current visitor may do with each listed ${name}: edit, delete, hide or report it`, + description: `What the current visitor may do with each listed ${name}: edit, delete, hide, report it or warn its author`, request: { query: z.object({ ids: z @@ -196,6 +225,7 @@ export const contentPermissionsRoute = < canEdit: z.boolean(), canHide: z.boolean(), canReport: z.boolean(), + canWarn: z.boolean(), id: schemas.params.shape.id, }), ), diff --git a/packages/vitnode/src/content/server/report-target.ts b/packages/vitnode/src/content/server/report-target.ts index 23064ad96c..d03cb0284f 100644 --- a/packages/vitnode/src/content/server/report-target.ts +++ b/packages/vitnode/src/content/server/report-target.ts @@ -7,6 +7,7 @@ import type { ModerationCaptureResult, ModerationReportTargetDefinition, ModerationTargetActionDefinition, + ModerationTargetAuthors, ModerationTargetState, } from "../../api/lib/moderation/targets"; import type { @@ -410,6 +411,35 @@ const currentContent = async ( }; }; +const contentAuthors = async ( + c: Context, + model: AnyContentModel, + targetId: string, +): Promise => { + const definition = contentDefinitionOf(model); + const itemId = parseContentId(definition.idStrategy, targetId); + if (itemId === null) return null; + + const db = c.get("db"); + const base = await readBaseRow(db, model, itemId); + if (!base) return null; + + const titleField = definition.reports.titleField; + let rawTitle: unknown = titleField ? base[titleField] : null; + if (titleField && rawTitle === undefined && definition.localization.enabled) { + const translation = await readTranslationRow(c, db, model, { + itemId, + locale: definition.localization.defaultLocale, + }); + rawTitle = translation?.[titleField] ?? null; + } + + return { + authorIds: await contentAuthorIds(c, model, itemId), + title: reportTitleOf(rawTitle), + }; +}; + export const visibilityAction = ( model: AnyContentModel, pluginId: string, @@ -485,6 +515,8 @@ export const contentReportTarget = ( return id === null ? null : contentIdKey(id); }, permissionModule: definition.permissionModule, + resolveAuthors: async (c, { targetId }) => + await contentAuthors(c, model, targetId), ...(scope && scopeColumn ? { resolveScope: async (c: Context, targetId: string) => { diff --git a/packages/vitnode/src/database/warnings.ts b/packages/vitnode/src/database/warnings.ts new file mode 100644 index 0000000000..6712101838 --- /dev/null +++ b/packages/vitnode/src/database/warnings.ts @@ -0,0 +1,218 @@ +import { sql } from "drizzle-orm"; +import { camelCase, index, unique, uniqueIndex } from "drizzle-orm/pg-core"; + +import type { + ModerationAuditType, + SanctionSource, + SanctionThresholdSnapshot, + SanctionType, + WarningContentSnapshot, + WarningReasonSnapshot, + WarningThresholdAction, +} from "@/lib/moderation/warnings"; + +import { + MODERATION_AUDIT_TYPES, + SANCTION_SOURCES, + SANCTION_TYPES, + WARNING_THRESHOLD_ACTIONS, +} from "@/lib/moderation/warnings"; + +import { core_moderation_reports } from "./moderation"; +import { core_users } from "./users"; + +/** Warning reasons. Their title, description and default message live in `core_languages_words`. */ +export const core_moderation_warning_reasons = camelCase.table.withRLS( + "core_moderation_warning_reasons", + t => ({ + id: t.serial().primaryKey(), + points: t.integer().notNull().default(0), + /** `null` means the points never expire. */ + expiresAfterHours: t.integer(), + enabled: t.boolean().notNull().default(true), + position: t.integer().notNull().default(0), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [index("core_moderation_warning_reasons_position_idx").on(t.position)], +); + +export const core_moderation_warning_thresholds = camelCase.table.withRLS( + "core_moderation_warning_thresholds", + t => ({ + id: t.serial().primaryKey(), + points: t.integer().notNull(), + action: t + .varchar({ enum: WARNING_THRESHOLD_ACTIONS, length: 32 }) + .$type() + .notNull(), + /** Length of a temporary suspension; `null` for an indefinite one. */ + durationHours: t.integer(), + enabled: t.boolean().notNull().default(true), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [ + // Two enabled rules at the same points would be ambiguous. + uniqueIndex("core_moderation_warning_thresholds_enabled_points_idx") + .on(t.points) + .where(sql`"enabled" = true`), + ], +); + +export const core_moderation_warnings = camelCase.table.withRLS( + "core_moderation_warnings", + t => ({ + id: t.serial().primaryKey(), + userId: t + .integer() + .notNull() + .references(() => core_users.id, { onDelete: "cascade" }), + issuedById: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + reasonId: t.integer().references(() => core_moderation_warning_reasons.id, { + onDelete: "set null", + }), + reasonSnapshot: t.jsonb().$type(), + points: t.integer().notNull(), + /** The reason's points when the warning was issued, for auditing overrides. */ + defaultPoints: t.integer(), + /** Staff-only explanation of a points or expiration override. */ + overrideNote: t.text(), + /** Shown to the warned member. */ + message: t.text().notNull(), + /** Shown to staff with `can_view_notes` only. */ + staffNote: t.text(), + issuedAt: t.timestamp().notNull().defaultNow(), + /** When the points stop counting; `null` never. */ + expiresAt: t.timestamp(), + /** Active points just before and after this warning, as the server computed them. */ + pointsBefore: t.integer().notNull().default(0), + pointsAfter: t.integer().notNull().default(0), + pluginId: t.varchar({ length: 100 }), + targetType: t.varchar({ length: 100 }), + targetId: t.varchar({ length: 64 }), + scopeFamily: t.varchar({ length: 100 }), + scopeId: t.varchar({ length: 64 }), + contentSnapshot: t.jsonb().$type(), + reportId: t.integer().references(() => core_moderation_reports.id, { + onDelete: "set null", + }), + idempotencyKey: t.varchar({ length: 64 }), + revokedAt: t.timestamp(), + revokedById: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + revokeReason: t.text(), + }), + t => [ + unique("core_moderation_warnings_idempotency_key").on( + t.issuedById, + t.idempotencyKey, + ), + index("core_moderation_warnings_user_idx").on(t.userId, t.issuedAt), + index("core_moderation_warnings_issued_at_id_idx").on(t.issuedAt, t.id), + index("core_moderation_warnings_issued_by_idx").on(t.issuedById), + index("core_moderation_warnings_reason_idx").on(t.reasonId), + index("core_moderation_warnings_target_idx").on( + t.pluginId, + t.targetType, + t.targetId, + ), + index("core_moderation_warnings_report_idx").on(t.reportId), + ], +); + +export const core_moderation_sanctions = camelCase.table.withRLS( + "core_moderation_sanctions", + t => ({ + id: t.serial().primaryKey(), + userId: t + .integer() + .notNull() + .references(() => core_users.id, { onDelete: "cascade" }), + type: t + .varchar({ enum: SANCTION_TYPES, length: 32 }) + .$type() + .notNull(), + source: t + .varchar({ enum: SANCTION_SOURCES, length: 32 }) + .$type() + .notNull(), + startsAt: t.timestamp().notNull().defaultNow(), + /** `null` for an indefinite sanction. */ + endsAt: t.timestamp(), + /** The warning whose threshold evaluation created it - at most one per warning. */ + warningId: t + .integer() + .unique() + .references(() => core_moderation_warnings.id, { onDelete: "set null" }), + thresholdId: t + .integer() + .references(() => core_moderation_warning_thresholds.id, { + onDelete: "set null", + }), + thresholdSnapshot: t.jsonb().$type(), + createdById: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + createdAt: t.timestamp().notNull().defaultNow(), + revokedAt: t.timestamp(), + revokedById: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + revokeReason: t.text(), + }), + t => [ + index("core_moderation_sanctions_user_idx").on( + t.userId, + t.revokedAt, + t.endsAt, + ), + index("core_moderation_sanctions_created_at_id_idx").on(t.createdAt, t.id), + ], +); + +/** + * The warning system's audit trail. Never holds staff notes or override + * explanations - those stay on the warning, behind `can_view_notes`. + */ +export const core_moderation_audit = camelCase.table.withRLS( + "core_moderation_audit", + t => ({ + id: t.serial().primaryKey(), + type: t + .varchar({ enum: MODERATION_AUDIT_TYPES, length: 40 }) + .$type() + .notNull(), + actorId: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + userId: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + warningId: t.integer().references(() => core_moderation_warnings.id, { + onDelete: "set null", + }), + sanctionId: t.integer().references(() => core_moderation_sanctions.id, { + onDelete: "set null", + }), + data: t.jsonb().$type>().notNull().default({}), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + index("core_moderation_audit_user_idx").on(t.userId, t.createdAt), + index("core_moderation_audit_created_at_id_idx").on(t.createdAt, t.id), + index("core_moderation_audit_warning_idx").on(t.warningId), + index("core_moderation_audit_sanction_idx").on(t.sanctionId), + ], +); diff --git a/packages/vitnode/src/lib/moderation/const.ts b/packages/vitnode/src/lib/moderation/const.ts index 2dabe6a6ce..b8ed079406 100644 --- a/packages/vitnode/src/lib/moderation/const.ts +++ b/packages/vitnode/src/lib/moderation/const.ts @@ -5,6 +5,7 @@ export const MODERATION_PERMISSIONS = { deleteNotes: "can_delete_notes", viewHidden: "can_view_hidden", hide: "can_hide", + warn: "can_warn", } as const; export const MODERATION_DEFAULT_REPORT_REASONS = [ @@ -63,6 +64,7 @@ export const MODERATION_REPORT_EVENT_TYPES = [ "action", "resolved", "reopened", + "warning", ] as const; export type ModerationReportEventType = diff --git a/packages/vitnode/src/lib/moderation/warnings.ts b/packages/vitnode/src/lib/moderation/warnings.ts new file mode 100644 index 0000000000..83cafdf343 --- /dev/null +++ b/packages/vitnode/src/lib/moderation/warnings.ts @@ -0,0 +1,193 @@ +/** Moderator permissions of core's `warnings` module. */ +export const WARNING_PERMISSIONS = { + view: "can_view", + warn: "can_warn", + overridePoints: "can_override_points", + overrideExpiration: "can_override_expiration", + viewNotes: "can_view_notes", + revoke: "can_revoke", + viewSanctions: "can_view_sanctions", + manageSanctions: "can_manage_sanctions", +} as const; + +/** The core permission module holding {@link WARNING_PERMISSIONS}. */ +export const WARNING_PERMISSION_MODULE = "warnings"; + +export const WARNING_LIMITS = { + maxPoints: 100, + maxThresholdPoints: 1000, + maxReasons: 50, + maxThresholds: 20, + /** Five years, the longest expiration or suspension a form accepts. */ + maxDurationHours: 24 * 365 * 5, + messageMinLength: 3, + messageMaxLength: 2000, + noteMaxLength: 2000, + explanationMinLength: 3, + reasonTitleMaxLength: 100, + reasonDescriptionMaxLength: 255, + reasonMessageMaxLength: 2000, + pageSize: 25, +} as const; + +export const WARNING_STATUSES = ["active", "expired", "revoked"] as const; + +export type WarningStatus = (typeof WARNING_STATUSES)[number]; + +export const WARNING_THRESHOLD_ACTIONS = [ + "suspend_temporary", + "suspend_indefinite", +] as const; + +export type WarningThresholdAction = (typeof WARNING_THRESHOLD_ACTIONS)[number]; + +/** Kinds of account sanction. Only suspensions exist so far. */ +export const SANCTION_TYPES = ["suspension"] as const; + +export type SanctionType = (typeof SANCTION_TYPES)[number]; + +/** What created a sanction. */ +export const SANCTION_SOURCES = ["threshold", "manual"] as const; + +export type SanctionSource = (typeof SANCTION_SOURCES)[number]; + +export const MODERATION_AUDIT_TYPES = [ + "warning_issued", + "warning_overridden", + "warning_revoked", + "threshold_evaluated", + "sanction_applied", + "sanction_revoked", + "reason_created", + "reason_updated", + "threshold_created", + "threshold_updated", + "threshold_deleted", +] as const; + +export type ModerationAuditType = (typeof MODERATION_AUDIT_TYPES)[number]; + +/** A multi-language value, as stored in `core_languages_words`. */ +export type WarningReasonText = { languageCode: string; value: string }[]; + +/** What a warning remembers about its reason, so renaming the reason never rewrites history. */ +export interface WarningReasonSnapshot { + title: WarningReasonText; +} + +/** What a warning remembers about the content it was issued for. */ +export interface WarningContentSnapshot { + title: null | string; + url: null | string; +} + +/** What a threshold sanction remembers about the rule that created it. */ +export interface SanctionThresholdSnapshot { + action: WarningThresholdAction; + durationHours: null | number; + points: number; +} + +/** + * The status of a warning at `now`. Derived from timestamps on every read, so + * it is right whether or not a scheduled job has run. Pure. + */ +export const warningStatusAt = ( + warning: { expiresAt: Date | null; revokedAt: Date | null }, + now: Date, +): WarningStatus => { + if (warning.revokedAt) return "revoked"; + if (warning.expiresAt && warning.expiresAt.getTime() <= now.getTime()) { + return "expired"; + } + + return "active"; +}; + +/** + * The points a user holds at `now`: warnings that are not revoked and have + * not expired. Pure. + */ +export const activeWarningPoints = ( + warnings: readonly { + expiresAt: Date | null; + points: number; + revokedAt: Date | null; + }[], + now: Date, +): number => + warnings.reduce( + (total, warning) => + warningStatusAt(warning, now) === "active" + ? total + warning.points + : total, + 0, + ); + +export interface WarningThresholdRule { + action: WarningThresholdAction; + durationHours: null | number; + enabled: boolean; + id: number; + points: number; +} + +export interface ThresholdEvaluation { + /** The one rule to apply: the highest crossed one, or `null`. */ + applied: null | T; + /** Every enabled rule the change crossed, lowest first. */ + crossed: T[]; +} + +/** + * Which threshold rules a change of active points from `before` to `after` + * crosses - a rule at `points` is crossed when `before < points <= after` - + * and which one applies: only the highest, so one warning never creates two + * suspensions. A rule already at or below `before` was crossed earlier and is + * not applied again. Pure. + */ +export const evaluateWarningThresholds = ({ + after, + before, + rules, +}: { + after: number; + before: number; + rules: readonly T[]; +}): ThresholdEvaluation => { + const crossed = rules + .filter( + rule => rule.enabled && before < rule.points && rule.points <= after, + ) + .sort((a, b) => a.points - b.points || a.id - b.id); + + return { applied: crossed.at(-1) ?? null, crossed }; +}; + +/** When a suspension created by `rule` at `now` ends; `null` for indefinite. Pure. */ +export const thresholdSuspensionEnd = ( + rule: Pick, + now: Date, +): Date | null => + rule.action === "suspend_temporary" && rule.durationHours + ? new Date(now.getTime() + rule.durationHours * 60 * 60 * 1000) + : null; + +/** Whether a sanction applies at `now`: not revoked, started and not ended. Pure. */ +export const isSanctionActiveAt = ( + sanction: { endsAt: Date | null; revokedAt: Date | null; startsAt: Date }, + now: Date, +): boolean => + !sanction.revokedAt && + sanction.startsAt.getTime() <= now.getTime() && + (sanction.endsAt === null || sanction.endsAt.getTime() > now.getTime()); + +/** Picks the reason text in `locale`, else English, else the first one. Pure. */ +export const warningReasonText = ( + values: null | undefined | WarningReasonText, + locale: string, +): string => + values?.find(item => item.languageCode === locale)?.value ?? + values?.find(item => item.languageCode === "en")?.value ?? + values?.[0]?.value ?? + ""; diff --git a/packages/vitnode/src/locales/api/en.json b/packages/vitnode/src/locales/api/en.json index 24a6aeaaff..3709f8f4d7 100644 --- a/packages/vitnode/src/locales/api/en.json +++ b/packages/vitnode/src/locales/api/en.json @@ -55,6 +55,31 @@ "admin_message": { "label": "Messages from administrators", "description": "Announcements an administrator sends you directly." + }, + "warning_issued": { + "label": "Warnings from moderators", + "description": "When a moderator warns you. Always on, so you never miss one.", + "title": "You received a warning ({points, plural, one {# point} other {# points}})", + "title_reason": "You received a warning for {reason} ({points, plural, one {# point} other {# points}})" + }, + "warning_revoked": { + "label": "Revoked warnings", + "description": "When a moderator takes back a warning you received.", + "title": "A warning was revoked", + "body": "Its {points, plural, one {# point no longer counts} other {# points no longer count}} against your account." + }, + "account_suspended": { + "label": "Account suspensions", + "description": "When your account is suspended after reaching a warning limit.", + "title": "Your account is suspended", + "body_until": "You can use your account again after {endsAt} (UTC).", + "body_indefinite": "Your account is suspended until a moderator lifts it." + }, + "suspension_revoked": { + "label": "Lifted suspensions", + "description": "When a moderator lifts a suspension of your account.", + "title": "A suspension was lifted", + "body": "A moderator lifted a suspension of your account. Check your account status to see if anything else still applies." } }, "email": { diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index b37ddb54ab..2e57f3bb63 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -877,7 +877,9 @@ "removed": "Cover image removed." } }, - "presence": "Online status" + "presence": "Online status", + "warn": "Warn", + "view_warnings": "Warnings ({points, plural, one {# point} other {# points}})" }, "files": { "title": "My Files", @@ -1163,7 +1165,8 @@ "security": "Security", "sso": "Connected accounts", "notifications": "Notifications", - "ai": "AI usage" + "ai": "AI usage", + "standing": "Account status" }, "overview": { "desc": "Your avatar and the details on your account.", @@ -1736,6 +1739,7 @@ "by_author": "Comment deleted by user", "by_moderator": "Comment removed by a moderator" }, + "warn": "Warn author", "live": { "new_comments": "New comments were posted.", "show_new": "Show new comments", @@ -2861,12 +2865,223 @@ "resolved": "closed it as {resolution}", "reopened": "reopened it", "note_edited": "edited a note", - "note_deleted": "deleted a note" + "note_deleted": "deleted a note", + "warning": "warned ({points, plural, one {# point} other {# points}})" }, "note_required": "Write something before adding the note.", "before": "Before", "after": "After" } + }, + "warnings": { + "title": "Warnings" + } + }, + "warn": { + "button": "Warn author", + "button_member": "Warn member", + "title": "Warn {name}", + "title_loading": "Warn member", + "desc": "The member gets your message in a notification. Points count across the whole site.", + "no_reasons": "No warning reasons are set up yet - an administrator can add them in the AdminCP.", + "cancel": "Cancel", + "submit": "Issue warning", + "points": "{count, plural, one {# point} other {# points}}", + "custom_reason": "Custom warning", + "deleted_user": "Deleted account", + "global": { + "title": "Points count everywhere", + "desc": "This warning is about content in one place, but its points count toward account-wide limits that can suspend the whole account." + }, + "consequences": { + "title": "What happens", + "current": "Active points now", + "warning": "This warning", + "projected": "Active points after", + "action": "Automatic action:", + "no_action": "None.", + "already_suspended": "This account is already suspended.", + "related": "{count, plural, one {# warning was} other {# warnings were}} already issued for this content.", + "advisory": "A preview. The server checks again when you send it, in case someone else warned this member in the meantime." + }, + "sanction": { + "indefinite": "Account suspended indefinitely.", + "for": "Account suspended for", + "until": "(until {endsAt}).", + "suspended_until": "Account suspended until {endsAt}." + }, + "form": { + "reason": "Reason", + "no_reason": "No reason (custom warning)", + "points": "Points", + "points_desc": "Starts at the reason's points. Changing them needs an explanation.", + "expiration": "Points expire", + "expiration_modes": { + "default": "As the reason says", + "never": "Never", + "hours": "After a number of hours", + "at": "On a date" + }, + "expiration_hours": "Expire after", + "hours_unit": "hours", + "expiration_at": "Expire on", + "override_note": "Why the change?", + "override_note_desc": "Staff only. Required when you change the points or the expiry.", + "override_note_required": "Explain why you changed the points or the expiry.", + "expiration_at_required": "Pick when the points expire.", + "message": "Message to the member", + "message_desc": "The member reads this in the notification and on their account status page.", + "staff_note": "Staff note", + "staff_note_desc": "Only staff who can read notes see this. The member never does.", + "resolve_report": "Also close the report as action taken", + "resolve_report_desc": "Closes the report in the same step. If either one fails, neither happens." + }, + "done": { + "title": "Warning issued", + "points": "{name} now has {after, plural, one {# active point} other {# active points}}.", + "suspended_until": "Their account is suspended until {endsAt}.", + "suspended_indefinitely": "Their account is suspended indefinitely.", + "report_resolved": "The report is closed.", + "differs": "That's different from the preview - their points changed in the meantime." + }, + "errors": { + "title": "Could not issue the warning", + "unknown": "Something went wrong. Try again in a moment.", + "unauthenticated": "Your session has ended. Sign in again.", + "validation": "Check the fields and try again.", + "forbidden": "You can't warn this member here.", + "not_found": "That no longer exists.", + "already_revoked": "It was already revoked or lifted.", + "author_mismatch": "This member didn't write that content.", + "content_not_found": "That content is gone, so the warning can't point at it.", + "expiration_override_forbidden": "You can't change when points expire.", + "idempotency_conflict": "Reopen the form and try again.", + "invalid_expiration": "Pick an expiry in the future, at most five years away.", + "invalid_points": "Points must be a whole number from 0 to 100.", + "override_note_required": "Explain why you changed the points or the expiry.", + "points_override_forbidden": "You can't change the points.", + "protected": "Staff accounts can't be warned here.", + "reason_disabled": "That reason was just disabled. Pick another one.", + "reason_not_found": "That reason was removed. Pick another one.", + "report_closed": "The report is already closed. Warn without closing it.", + "report_mismatch": "The report is about different content.", + "self": "You can't warn yourself.", + "user_not_found": "That member no longer exists." + }, + "duration": { + "days": "{count, plural, one {# day} other {# days}}", + "hours": "{count, plural, one {# hour} other {# hours}}" + }, + "statuses": { + "active": "Active", + "expired": "Expired", + "revoked": "Revoked" + }, + "revoke": { + "button": "Revoke", + "title": "Revoke this warning?", + "desc": "Its points stop counting right away. The warning stays in the history, and a suspension it led to stays until you lift it separately.", + "reason": "Reason", + "reason_desc": "Kept with the warning for staff.", + "done": "Warning revoked", + "done_desc": "The member was notified." + }, + "list": { + "title": "Warnings", + "desc": "Every warning you can see, newest first.", + "status_label": "Filter by status", + "all": "All", + "reason_filter": "Reason", + "all_reasons": "All reasons", + "empty_title": "No warnings here", + "empty_desc": "Nothing matches these filters. Peaceful.", + "member": "Member", + "issued_by": "Issued by", + "expires": "Points expire", + "never": "Never", + "default_points": "Reason's points", + "content": "Content", + "untitled": "Untitled", + "report": "Report", + "report_link": "Report #{id}", + "message": "Message to the member", + "staff_only": "Staff only", + "staff_note": "Staff note", + "override_note": "Why the points or expiry changed", + "revoked": "Revoked by .", + "lifted": "Lifted by .", + "sanction_stays": "The suspension this warning led to was not lifted with it." + }, + "lookup": { + "title": "Find a member", + "label": "Member name", + "placeholder": "Search by name", + "results": "{count, plural, =0 {No members found} one {# member found} other {# members found}}", + "empty": "No members match that name.", + "forbidden": "You can't look up members." + }, + "member": { + "title": "{name}'s standing", + "breadcrumb": "Member", + "desc": "Active points, warnings and suspensions.", + "active_points": "Active points", + "status": "Status", + "suspended": "Suspended", + "in_good_standing": "In good standing", + "sanctions": "Suspensions", + "no_sanctions": "No suspensions.", + "warnings": "Warnings", + "no_warnings": "No warnings you can see.", + "hidden_warnings": "{count, plural, one {# more warning is} other {# more warnings are}} outside your moderation scope." + }, + "sanctions": { + "temporary": "Temporary suspension", + "indefinite": "Indefinite suspension", + "active": "Active", + "lifted": "Lifted", + "ended": "Ended", + "lift": "Lift", + "lift_title": "Lift this suspension?", + "lift_desc": "The account works again unless another suspension still applies.", + "lifted_done": "Suspension lifted", + "lifted_done_desc": "Any other active suspension still applies.", + "ends": "Ends", + "source": "Source", + "from_threshold": "Reached {points, plural, one {# point} other {# points}}", + "manual": "Applied by staff" + } + }, + "account_status": { + "title": "Account status", + "desc": "Your warnings and whether anything restricts your account.", + "loading": "Loading your account status", + "signed_out": "Sign in to see your account status.", + "sign_in": "Sign in", + "suspended_title": "Your account is suspended", + "suspended_until": "You can use your account again after {endsAt}.", + "suspended_indefinite": "Your account stays suspended until a moderator lifts the suspension.", + "suspended_restrictions": "Until then you can browse like a visitor, but you can't post, comment or change anything on your account.", + "contact": "Think this is a mistake? Contact the site's administrators.", + "good_title": "Your account is in good standing", + "good_desc": "Nothing restricts your account.", + "points_title": "Active points", + "points_desc": "Points from warnings that haven't expired or been revoked. Reaching a limit can suspend your account.", + "warnings_title": "Your warnings", + "no_warnings": "You haven't received any warnings. Keep it up!", + "expires": "Points expire:", + "never": "Never", + "content": "About:", + "untitled": "Untitled", + "sanctions_title": "Suspensions", + "sanction_temporary": "Temporary suspension", + "sanction_indefinite": "Indefinite suspension", + "sanction_active": "Active", + "sanction_lifted": "Lifted", + "sanction_ended": "Ended", + "notice": { + "until": "Your account is suspended until {endsAt}.", + "indefinite": "Your account is suspended.", + "details": "See details" } } }, @@ -3246,7 +3461,9 @@ }, "moderation": { "title": "Moderation", - "report_reasons": "Report reasons" + "report_reasons": "Report reasons", + "warning_reasons": "Warning reasons", + "warning_thresholds": "Warning thresholds" }, "user_bar": { "home_page": "Home Page", @@ -3992,6 +4209,116 @@ "last_reason": "Keep at least one reason - members need something to pick.", "unknown": "Something went wrong. Try again in a moment." } + }, + "warning_reasons": { + "title": "Warning reasons", + "desc": "The reasons moderators pick when they warn someone, with their default points and expiry. Drag to change the order.", + "create": "Add reason", + "limit": "You can have up to {max} reasons. Disable one you no longer use.", + "columns": { + "reason": "Reason", + "points": "Points", + "expires": "Points expire", + "status": "Status" + }, + "never": "Never", + "enabled": "Enabled", + "disabled": "Disabled", + "edit": "Edit", + "empty_title": "No warning reasons yet", + "empty_desc": "Add one so moderators can warn with consistent points.", + "reorder": { + "success": "Order saved", + "success_desc": "Moderators see the reasons in the new order." + }, + "form": { + "create_title": "Add warning reason", + "edit_title": "Edit warning reason", + "sheet_desc": "Changes apply to new warnings only. Warnings already issued keep their points, expiry and title.", + "title": "Title", + "title_desc": "Short and specific, like “Spam” or “Harassment”.", + "description": "Description", + "description_desc": "Helps moderators pick the right reason. Members don't see it.", + "points": "Points", + "points_desc": "How many points a warning adds. 0 makes it an official warning that adds none.", + "points_unit": "points", + "expires": "Points expire after", + "expires_desc": "After this, the points stop counting. The warning stays in the history.", + "or": "or", + "never_expires": "Never expire", + "days_unit": "days", + "message": "Default message", + "message_desc": "Prefilled in the warn form. Moderators can edit it before sending.", + "enabled": "Enabled", + "enabled_desc": "Disabled reasons can't be picked for new warnings. Old warnings stay as they are.", + "submit_create": "Add reason", + "submit_edit": "Save changes", + "created": "Reason added", + "created_desc": "Moderators can pick “{reason}” when they warn someone.", + "updated": "Reason saved", + "updated_desc": "New warnings use the new settings.", + "error": "Could not save the reason", + "error_desc": "Check the fields and try again.", + "limit_reached": "You can have up to {max} reasons." + } + }, + "warning_thresholds": { + "title": "Warning thresholds", + "desc": "What happens automatically when a warning brings a member's active points up to a limit.", + "create": "Add threshold", + "limit": "You can have up to {max} thresholds.", + "columns": { + "points": "Active points", + "action": "Action", + "status": "Status" + }, + "at_points": "At {points, plural, one {# point} other {# points}}", + "suspend_for": "Suspend for", + "suspend_indefinitely": "Suspend indefinitely", + "enabled": "Enabled", + "disabled": "Disabled", + "edit": "Edit", + "actions_label": "Actions for the threshold at {points} points", + "empty_title": "No thresholds yet", + "empty_desc": "Without one, warnings never suspend anyone automatically.", + "delete": { + "button": "Delete", + "title": "Delete the threshold at {points} points?", + "desc": "New warnings won't trigger it anymore. Suspensions it already created stay until they end or are lifted.", + "error": "Could not delete the threshold", + "success": "Threshold deleted", + "success_desc": "Reaching {points} points no longer suspends anyone." + }, + "form": { + "create_title": "Add threshold", + "edit_title": "Edit threshold", + "sheet_desc": "When a warning brings active points from below this number to it or above, the action runs once.", + "future_only": "Changes apply to warnings issued from now on. Nobody is suspended or unsuspended just because you saved this.", + "points": "Active points", + "points_desc": "Only one enabled threshold can use each number.", + "points_unit": "points", + "action": "Action", + "actions": { + "suspend_temporary": "Temporary suspension", + "suspend_temporary_desc": "Ends on its own after the duration, even if the points are still there.", + "suspend_indefinite": "Indefinite suspension", + "suspend_indefinite_desc": "Lasts until a moderator lifts it." + }, + "duration": "Duration", + "duration_desc": "How long the suspension lasts. 24 is a day, 168 a week.", + "hours_unit": "hours", + "enabled": "Enabled", + "enabled_desc": "Disabled thresholds never run.", + "submit_create": "Add threshold", + "submit_edit": "Save changes", + "created": "Threshold added", + "updated": "Threshold saved", + "saved_desc": "It applies to the next warnings.", + "error": "Could not save the threshold", + "error_desc": "Check the fields and try again.", + "duplicate_points": "Another enabled threshold already uses this number of points.", + "limit_reached": "You can have up to {max} thresholds." + } } }, "system": { @@ -5048,5 +5375,18 @@ "description": "Rewrites the selected text: shorter, corrected, simpler or in another tone." } } - } + }, + "@vitnode/core:warnings": "Warnings", + "@vitnode/core:warnings:can_view": "View every member's warnings", + "@vitnode/core:warnings:can_warn": "Warn members directly, without content", + "@vitnode/core:warnings:can_override_points": "Change a warning's points", + "@vitnode/core:warnings:can_override_expiration": "Change when a warning's points expire", + "@vitnode/core:warnings:can_view_notes": "Read staff notes on warnings", + "@vitnode/core:warnings:can_revoke": "Revoke warnings", + "@vitnode/core:warnings:can_view_sanctions": "View suspensions", + "@vitnode/core:warnings:can_manage_sanctions": "Lift suspensions", + "@vitnode/core:warning_settings": "Warning settings", + "@vitnode/core:warning_settings:can_view": "View warning reasons and thresholds", + "@vitnode/core:warning_settings:can_manage": "Manage warning reasons and thresholds", + "@vitnode/core:moderation:can_warn": "Warn authors of this content" } diff --git a/packages/vitnode/src/pages/account-status.tsx b/packages/vitnode/src/pages/account-status.tsx new file mode 100644 index 0000000000..530c8790b1 --- /dev/null +++ b/packages/vitnode/src/pages/account-status.tsx @@ -0,0 +1,22 @@ +import type { AccountStatusRouteData } from "../tanstack/account-status/route"; + +import { loadAccountStatusRoute } from "../tanstack/account-status/route"; +import { + AccountStatusBreadcrumb, + AccountStatusRouteContent, +} from "../tanstack/account-status/screen"; +import { defineRoute } from "../tanstack/plugin-routes"; + +const AccountStatusPage = () => ; + +// Open to every visitor on purpose: a suspended account is a guest to every +// other route, and this is where it learns why. +export const route = defineRoute({ + load: async ({ context, t }) => + await loadAccountStatusRoute({ ...context, t }), + head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), + + breadcrumb: AccountStatusBreadcrumb, +}); + +export default AccountStatusPage; diff --git a/packages/vitnode/src/pages/admin/moderation/warning-reasons.tsx b/packages/vitnode/src/pages/admin/moderation/warning-reasons.tsx new file mode 100644 index 0000000000..c3727a6d96 --- /dev/null +++ b/packages/vitnode/src/pages/admin/moderation/warning-reasons.tsx @@ -0,0 +1,25 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminWarningsRouteData } from "@/tanstack/admin/warnings/route"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminWarningReasonsRoute } from "@/tanstack/admin/warnings/route"; +import { AdminWarningReasonsRouteContent } from "@/tanstack/admin/warnings/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminWarningReasonsPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute({ + load: async ({ context, t }) => + await loadAdminWarningReasonsRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ + segments: ["core", "moderation", "warning-reasons"], + }), +}); + +export default AdminWarningReasonsPage; diff --git a/packages/vitnode/src/pages/admin/moderation/warning-thresholds.tsx b/packages/vitnode/src/pages/admin/moderation/warning-thresholds.tsx new file mode 100644 index 0000000000..eaeaa132a0 --- /dev/null +++ b/packages/vitnode/src/pages/admin/moderation/warning-thresholds.tsx @@ -0,0 +1,25 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminWarningsRouteData } from "@/tanstack/admin/warnings/route"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminWarningThresholdsRoute } from "@/tanstack/admin/warnings/route"; +import { AdminWarningThresholdsRouteContent } from "@/tanstack/admin/warnings/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminWarningThresholdsPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute({ + load: async ({ context, t }) => + await loadAdminWarningThresholdsRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ + segments: ["core", "moderation", "warning-thresholds"], + }), +}); + +export default AdminWarningThresholdsPage; diff --git a/packages/vitnode/src/pages/modcp/member.tsx b/packages/vitnode/src/pages/modcp/member.tsx new file mode 100644 index 0000000000..5672c9d143 --- /dev/null +++ b/packages/vitnode/src/pages/modcp/member.tsx @@ -0,0 +1,24 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import type { ModcpMemberRouteData } from "../../tanstack/moderation/warnings-route"; + +import { ModcpMemberBreadcrumb } from "../../tanstack/moderation/breadcrumb"; +import { loadModcpMemberRoute } from "../../tanstack/moderation/warnings-route"; +import { ModcpMemberRouteContent } from "../../tanstack/moderation/warnings-screen"; +import { defineAuthenticatedRoute } from "../../tanstack/plugin-routes"; + +const ModcpMemberPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAuthenticatedRoute({ + load: async ({ context, params, t }) => + await loadModcpMemberRoute({ ...context, id: params.id ?? "", t }), + head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), + + breadcrumb: ModcpMemberBreadcrumb, +}); + +export default ModcpMemberPage; diff --git a/packages/vitnode/src/pages/modcp/warnings.tsx b/packages/vitnode/src/pages/modcp/warnings.tsx new file mode 100644 index 0000000000..ec13779a49 --- /dev/null +++ b/packages/vitnode/src/pages/modcp/warnings.tsx @@ -0,0 +1,34 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import type { ModcpRouteData } from "../../tanstack/moderation/route"; +import type { ModcpWarningsRouteSearch } from "../../tanstack/moderation/warnings-route"; + +import { ModcpWarningsBreadcrumb } from "../../tanstack/moderation/breadcrumb"; +import { loadModcpWarningsRoute } from "../../tanstack/moderation/warnings-route"; +import { ModcpWarningsRouteContent } from "../../tanstack/moderation/warnings-screen"; +import { defineAuthenticatedRoute } from "../../tanstack/plugin-routes"; + +const ModcpWarningsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAuthenticatedRoute< + ModcpRouteData, + ModcpWarningsRouteSearch +>({ + load: async ({ context, search, t }) => + await loadModcpWarningsRoute({ ...context, search, t }), + head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), + + breadcrumb: ModcpWarningsBreadcrumb, +}); + +export default ModcpWarningsPage; diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 9c56c1f056..f0f877036b 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -1,4 +1,5 @@ import { defineRoutes, index, layout, lazy, page } from "./routing"; +import { ACCOUNT_STATUS_NAMESPACES } from "./tanstack/account-status/route"; import { ADMIN_AI_NAMESPACES } from "./tanstack/admin/ai/route"; import { normalizeAiHistoryRouteSearch, @@ -41,6 +42,7 @@ import { normalizeStaffRouteSearch } from "./tanstack/admin/staff/route-search"; import { ADMIN_USER_NAMESPACES } from "./tanstack/admin/users/detail-route"; import { ADMIN_USERS_NAMESPACES } from "./tanstack/admin/users/route"; import { normalizeUsersRouteSearch } from "./tanstack/admin/users/route-search"; +import { ADMIN_WARNINGS_NAMESPACES } from "./tanstack/admin/warnings/route"; import { LOGIN_NAMESPACES } from "./tanstack/auth/login-route"; import { LoginPendingSkeleton, @@ -65,6 +67,7 @@ import { normalizeMyFilesRouteSearch } from "./tanstack/files/route-search"; import { normalizeModcpApprovalsSearch } from "./tanstack/moderation/approvals-route-search"; import { MODCP_NAMESPACES } from "./tanstack/moderation/namespaces"; import { normalizeModcpReportsSearch } from "./tanstack/moderation/route-search"; +import { normalizeModcpWarningsSearch } from "./tanstack/moderation/warnings-route"; import { NOTIFICATIONS_NAMESPACES } from "./tanstack/notifications/namespaces"; import { normalizeNotificationsRouteSearch } from "./tanstack/notifications/route-search"; import { @@ -237,9 +240,26 @@ export const routes = defineRoutes([ component: lazy(() => import("./pages/modcp/approval")), pendingComponent: () => , }), + + page("warnings", { + component: lazy(() => import("./pages/modcp/warnings")), + pendingComponent: () => , + search: normalizeModcpWarningsSearch, + }), + + page("members/:id", { + component: lazy(() => import("./pages/modcp/member")), + pendingComponent: () => , + }), ], }), + page("/account-status", { + component: lazy(() => import("./pages/account-status")), + messages: ACCOUNT_STATUS_NAMESPACES, + pendingComponent: () => , + }), + page("/notifications", { component: lazy(() => import("./pages/notifications")), messages: NOTIFICATIONS_NAMESPACES, @@ -371,6 +391,22 @@ export const routes = defineRoutes([ pendingComponent: TablePendingSkeleton, }), + page("/admin/core/moderation/warning-reasons", { + area: "admin", + component: lazy(() => import("./pages/admin/moderation/warning-reasons")), + messages: ADMIN_WARNINGS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + }), + + page("/admin/core/moderation/warning-thresholds", { + area: "admin", + component: lazy( + () => import("./pages/admin/moderation/warning-thresholds"), + ), + messages: ADMIN_WARNINGS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + }), + page("/admin/core/users", { area: "admin", component: lazy(() => import("./pages/admin/users/index")), diff --git a/packages/vitnode/src/tanstack/account-status/route.ts b/packages/vitnode/src/tanstack/account-status/route.ts new file mode 100644 index 0000000000..d964163bff --- /dev/null +++ b/packages/vitnode/src/tanstack/account-status/route.ts @@ -0,0 +1,33 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { PluginRouteTranslator } from "@/routing"; + +import { accountStatusQueryOptions } from "@/views/moderation/warnings/warnings-api"; + +export const ACCOUNT_STATUS_NAMESPACES = [ + "core.global", + "core.moderation", +] as const; + +export interface AccountStatusRouteData { + description: string; + title: string; +} + +export const loadAccountStatusRoute = async ({ + queryClient, + t, +}: { + queryClient: QueryClient; + t: PluginRouteTranslator; +}): Promise => { + await queryClient.query({ + ...accountStatusQueryOptions(), + staleTime: "static", + }); + + return { + description: t("core.moderation.account_status.desc"), + title: t("core.moderation.account_status.title"), + }; +}; diff --git a/packages/vitnode/src/tanstack/account-status/screen.tsx b/packages/vitnode/src/tanstack/account-status/screen.tsx new file mode 100644 index 0000000000..2700b3e7ef --- /dev/null +++ b/packages/vitnode/src/tanstack/account-status/screen.tsx @@ -0,0 +1,18 @@ +import { useTranslations } from "use-intl"; + +import { AccountStatusContent } from "@/views/moderation/warnings/account-status-content"; + +import { RouteMessages } from "../i18n/route-messages"; +import { ACCOUNT_STATUS_NAMESPACES } from "./route"; + +export const AccountStatusBreadcrumb = () => { + const t = useTranslations("core.moderation.account_status"); + + return <>{t("title")}; +}; + +export const AccountStatusRouteContent = () => ( + + + +); diff --git a/packages/vitnode/src/tanstack/admin/warnings/route.ts b/packages/vitnode/src/tanstack/admin/warnings/route.ts new file mode 100644 index 0000000000..eb2db6188b --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/warnings/route.ts @@ -0,0 +1,62 @@ +import type { PluginRouteTranslator } from "@/routing"; + +import { + adminWarningReasonsQueryOptions, + adminWarningThresholdsQueryOptions, +} from "@/views/admin/views/core/moderation/warnings/warnings-admin-api"; +import { ADMIN_WARNING_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions"; + +import type { AdminScreenContext } from "../screen"; + +import { adminIdentityOf } from "../identity"; +import { requireAdminPermission } from "../screen"; + +export const ADMIN_WARNINGS_NAMESPACES = [ + "admin.moderation.warning_reasons", + "admin.moderation.warning_thresholds", + "core.global", + "core.moderation", +] as const; + +export interface AdminWarningsRouteData { + description: string; + title: string; +} + +export const loadAdminWarningReasonsRoute = async ({ + adminAccess, + queryClient, + t, +}: AdminScreenContext & { + t: PluginRouteTranslator; +}): Promise => { + requireAdminPermission(adminAccess, ADMIN_WARNING_PERMISSIONS.view); + await queryClient.query({ + ...adminWarningReasonsQueryOptions(adminIdentityOf(adminAccess)), + staleTime: "static", + }); + + return { + description: t("admin.moderation.warning_reasons.desc"), + title: t("admin.moderation.warning_reasons.title"), + }; +}; + +export const loadAdminWarningThresholdsRoute = async ({ + adminAccess, + queryClient, + t, +}: AdminScreenContext & { + t: PluginRouteTranslator; +}): Promise => { + requireAdminPermission(adminAccess, ADMIN_WARNING_PERMISSIONS.view); + await queryClient.query({ + ...adminWarningThresholdsQueryOptions(adminIdentityOf(adminAccess)), + staleTime: "static", + }); + + return { + description: t("admin.moderation.warning_thresholds.desc"), + title: t("admin.moderation.warning_thresholds.title"), + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/warnings/screen.tsx b/packages/vitnode/src/tanstack/admin/warnings/screen.tsx new file mode 100644 index 0000000000..82907f0ac0 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/warnings/screen.tsx @@ -0,0 +1,188 @@ +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { WarningReasonSheetState } from "@/views/admin/views/core/moderation/warnings/warning-reasons-content"; +import type { WarningThresholdSheetState } from "@/views/admin/views/core/moderation/warnings/warning-thresholds-content"; + +import { + AdminStaffPermissionGate, + useAdminStaffPermission, +} from "@/components/staff-permission/provider"; +import { PageTitle } from "@/components/ui/page-title"; +import { + CreateWarningReasonButton, + WarningReasonsContent, +} from "@/views/admin/views/core/moderation/warnings/warning-reasons-content"; +import { + CreateWarningThresholdButton, + WarningThresholdsContent, +} from "@/views/admin/views/core/moderation/warnings/warning-thresholds-content"; +import { + adminWarningReasonsQueryOptions, + adminWarningReasonsQueryRoot, + adminWarningThresholdsQueryOptions, + adminWarningThresholdsQueryRoot, + deleteAdminWarningThreshold, + reorderAdminWarningReasons, + saveAdminWarningReason, + saveAdminWarningThreshold, +} from "@/views/admin/views/core/moderation/warnings/warnings-admin-api"; +import { isAdminMutationError } from "@/views/admin/views/core/shared/admin-mutation"; +import { ADMIN_WARNING_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions"; +import { WARNINGS_QUERY_ROOT } from "@/views/moderation/warnings/warnings-api"; + +import type { AdminWarningsRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { useAdminIdentity } from "../identity"; +import { ADMIN_WARNINGS_NAMESPACES } from "./route"; + +const WarningReasonsScreen = ({ + description, + title, +}: AdminWarningsRouteData) => { + const t = useTranslations("admin.moderation.warning_reasons"); + const adminUserId = useAdminIdentity(); + const queryClient = useQueryClient(); + const { data } = useSuspenseQuery( + adminWarningReasonsQueryOptions(adminUserId), + ); + const canEdit = useAdminStaffPermission(ADMIN_WARNING_PERMISSIONS.manage); + const [sheet, setSheet] = React.useState({ + mode: "create", + }); + const [sheetOpen, setSheetOpen] = React.useState(false); + const refresh = async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: adminWarningReasonsQueryRoot(adminUserId), + }), + queryClient.invalidateQueries({ queryKey: WARNINGS_QUERY_ROOT }), + ]); + }; + + return ( +
+ + + { + setSheet({ mode: "create" }); + setSheetOpen(true); + }} + /> + + + + { + setSheet(state); + setSheetOpen(true); + }} + onReorder={async ids => { + const result = await reorderAdminWarningReasons(ids); + if (isAdminMutationError(result)) { + throw new Error( + `Saving the order answered ${result.error.status}.`, + ); + } + await refresh(); + toast.success(t("reorder.success"), { + description: t("reorder.success_desc"), + }); + }} + onSave={async args => { + const result = await saveAdminWarningReason(args); + if (!isAdminMutationError(result)) await refresh(); + + return result; + }} + onSheetOpenChange={setSheetOpen} + reasons={data} + sheet={sheet} + sheetOpen={sheetOpen} + /> +
+ ); +}; + +export const AdminWarningReasonsRouteContent = ( + props: AdminWarningsRouteData, +) => ( + + + +); + +const WarningThresholdsScreen = ({ + description, + title, +}: AdminWarningsRouteData) => { + const adminUserId = useAdminIdentity(); + const queryClient = useQueryClient(); + const { data } = useSuspenseQuery( + adminWarningThresholdsQueryOptions(adminUserId), + ); + const canEdit = useAdminStaffPermission(ADMIN_WARNING_PERMISSIONS.manage); + const [sheet, setSheet] = React.useState({ + mode: "create", + }); + const [sheetOpen, setSheetOpen] = React.useState(false); + const refresh = async () => { + await queryClient.invalidateQueries({ + queryKey: adminWarningThresholdsQueryRoot(adminUserId), + }); + }; + + return ( +
+ + + { + setSheet({ mode: "create" }); + setSheetOpen(true); + }} + /> + + + + { + const result = await deleteAdminWarningThreshold(id); + if (!isAdminMutationError(result)) await refresh(); + + return result; + }} + onOpenSheet={state => { + setSheet(state); + setSheetOpen(true); + }} + onSave={async args => { + const result = await saveAdminWarningThreshold(args); + if (!isAdminMutationError(result)) await refresh(); + + return result; + }} + onSheetOpenChange={setSheetOpen} + sheet={sheet} + sheetOpen={sheetOpen} + thresholds={data} + /> +
+ ); +}; + +export const AdminWarningThresholdsRouteContent = ( + props: AdminWarningsRouteData, +) => ( + + + +); diff --git a/packages/vitnode/src/tanstack/comments/author.ts b/packages/vitnode/src/tanstack/comments/author.ts new file mode 100644 index 0000000000..0db6795df3 --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/author.ts @@ -0,0 +1,21 @@ +import type { ContentPublicPerson } from "@/content/public-person"; + +import { zodContentPublicPerson } from "@/content/public-person"; + +const zodAuthor = zodContentPublicPerson.nullable().catch(null); + +/** + * The comment's author from its public record's `author` field, or `null` + * for a deleted account or a content type without one. Pure. + */ +export const contentCommentAuthor = ( + record: unknown, +): ContentPublicPerson | null => + typeof record === "object" && record !== null && "author" in record + ? zodAuthor.parse(record.author) + : null; + +/** The name to show for a person, preferring a real name they share. Pure. */ +export const contentCommentAuthorName = ( + person: ContentPublicPerson | null, +): null | string => (person ? (person.fullName ?? person.name) : null); diff --git a/packages/vitnode/src/tanstack/comments/item.tsx b/packages/vitnode/src/tanstack/comments/item.tsx index c29def8970..cf7b13b1e4 100644 --- a/packages/vitnode/src/tanstack/comments/item.tsx +++ b/packages/vitnode/src/tanstack/comments/item.tsx @@ -6,13 +6,12 @@ import { FlagIcon, PencilIcon, Trash2Icon, + TriangleAlertIcon, } from "lucide-react"; import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; -import type { ContentPublicPerson } from "@/content/public-person"; - import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -29,13 +28,13 @@ import { PopoverTitle, } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; -import { zodContentPublicPerson } from "@/content/public-person"; import type { ContentCommentComposerFormProps } from "./composer-form"; import type { ContentCommentHideFormProps } from "./hide-form"; import type { ContentCommentNode } from "./thread"; import type { ContentCommentEditable } from "./use-content-comments"; +import { contentCommentAuthor, contentCommentAuthorName } from "./author"; import { useContentCommentErrorToast, useContentCommentsContext } from "./root"; const loadComposer = async () => await import("./composer-form"); @@ -75,23 +74,7 @@ const LazyHideForm = (props: ContentCommentHideFormProps) => ( ); -const zodAuthor = zodContentPublicPerson.nullable().catch(null); - -/** - * The comment's author from its public record's `author` field, or `null` - * for a deleted account or a content type without one. Pure. - */ -export const contentCommentAuthor = ( - record: unknown, -): ContentPublicPerson | null => - typeof record === "object" && record !== null && "author" in record - ? zodAuthor.parse(record.author) - : null; - -/** The name to show for a person, preferring a real name they share. Pure. */ -export const contentCommentAuthorName = ( - person: ContentPublicPerson | null, -): null | string => (person ? (person.fullName ?? person.name) : null); +export { contentCommentAuthor, contentCommentAuthorName } from "./author"; /** * The state one comment needs: what the visitor may do with it, whether an @@ -142,6 +125,10 @@ export const useContentCommentItem = (node: ContentCommentNode) => { canEdit: comments.features.edit && permissions.canEdit, canHide: comments.features.hide && permissions.canHide, canReport: comments.features.report && permissions.canReport, + canWarn: + comments.features.warn && + permissions.canWarn && + contentCommentAuthor(node.record) !== null, }, setIsReplying, /** Loads the editable values, then opens the edit form. */ @@ -174,7 +161,7 @@ const HIDE_PROMPT_MORPH_CLASS = "[clip-path:inset(-2rem_round_var(--radius-lg))] transition-[opacity,scale,clip-path] duration-200 data-starting-style:scale-100 data-starting-style:[clip-path:inset(0_calc(100%-var(--menu-width,100%))_calc(100%-var(--menu-height,100%))_0_round_var(--radius-lg))] data-[side=top]:data-starting-style:[clip-path:inset(calc(100%-var(--menu-height,100%))_calc(100%-var(--menu-width,100%))_0_0_round_var(--radius-lg))]"; /** - * The **More actions** menu of one comment: Edit, Report, Hide or Unhide and + * The **More actions** menu of one comment: Edit, Report, Warn author, Hide or Unhide and * Delete, each shown only when the plugin wired it up and the server allows * it. Hiding asks for an optional reason in a popover; deleting asks for * confirmation. Renders nothing when no action is available. @@ -194,7 +181,7 @@ export const ContentCommentActions = ({ node: ContentCommentNode; }) => { const t = useTranslations("core.comments"); - const { comments, requestDelete, requestReport } = + const { comments, requestDelete, requestReport, requestWarn } = useContentCommentsContext(); const showError = useContentCommentErrorToast(); const menuRef = React.useRef(null); @@ -202,13 +189,16 @@ export const ContentCommentActions = ({ const [isHidePromptOpen, setIsHidePromptOpen] = React.useState(false); const [hidePromptOrigin, setHidePromptOrigin] = React.useState(null); - const { canDelete, canEdit, canHide, canReport } = item.permissions; + const { canDelete, canEdit, canHide, canReport, canWarn } = item.permissions; const isHidden = node.hidden; const name = contentCommentAuthorName(contentCommentAuthor(node.record)) ?? t("deleted_user"); - if (!node.record || (!canEdit && !canReport && !canHide && !canDelete)) { + if ( + !node.record || + (!canEdit && !canReport && !canWarn && !canHide && !canDelete) + ) { return children ? ( ) : null} - {(canHide || canDelete) && (canEdit || canReport || children) ? ( + {canWarn ? ( + { + requestWarn(node); + }} + > + + ) : null} + {(canHide || canDelete) && + (canEdit || canReport || canWarn || children) ? ( ) : null} {canHide ? ( diff --git a/packages/vitnode/src/tanstack/comments/root.tsx b/packages/vitnode/src/tanstack/comments/root.tsx index 33a5aa8739..9bb82af208 100644 --- a/packages/vitnode/src/tanstack/comments/root.tsx +++ b/packages/vitnode/src/tanstack/comments/root.tsx @@ -17,6 +17,8 @@ import { useLocale } from "../i18n/locale"; import { intlQueryOptions } from "../i18n/query"; import { getIntlRuntime } from "../i18n/runtime"; import { ReportDialog } from "../moderation/report-button"; +import { WarnDialog } from "../moderation/warn-button"; +import { contentCommentAuthor } from "./author"; /** The message namespaces the shared comments components render with. */ export const CONTENT_COMMENTS_NAMESPACES = [ @@ -30,6 +32,8 @@ interface ContentCommentsContextValue { requestDelete: (node: ContentCommentNode) => void; /** Opens the report dialog for one comment. */ requestReport: (node: ContentCommentNode) => void; + /** Opens the warn dialog for one comment's author. */ + requestWarn: (node: ContentCommentNode) => void; } /** @@ -108,17 +112,23 @@ const RootDialogs = ({ deleting, onDeleteClosed, onReportClosed, + onWarnClosed, reporting, + warning, }: { deleting: ContentCommentNode | null; onDeleteClosed: () => void; onReportClosed: () => void; + onWarnClosed: () => void; reporting: ContentCommentNode | null; + warning: ContentCommentNode | null; }) => { const t = useTranslations("core.comments"); const { comments } = useContentCommentsContext(); const showError = useContentCommentErrorToast(); const target = reporting ? comments.reportTarget(reporting.id) : null; + const warnTarget = warning ? comments.reportTarget(warning.id) : null; + const warnAuthor = warning ? contentCommentAuthor(warning.record) : null; return ( <> @@ -162,14 +172,25 @@ const RootDialogs = ({ typeLabel={t("report_type")} /> ) : null} + + {warnTarget && warnAuthor ? ( + { + if (!open) onWarnClosed(); + }} + open + userId={warnAuthor.id} + /> + ) : null} ); }; /** * Provides one comment thread to the shared comment components below it and - * renders the dialogs they open: the delete confirmation and the report - * dialog. Adds its own messages to the page's, so it works on any page and + * renders the dialogs they open: the delete confirmation, the report + * dialog and the warn dialog. Adds its own messages to the page's, so it works on any page and * your components inside keep their translations. Use it to lay out * comments your own way; `ContentComments` renders a complete section with * it. @@ -204,6 +225,8 @@ export const ContentCommentsRoot = ({ React.useState | null>(null); const [reporting, setReporting] = React.useState | null>(null); + const [warning, setWarning] = + React.useState | null>(null); const value = React.useMemo( () => ({ @@ -211,6 +234,7 @@ export const ContentCommentsRoot = ({ comments: comments as unknown as ContentCommentsController, requestDelete: setDeleting, requestReport: setReporting, + requestWarn: setWarning, }), [comments], ); @@ -228,7 +252,11 @@ export const ContentCommentsRoot = ({ onReportClosed={() => { setReporting(null); }} + onWarnClosed={() => { + setWarning(null); + }} reporting={reporting} + warning={warning} /> diff --git a/packages/vitnode/src/tanstack/comments/use-content-comments.ts b/packages/vitnode/src/tanstack/comments/use-content-comments.ts index ab509fffaf..6daf45d55d 100644 --- a/packages/vitnode/src/tanstack/comments/use-content-comments.ts +++ b/packages/vitnode/src/tanstack/comments/use-content-comments.ts @@ -346,6 +346,8 @@ export interface ContentCommentsController< hide: boolean; replies: boolean; report: boolean; + /** Warning authors reuses `report`'s target, so it is on whenever `report` is. */ + warn: boolean; }; /** The rich-text field that holds a comment's text. */ field: string; @@ -795,6 +797,7 @@ export const useContentComments = < hide: Boolean(visibility), replies: maxDepth !== undefined, report: Boolean(report), + warn: Boolean(report), }, field, hasMore: comments.hasNextPage, diff --git a/packages/vitnode/src/tanstack/content/permissions.ts b/packages/vitnode/src/tanstack/content/permissions.ts index 81304b7f4b..dbea1c7bef 100644 --- a/packages/vitnode/src/tanstack/content/permissions.ts +++ b/packages/vitnode/src/tanstack/content/permissions.ts @@ -26,11 +26,19 @@ export interface ContentPermissions { * the visitor's - unless `reports.selfReport` lets authors report their own. */ canReport: boolean; + /** + * A **Warn author** action makes sense: the visitor is a moderator with + * `can_warn` for the record's scope and did not write it. + */ + canWarn: boolean; } /** The permissions route's answer, as `fetch` returns it. */ export interface ContentPermissionsResponse { - items: (ContentPermissions & { id: TId })[]; + items: (Omit & { + canWarn?: boolean; + id: TId; + })[]; } const NO_PERMISSIONS: ContentPermissions = { @@ -38,6 +46,7 @@ const NO_PERMISSIONS: ContentPermissions = { canEdit: false, canHide: false, canReport: false, + canWarn: false, }; /** Batches in flight at once for one hook. */ @@ -194,6 +203,7 @@ export const useContentPermissions = ({ canEdit: item.canEdit, canHide: item.canHide, canReport: item.canReport, + canWarn: item.canWarn ?? false, } : NO_PERMISSIONS; }, diff --git a/packages/vitnode/src/tanstack/layout/main-header.tsx b/packages/vitnode/src/tanstack/layout/main-header.tsx index f9bed610ef..bcea09f740 100644 --- a/packages/vitnode/src/tanstack/layout/main-header.tsx +++ b/packages/vitnode/src/tanstack/layout/main-header.tsx @@ -1,7 +1,16 @@ +import { SuspensionNotice } from "../moderation/suspension-notice"; import { Header } from "./header"; import { UserHeader } from "./user-header"; -/** The site `Header` with the signed-in user's menu - what the main layout renders. */ +/** + * The site `Header` with the signed-in user's menu - what the main layout + * renders - followed by the suspension notice while the account is suspended. + */ export const MainHeader = ( props: Omit, "user">, -) =>
} />; +) => ( + <> +
} /> + + +); diff --git a/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx b/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx index 2d28b347f8..5dc70417eb 100644 --- a/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx @@ -37,3 +37,19 @@ export const ModcpApprovalBreadcrumb = ({ return <>{t("title", { id: loaderData?.id ?? 0 })}; }; + +export const ModcpWarningsBreadcrumb = () => { + const t = useTranslations("core.moderation.warn.list"); + + return <>{t("title")}; +}; + +export const ModcpMemberBreadcrumb = ({ + loaderData, +}: { + loaderData?: { title: string }; +}) => { + const t = useTranslations("core.moderation.warn.member"); + + return <>{loaderData?.title ?? t("breadcrumb")}; +}; diff --git a/packages/vitnode/src/tanstack/moderation/index.ts b/packages/vitnode/src/tanstack/moderation/index.ts index 5be0fd2790..e8e1734b9b 100644 --- a/packages/vitnode/src/tanstack/moderation/index.ts +++ b/packages/vitnode/src/tanstack/moderation/index.ts @@ -6,4 +6,10 @@ export { ReportDialog, type ReportDialogProps, } from "./report-button"; +export { + WarnAuthorButton, + type WarnAuthorButtonProps, + WarnDialog, + type WarnDialogProps, +} from "./warn-button"; export type { ReportPreview } from "@/views/moderation/report/report-dialog-content"; diff --git a/packages/vitnode/src/tanstack/moderation/route.ts b/packages/vitnode/src/tanstack/moderation/route.ts index 5c386391de..f5d00a7a0b 100644 --- a/packages/vitnode/src/tanstack/moderation/route.ts +++ b/packages/vitnode/src/tanstack/moderation/route.ts @@ -39,7 +39,7 @@ export const requireModeratorRoute = (auth: AuthenticatedState): void => { }; /** A serial id from the URL, or the route's 404. */ -const routeIdOf = (raw: string): number => { +export const routeIdOf = (raw: string): number => { const id = /^[1-9]\d{0,9}$/.test(raw) ? Number(raw) : Number.NaN; if (!Number.isSafeInteger(id) || id > 2_147_483_647) { // oxlint-disable-next-line typescript/only-throw-error @@ -50,7 +50,9 @@ const routeIdOf = (raw: string): number => { }; /** Turns the API's 404 - which also covers "not in your scope" - into the route's. */ -const withNotFound = async (load: () => Promise): Promise => { +export const withNotFound = async ( + load: () => Promise, +): Promise => { try { await load(); } catch (error) { diff --git a/packages/vitnode/src/tanstack/moderation/suspension-notice.tsx b/packages/vitnode/src/tanstack/moderation/suspension-notice.tsx new file mode 100644 index 0000000000..9a0fac9090 --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/suspension-notice.tsx @@ -0,0 +1,61 @@ +import { Link } from "@tanstack/react-router"; +import { ShieldAlertIcon } from "lucide-react"; +import { useFormatter, useTranslations } from "use-intl"; + +import { useSessionQuery } from "../auth/session-query"; +import { RouteMessages } from "../i18n/route-messages"; +import { REPORT_NAMESPACES } from "./report-button"; + +export const ACCOUNT_STATUS_PATH = "/account-status"; + +const Notice = ({ endsAt }: { endsAt: Date | null | string }) => { + const t = useTranslations("core.moderation.account_status.notice"); + const format = useFormatter(); + + return ( +
+
+
+
+ ); +}; + +/** + * A strip under the header while the session's account is suspended. The API + * treats such a session as a guest everywhere; this tells the member why and + * links to their account status. Renders nothing otherwise. + */ +export const SuspensionNotice = () => { + const { data } = useSessionQuery(); + const suspension = data?.suspension; + if (!suspension) return null; + + return ( + + + + ); +}; diff --git a/packages/vitnode/src/tanstack/moderation/warn-button.tsx b/packages/vitnode/src/tanstack/moderation/warn-button.tsx new file mode 100644 index 0000000000..e98c0b2953 --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/warn-button.tsx @@ -0,0 +1,155 @@ +import { TriangleAlertIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { ContentTarget } from "@/content/target"; +import type { IssuedWarning } from "@/views/moderation/warnings/warnings-api"; + +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { RouteMessages } from "../i18n/route-messages"; +import { REPORT_NAMESPACES } from "./report-button"; + +const WarnDialogContent = React.lazy( + async () => await import("@/views/moderation/warnings/warn-dialog-content"), +); + +export interface WarnDialogProps { + /** + * The content the warning is for. The server resolves its authors and + * moderation scope itself and refuses the warning unless `userId` is one of + * the authors and the moderator has `can_warn` there. Leave it out for a + * direct warning, which needs the core `warnings` `can_warn` permission. + */ + content?: ContentTarget | null; + /** Called with the server's result once the warning is issued. */ + onIssued?: (result: IssuedWarning) => void; + onOpenChange: (open: boolean) => void; + open: boolean; + /** The report the warning answers, for the ModCP report screen. */ + reportId?: null | number; + /** The member to warn. */ + userId: number; +} + +const WarnDialogInner = ({ onOpenChange, open, ...props }: WarnDialogProps) => ( + + + {open ? ( + + + + + + } + > + { + onOpenChange(false); + }} + /> + + ) : null} + + +); + +/** + * The warn dialog on its own, for a menu item or any trigger you already + * have. Shows the reason, points, expiry, the member's message, a staff note + * and a live preview of the consequences; the form loads lazily. + * + * @example + * ```tsx + * import { WarnDialog } from "@vitnode/core/tanstack/moderation"; + * + * ; + * ``` + */ +export const WarnDialog = (props: WarnDialogProps) => ( + + + +); + +export interface WarnAuthorButtonProps extends Omit< + WarnDialogProps, + "onOpenChange" | "open" +> { + className?: string; + /** The id of the element naming the author, read after the button's label. */ + describedBy?: string; +} + +/** + * A **Warn author** button that opens {@link WarnDialog}. Render it only when + * the server allowed it - for a content type, when the permissions route + * answers `canWarn` - and the API checks everything again anyway. + * + * @example + * ```tsx + * import { contentTarget } from "@vitnode/core/content"; + * import { WarnAuthorButton } from "@vitnode/core/tanstack/moderation"; + * + * {permissions.canWarn ? ( + * + * ) : null} + * ``` + */ +export const WarnAuthorButton = ({ + className, + describedBy, + ...props +}: WarnAuthorButtonProps) => { + const [open, setOpen] = React.useState(false); + + return ( + + { + setOpen(true); + }} + /> + + + ); +}; + +const WarnAuthorButtonLabel = ({ + className, + describedBy, + onClick, +}: { + className?: string; + describedBy?: string; + onClick: () => void; +}) => { + const t = useTranslations("core.moderation.warn"); + + return ( + + ); +}; diff --git a/packages/vitnode/src/tanstack/moderation/warnings-route.ts b/packages/vitnode/src/tanstack/moderation/warnings-route.ts new file mode 100644 index 0000000000..84c51e9974 --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/warnings-route.ts @@ -0,0 +1,154 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { notFound } from "@tanstack/react-router"; + +import type { PluginRouteTranslator } from "@/routing"; +import type { ModcpWarningsFilter } from "@/views/moderation/warnings/warnings-api"; + +import { asSearchValue, readPageSize } from "@/lib/table-params"; +import { ModcpRequestError } from "@/views/moderation/modcp/modcp-query"; +import { + memberStandingQueryOptions, + warningsListQueryOptions, +} from "@/views/moderation/warnings/warnings-api"; + +import type { AuthenticatedState } from "../auth/state"; +import type { ModcpRouteData } from "./route"; + +import { requireModeratorRoute, routeIdOf, withNotFound } from "./route"; + +export const MODCP_WARNINGS_PAGE_SIZE = 25; +const MAX_PAGE = 10_000; +const MAX_ID = 2_147_483_647; +const STATUSES = ["active", "expired", "revoked"] as const; + +export interface ModcpWarningsRouteSearch { + first?: number; + page?: number; + reason?: number; + /** `all` is what the URL says by saying nothing. */ + status?: (typeof STATUSES)[number]; + user?: number; +} + +const positiveInt = (raw: string | undefined, max: number) => { + if (raw === undefined || !/^\d+$/.test(raw)) return undefined; + const value = Number(raw); + + return Number.isSafeInteger(value) && value > 0 && value <= max + ? value + : undefined; +}; + +export const normalizeModcpWarningsSearch = ( + input: Record, +): ModcpWarningsRouteSearch => { + const status = asSearchValue(input.status); + const reason = positiveInt(asSearchValue(input.reason), MAX_ID); + const user = positiveInt(asSearchValue(input.user), MAX_ID); + const page = positiveInt(asSearchValue(input.page), MAX_PAGE); + const rawFirst = asSearchValue(input.first); + const first = + rawFirst === undefined ? undefined : readPageSize(rawFirst, 100); + + return { + ...(STATUSES.find(value => value === status) + ? { status: status as (typeof STATUSES)[number] } + : {}), + ...(reason === undefined ? {} : { reason }), + ...(user === undefined ? {} : { user }), + ...(page === undefined || page === 1 ? {} : { page }), + ...(first === undefined || Number(first) === MODCP_WARNINGS_PAGE_SIZE + ? {} + : { first: Number(first) }), + }; +}; + +export const modcpWarningsFilter = ( + search: ModcpWarningsRouteSearch, +): ModcpWarningsFilter => ({ + page: search.page ?? 1, + pageSize: search.first ?? MODCP_WARNINGS_PAGE_SIZE, + status: search.status ?? "all", + ...(search.reason === undefined ? {} : { reasonId: search.reason }), + ...(search.user === undefined ? {} : { userId: search.user }), +}); + +export const modcpWarningsSearchParams = ( + search: ModcpWarningsRouteSearch, +): URLSearchParams => + new URLSearchParams( + Object.entries(search) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, String(value)]), + ); + +export const modcpWarningsSearchFrom = ( + query: string, +): ModcpWarningsRouteSearch => + normalizeModcpWarningsSearch(Object.fromEntries(new URLSearchParams(query))); + +export const loadModcpWarningsRoute = async ({ + auth, + queryClient, + search, + t, +}: { + auth: AuthenticatedState; + queryClient: QueryClient; + search: ModcpWarningsRouteSearch; + t: PluginRouteTranslator; +}): Promise => { + requireModeratorRoute(auth); + await queryClient.query({ + ...warningsListQueryOptions(modcpWarningsFilter(search)), + staleTime: "static", + }); + + return { + description: t("core.moderation.warn.list.desc"), + title: t("core.moderation.warn.list.title"), + }; +}; + +export interface ModcpMemberRouteData extends ModcpRouteData { + id: number; +} + +export const loadModcpMemberRoute = async ({ + auth, + id: raw, + queryClient, + t, +}: { + auth: AuthenticatedState; + id: string; + queryClient: QueryClient; + t: PluginRouteTranslator; +}): Promise => { + requireModeratorRoute(auth); + const id = routeIdOf(raw); + let name = ""; + try { + await withNotFound(async () => { + const standing = await queryClient.query({ + ...memberStandingQueryOptions(id), + staleTime: "static", + }); + name = standing.user.name; + }); + } catch (error) { + // A moderator who may neither warn nor view warnings gets the same 404. + if (error instanceof ModcpRequestError && error.status === 403) { + // oxlint-disable-next-line typescript/only-throw-error + throw notFound(); + } + throw error; + } + + return { + description: t("core.moderation.warn.member.desc"), + id, + title: t("core.moderation.warn.member.title", { name }), + }; +}; diff --git a/packages/vitnode/src/tanstack/moderation/warnings-screen.tsx b/packages/vitnode/src/tanstack/moderation/warnings-screen.tsx new file mode 100644 index 0000000000..f662c3a588 --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/warnings-screen.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +import type { DataTableNavigation } from "@/components/table/navigation"; + +import { DataTableNavigationProvider } from "@/components/table/navigation"; +import { ModcpMemberContent } from "@/views/moderation/warnings/modcp-member-content"; +import { ModcpWarningsContent } from "@/views/moderation/warnings/modcp-warnings-content"; + +import type { ModcpRouteData } from "./route"; +import type { ModcpWarningsRouteSearch } from "./warnings-route"; + +import { RouteMessages } from "../i18n/route-messages"; +import { MODCP_NAMESPACES } from "./namespaces"; +import { + modcpWarningsFilter, + modcpWarningsSearchFrom, + modcpWarningsSearchParams, +} from "./warnings-route"; + +export const ModcpWarningsRouteContent = ({ + description, + navigate, + search, + title, +}: ModcpRouteData & { + navigate: (options: { + resetScroll?: boolean; + search: ModcpWarningsRouteSearch; + }) => Promise; + search: ModcpWarningsRouteSearch; +}) => { + const navigation = React.useMemo( + () => ({ + navigate: async nextSearch => { + await navigate({ + resetScroll: false, + search: modcpWarningsSearchFrom(nextSearch), + }); + }, + searchParams: modcpWarningsSearchParams(search), + }), + [navigate, search], + ); + + return ( + + + + + + ); +}; + +export const ModcpMemberRouteContent = ({ id }: { id: number }) => ( + + + +); diff --git a/packages/vitnode/src/tanstack/profile/screen.tsx b/packages/vitnode/src/tanstack/profile/screen.tsx index 641990f12e..f49c0aebc2 100644 --- a/packages/vitnode/src/tanstack/profile/screen.tsx +++ b/packages/vitnode/src/tanstack/profile/screen.tsx @@ -6,13 +6,14 @@ import { } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { cn } from "cn"; -import { UserRoundPenIcon } from "lucide-react"; +import { ShieldIcon, TriangleAlertIcon, UserRoundPenIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; -import { buttonVariants } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { UserPresenceStatus } from "@/components/user-presence-status"; import { USER_HEADER_HREF } from "@/views/layouts/theme/header/user/user-header-model"; +import { memberStandingQueryOptions } from "@/views/moderation/warnings/warnings-api"; import { userPresenceQueryOptions } from "@/views/presence/presence-query"; import { ProfileContent } from "@/views/profile/profile-content"; import { userProfileQueryKey } from "@/views/profile/profile-query"; @@ -22,6 +23,7 @@ import { SearchFeedList } from "@/views/search/search-feed-content"; import { useSessionQuery } from "../auth/session-query"; import { useLocale } from "../i18n/locale"; import { RouteMessages } from "../i18n/route-messages"; +import { WarnDialog } from "../moderation/warn-button"; import { useOwnUserImageEditor } from "./own-image-editor"; import { userProfileQuery } from "./query"; import { PROFILE_NAMESPACES } from "./route"; @@ -72,6 +74,43 @@ const ProfilePresence = ({ ); }; +/** + * Warn and View warnings for moderators. Shown only when the server's + * standing route answers for this member - it decides who may warn directly. + */ +const ModeratorActions = ({ userId }: { userId: number }) => { + const t = useTranslations("core.profile"); + const [open, setOpen] = React.useState(false); + const { data } = useQuery(memberStandingQueryOptions(userId)); + if (!data) return null; + + return ( +
+ +
+ ); +}; + const ProfileTimeline = ({ userId, viewerId, @@ -116,7 +155,13 @@ const ProfileScreen = ({ children, nameCode }: ProfileRouteProps) => { return ( : null} + action={ + isOwner ? ( + + ) : session?.user?.isModerator ? ( + + ) : null + } editor={editor} presence={ ({ href: "/admin/core/moderation/report-reasons", permission: core("report_reasons"), }, + { + title: key("admin.global.nav.moderation.warning_reasons"), + href: "/admin/core/moderation/warning-reasons", + permission: core("warning_settings"), + }, + { + title: key("admin.global.nav.moderation.warning_thresholds"), + href: "/admin/core/moderation/warning-thresholds", + permission: core("warning_settings"), + }, ], }, { diff --git a/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-reason-form.tsx b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-reason-form.tsx new file mode 100644 index 0000000000..63d64f6bf4 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-reason-form.tsx @@ -0,0 +1,205 @@ +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormSheetFooter } from "@/components/form/auto-form-sheet-footer"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; +import { AutoFormNumber } from "@/components/form/fields/number"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { multiLangValueSchema } from "@/lib/helpers/multi-lang"; +import { WARNING_LIMITS } from "@/lib/moderation/warnings"; +import { resolveReasonText } from "@/views/moderation/report-reasons-query"; + +import type { + AdminWarningReason, + AdminWarningReasonInput, +} from "./warnings-admin-api"; + +export interface WarningReasonFormProps { + onSave: (args: { + id?: number; + values: AdminWarningReasonInput; + }) => Promise>; + onSaved: () => void; + reason?: AdminWarningReason; +} + +export const WarningReasonForm = ({ + onSave, + onSaved, + reason, +}: WarningReasonFormProps) => { + const t = useTranslations("admin.moderation.warning_reasons.form"); + const locale = useLocale(); + + const formSchema = z.object({ + title: multiLangValueSchema({ + maxLength: WARNING_LIMITS.reasonTitleMaxLength, + minLength: 1, + }) + .min(1) + .default(reason?.title ?? []), + description: multiLangValueSchema({ + maxLength: WARNING_LIMITS.reasonDescriptionMaxLength, + }).default(reason?.description ?? []), + points: z + .number() + .int() + .min(0) + .max(WARNING_LIMITS.maxPoints) + .default(reason?.points ?? 1), + expiresAfterDays: z + .number() + .int() + .min(1) + .max(WARNING_LIMITS.maxDurationHours / 24) + .nullable() + .default( + reason + ? reason.expiresAfterHours === null + ? null + : Math.max(1, Math.round(reason.expiresAfterHours / 24)) + : 30, + ), + message: multiLangValueSchema({ + maxLength: WARNING_LIMITS.reasonMessageMaxLength, + }).default(reason?.message ?? []), + enabled: z.boolean().default(reason?.enabled ?? true), + }); + + const onSubmit: AutoFormOnSubmit = async ({ + expiresAfterDays, + ...values + }) => { + const result = await onSave({ + id: reason?.id, + values: { + ...values, + expiresAfterHours: + expiresAfterDays === null ? null : expiresAfterDays * 24, + }, + }); + + if ("error" in result) { + toast.error(t("error"), { + description: + result.error.status === 409 + ? t("limit_reached", { max: WARNING_LIMITS.maxReasons }) + : t("error_desc"), + }); + + return; + } + + toast.success(reason ? t("updated") : t("created"), { + description: reason + ? t("updated_desc") + : t("created_desc", { + reason: resolveReasonText(values.title, locale), + }), + }); + onSaved(); + }; + + return ( + ( + + ), + }, + { + id: "description", + component: props => ( + + ), + }, + { + id: "points", + component: props => ( + + ), + }, + { + id: "expiresAfterDays", + component: props => ( + + ), + }, + { + id: "message", + component: props => ( + + ), + }, + { + id: "enabled", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + id={reason ? `warning-reason-${reason.id}` : "warning-reason-new"} + layout={rendered => ( + <> +
+ {rendered.title} + {rendered.description} + {rendered.points} + {rendered.expiresAfterDays} + {rendered.message} + {rendered.enabled} +
+ + + )} + onSubmit={onSubmit} + /> + ); +}; + +export default WarningReasonForm; diff --git a/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-reasons-content.tsx b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-reasons-content.tsx new file mode 100644 index 0000000000..9d4dcad238 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-reasons-content.tsx @@ -0,0 +1,216 @@ +import { PencilIcon, PlusIcon, TriangleAlertIcon } from "lucide-react"; +import React from "react"; +import { useLocale, useTranslations } from "use-intl"; + +import type { DataTableNavigation } from "@/components/table/navigation"; + +import { ContentDataTable } from "@/components/table/content"; +import { DataTableNavigationProvider } from "@/components/table/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { WARNING_LIMITS } from "@/lib/moderation/warnings"; +import { resolveReasonText } from "@/views/moderation/report-reasons-query"; +import { WarningDuration } from "@/views/moderation/warnings/warning-format"; + +import type { WarningReasonFormProps } from "./warning-reason-form"; +import type { AdminWarningReason } from "./warnings-admin-api"; + +const WarningReasonForm = React.lazy( + async () => await import("./warning-reason-form"), +); + +export type WarningReasonSheetState = + | { mode: "create" } + | { mode: "edit"; reason: AdminWarningReason }; + +const STATIC_NAVIGATION: DataTableNavigation = { + navigate: () => undefined, + searchParams: new URLSearchParams(), +}; + +export const CreateWarningReasonButton = ({ + count, + onCreate, +}: { + count: number; + onCreate: () => void; +}) => { + const t = useTranslations("admin.moderation.warning_reasons"); + const atLimit = count >= WARNING_LIMITS.maxReasons; + + return ( + + ); +}; + +export const WarningReasonsContent = ({ + canEdit, + onOpenSheet, + onReorder, + onSave, + onSheetOpenChange, + reasons, + sheet, + sheetOpen, +}: { + canEdit: boolean; + onOpenSheet: (state: WarningReasonSheetState) => void; + onReorder: (ids: number[]) => Promise; + onSave: WarningReasonFormProps["onSave"]; + onSheetOpenChange: (open: boolean) => void; + reasons: AdminWarningReason[]; + sheet: WarningReasonSheetState; + sheetOpen: boolean; +}) => { + const t = useTranslations("admin.moderation.warning_reasons"); + const locale = useLocale(); + + return ( + <> + + + columns={[ + { + accessorKey: "title", + cell: ({ row }) => { + const description = resolveReasonText(row.description, locale); + + return ( + + + {resolveReasonText(row.title, locale)} + + {description ? ( + + {description} + + ) : null} + + ); + }, + header: t("columns.reason"), + }, + { + accessorKey: "points", + cell: ({ row }) => ( + {row.points} + ), + className: "w-24", + header: t("columns.points"), + }, + { + accessorKey: "expiresAfterHours", + cell: ({ row }) => + row.expiresAfterHours === null ? ( + + {t("never")} + + ) : ( + + ), + className: "w-32", + header: t("columns.expires"), + }, + { + accessorKey: "enabled", + cell: ({ row }) => + row.enabled ? ( + {t("enabled")} + ) : ( + {t("disabled")} + ), + className: "w-28", + header: t("columns.status"), + }, + { + align: "right", + cell: ({ row }) => { + const title = resolveReasonText(row.title, locale); + + return canEdit ? ( + + + + ) : null; + }, + className: "w-16", + header: "", + id: "actions", + }, + ]} + customNoResults={{ + description: t("empty_desc"), + icon: , + title: t("empty_title"), + }} + edges={reasons} + id="warning-reasons-table" + order={{ defaultOrder: { column: "position", order: "asc" } }} + reorderable={{ + disabled: !canEdit, + getRowLabel: row => resolveReasonText(row.title, locale), + onReorder, + }} + /> + + + + + + + {sheet.mode === "edit" + ? t("form.edit_title") + : t("form.create_title")} + + + {t("form.sheet_desc")} + + + + + + } + > + { + onSheetOpenChange(false); + }} + reason={sheet.mode === "edit" ? sheet.reason : undefined} + /> + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-threshold-form.tsx b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-threshold-form.tsx new file mode 100644 index 0000000000..86b7315685 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-threshold-form.tsx @@ -0,0 +1,166 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormSheetFooter } from "@/components/form/auto-form-sheet-footer"; +import { AutoFormNumber } from "@/components/form/fields/number"; +import { AutoFormRadioGroup } from "@/components/form/fields/radio-group"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { + WARNING_LIMITS, + WARNING_THRESHOLD_ACTIONS, +} from "@/lib/moderation/warnings"; + +import type { + AdminWarningThreshold, + AdminWarningThresholdInput, +} from "./warnings-admin-api"; + +export interface WarningThresholdFormProps { + onSave: (args: { + id?: number; + values: AdminWarningThresholdInput; + }) => Promise>; + onSaved: () => void; + threshold?: AdminWarningThreshold; +} + +export const WarningThresholdForm = ({ + onSave, + onSaved, + threshold, +}: WarningThresholdFormProps) => { + const t = useTranslations("admin.moderation.warning_thresholds.form"); + + const formSchema = z.object({ + points: z + .number() + .int() + .min(1) + .max(WARNING_LIMITS.maxThresholdPoints) + .default(threshold?.points ?? 10), + action: z + .enum(WARNING_THRESHOLD_ACTIONS) + .default(threshold?.action ?? "suspend_temporary"), + durationHours: z + .number() + .int() + .min(1) + .max(WARNING_LIMITS.maxDurationHours) + .default(threshold?.durationHours ?? 24), + enabled: z.boolean().default(threshold?.enabled ?? true), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const result = await onSave({ + id: threshold?.id, + values: { + ...values, + durationHours: + values.action === "suspend_temporary" ? values.durationHours : null, + }, + }); + + if ("error" in result) { + toast.error(t("error"), { + description: + result.error.status === 409 + ? result.error.message?.includes("limit_reached") + ? t("limit_reached", { max: WARNING_LIMITS.maxThresholds }) + : t("duplicate_points") + : t("error_desc"), + }); + + return; + } + + toast.success(threshold ? t("updated") : t("created"), { + description: t("saved_desc"), + }); + onSaved(); + }; + + return ( + ( + + ), + }, + { + id: "action", + component: props => ( + ({ + description: t(`actions.${action}_desc`), + label: t(`actions.${action}`), + value: action, + }))} + /> + ), + }, + { + id: "durationHours", + hidden: values => values.action !== "suspend_temporary", + component: props => ( + + ), + }, + { + id: "enabled", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + id={ + threshold + ? `warning-threshold-${threshold.id}` + : "warning-threshold-new" + } + layout={rendered => ( + <> +
+

+ {t("future_only")} +

+ {rendered.points} + {rendered.action} + {rendered.durationHours} + {rendered.enabled} +
+ + + )} + onSubmit={onSubmit} + /> + ); +}; + +export default WarningThresholdForm; diff --git a/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-thresholds-content.tsx b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-thresholds-content.tsx new file mode 100644 index 0000000000..33fb9e1fea --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warning-thresholds-content.tsx @@ -0,0 +1,241 @@ +import { GaugeIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { DataTableNavigation } from "@/components/table/navigation"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { ContentDataTable } from "@/components/table/content"; +import { DataTableNavigationProvider } from "@/components/table/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { WARNING_LIMITS } from "@/lib/moderation/warnings"; +import { WarningDuration } from "@/views/moderation/warnings/warning-format"; + +import type { WarningThresholdFormProps } from "./warning-threshold-form"; +import type { AdminWarningThreshold } from "./warnings-admin-api"; + +const WarningThresholdForm = React.lazy( + async () => await import("./warning-threshold-form"), +); + +export type WarningThresholdSheetState = + | { mode: "create" } + | { mode: "edit"; threshold: AdminWarningThreshold }; + +const STATIC_NAVIGATION: DataTableNavigation = { + navigate: () => undefined, + searchParams: new URLSearchParams(), +}; + +export const CreateWarningThresholdButton = ({ + count, + onCreate, +}: { + count: number; + onCreate: () => void; +}) => { + const t = useTranslations("admin.moderation.warning_thresholds"); + const atLimit = count >= WARNING_LIMITS.maxThresholds; + + return ( + + ); +}; + +export const WarningThresholdsContent = ({ + canEdit, + onDelete, + onOpenSheet, + onSave, + onSheetOpenChange, + sheet, + sheetOpen, + thresholds, +}: { + canEdit: boolean; + onDelete: (id: number) => Promise>; + onOpenSheet: (state: WarningThresholdSheetState) => void; + onSave: WarningThresholdFormProps["onSave"]; + onSheetOpenChange: (open: boolean) => void; + sheet: WarningThresholdSheetState; + sheetOpen: boolean; + thresholds: AdminWarningThreshold[]; +}) => { + const t = useTranslations("admin.moderation.warning_thresholds"); + const [deleting, setDeleting] = React.useState( + null, + ); + + return ( + <> + + + columns={[ + { + accessorKey: "points", + cell: ({ row }) => ( + + {t("at_points", { points: row.points })} + + ), + header: t("columns.points"), + }, + { + accessorKey: "action", + cell: ({ row }) => + row.action === "suspend_temporary" && row.durationHours ? ( + + {t("suspend_for")}{" "} + + + ) : ( + {t("suspend_indefinitely")} + ), + header: t("columns.action"), + }, + { + accessorKey: "enabled", + cell: ({ row }) => + row.enabled ? ( + {t("enabled")} + ) : ( + {t("disabled")} + ), + className: "w-28", + header: t("columns.status"), + }, + { + align: "right", + cell: ({ row }) => + canEdit ? ( +
+ + + + + + +
+ ) : null, + className: "w-24", + header: "", + id: "actions", + }, + ]} + customNoResults={{ + description: t("empty_desc"), + icon: , + title: t("empty_title"), + }} + edges={thresholds} + id="warning-thresholds-table" + order={{ defaultOrder: { column: "points", order: "asc" } }} + /> +
+ + + + + + {sheet.mode === "edit" + ? t("form.edit_title") + : t("form.create_title")} + + + {t("form.sheet_desc")} + + + + + + } + > + { + onSheetOpenChange(false); + }} + threshold={sheet.mode === "edit" ? sheet.threshold : undefined} + /> + + + + + } + onOpenChange={open => { + if (!open) setDeleting(null); + }} + onSubmit={async ({ onClose }) => { + if (!deleting) return; + const result = await onDelete(deleting.id); + if ("error" in result) { + toast.error(t("delete.error")); + + return; + } + toast.success(t("delete.success"), { + description: t("delete.success_desc", { points: deleting.points }), + }); + onClose(); + }} + open={deleting !== null} + submitVariant="destructive" + textSubmit={ + <> +
; +}) => { + const t = useTranslations("core.moderation.account_status"); + const format = useFormatter(); + + return ( + } variant="destructive"> + {t("suspended_title")} + +

+ {suspension.endsAt + ? t("suspended_until", { + endsAt: format.dateTime(new Date(suspension.endsAt), { + dateStyle: "full", + timeStyle: "short", + }), + }) + : t("suspended_indefinite")} +

+

{t("suspended_restrictions")}

+

{t("contact")}

+
+
+ ); +}; + +const OwnWarning = ({ + warning, +}: { + warning: AccountStatus["warnings"][number]; +}) => { + const t = useTranslations("core.moderation.account_status"); + const tWarn = useTranslations("core.moderation.warn"); + const locale = useLocale(); + const titleId = React.useId(); + + return ( +
+ ); +}; + +const Pending = ({ label }: { label: string }) => ( +
+ + {label} + + + + +
+); + +/** + * The member's own standing: an active suspension, if any, active points and + * warning history. Works for a suspended account too. Never shows staff notes + * or who issued a warning. + */ +export const AccountStatusContent = () => { + const t = useTranslations("core.moderation.account_status"); + const { data, isPending } = useQuery(accountStatusQueryOptions()); + + return ( +
+ + {isPending ? ( + + ) : !data ? ( +
+

{t("signed_out")}

+ +
+ ) : ( + <> + {data.suspension ? ( + + ) : ( + + {t("good_title")} + + {t("good_desc")} + + + )} + +
+
+

+ {t("points_title")} +

+

+ {t("points_desc")} +

+
+

+ {data.activePoints} +

+
+ +
+

+ {t("warnings_title")} +

+ {data.warnings.length === 0 ? ( +

+ {t("no_warnings")} +

+ ) : ( +
    + {data.warnings.map(warning => ( +
  • + +
  • + ))} +
+ )} +
+ + {data.sanctions.length > 0 ? ( +
+

+ {t("sanctions_title")} +

+
    + {data.sanctions.map(sanction => ( +
  • + + + {sanction.endsAt + ? t("sanction_temporary") + : t("sanction_indefinite")} + + + + {sanction.endsAt ? ( + <> + {" – "} + + + ) : null} + + + + {sanction.active + ? t("sanction_active") + : sanction.revoked + ? t("sanction_lifted") + : t("sanction_ended")} + +
  • + ))} +
+
+ ) : null} + + )} +
+ ); +}; diff --git a/packages/vitnode/src/views/moderation/warnings/modcp-member-content.tsx b/packages/vitnode/src/views/moderation/warnings/modcp-member-content.tsx new file mode 100644 index 0000000000..7c767c2b57 --- /dev/null +++ b/packages/vitnode/src/views/moderation/warnings/modcp-member-content.tsx @@ -0,0 +1,138 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { TriangleAlertIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { PageTitle } from "@/components/ui/page-title"; +import { WarnDialog } from "@/tanstack/moderation/warn-button"; + +import { Person } from "../modcp/report-tone"; +import { SanctionCard, StaffWarningCard } from "./warning-list"; +import { memberStandingQueryOptions } from "./warnings-api"; + +const Section = ({ + children, + title, +}: { + children: React.ReactNode; + title: string; +}) => { + const id = React.useId(); + + return ( +
+

+ {title} +

+ {children} +
+ ); +}; + +const Empty = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +/** A member's standing in the ModCP: points, warnings and suspensions. */ +export const ModcpMemberContent = ({ userId }: { userId: number }) => { + const t = useTranslations("core.moderation.warn"); + const { data } = useSuspenseQuery(memberStandingQueryOptions(userId)); + const [warning, setWarning] = React.useState(false); + const { permissions } = data; + + return ( +
+ + {permissions.warn ? ( + + ) : null} + + +
+ +
+
+
+ {t("member.active_points")} +
+
+ {data.activePoints} +
+
+ {data.sanctions ? ( +
+
+ {t("member.status")} +
+
+ {data.sanctions.some(sanction => sanction.active) + ? t("member.suspended") + : t("member.in_good_standing")} +
+
+ ) : null} +
+
+ + {data.sanctions ? ( +
+ {data.sanctions.length === 0 ? ( + {t("member.no_sanctions")} + ) : ( +
    + {data.sanctions.map(sanction => ( +
  • + +
  • + ))} +
+ )} +
+ ) : null} + +
+ {data.hiddenWarnings > 0 ? ( +

+ {t("member.hidden_warnings", { count: data.hiddenWarnings })} +

+ ) : null} + {data.warnings.length === 0 ? ( + {t("member.no_warnings")} + ) : ( +
    + {data.warnings.map(item => ( +
  • + +
  • + ))} +
+ )} +
+ + +
+ ); +}; diff --git a/packages/vitnode/src/views/moderation/warnings/modcp-warnings-content.tsx b/packages/vitnode/src/views/moderation/warnings/modcp-warnings-content.tsx new file mode 100644 index 0000000000..6630e71ecb --- /dev/null +++ b/packages/vitnode/src/views/moderation/warnings/modcp-warnings-content.tsx @@ -0,0 +1,257 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { cn } from "cn"; +import { ChevronRightIcon, SearchIcon, TriangleAlertIcon } from "lucide-react"; +import React from "react"; +import { useLocale, useTranslations } from "use-intl"; + +import { FiltersDataTable } from "@/components/table/filters"; +import { useDataTableUrl } from "@/components/table/navigation"; +import { PaginationDataTable } from "@/components/table/pagination"; +import { withTableFilter } from "@/components/table/url-state"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Input } from "@/components/ui/input"; +import { PageTitle } from "@/components/ui/page-title"; + +import type { ModcpWarningsFilter } from "./warnings-api"; + +import { Person } from "../modcp/report-tone"; +import { resolveReasonText } from "../report-reasons-query"; +import { StaffWarningCard } from "./warning-list"; +import { + memberSearchQueryOptions, + warningReasonsQueryOptions, + warningsListQueryOptions, +} from "./warnings-api"; + +export const MODCP_WARNING_STATUSES = [ + "all", + "active", + "expired", + "revoked", +] as const; + +const StatusTabs = ({ status }: { status: ModcpWarningsFilter["status"] }) => { + const t = useTranslations("core.moderation.warn"); + const { navigate, searchParams } = useDataTableUrl(); + + return ( + + ); +}; + +const ReasonFilter = () => { + const t = useTranslations("core.moderation.warn"); + const locale = useLocale(); + // Moderators who can only view warnings cannot list reasons; no filter then. + const reasons = useQuery(warningReasonsQueryOptions()); + if (!reasons.data || reasons.data.length === 0) return null; + + return ( + { + const label = resolveReasonText(reason.title, locale); + + return { keywords: [label], label, value: String(reason.id) }; + }), + single: { allLabel: t("list.all_reasons") }, + }, + ]} + /> + ); +}; + +/** Finds a member by name and links to their standing. */ +export const MemberLookup = () => { + const t = useTranslations("core.moderation.warn.lookup"); + const [search, setSearch] = React.useState(""); + const deferred = React.useDeferredValue(search); + const results = useQuery(memberSearchQueryOptions(deferred)); + const inputId = React.useId(); + const statusId = React.useId(); + + return ( +
+

+ {t("title")} +

+
+
+

+ {results.data ? t("results", { count: results.data.length }) : ""} +

+ {deferred.trim() && results.data ? ( + results.data.length === 0 ? ( +

{t("empty")}

+ ) : ( +
    + {results.data.map(person => ( +
  • + + +
  • + ))} +
+ ) + ) : null} + {results.isError ? ( +

{t("forbidden")}

+ ) : null} +
+ ); +}; + +const List = ({ filter }: { filter: ModcpWarningsFilter }) => { + const t = useTranslations("core.moderation.warn"); + const { data } = useSuspenseQuery(warningsListQueryOptions(filter)); + const { isPending } = useDataTableUrl(); + const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize)); + + return ( + <> + {data.items.length === 0 ? ( + + + + + + {t("list.empty_title")} + {t("list.empty_desc")} + + + ) : ( +
    + {data.items.map(item => ( +
  • + +
  • + ))} +
+ )} + + 1, + pageSize: data.pageSize, + startCursor: null, + totalCount: data.total, + totalPages, + }} + /> + + ); +}; + +/** The ModCP warning log: every warning the moderator may see, filtered. */ +export const ModcpWarningsContent = ({ + description, + filter, + title, +}: { + description: string; + filter: ModcpWarningsFilter; + title: string; +}) => ( +
+ + +
+ + +
+ +
+); diff --git a/packages/vitnode/src/views/moderation/warnings/warn-dialog-content.tsx b/packages/vitnode/src/views/moderation/warnings/warn-dialog-content.tsx new file mode 100644 index 0000000000..4a2815909f --- /dev/null +++ b/packages/vitnode/src/views/moderation/warnings/warn-dialog-content.tsx @@ -0,0 +1,706 @@ +import { useSelector } from "@tanstack/react-form"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { ShieldAlertIcon, TriangleAlertIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useFormatter, useLocale, useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { + AutoFormOnSubmit, + ItemAutoFormComponentProps, +} from "@/components/form/auto-form"; + +import { AutoForm, AutoFormSubmitButton } from "@/components/form/auto-form"; +import { AutoFormDateTime } from "@/components/form/fields/date-time"; +import { AutoFormNumber } from "@/components/form/fields/number"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useFormApi } from "@/components/ui/form"; +import { Skeleton } from "@/components/ui/skeleton"; +import { WARNING_LIMITS } from "@/lib/moderation/warnings"; + +import type { + IssuedWarning, + ModcpWarningReason, + WarningDraft, + WarningFailure, + WarningPreview, +} from "./warnings-api"; + +import { ModcpRequestError } from "../modcp/modcp-query"; +import { resolveReasonText } from "../report-reasons-query"; +import { WarningDuration } from "./warning-format"; +import { + ACCOUNT_STATUS_QUERY_KEY, + issueWarning, + previewWarning, + warningReasonsQueryOptions, + WARNINGS_QUERY_ROOT, +} from "./warnings-api"; + +/** The content a warning is about, as a `ContentTarget`. */ +export interface WarnContentRef { + id: string; + pluginId: string; + type: string; +} + +export interface WarnDialogContentProps { + content?: null | WarnContentRef; + onClose: () => void; + /** Called with the server's result once the warning is issued. */ + onIssued?: (result: IssuedWarning) => void; + reportId?: null | number; + userId: number; +} + +const NO_REASON = "none"; +const EXPIRATION_MODES = ["default", "never", "hours", "at"] as const; + +interface FormValues { + expirationAt?: null | string; + expirationHours?: number; + expirationMode?: (typeof EXPIRATION_MODES)[number]; + message?: string; + overrideNote?: string; + points?: number; + reasonId?: string; + resolveReport?: boolean; + staffNote?: string; +} + +const draftOf = ( + values: FormValues, + base: Pick, + canOverridePoints: boolean, +): WarningDraft => { + const mode = values.expirationMode ?? "default"; + + return { + ...base, + expiration: + mode === "hours" + ? { hours: values.expirationHours ?? 24, mode } + : mode === "at" && values.expirationAt + ? { at: values.expirationAt, mode } + : mode === "never" + ? { mode } + : { mode: "default" }, + points: canOverridePoints ? (values.points ?? null) : null, + reasonId: + !values.reasonId || values.reasonId === NO_REASON + ? null + : Number(values.reasonId), + }; +}; + +const failureOfError = (error: Error | null): WarningFailure => { + if (error instanceof ModcpRequestError) { + return error.status === 403 ? "forbidden" : "unknown"; + } + + return (error?.message as undefined | WarningFailure) ?? "unknown"; +}; + +const useFailureMessage = () => { + const t = useTranslations("core.moderation.warn.errors"); + + return (failure: WarningFailure) => + failure === "unknown" || failure === "conflict" ? t("unknown") : t(failure); +}; + +const PointsLine = ({ + label, + strong, + value, +}: { + label: string; + strong?: boolean; + value: React.ReactNode; +}) => ( +
+
{label}
+
+ {value} +
+
+); + +/** The sanction a warning leads to, in words. */ +export const SanctionSummary = ({ + sanction, +}: { + sanction: { durationHours?: null | number; endsAt: Date | null | string }; +}) => { + const t = useTranslations("core.moderation.warn"); + const format = useFormatter(); + if (sanction.endsAt === null) return <>{t("sanction.indefinite")}; + const endsAt = format.dateTime(new Date(sanction.endsAt), { + dateStyle: "medium", + timeStyle: "short", + }); + + return sanction.durationHours ? ( + <> + {t("sanction.for")} {" "} + {t("sanction.until", { endsAt })} + + ) : ( + <>{t("sanction.suspended_until", { endsAt })} + ); +}; + +const Consequences = ({ + base, + canOverridePoints, + initial, +}: { + base: Pick; + canOverridePoints: boolean; + initial: WarningPreview; +}) => { + const t = useTranslations("core.moderation.warn"); + const { form } = useFormApi(); + const values = useSelector(form.store, state => state.values as FormValues); + const draft = React.useDeferredValue( + draftOf(values, base, canOverridePoints), + ); + const preview = useQuery({ + placeholderData: keepPreviousData, + queryFn: async () => { + const result = await previewWarning(draft); + if (!result.ok) throw new Error(result.failure); + + return result.data; + }, + queryKey: [...WARNINGS_QUERY_ROOT, "preview", draft], + retry: false, + staleTime: 5_000, + }); + const data = preview.data ?? initial; + const failure = useFailureMessage(); + + return ( +
+

+ {t("consequences.title")} +

+
+ + + +
+

+ {t("consequences.action")} + {data.sanction ? ( + + ) : ( + t("consequences.no_action") + )} +

+ {preview.isError ? ( +

+ {failure(preview.error.message as WarningFailure)} +

+ ) : null} + {data.suspendedUntil ? ( +

+ {t("consequences.already_suspended")} +

+ ) : null} + {data.related.length > 0 ? ( +

+ {t("consequences.related", { count: data.related.length })} +

+ ) : null} +

+ {t("consequences.advisory")} +

+
+ ); +}; + +/** + * The reason dropdown. Picking a reason resets the points to its default and + * swaps in its message, unless the moderator already wrote their own. + */ +const ReasonSelect = ({ + labels, + messageOf, + reasonOf, + ...props +}: ItemAutoFormComponentProps & { + labels: { label: string; value: string }[]; + messageOf: (reason: ModcpWarningReason | undefined) => string; + reasonOf: (id: string | undefined) => ModcpWarningReason | undefined; +}) => { + const t = useTranslations("core.moderation.warn"); + const { form } = useFormApi(); + + return ( + { + const values = form.state.values as FormValues; + const previous = reasonOf(values.reasonId); + const next = reasonOf(String(value)); + form.setFieldValue("points", next?.points ?? 0); + if (!values.message || values.message === messageOf(previous)) { + form.setFieldValue("message", messageOf(next)); + } + }} + /> + ); +}; + +const WarnForm = ({ + base, + initial, + onClose, + onIssued, + reasons, +}: { + base: Pick; + initial: WarningPreview; + onClose: () => void; + onIssued?: (result: IssuedWarning) => void; + reasons: ModcpWarningReason[]; +}) => { + const t = useTranslations("core.moderation.warn"); + const locale = useLocale(); + const format = useFormatter(); + const queryClient = useQueryClient(); + const failure = useFailureMessage(); + const [idempotencyKey] = React.useState(() => crypto.randomUUID()); + const { permissions } = initial; + const reasonOf = (id: string | undefined) => + reasons.find(reason => String(reason.id) === id); + const messageOf = (reason: ModcpWarningReason | undefined) => + reason ? resolveReasonText(reason.message, locale) : ""; + const first = reasons[0]; + + const formSchema = z + .object({ + reasonId: z + .enum([NO_REASON, ...reasons.map(reason => String(reason.id))]) + .default(first ? String(first.id) : NO_REASON), + points: z + .number() + .int() + .min(0) + .max(WARNING_LIMITS.maxPoints) + .default(first?.points ?? 0), + expirationMode: z.enum(EXPIRATION_MODES).default("default"), + expirationHours: z + .number() + .int() + .min(1) + .max(WARNING_LIMITS.maxDurationHours) + .default(24), + expirationAt: z.string().nullable().default(null), + overrideNote: z + .string() + .trim() + .max(WARNING_LIMITS.noteMaxLength) + .default(""), + message: z + .string() + .trim() + .min(WARNING_LIMITS.messageMinLength) + .max(WARNING_LIMITS.messageMaxLength) + .default(messageOf(first)), + staffNote: z + .string() + .trim() + .max(WARNING_LIMITS.noteMaxLength) + .default(""), + resolveReport: z.boolean().default(false), + }) + .superRefine((values, ctx) => { + const defaultPoints = reasonOf(values.reasonId)?.points ?? 0; + const overridden = + (permissions.overridePoints && values.points !== defaultPoints) || + values.expirationMode !== "default"; + if ( + overridden && + values.overrideNote.length < WARNING_LIMITS.explanationMinLength + ) { + ctx.addIssue({ + code: "custom", + message: t("form.override_note_required"), + path: ["overrideNote"], + }); + } + if (values.expirationMode === "at" && !values.expirationAt) { + ctx.addIssue({ + code: "custom", + message: t("form.expiration_at_required"), + path: ["expirationAt"], + }); + } + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const draft = draftOf(values, base, permissions.overridePoints); + const result = await issueWarning({ + ...draft, + idempotencyKey, + message: values.message, + overrideNote: values.overrideNote || null, + resolveReport: values.resolveReport, + staffNote: values.staffNote || null, + }); + if (!result.ok) { + toast.error(t("errors.title"), { description: failure(result.failure) }); + + return; + } + + const { data } = result; + const summary = t("done.points", { + after: data.points.after, + name: initial.user.name, + }); + const sanction = data.sanction + ? data.sanction.endsAt + ? t("done.suspended_until", { + endsAt: format.dateTime(new Date(data.sanction.endsAt), { + dateStyle: "medium", + timeStyle: "short", + }), + }) + : t("done.suspended_indefinitely") + : null; + // Someone else warned the member after the form opened. + const differs = + !data.duplicate && data.points.before !== initial.points.current; + toast.success(t("done.title"), { + description: [ + summary, + sanction, + data.reportResolved ? t("done.report_resolved") : null, + differs ? t("done.differs") : null, + ] + .filter(Boolean) + .join(" "), + }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: WARNINGS_QUERY_ROOT }), + queryClient.invalidateQueries({ queryKey: ACCOUNT_STATUS_QUERY_KEY }), + queryClient.invalidateQueries({ + queryKey: ["vitnode", "moderation"], + }), + ]); + onIssued?.(data); + onClose(); + }; + + const reasonLabels = [ + ...reasons.map(reason => ({ + label: `${resolveReasonText(reason.title, locale)} (${t("points", { count: reason.points })})`, + value: String(reason.id), + })), + { label: t("form.no_reason"), value: NO_REASON }, + ]; + + return ( + ( + + ), + }, + { + id: "points", + hidden: () => !permissions.overridePoints, + component: props => ( + + ), + }, + { + id: "expirationMode", + hidden: () => !permissions.overrideExpiration, + component: props => ( + ({ + label: t(`form.expiration_modes.${mode}`), + value: mode, + }))} + /> + ), + }, + { + id: "expirationHours", + hidden: values => values.expirationMode !== "hours", + component: props => ( + + ), + }, + { + id: "expirationAt", + hidden: values => values.expirationMode !== "at", + component: props => ( + + ), + }, + { + id: "overrideNote", + hidden: values => + !( + (permissions.overridePoints && + values.points !== (reasonOf(values.reasonId)?.points ?? 0)) || + (values.expirationMode ?? "default") !== "default" + ), + component: props => ( + + ), + }, + { + id: "message", + component: props => ( + + ), + }, + { + id: "staffNote", + component: props => ( + + ), + }, + { + id: "resolveReport", + hidden: () => !base.reportId || !permissions.resolveReport, + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <> +
+ {base.content || base.reportId ? ( + } variant="warning"> + {t("global.title")} + + {t("global.desc")} + + + ) : null} + {rendered.reasonId} + {rendered.points} + {rendered.expirationMode} + {rendered.expirationHours} + {rendered.expirationAt} + {rendered.overrideNote} + + {rendered.message} + {rendered.staffNote} + {rendered.resolveReport} +
+ + }> + {t("cancel")} + + + + + + )} + onSubmit={onSubmit} + /> + ); +}; + +const DialogSkeleton = () => ( +
+ + + + +
+); + +/** + * The shared warn form: reason, points, expiry, the message the member reads, + * a private staff note and a live preview of the consequences. Loads lazily + * inside `WarnDialog`; every check runs again on the server when it submits. + */ +export const WarnDialogContent = ({ + content = null, + onClose, + onIssued, + reportId = null, + userId, +}: WarnDialogContentProps) => { + const t = useTranslations("core.moderation.warn"); + const failure = useFailureMessage(); + const reasons = useQuery(warningReasonsQueryOptions()); + const base = React.useMemo( + () => ({ + content: content + ? { + pluginId: content.pluginId, + targetId: content.id, + targetType: content.type, + } + : null, + reportId, + userId, + }), + [content, reportId, userId], + ); + const first = reasons.data?.[0]; + const initial = useQuery({ + enabled: reasons.isSuccess, + queryFn: async () => { + const result = await previewWarning({ + ...base, + expiration: { mode: "default" }, + reasonId: first?.id ?? null, + }); + if (!result.ok) throw new Error(result.failure); + + return result.data; + }, + queryKey: [...WARNINGS_QUERY_ROOT, "initial", base, first?.id ?? null], + retry: false, + staleTime: 0, + }); + + if (reasons.isPending || (reasons.isSuccess && initial.isPending)) { + return ( + <> + + {t("title_loading")} + + + + ); + } + + const error = reasons.error ?? initial.error; + if (error || !initial.data || !reasons.data) { + return ( +
+ + + {t("title_loading")} + + + {failure(failureOfError(error))} + + + + }> + {t("cancel")} + + +
+ ); + } + + return ( + <> + + + {t("title", { name: initial.data.user.name })} + + + {t("desc")} + {first ? null : ` ${t("no_reasons")}`} + + + + + ); +}; + +export default WarnDialogContent; diff --git a/packages/vitnode/src/views/moderation/warnings/warning-format.tsx b/packages/vitnode/src/views/moderation/warnings/warning-format.tsx new file mode 100644 index 0000000000..dae7560b5f --- /dev/null +++ b/packages/vitnode/src/views/moderation/warnings/warning-format.tsx @@ -0,0 +1,30 @@ +import { useTranslations } from "use-intl"; + +import type { WarningStatus } from "@/lib/moderation/warnings"; + +import { Badge } from "@/components/ui/badge"; + +/** A number of hours, as whole days when it divides evenly. */ +export const WarningDuration = ({ hours }: { hours: number }) => { + const t = useTranslations("core.moderation.warn.duration"); + + return ( + <> + {hours % 24 === 0 + ? t("days", { count: hours / 24 }) + : t("hours", { count: hours })} + + ); +}; + +const STATUS_VARIANT = { + active: "warning", + expired: "secondary", + revoked: "outline", +} as const; + +export const WarningStatusBadge = ({ status }: { status: WarningStatus }) => { + const t = useTranslations("core.moderation.warn.statuses"); + + return {t(status)}; +}; diff --git a/packages/vitnode/src/views/moderation/warnings/warning-list.tsx b/packages/vitnode/src/views/moderation/warnings/warning-list.tsx new file mode 100644 index 0000000000..07e2134f20 --- /dev/null +++ b/packages/vitnode/src/views/moderation/warnings/warning-list.tsx @@ -0,0 +1,438 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { + ExternalLinkIcon, + FlagIcon, + LockIcon, + ShieldOffIcon, + Undo2Icon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { DateFormat } from "@/components/date-format"; +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { WARNING_LIMITS } from "@/lib/moderation/warnings"; + +import type { ReportPerson } from "../modcp/report-tone"; +import type { + ModcpSanction, + ModcpStaffWarning, + WarningResult, +} from "./warnings-api"; + +import { Person } from "../modcp/report-tone"; +import { resolveReasonText } from "../report-reasons-query"; +import { SanctionSummary } from "./warn-dialog-content"; +import { WarningDuration, WarningStatusBadge } from "./warning-format"; +import { + ACCOUNT_STATUS_QUERY_KEY, + revokeSanction, + revokeWarning, + WARNINGS_QUERY_ROOT, +} from "./warnings-api"; + +const RevokeDialog = ({ + description, + onClose, + onRevoke, + open, + submitLabel, + success, + successDesc, + title, +}: { + description: string; + onClose: () => void; + onRevoke: (reason: string) => Promise>; + open: boolean; + submitLabel: string; + success: string; + successDesc: string; + title: string; +}) => { + const t = useTranslations("core.moderation.warn"); + const queryClient = useQueryClient(); + const formSchema = z.object({ + reason: z + .string() + .trim() + .min(WARNING_LIMITS.explanationMinLength) + .max(WARNING_LIMITS.noteMaxLength), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const result = await onRevoke(values.reason); + if (!result.ok) { + toast.error(t("errors.title"), { + description: t( + `errors.${result.failure === "conflict" ? "unknown" : result.failure}`, + ), + }); + + return; + } + toast.success(success, { description: successDesc }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: WARNINGS_QUERY_ROOT }), + queryClient.invalidateQueries({ queryKey: ACCOUNT_STATUS_QUERY_KEY }), + ]); + onClose(); + }; + + return ( + { + if (!next) onClose(); + }} + open={open} + > + + + {title} + + {description} + + + ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + children: ( + <> + + + ); +}; + +const Detail = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
+
{label}
+
+ {children} +
+
+); + +const PersonOrDeleted = ({ person }: { person: null | ReportPerson }) => { + const t = useTranslations("core.moderation.warn"); + + return person ? : <>{t("deleted_user")}; +}; + +/** One warning as staff see it, with Revoke when allowed. */ +export const StaffWarningCard = ({ + canRevoke, + member, + warning, +}: { + canRevoke: boolean; + /** Shown in lists that mix members. */ + member?: null | ReportPerson; + warning: ModcpStaffWarning; +}) => { + const t = useTranslations("core.moderation.warn"); + const locale = useLocale(); + const [revoking, setRevoking] = React.useState(false); + const reason = warning.reason + ? resolveReasonText(warning.reason, locale) + : t("custom_reason"); + const titleId = React.useId(); + + return ( +
+
+
+

+ {reason} +

+

+ +

+
+
+ + {t("points", { count: warning.points })} + + + {canRevoke && warning.status !== "revoked" ? ( + + ) : null} +
+
+ +
+ {member !== undefined ? ( + + {member ? ( + + + + ) : ( + + )} + + ) : null} + + + + + {warning.expiresAt ? ( + + ) : ( + t("list.never") + )} + + {warning.defaultPoints !== null && + warning.defaultPoints !== warning.points ? ( + + {t("points", { count: warning.defaultPoints })} + + ) : null} + {warning.content ? ( + + {warning.content.url ? ( + + {warning.content.title ?? t("list.untitled")} + + ) : ( + (warning.content.title ?? t("list.untitled")) + )} + + ) : null} + {warning.reportId ? ( + + + + ) : null} +
+ + + {warning.message} + + + {warning.staffNote || warning.overrideNote ? ( +
+

+

+ {warning.staffNote ? ( + + {warning.staffNote} + + ) : null} + {warning.overrideNote ? ( + + + {warning.overrideNote} + + + ) : null} +
+ ) : null} + + {warning.revokedAt ? ( +

+ {t.rich("list.revoked", { + by: () => , + date: () => , + })} + {warning.revokeReason ? ` ${warning.revokeReason}` : null} +

+ ) : null} + + {warning.sanctionId && warning.revokedAt ? ( +

+ {t("list.sanction_stays")} +

+ ) : null} + + { + setRevoking(false); + }} + onRevoke={async reasonText => + await revokeWarning({ id: warning.id, reason: reasonText }) + } + open={revoking} + submitLabel={t("revoke.button")} + success={t("revoke.done")} + successDesc={t("revoke.done_desc")} + title={t("revoke.title")} + /> +
+ ); +}; + +/** One suspension, with Lift when allowed. */ +export const SanctionCard = ({ + canManage, + sanction, +}: { + canManage: boolean; + sanction: ModcpSanction; +}) => { + const t = useTranslations("core.moderation.warn"); + const [lifting, setLifting] = React.useState(false); + + return ( +
+
+
+

+ {sanction.endsAt + ? t("sanctions.temporary") + : t("sanctions.indefinite")} +

+

+ +

+
+
+ + {sanction.active + ? t("sanctions.active") + : sanction.revokedAt + ? t("sanctions.lifted") + : t("sanctions.ended")} + + {canManage && sanction.active ? ( + + ) : null} +
+
+
+ + + + + {sanction.threshold + ? t("sanctions.from_threshold", { + points: sanction.threshold.points, + }) + : t("sanctions.manual")} + {sanction.threshold?.durationHours ? ( + <> + {" "} + () + + ) : null} + +
+ {sanction.revokedAt ? ( +

+ {t.rich("list.lifted", { + by: () => , + date: () => , + })} + {sanction.revokeReason ? ` ${sanction.revokeReason}` : null} +

+ ) : null} + { + setLifting(false); + }} + onRevoke={async reason => + await revokeSanction({ id: sanction.id, reason }) + } + open={lifting} + submitLabel={t("sanctions.lift")} + success={t("sanctions.lifted_done")} + successDesc={t("sanctions.lifted_done_desc")} + title={t("sanctions.lift_title")} + /> +
+ ); +}; diff --git a/packages/vitnode/src/views/moderation/warnings/warnings-api.ts b/packages/vitnode/src/views/moderation/warnings/warnings-api.ts new file mode 100644 index 0000000000..9ea7595444 --- /dev/null +++ b/packages/vitnode/src/views/moderation/warnings/warnings-api.ts @@ -0,0 +1,348 @@ +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; + +import { fetcherClient } from "@/lib/fetcher-client"; +import { manifest } from "@/manifest"; +import { fetcher } from "@/tanstack/fetcher"; + +import { ModcpRequestError } from "../modcp/modcp-query"; + +export const WARNINGS_QUERY_ROOT = [ + "vitnode", + "moderation", + "warnings", +] as const; + +/** Every query that shows points, warnings or sanctions - invalidate after a write. */ +export const ACCOUNT_STATUS_QUERY_KEY = [ + "vitnode", + "moderation", + "account-status", +] as const; + +const fetchWarningReasons = async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "moderation", + path: "/warnings/reasons", + }); + if (!response.ok) { + throw new ModcpRequestError(response.status, "warning reasons"); + } + + return (await response.json()).reasons; +}; + +export type ModcpWarningReason = Awaited< + ReturnType +>[number]; + +export const warningReasonsQueryOptions = () => + queryOptions({ + queryFn: fetchWarningReasons, + queryKey: [...WARNINGS_QUERY_ROOT, "reasons"], + retry: false, + staleTime: 60_000, + }); + +const fetchMemberStanding = async (userId: number) => { + const response = await fetcher({ + plugin: manifest.id, + args: { params: { id: userId } }, + method: "get", + module: "moderation", + path: "/members/{id}", + }); + if (!response.ok) { + throw new ModcpRequestError(response.status, "the member's standing"); + } + + return await response.json(); +}; + +export type ModcpMemberStanding = Awaited< + ReturnType +>; + +export type ModcpStaffWarning = ModcpMemberStanding["warnings"][number]; + +export type ModcpSanction = NonNullable< + ModcpMemberStanding["sanctions"] +>[number]; + +export const memberStandingQueryOptions = (userId: number) => + queryOptions({ + queryFn: async () => await fetchMemberStanding(userId), + queryKey: [...WARNINGS_QUERY_ROOT, "member", userId], + retry: false, + staleTime: 10_000, + }); + +export interface ModcpWarningsFilter { + issuedById?: number; + page: number; + pageSize: number; + pluginId?: string; + reasonId?: number; + status: "active" | "all" | "expired" | "revoked"; + userId?: number; +} + +const fetchWarnings = async (filter: ModcpWarningsFilter) => { + const response = await fetcher({ + plugin: manifest.id, + args: { query: filter }, + method: "get", + module: "moderation", + path: "/warnings", + }); + if (!response.ok) throw new ModcpRequestError(response.status, "warnings"); + + return await response.json(); +}; + +export type ModcpWarningsPage = Awaited>; + +export const warningsListQueryOptions = (filter: ModcpWarningsFilter) => + queryOptions({ + placeholderData: keepPreviousData, + queryFn: async () => await fetchWarnings(filter), + queryKey: [...WARNINGS_QUERY_ROOT, "list", filter], + retry: false, + staleTime: 10_000, + }); + +const fetchMembers = async (search: string) => { + const response = await fetcher({ + plugin: manifest.id, + args: { query: { search } }, + method: "get", + module: "moderation", + path: "/members", + }); + if (!response.ok) throw new ModcpRequestError(response.status, "members"); + + return (await response.json()).items; +}; + +export const memberSearchQueryOptions = (search: string) => + queryOptions({ + enabled: search.trim().length > 0, + placeholderData: keepPreviousData, + queryFn: async () => await fetchMembers(search.trim()), + queryKey: [...WARNINGS_QUERY_ROOT, "members", search.trim()], + retry: false, + staleTime: 30_000, + }); + +const fetchAccountStatus = async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "moderation", + path: "/account-status", + }); + const { status } = response; + if (!response.ok) { + if (status === 401) return null; + throw new ModcpRequestError(status, "the account status"); + } + + return await response.json(); +}; + +export type AccountStatus = NonNullable< + Awaited> +>; + +export const accountStatusQueryOptions = () => + queryOptions({ + queryFn: fetchAccountStatus, + queryKey: ACCOUNT_STATUS_QUERY_KEY, + retry: false, + staleTime: 10_000, + }); + +const WARNING_FAILURES = [ + "already_revoked", + "author_mismatch", + "content_not_found", + "expiration_override_forbidden", + "forbidden", + "idempotency_conflict", + "invalid_expiration", + "invalid_points", + "not_found", + "override_note_required", + "points_override_forbidden", + "protected", + "reason_disabled", + "reason_not_found", + "report_closed", + "report_mismatch", + "self", + "user_not_found", + "conflict", +] as const; + +export type WarningFailure = + | "unauthenticated" + | "unknown" + | "validation" + | (typeof WARNING_FAILURES)[number]; + +export type WarningResult = + | { data: T; ok: true } + | { failure: WarningFailure; ok: false }; + +const failureOf = async (response: Response): Promise => { + const body = (await response.json().catch(() => null)) as null | { + error?: unknown; + }; + const reported = WARNING_FAILURES.find(failure => failure === body?.error); + if (reported) return reported; + if (response.status === 400) return "validation"; + if (response.status === 401) return "unauthenticated"; + if (response.status === 403) return "forbidden"; + if (response.status === 404) return "not_found"; + + return "unknown"; +}; + +const settle = async ( + request: () => Promise, +): Promise> => { + try { + const response = await request(); + if (!response.ok) return { failure: await failureOf(response), ok: false }; + + return { data: (await response.json()) as T, ok: true }; + } catch { + return { failure: "unknown", ok: false }; + } +}; + +const options = { credentials: "include" } as const; + +export type WarningExpirationInput = + | { at: string; mode: "at" } + | { hours: number; mode: "hours" } + | { mode: "default" } + | { mode: "never" }; + +export interface WarningDraft { + content?: null | { pluginId: string; targetId: string; targetType: string }; + expiration: WarningExpirationInput; + points?: null | number; + reasonId: null | number; + reportId?: null | number; + userId: number; +} + +/** What `POST /moderation/warnings/preview` answers. */ +export interface WarningPreview { + defaults: { expiresAfterHours: null | number; points: number }; + expiresAt: null | string; + overrides: { expiration: boolean; points: boolean }; + permissions: { + overrideExpiration: boolean; + overridePoints: boolean; + resolveReport: boolean; + viewWarnings: boolean; + }; + points: { current: number; projected: number; warning: number }; + related: ModcpStaffWarning[]; + sanction: null | { + action: "suspend_indefinite" | "suspend_temporary"; + durationHours: null | number; + endsAt: null | string; + points: number; + thresholdId: number; + }; + suspendedUntil: null | { endsAt: null | string }; + user: ModcpMemberStanding["user"]; +} + +export const previewWarning = async (draft: WarningDraft) => + await settle( + async () => + await fetcherClient({ + plugin: manifest.id, + args: { body: draft }, + method: "post", + module: "moderation", + options, + path: "/warnings/preview", + }), + ); + +export interface IssueWarningInput extends WarningDraft { + idempotencyKey: string; + message: string; + overrideNote?: null | string; + resolveReport: boolean; + staffNote?: null | string; +} + +export interface IssuedWarning { + duplicate: boolean; + points: { after: number; before: number }; + reportResolved: boolean; + sanction: null | { + endsAt: null | string; + id: number; + thresholdId: null | number; + }; + warningId: number; +} + +export const issueWarning = async (input: IssueWarningInput) => + await settle( + async () => + await fetcherClient({ + plugin: manifest.id, + args: { body: input }, + method: "post", + module: "moderation", + options, + path: "/warnings", + }), + ); + +export const revokeWarning = async ({ + id, + reason, +}: { + id: number; + reason: string; +}) => + await settle<{ userId: number; warningId: number }>( + async () => + await fetcherClient({ + plugin: manifest.id, + args: { body: { reason }, params: { id } }, + method: "post", + module: "moderation", + options, + path: "/warnings/{id}/revoke", + }), + ); + +export const revokeSanction = async ({ + id, + reason, +}: { + id: number; + reason: string; +}) => + await settle<{ sanctionId: number; userId: number }>( + async () => + await fetcherClient({ + plugin: manifest.id, + args: { body: { reason }, params: { id } }, + method: "post", + module: "moderation", + options, + path: "/sanctions/{id}/revoke", + }), + ); From cd96742cb6bac053c40ca0717ebf69d8a81663b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:20:16 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(moderation):=20=E2=9C=85=20cover=20war?= =?UTF-8?q?nings,=20thresholds=20and=20suspensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pure point, threshold and sanction rules. - Routes and service over the memory DB: reasons and snapshots, overrides, protected targets, scoped content warnings, threshold crossings, idempotent retries, concurrency, revocation, report linking and that staff notes never reach members, notifications, events or the audit log. - Concurrency and idempotency against real PostgreSQL. - Update existing expectations for `canWarn`, the session's `suspension` and the Account status settings link. Also answer a concurrent reuse of one idempotency key for two members with the same result as a sequential one instead of a unique-index error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY --- .../moderation/lib/warnings-service.ts | 322 +++--- .../moderation/warnings.postgres.test.ts | 199 ++++ .../api/modules/moderation/warnings.test.ts | 947 ++++++++++++++++++ .../users/routes/session.route.test.ts | 5 +- .../src/lib/moderation/warnings.test.ts | 100 ++ .../comments/use-content-comments.test.tsx | 1 + .../src/tanstack/content/permissions.test.tsx | 35 +- .../views/auth/settings/settings-nav.test.ts | 14 +- .../warnings/account-status-content.tsx | 6 +- packages/vitnode/vitest.config.ts | 1 + 10 files changed, 1458 insertions(+), 172 deletions(-) create mode 100644 packages/vitnode/src/api/modules/moderation/warnings.postgres.test.ts create mode 100644 packages/vitnode/src/api/modules/moderation/warnings.test.ts create mode 100644 packages/vitnode/src/lib/moderation/warnings.test.ts diff --git a/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts b/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts index b25b1697ed..0dbf51326b 100644 --- a/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts +++ b/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts @@ -11,6 +11,7 @@ import type { WarningThresholdAction, } from "@/lib/moderation/warnings"; +import { isUniqueViolation } from "@/api/lib/db-errors"; import { canOnReport } from "@/api/lib/moderation/access"; import { findReportTarget } from "@/api/lib/moderation/registry"; import { @@ -535,201 +536,212 @@ export const issueWarning = async ( if (report.status === "done") throw reportConflict(report, "report_closed"); } - const result = await c.get("db").transaction(async (tx: Db) => { - await lockUserWarnings(tx, draft.user.id); + const issue = async () => + await c.get("db").transaction(async (tx: Db) => { + await lockUserWarnings(tx, draft.user.id); - const existing = await findIssuedWarning(tx, moderator, input); - if (existing) return existing; + const existing = await findIssuedWarning(tx, moderator, input); + if (existing) return existing; - const now = new Date(); - const expiresAt = draft.expiresAt(now); - const before = await loadActivePoints(tx, draft.user.id, now); - const counts = expiresAt === null || expiresAt.getTime() > now.getTime(); - const after = before + (counts ? draft.points : 0); - - const [warning] = await tx - .insert(core_moderation_warnings) - .values({ - contentSnapshot: draft.content?.snapshot ?? null, - defaultPoints: draft.defaultPoints, - expiresAt, - idempotencyKey: input.idempotencyKey, - issuedAt: now, - issuedById: moderator.user.id, - message: input.message.trim(), - overrideNote: overridden ? overrideNote : null, - pluginId: draft.content?.pluginId ?? null, - points: draft.points, - pointsAfter: after, - pointsBefore: before, - reasonId: draft.reason?.id ?? null, - reasonSnapshot: draft.reason ? { title: draft.reason.title } : null, - reportId: report?.id ?? null, - scopeFamily: draft.content?.scopeFamily ?? null, - scopeId: draft.content?.scopeId ?? null, - staffNote: staffNote === "" ? null : staffNote, - targetId: draft.content?.targetId ?? null, - targetType: draft.content?.targetType ?? null, - userId: draft.user.id, - }) - .returning({ id: core_moderation_warnings.id }); + const now = new Date(); + const expiresAt = draft.expiresAt(now); + const before = await loadActivePoints(tx, draft.user.id, now); + const counts = expiresAt === null || expiresAt.getTime() > now.getTime(); + const after = before + (counts ? draft.points : 0); + + const [warning] = await tx + .insert(core_moderation_warnings) + .values({ + contentSnapshot: draft.content?.snapshot ?? null, + defaultPoints: draft.defaultPoints, + expiresAt, + idempotencyKey: input.idempotencyKey, + issuedAt: now, + issuedById: moderator.user.id, + message: input.message.trim(), + overrideNote: overridden ? overrideNote : null, + pluginId: draft.content?.pluginId ?? null, + points: draft.points, + pointsAfter: after, + pointsBefore: before, + reasonId: draft.reason?.id ?? null, + reasonSnapshot: draft.reason ? { title: draft.reason.title } : null, + reportId: report?.id ?? null, + scopeFamily: draft.content?.scopeFamily ?? null, + scopeId: draft.content?.scopeId ?? null, + staffNote: staffNote === "" ? null : staffNote, + targetId: draft.content?.targetId ?? null, + targetType: draft.content?.targetType ?? null, + userId: draft.user.id, + }) + .returning({ id: core_moderation_warnings.id }); - await appendModerationAudit(tx, { - actorId: moderator.user.id, - data: { - content: draft.content - ? { - pluginId: draft.content.pluginId, - targetId: draft.content.targetId, - targetType: draft.content.targetType, - } - : null, - expiresAt: expiresAt?.toISOString() ?? null, - points: draft.points, - reasonId: draft.reason?.id ?? null, - reportId: report?.id ?? null, - }, - type: "warning_issued", - userId: draft.user.id, - warningId: warning.id, - }); - if (overridden) { await appendModerationAudit(tx, { actorId: moderator.user.id, data: { - defaultExpiresAfterHours: draft.defaultExpiresAfterHours, - defaultPoints: draft.defaultPoints, + content: draft.content + ? { + pluginId: draft.content.pluginId, + targetId: draft.content.targetId, + targetType: draft.content.targetType, + } + : null, expiresAt: expiresAt?.toISOString() ?? null, - expiration: draft.overrides.expiration, points: draft.points, - pointsOverridden: draft.overrides.points, + reasonId: draft.reason?.id ?? null, + reportId: report?.id ?? null, }, - type: "warning_overridden", + type: "warning_issued", userId: draft.user.id, warningId: warning.id, }); - } - - const rules = await loadEnabledThresholds(tx); - const { applied, crossed } = evaluateWarningThresholds({ - after, - before, - rules, - }); - await appendModerationAudit(tx, { - actorId: moderator.user.id, - data: { - after, - appliedThresholdId: applied?.id ?? null, - before, - crossedThresholdIds: crossed.map(rule => rule.id), - }, - type: "threshold_evaluated", - userId: draft.user.id, - warningId: warning.id, - }); - - let sanction: IssuedWarning["sanction"] = null; - if (applied) { - const endsAt = thresholdSuspensionEnd(applied, now); - const [row] = await tx - .insert(core_moderation_sanctions) - .values({ - createdById: moderator.user.id, - endsAt, - source: "threshold", - startsAt: now, - thresholdId: applied.id, - thresholdSnapshot: { - action: applied.action, - durationHours: applied.durationHours, - points: applied.points, + if (overridden) { + await appendModerationAudit(tx, { + actorId: moderator.user.id, + data: { + defaultExpiresAfterHours: draft.defaultExpiresAfterHours, + defaultPoints: draft.defaultPoints, + expiresAt: expiresAt?.toISOString() ?? null, + expiration: draft.overrides.expiration, + points: draft.points, + pointsOverridden: draft.overrides.points, }, - type: "suspension", + type: "warning_overridden", userId: draft.user.id, warningId: warning.id, - }) - .returning({ id: core_moderation_sanctions.id }); - sanction = { endsAt, id: row.id, thresholdId: applied.id }; + }); + } + + const rules = await loadEnabledThresholds(tx); + const { applied, crossed } = evaluateWarningThresholds({ + after, + before, + rules, + }); await appendModerationAudit(tx, { actorId: moderator.user.id, data: { - action: applied.action, - endsAt: endsAt?.toISOString() ?? null, - source: "threshold", - thresholdId: applied.id, - thresholdPoints: applied.points, + after, + appliedThresholdId: applied?.id ?? null, + before, + crossedThresholdIds: crossed.map(rule => rule.id), }, - sanctionId: row.id, - type: "sanction_applied", + type: "threshold_evaluated", userId: draft.user.id, warningId: warning.id, }); - } - if (report) { - await appendReportEvent(tx, { - actorId: moderator.user.id, - data: { - points: draft.points, + let sanction: IssuedWarning["sanction"] = null; + if (applied) { + const endsAt = thresholdSuspensionEnd(applied, now); + const [row] = await tx + .insert(core_moderation_sanctions) + .values({ + createdById: moderator.user.id, + endsAt, + source: "threshold", + startsAt: now, + thresholdId: applied.id, + thresholdSnapshot: { + action: applied.action, + durationHours: applied.durationHours, + points: applied.points, + }, + type: "suspension", + userId: draft.user.id, + warningId: warning.id, + }) + .returning({ id: core_moderation_sanctions.id }); + sanction = { endsAt, id: row.id, thresholdId: applied.id }; + await appendModerationAudit(tx, { + actorId: moderator.user.id, + data: { + action: applied.action, + endsAt: endsAt?.toISOString() ?? null, + source: "threshold", + thresholdId: applied.id, + thresholdPoints: applied.points, + }, + sanctionId: row.id, + type: "sanction_applied", userId: draft.user.id, warningId: warning.id, - }, - reportId: report.id, - type: "warning", - }); - if (resolveReport) { - await updateReport(tx, report, { - openKey: null, - resolution: "action_taken", - resolvedAt: now, - resolvedById: moderator.user.id, - status: "done", }); + } + + if (report) { await appendReportEvent(tx, { actorId: moderator.user.id, - data: { resolution: "action_taken", warningId: warning.id }, + data: { + points: draft.points, + userId: draft.user.id, + warningId: warning.id, + }, reportId: report.id, - type: "resolved", + type: "warning", }); + if (resolveReport) { + await updateReport(tx, report, { + openKey: null, + resolution: "action_taken", + resolvedAt: now, + resolvedById: moderator.user.id, + status: "done", + }); + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { resolution: "action_taken", warningId: warning.id }, + reportId: report.id, + type: "resolved", + }); + } } - } - const notifications = c.get("notifications"); - await notifications.publish({ - actorId: null, - data: { - message: input.message.trim(), - points: draft.points, - reason: draft.reason?.title ?? null, - warningId: warning.id, - }, - idempotencyKey: `warning:${warning.id}`, - recipients: [draft.user.id], - tx, - type: warningIssuedNotification, - }); - if (sanction) { + const notifications = c.get("notifications"); await notifications.publish({ actorId: null, data: { - endsAt: sanction.endsAt?.toISOString() ?? null, - sanctionId: sanction.id, + message: input.message.trim(), + points: draft.points, + reason: draft.reason?.title ?? null, + warningId: warning.id, }, - idempotencyKey: `sanction:${sanction.id}`, + idempotencyKey: `warning:${warning.id}`, recipients: [draft.user.id], tx, - type: accountSuspendedNotification, + type: warningIssuedNotification, }); - } + if (sanction) { + await notifications.publish({ + actorId: null, + data: { + endsAt: sanction.endsAt?.toISOString() ?? null, + sanctionId: sanction.id, + }, + idempotencyKey: `sanction:${sanction.id}`, + recipients: [draft.user.id], + tx, + type: accountSuspendedNotification, + }); + } + + return { + duplicate: false, + points: { after, before }, + reportResolved: resolveReport, + sanction, + warningId: warning.id, + } satisfies IssuedWarning; + }); + + // The lock is per warned member, so the same key sent at once for two + // members only meets at the unique (issuedById, idempotencyKey) index. + const result = await issue().catch(async (error: unknown) => { + if (!isUniqueViolation(error)) throw error; + const existing = await findIssuedWarning(c.get("db"), moderator, input); + if (!existing) throw error; - return { - duplicate: false, - points: { after, before }, - reportResolved: resolveReport, - sanction, - warningId: warning.id, - } satisfies IssuedWarning; + return existing; }); if (result.duplicate) return result; diff --git a/packages/vitnode/src/api/modules/moderation/warnings.postgres.test.ts b/packages/vitnode/src/api/modules/moderation/warnings.postgres.test.ts new file mode 100644 index 0000000000..20a80c2a6e --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/warnings.postgres.test.ts @@ -0,0 +1,199 @@ +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { asc, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { PostgresTestSchema } from "@/tests/postgres"; + +import { core_languages, core_languages_words } from "@/database/languages"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { + core_moderation_sanctions, + core_moderation_warning_reasons, + core_moderation_warning_thresholds, + core_moderation_warnings, +} from "@/database/warnings"; +import { + WARNING_PERMISSION_MODULE, + WARNING_PERMISSIONS, +} from "@/lib/moderation/warnings"; +import { resolvePersonalInformationFields } from "@/lib/user-personal-information"; +import { manifest } from "@/manifest"; +import { createTestCache } from "@/tests/cache"; +import { createPostgresTestSchema, POSTGRES_TEST_URL } from "@/tests/postgres"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { moderationModule } from "./moderation.module"; + +const MODERATOR = 1; +const CONCURRENT_MEMBER = 2; +const RETRIED_MEMBER = 3; +const USERS = [MODERATOR, CONCURRENT_MEMBER, RETRIED_MEMBER]; + +interface Issued { + duplicate: boolean; + sanction: null | { id: number }; + warningId: number; +} + +describe.skipIf(!POSTGRES_TEST_URL)("issuing warnings on PostgreSQL", () => { + let schema: PostgresTestSchema; + let reasonId: number; + const thresholds: Record<"eight" | "ten", number> = { eight: 0, ten: 0 }; + const cache = createTestCache(); + const publish = vi.fn(async (_args: unknown) => Promise.resolve()); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("user", { + id: Number(c.req.header("x-user")), + } as unknown as Context["var"]["user"]); + c.set("suspension", null); + c.set("cache", cache); + c.set("db", schema.db as unknown as Context["var"]["db"]); + c.set("events", { + emit: async () => Promise.resolve(), + } as unknown as Context["var"]["events"]); + c.set("notifications", { + publish, + } as unknown as Context["var"]["notifications"]); + c.set("core", { + moderation: { scopes: [], targets: [] }, + permissionStaff: [], + personalInformationFields: resolvePersonalInformationFields(), + } as unknown as Context["var"]["core"]); + await next(); + }); + app.route("/", moderationModule.hono); + + const issue = async (userId: number, idempotencyKey: string) => { + const res = await app.request("/warnings", { + body: JSON.stringify({ + idempotencyKey, + message: "Please keep to the topic.", + reasonId, + userId, + }), + headers: { + "content-type": "application/json", + "x-user": String(MODERATOR), + }, + method: "POST", + }); + expect(res.status).toBe(200); + + return (await res.json()) as Issued; + }; + + beforeAll(async () => { + schema = await createPostgresTestSchema({ prefix: "vitnode_warnings" }); + await schema.db + .insert(core_languages) + .values({ code: "en", name: "English" }); + const [role] = await schema.db + .insert(core_roles) + .values({ updatedAt: new Date() }) + .returning({ id: core_roles.id }); + await schema.db.insert(core_users).values( + USERS.map(id => ({ + avatarColor: "000000", + email: `user${id}@example.com`, + id, + ipAddress: "127.0.0.1", + name: `user${id}`, + nameCode: `user${id}`, + roleId: role.id, + })), + ); + const [reason] = await schema.db + .insert(core_moderation_warning_reasons) + .values({ expiresAfterHours: null, points: 2 }) + .returning({ id: core_moderation_warning_reasons.id }); + reasonId = reason.id; + await schema.db.insert(core_languages_words).values({ + itemId: reasonId, + languageCode: "en", + pluginCode: "core", + tableName: "core_moderation_warning_reasons", + value: "Off-topic posting", + variable: "title", + }); + const rows = await schema.db + .insert(core_moderation_warning_thresholds) + .values([ + { action: "suspend_temporary", durationHours: 24, points: 8 }, + { action: "suspend_indefinite", durationHours: null, points: 10 }, + ]) + .returning({ id: core_moderation_warning_thresholds.id }); + thresholds.eight = rows[0]?.id ?? 0; + thresholds.ten = rows[1]?.id ?? 0; + await grantStaffPermissions(cache, { + permissions: Object.values(WARNING_PERMISSIONS).map(permission => ({ + module: WARNING_PERMISSION_MODULE, + permission, + plugin: manifest.id, + })), + type: "moderator", + userId: MODERATOR, + }); + }, 120_000); + + afterAll(async () => { + await schema?.drop(); + }); + + const warningsOf = async (userId: number) => + await schema.db + .select({ + pointsAfter: core_moderation_warnings.pointsAfter, + pointsBefore: core_moderation_warnings.pointsBefore, + }) + .from(core_moderation_warnings) + .where(eq(core_moderation_warnings.userId, userId)) + .orderBy(asc(core_moderation_warnings.pointsBefore)); + + const sanctionsOf = async (userId: number) => + await schema.db + .select({ thresholdId: core_moderation_sanctions.thresholdId }) + .from(core_moderation_sanctions) + .where(eq(core_moderation_sanctions.userId, userId)) + .orderBy(asc(core_moderation_sanctions.thresholdId)); + + it("chains points across concurrent warnings and applies each crossed threshold once", async () => { + await Promise.all( + [1, 2, 3, 4, 5].map( + async n => await issue(CONCURRENT_MEMBER, `concurrent-${n}`), + ), + ); + + expect(await warningsOf(CONCURRENT_MEMBER)).toEqual([ + { pointsAfter: 2, pointsBefore: 0 }, + { pointsAfter: 4, pointsBefore: 2 }, + { pointsAfter: 6, pointsBefore: 4 }, + { pointsAfter: 8, pointsBefore: 6 }, + { pointsAfter: 10, pointsBefore: 8 }, + ]); + expect(await sanctionsOf(CONCURRENT_MEMBER)).toEqual([ + { thresholdId: thresholds.eight }, + { thresholdId: thresholds.ten }, + ]); + }); + + it("turns concurrent retries of one request into a single warning", async () => { + publish.mockClear(); + + const results = await Promise.all( + Array.from( + { length: 4 }, + async () => await issue(RETRIED_MEMBER, "retried-request"), + ), + ); + + expect(results.filter(result => !result.duplicate)).toHaveLength(1); + expect(new Set(results.map(result => result.warningId)).size).toBe(1); + expect(await warningsOf(RETRIED_MEMBER)).toHaveLength(1); + expect(publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vitnode/src/api/modules/moderation/warnings.test.ts b/packages/vitnode/src/api/modules/moderation/warnings.test.ts new file mode 100644 index 0000000000..866870beb8 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/warnings.test.ts @@ -0,0 +1,947 @@ +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + PermissionsStaffArgs, + StaffPermissionSet, +} from "@/api/lib/permission-staff"; + +import { findActiveSuspension } from "@/api/lib/moderation/warnings/suspension"; +import { + accountSuspendedNotification, + warningIssuedNotification, +} from "@/api/lib/notifications/core-types"; +import { defineModerationScope, defineReportTarget } from "@/api/moderation"; +import { core_languages_words } from "@/database/languages"; +import { + core_moderation_report_events, + core_moderation_report_reasons, + core_moderation_reports, +} from "@/database/moderation"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { + core_moderation_audit, + core_moderation_sanctions, + core_moderation_warning_reasons, + core_moderation_warning_thresholds, + core_moderation_warnings, +} from "@/database/warnings"; +import { + WARNING_PERMISSION_MODULE, + WARNING_PERMISSIONS, +} from "@/lib/moderation/warnings"; +import { resolvePersonalInformationFields } from "@/lib/user-personal-information"; +import { manifest } from "@/manifest"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; + +import { moderationModule } from "./moderation.module"; + +const FORUM = "@vitnode/forum"; +const FAMILY = "forum.section"; +const TYPE = "forum.topic"; +const SECTION_A = "10"; +const SECTION_B = "20"; + +const MEMBER_ROLE = 3; +const ROOT_ROLE = 4; + +const MEMBER = 100; +const OTHER_MEMBER = 101; +const ROOT_ROLE_MEMBER = 102; +const SENIOR = 200; +const BASIC = 201; +const VIEWER = 202; +const SCOPED = 203; +const ROOT_MOD = 300; + +const REASON_TWO = 1; +const REASON_FIVE = 2; +const REASON_ZERO = 3; +const REASON_OFF = 4; +const TITLE_OF_TWO = "Off-topic posting"; + +const THRESHOLD_EIGHT = 1; +const THRESHOLD_TEN = 2; + +const HOUR = 60 * 60 * 1000; + +const STAFF_NOTE = "staff-only-note"; +const OVERRIDE_NOTE = "override-explanation"; + +const warnings = (...permissions: string[]): PermissionsStaffArgs[] => + permissions.map(permission => ({ + module: WARNING_PERMISSION_MODULE, + permission, + plugin: manifest.id, + })); + +const sectionA = (permission: string): PermissionsStaffArgs => ({ + module: "topics", + permission, + plugin: FORUM, + scope: { family: FAMILY, ids: [SECTION_A], mode: "selected" }, +}); + +let keys = 0; +const nextKey = () => `warn-key-${++keys}`; + +const harness = async () => { + const sections = new Map([ + ["1", SECTION_A], + ["3", SECTION_B], + ]); + const authorOf = (targetId: string) => (targetId === "4" ? null : MEMBER); + + const target = defineReportTarget({ + capture: async (_c, { targetId }) => + Promise.resolve({ + authors: [{ id: MEMBER, name: "Member" }], + fields: [{ kind: "text", label: null, name: "body", value: "Body" }], + locale: { fallback: false, requested: null, served: "en" }, + scope: null, + source: { publishedAt: null, updatedAt: null, version: null }, + title: `Topic ${targetId}`, + url: `/topics/${targetId}`, + }), + current: async (_c, { targetId }) => + Promise.resolve({ hidden: false, url: `/topics/${targetId}` }), + label: "Topic", + parseId: value => (/^\d+$/.test(value) ? value : null), + permissionModule: "topics", + resolveAuthors: async (_c, { targetId }) => { + const author = authorOf(targetId); + + return Promise.resolve( + author === null + ? null + : { authorIds: [author], title: `Topic ${targetId}` }, + ); + }, + resolveScope: async (_c, targetId) => + Promise.resolve(sections.get(targetId)), + scope: FAMILY, + type: TYPE, + }); + const scope = defineModerationScope({ + find: async () => Promise.resolve([]), + id: FAMILY, + label: "Section", + list: async () => Promise.resolve({ items: [], nextCursor: null }), + }); + + const cache = createTestCache(); + const grants: [number, PermissionsStaffArgs[] | StaffPermissionSet][] = [ + [SENIOR, warnings(...Object.values(WARNING_PERMISSIONS))], + [BASIC, warnings(WARNING_PERMISSIONS.warn)], + [VIEWER, warnings(WARNING_PERMISSIONS.view)], + [ + SCOPED, + [ + sectionA("can_warn"), + sectionA("can_view_reports"), + sectionA("can_manage_reports"), + ], + ], + [ROOT_MOD, ROOT_STAFF_PERMISSIONS], + ]; + for (const [userId, permissions] of grants) { + await grantStaffPermissions(cache, { + permissions, + type: "moderator", + userId, + }); + } + + const users = [ + MEMBER, + OTHER_MEMBER, + ROOT_ROLE_MEMBER, + SENIOR, + BASIC, + VIEWER, + SCOPED, + ROOT_MOD, + ]; + const reasons = [ + { enabled: true, expiresAfterHours: 720, id: REASON_TWO, points: 2 }, + { enabled: true, expiresAfterHours: null, id: REASON_FIVE, points: 5 }, + { enabled: true, expiresAfterHours: null, id: REASON_ZERO, points: 0 }, + { enabled: false, expiresAfterHours: null, id: REASON_OFF, points: 3 }, + ]; + const memory = createMemoryDb([ + [ + core_users, + users.map(id => ({ + email: `${id}@example.com`, + id, + name: `User ${id}`, + nameCode: `user-${id}`, + roleId: id === ROOT_ROLE_MEMBER ? ROOT_ROLE : MEMBER_ROLE, + })), + ], + [ + core_roles, + [ + { color: null, id: MEMBER_ROLE, prefix: null, root: false }, + { color: null, id: ROOT_ROLE, prefix: null, root: true }, + ], + ], + [ + core_moderation_report_reasons, + [{ code: "spam", id: 1, position: 0, requiresDetails: false }], + ], + [ + core_moderation_warning_reasons, + reasons.map(reason => ({ ...reason, position: reason.id })), + ], + [ + core_languages_words, + reasons.map(reason => ({ + id: reason.id, + itemId: reason.id, + languageCode: "en", + pluginCode: "core", + tableName: "core_moderation_warning_reasons", + value: reason.id === REASON_TWO ? TITLE_OF_TWO : `Reason ${reason.id}`, + variable: "title", + })), + ], + [ + core_moderation_warning_thresholds, + [ + { + action: "suspend_temporary", + durationHours: 24, + enabled: true, + id: THRESHOLD_EIGHT, + points: 8, + }, + { + action: "suspend_indefinite", + durationHours: null, + enabled: true, + id: THRESHOLD_TEN, + points: 10, + }, + ], + ], + ]); + const emit = vi.fn(async (_name: string, _payload: unknown) => + Promise.resolve(), + ); + const publish = vi.fn(async (_args: Record) => + Promise.resolve(), + ); + const closeUserConnections = vi.fn((_userId: number) => undefined); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + const userId = Number(c.req.header("x-user")); + const suspendedId = Number(c.req.header("x-suspended")); + c.set( + "user", + (users.includes(userId) + ? { id: userId, roleId: MEMBER_ROLE } + : null) as unknown as Context["var"]["user"], + ); + c.set( + "suspension", + suspendedId ? { endsAt: null, userId: suspendedId } : null, + ); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + c.set("notifications", { + publish, + } as unknown as Context["var"]["notifications"]); + c.set("realtime", { + closeUserConnections, + } as unknown as Context["var"]["realtime"]); + c.set("core", { + moderation: { + scopes: [{ definition: scope, pluginId: FORUM }], + targets: [{ definition: target, pluginId: FORUM }], + }, + permissionStaff: [], + personalInformationFields: resolvePersonalInformationFields(), + } as unknown as Context["var"]["core"]); + await next(); + }); + app.route("/", moderationModule.hono); + + const call = async ( + userId: number, + method: "GET" | "POST", + path: string, + body?: Record, + headers: Record = {}, + ) => + await app.request(path, { + method, + headers: { + "content-type": "application/json", + "x-user": String(userId), + ...headers, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + const issue = async (userId: number, body: Record = {}) => + await call(userId, "POST", "/warnings", { + idempotencyKey: nextKey(), + message: "Please keep to the topic.", + reasonId: REASON_TWO, + userId: MEMBER, + ...body, + }); + + const issued = async (userId: number, body: Record = {}) => { + const res = await issue(userId, body); + expect(res.status).toBe(200); + + return (await res.json()) as Issued; + }; + + const revoke = async ( + userId: number, + kind: "sanctions" | "warnings", + id: number, + ) => + await call(userId, "POST", `/${kind}/${id}/revoke`, { + reason: "Issued by mistake", + }); + + /** Active points the member already holds, as one warning outside the API. */ + const seedPoints = async (userId: number, points: number) => { + await memory.db.insert(core_moderation_warnings).values({ + issuedById: SENIOR, + message: "Earlier warning", + points, + userId, + }); + }; + + const standing = async (moderatorId: number, userId = MEMBER) => { + const res = await call(moderatorId, "GET", `/members/${userId}`); + expect(res.status).toBe(200); + + return (await res.json()) as Standing; + }; + + const suspensionOf = async (userId: number) => + await findActiveSuspension( + { + get: (key: string) => + key === "cache" ? cache : key === "db" ? memory.db : undefined, + } as unknown as Context, + userId, + ); + + const warningRows = () => memory.rows(core_moderation_warnings); + const sanctionRows = () => memory.rows(core_moderation_sanctions); + const reportEvents = (id: number) => + memory + .rows(core_moderation_report_events) + .filter(entry => entry.reportId === id) + .map(entry => entry.type); + + const fileReport = async (targetId: string) => { + const res = await call(OTHER_MEMBER, "POST", "/reports", { + pluginId: FORUM, + reason: "spam", + targetId, + targetType: TYPE, + }); + expect(res.status).toBe(201); + + return ((await res.json()) as { id: number }).id; + }; + const reportRow = (id: number) => + memory.rows(core_moderation_reports).find(entry => entry.id === id); + + return { + call, + closeUserConnections, + emit, + fileReport, + issue, + issued, + memory, + publish, + reportEvents, + reportRow, + revoke, + sanctionRows, + seedPoints, + standing, + suspensionOf, + warningRows, + }; +}; + +interface Issued { + duplicate: boolean; + points: { after: number; before: number }; + reportResolved: boolean; + sanction: null | { endsAt: null | string; id: number; thresholdId: number }; + warningId: number; +} + +interface StaffWarning { + id: number; + overrideNote: null | string; + reason: null | { languageCode: string; value: string }[]; + revokeReason: null | string; + staffNote: null | string; + status: string; +} + +interface Standing { + activePoints: number; + hiddenWarnings: number; + warnings: StaffWarning[]; +} + +interface AccountStatus { + activePoints: number; + sanctions: { active: boolean; id: number }[]; + suspension: null | { endsAt: null | string }; + warnings: Record[]; +} + +const accountStatus = async ( + h: Awaited>, + userId = MEMBER, +) => { + const res = await h.call(userId, "GET", "/account-status"); + expect(res.status).toBe(200); + + return (await res.json()) as AccountStatus; +}; + +const inHours = (hours: number) => + new Date(Date.now() + hours * HOUR).toISOString(); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("issuing a warning", () => { + it("takes points and expiry from the reason and keeps its title after the reason changes", async () => { + const h = await harness(); + + const { points, warningId } = await h.issued(SENIOR); + + expect(points).toEqual({ after: 2, before: 0 }); + const [row] = h.warningRows(); + expect(row).toMatchObject({ + defaultPoints: 2, + id: warningId, + overrideNote: null, + points: 2, + }); + expect( + (row.expiresAt as Date).getTime() - (row.issuedAt as Date).getTime(), + ).toBe(720 * HOUR); + + await h.memory.db + .update(core_languages_words) + .set({ value: "Renamed" }) + .where(eq(core_languages_words.itemId, REASON_TWO)); + await h.memory.db + .update(core_moderation_warning_reasons) + .set({ enabled: false }) + .where(eq(core_moderation_warning_reasons.id, REASON_TWO)); + + const original = [{ languageCode: "en", value: TITLE_OF_TWO }]; + expect((await h.standing(SENIOR)).warnings[0]?.reason).toEqual(original); + expect((await accountStatus(h)).warnings[0]?.reason).toEqual(original); + }); + + it("issues a zero-point warning that notifies the member without moving their points", async () => { + const h = await harness(); + await h.seedPoints(MEMBER, 7); + + const result = await h.issued(SENIOR, { reasonId: REASON_ZERO }); + + expect(result.points).toEqual({ after: 7, before: 7 }); + expect(result.sanction).toBeNull(); + expect(h.publish).toHaveBeenCalledTimes(1); + expect(h.publish.mock.calls[0]?.[0]).toMatchObject({ + recipients: [MEMBER], + type: warningIssuedNotification, + }); + }); + + it("accepts a points and expiration override with its explanation", async () => { + const h = await harness(); + + await h.issued(SENIOR, { + expiration: { hours: 48, mode: "hours" }, + overrideNote: OVERRIDE_NOTE, + points: 4, + }); + + const [row] = h.warningRows(); + expect(row).toMatchObject({ + defaultPoints: 2, + overrideNote: OVERRIDE_NOTE, + points: 4, + }); + expect( + (row.expiresAt as Date).getTime() - (row.issuedAt as Date).getTime(), + ).toBe(48 * HOUR); + expect(h.memory.rows(core_moderation_audit).map(e => e.type)).toContain( + "warning_overridden", + ); + }); + + it("lets a root moderator warn other staff", async () => { + const h = await harness(); + + expect((await h.issue(ROOT_MOD, { userId: SCOPED })).status).toBe(200); + }); + + it.each([ + [ + "a points override without permission", + BASIC, + { overrideNote: OVERRIDE_NOTE, points: 4 }, + 403, + "points_override_forbidden", + ], + [ + "a points override without explanation", + SENIOR, + { points: 4 }, + 400, + "override_note_required", + ], + [ + "an expiration override without permission", + BASIC, + { expiration: { mode: "never" }, overrideNote: OVERRIDE_NOTE }, + 403, + "expiration_override_forbidden", + ], + [ + "an expiration in the past", + SENIOR, + { expiration: { at: inHours(-1), mode: "at" }, overrideNote: "Past" }, + 400, + "invalid_expiration", + ], + [ + "an expiration beyond the longest allowed", + SENIOR, + { + expiration: { at: inHours(24 * 365 * 6), mode: "at" }, + overrideNote: "Far", + }, + 400, + "invalid_expiration", + ], + [ + "a disabled reason", + SENIOR, + { reasonId: REASON_OFF }, + 409, + "reason_disabled", + ], + ["a moderator's own account", SENIOR, { userId: SENIOR }, 403, "self"], + ["another staff member", BASIC, { userId: SCOPED }, 403, "protected"], + [ + "a root-role member, even by a root moderator", + ROOT_MOD, + { userId: ROOT_ROLE_MEMBER }, + 403, + "protected", + ], + ["a direct warning without core can_warn", SCOPED, {}, 403, "forbidden"], + [ + "content outside the moderator's scope", + SCOPED, + { content: { pluginId: FORUM, targetId: "3", targetType: TYPE } }, + 403, + "forbidden", + ], + [ + "a member who did not write the content", + SCOPED, + { + content: { pluginId: FORUM, targetId: "1", targetType: TYPE }, + userId: OTHER_MEMBER, + }, + 400, + "author_mismatch", + ], + ])("refuses %s", async (_name, actor, body, status, error) => { + const h = await harness(); + + const res = await h.issue(actor, body); + + expect(res.status).toBe(status); + expect(await res.json()).toMatchObject({ error }); + expect(h.warningRows()).toEqual([]); + expect(h.publish).not.toHaveBeenCalled(); + }); + + it("lets a scoped moderator warn an author, with the scope resolved on the server", async () => { + const h = await harness(); + + await h.issued(SCOPED, { + content: { + pluginId: FORUM, + scopeId: SECTION_B, + targetId: "1", + targetType: TYPE, + }, + reasonId: REASON_ZERO, + }); + + expect(h.warningRows()[0]).toMatchObject({ + contentSnapshot: { title: "Topic 1", url: "/topics/1" }, + scopeFamily: FAMILY, + scopeId: SECTION_A, + targetId: "1", + }); + }); +}); + +describe("thresholds", () => { + it.each([ + [REASON_TWO, 8, THRESHOLD_EIGHT, 24], + [REASON_FIVE, 11, THRESHOLD_TEN, null], + ])( + "reason %i takes 6 points to %i and applies only threshold %i", + async (reasonId, after, thresholdId, hours) => { + const h = await harness(); + await h.seedPoints(MEMBER, 6); + expect((await accountStatus(h)).suspension).toBeNull(); + + const result = await h.issued(SENIOR, { reasonId }); + + expect(result.points).toEqual({ after, before: 6 }); + expect(result.sanction?.thresholdId).toBe(thresholdId); + const sanctions = h.sanctionRows(); + expect(sanctions).toHaveLength(1); + const [sanction] = sanctions; + expect(sanction).toMatchObject({ + source: "threshold", + thresholdId, + userId: MEMBER, + warningId: result.warningId, + }); + expect( + hours === null + ? sanction.endsAt + : (sanction.endsAt as Date).getTime() - + (sanction.startsAt as Date).getTime(), + ).toBe(hours === null ? null : hours * HOUR); + + const status = await accountStatus(h); + expect(status.suspension).toEqual({ + endsAt: (sanction.endsAt as Date | null)?.toISOString() ?? null, + }); + expect(h.closeUserConnections).toHaveBeenCalledWith(MEMBER); + expect(h.publish.mock.calls.map(([args]) => args.type)).toEqual([ + warningIssuedNotification, + accountSuspendedNotification, + ]); + }, + ); + + it("answers a retry with the first result and writes nothing again", async () => { + const h = await harness(); + await h.seedPoints(MEMBER, 6); + const idempotencyKey = nextKey(); + + const first = await h.issued(SENIOR, { idempotencyKey }); + const writes = { + emits: h.emit.mock.calls.length, + publishes: h.publish.mock.calls.length, + }; + const retry = await h.issued(SENIOR, { idempotencyKey }); + + expect(retry).toEqual({ + ...first, + duplicate: true, + sanction: { ...first.sanction }, + }); + expect(h.warningRows()).toHaveLength(2); + expect(h.sanctionRows()).toHaveLength(1); + expect(h.emit).toHaveBeenCalledTimes(writes.emits); + expect(h.publish).toHaveBeenCalledTimes(writes.publishes); + + const other = await h.issue(SENIOR, { + idempotencyKey, + userId: OTHER_MEMBER, + }); + expect(other.status).toBe(409); + expect(await other.json()).toEqual({ error: "idempotency_conflict" }); + }); + + it("serializes concurrent warnings for one member", async () => { + const h = await harness(); + const repeated = nextKey(); + + const results = await Promise.all( + [repeated, nextKey(), nextKey(), nextKey(), nextKey(), repeated].map( + async idempotencyKey => await h.issued(SENIOR, { idempotencyKey }), + ), + ); + + expect(results.filter(result => result.duplicate)).toHaveLength(1); + expect( + h + .warningRows() + .map(row => [row.pointsBefore, row.pointsAfter]) + .sort(([a], [b]) => Number(a) - Number(b)), + ).toEqual([ + [0, 2], + [2, 4], + [4, 6], + [6, 8], + [8, 10], + ]); + expect( + h + .sanctionRows() + .map(row => row.thresholdId) + .sort((a, b) => Number(a) - Number(b)), + ).toEqual([THRESHOLD_EIGHT, THRESHOLD_TEN]); + }); + + it("does not suspend a member already above a threshold that is lowered", async () => { + const h = await harness(); + await h.seedPoints(MEMBER, 9); + await h.memory.db + .update(core_moderation_warning_thresholds) + .set({ points: 9 }) + .where(eq(core_moderation_warning_thresholds.id, THRESHOLD_TEN)); + + await h.issued(SENIOR, { reasonId: REASON_ZERO }); + await h.issued(SENIOR); + + expect(h.sanctionRows()).toEqual([]); + }); + + it("applies a threshold again once a revoked warning takes the member back under it", async () => { + const h = await harness(); + await h.seedPoints(MEMBER, 6); + + const first = await h.issued(SENIOR); + expect((await h.revoke(SENIOR, "warnings", first.warningId)).status).toBe( + 200, + ); + const second = await h.issued(SENIOR); + + expect(second.points).toEqual({ after: 8, before: 6 }); + expect(h.sanctionRows().map(row => row.thresholdId)).toEqual([ + THRESHOLD_EIGHT, + THRESHOLD_EIGHT, + ]); + }); + + it("stops a temporary suspension once it ends, with no job involved", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-28T12:00:00.000Z")); + const h = await harness(); + await h.seedPoints(MEMBER, 6); + await h.issued(SENIOR); + expect((await accountStatus(h)).suspension).toEqual({ + endsAt: "2026-10-29T12:00:00.000Z", + }); + + vi.setSystemTime(new Date("2026-10-29T12:00:00.001Z")); + + const status = await accountStatus(h); + expect(status.suspension).toBeNull(); + expect(status.sanctions.map(sanction => sanction.active)).toEqual([false]); + }); +}); + +describe("revoking", () => { + it("removes a warning's points but keeps its record and the suspension it caused", async () => { + const h = await harness(); + await h.seedPoints(MEMBER, 6); + const { warningId } = await h.issued(BASIC); + + expect((await h.revoke(BASIC, "warnings", warningId)).status).toBe(403); + expect((await h.revoke(SENIOR, "warnings", warningId)).status).toBe(200); + const again = await h.revoke(SENIOR, "warnings", warningId); + expect(again.status).toBe(409); + + const { activePoints, warnings: list } = await h.standing(SENIOR); + expect(activePoints).toBe(6); + expect(list.find(warning => warning.id === warningId)).toMatchObject({ + revokeReason: "Issued by mistake", + status: "revoked", + }); + expect(await h.suspensionOf(MEMBER)).not.toBeNull(); + }); + + it("lifts one suspension and leaves the others in force", async () => { + const h = await harness(); + await h.seedPoints(MEMBER, 6); + const temporary = await h.issued(SENIOR); + const indefinite = await h.issued(SENIOR); + expect((await h.suspensionOf(MEMBER))?.endsAt).toBeNull(); + const indefiniteId = indefinite.sanction?.id ?? 0; + + expect((await h.revoke(BASIC, "sanctions", indefiniteId)).status).toBe(403); + expect((await h.revoke(SENIOR, "sanctions", indefiniteId)).status).toBe( + 200, + ); + + expect(await h.suspensionOf(MEMBER)).toEqual({ + endsAt: new Date(temporary.sanction?.endsAt ?? 0), + sanctionIds: [temporary.sanction?.id], + }); + }); +}); + +describe("reports", () => { + it("links a warning to a report and resolves it only when asked", async () => { + const h = await harness(); + const reportId = await h.fileReport("1"); + + const wrongUser = await h.issue(SCOPED, { reportId, userId: OTHER_MEMBER }); + expect(wrongUser.status).toBe(400); + expect(await wrongUser.json()).toEqual({ error: "author_mismatch" }); + + const linked = await h.issued(SCOPED, { + reasonId: REASON_ZERO, + reportId, + }); + expect(linked.reportResolved).toBe(false); + expect(h.reportRow(reportId)?.status).not.toBe("done"); + expect(h.reportEvents(reportId)).toEqual(["created", "warning"]); + + const resolving = await h.issued(SCOPED, { + reasonId: REASON_ZERO, + reportId, + resolveReport: true, + }); + expect(resolving.reportResolved).toBe(true); + expect(h.reportRow(reportId)).toMatchObject({ + resolution: "action_taken", + status: "done", + }); + expect(h.reportEvents(reportId)).toEqual([ + "created", + "warning", + "warning", + "resolved", + ]); + expect(h.warningRows().map(row => row.reportId)).toEqual([ + reportId, + reportId, + ]); + }); + + it("refuses to resolve a closed report and writes nothing", async () => { + const h = await harness(); + const reportId = await h.fileReport("1"); + const closed = await h.call( + SCOPED, + "POST", + `/reports/${reportId}/resolve`, + { + expectedVersion: h.reportRow(reportId)?.version, + resolution: "no_violation", + }, + ); + expect(closed.status).toBe(200); + const events = h.reportEvents(reportId); + + const res = await h.issue(SCOPED, { + reasonId: REASON_ZERO, + reportId, + resolveReport: true, + }); + + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ error: "report_closed" }); + expect(h.warningRows()).toEqual([]); + expect(h.reportEvents(reportId)).toEqual(events); + expect(h.publish).not.toHaveBeenCalled(); + }); +}); + +describe("staff notes", () => { + it("shows them only with can_view_notes or to the moderator who wrote them", async () => { + const h = await harness(); + await h.issued(SENIOR, { + overrideNote: OVERRIDE_NOTE, + points: 1, + staffNote: STAFF_NOTE, + }); + await h.issued(BASIC, { staffNote: "basic-note" }); + + const notesOf = (list: StaffWarning[]) => + list.map(warning => [warning.staffNote, warning.overrideNote]); + + expect(notesOf((await h.standing(SENIOR)).warnings)).toEqual([ + ["basic-note", null], + [STAFF_NOTE, OVERRIDE_NOTE], + ]); + expect(notesOf((await h.standing(VIEWER)).warnings)).toEqual([ + [null, null], + [null, null], + ]); + const listed = await h.call(VIEWER, "GET", `/warnings?userId=${MEMBER}`); + expect( + notesOf(((await listed.json()) as { items: StaffWarning[] }).items), + ).toEqual([ + [null, null], + [null, null], + ]); + const basic = await h.standing(BASIC); + expect(notesOf(basic.warnings)).toEqual([["basic-note", null]]); + expect(basic.hiddenWarnings).toBe(1); + }); + + it("never reach the member's own status, notifications, events or audit trail", async () => { + const h = await harness(); + await h.seedPoints(MEMBER, 6); + const { sanction, warningId } = await h.issued(SENIOR, { + overrideNote: OVERRIDE_NOTE, + points: 3, + staffNote: STAFF_NOTE, + }); + await h.revoke(SENIOR, "warnings", warningId); + await h.revoke(SENIOR, "sanctions", sanction?.id ?? 0); + + const suspended = await h.call(0, "GET", "/account-status", undefined, { + "x-suspended": String(MEMBER), + }); + expect(suspended.status).toBe(200); + const status = await suspended.text(); + expect((await h.call(0, "GET", "/account-status")).status).toBe(401); + + expect(h.emit.mock.calls.map(([name]) => name)).toEqual([ + "moderation.warning.issued", + "moderation.sanction.applied", + "moderation.warning.revoked", + "moderation.sanction.revoked", + ]); + const leaked = JSON.stringify([ + status, + h.emit.mock.calls, + h.publish.mock.calls.map(([args]) => args.data), + h.memory.rows(core_moderation_audit), + ]); + expect(leaked).not.toContain(STAFF_NOTE); + expect(leaked).not.toContain(OVERRIDE_NOTE); + }); +}); diff --git a/packages/vitnode/src/api/modules/users/routes/session.route.test.ts b/packages/vitnode/src/api/modules/users/routes/session.route.test.ts index 452297d818..bc382479bc 100644 --- a/packages/vitnode/src/api/modules/users/routes/session.route.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/session.route.test.ts @@ -86,6 +86,9 @@ describe("GET /users/session", () => { }); it("answers with no user when signed out", async () => { - expect(await readSession(null)).toEqual({ user: null }); + expect(await readSession(null)).toEqual({ + suspension: null, + user: null, + }); }); }); diff --git a/packages/vitnode/src/lib/moderation/warnings.test.ts b/packages/vitnode/src/lib/moderation/warnings.test.ts new file mode 100644 index 0000000000..cae903da9a --- /dev/null +++ b/packages/vitnode/src/lib/moderation/warnings.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import type { WarningThresholdRule } from "./warnings"; + +import { + activeWarningPoints, + evaluateWarningThresholds, + isSanctionActiveAt, + thresholdSuspensionEnd, +} from "./warnings"; + +const NOW = new Date("2026-10-28T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; +const at = (hours: number) => new Date(NOW.getTime() + hours * HOUR); + +const rule = ( + id: number, + points: number, + extra: Partial = {}, +): WarningThresholdRule => ({ + action: "suspend_temporary", + durationHours: 24, + enabled: true, + id, + points, + ...extra, +}); + +describe("activeWarningPoints", () => { + it("counts warnings that are neither expired nor revoked", () => { + expect( + activeWarningPoints( + [ + { expiresAt: null, points: 3, revokedAt: null }, + { expiresAt: at(1), points: 2, revokedAt: null }, + { expiresAt: null, points: 0, revokedAt: null }, + { expiresAt: NOW, points: 5, revokedAt: null }, + { expiresAt: at(-1), points: 7, revokedAt: null }, + { expiresAt: null, points: 11, revokedAt: at(-2) }, + { expiresAt: at(1), points: 13, revokedAt: at(-2) }, + ], + NOW, + ), + ).toBe(5); + }); +}); + +describe("evaluateWarningThresholds", () => { + const eight = rule(1, 8); + const ten = rule(2, 10, { + action: "suspend_indefinite", + durationHours: null, + }); + const twelveOff = rule(3, 12, { enabled: false }); + const rules = [ten, twelveOff, eight]; + + it.each([ + { after: 8, applied: eight, before: 6, crossed: [eight] }, + { after: 11, applied: ten, before: 6, crossed: [eight, ten] }, + { after: 9, applied: null, before: 8, crossed: [] }, + { after: 12, applied: null, before: 10, crossed: [] }, + { after: 7, applied: null, before: 6, crossed: [] }, + { after: 20, applied: null, before: 11, crossed: [] }, + ])( + "applies only the highest enabled rule crossed from $before to $after", + ({ after, applied, before, crossed }) => { + expect(evaluateWarningThresholds({ after, before, rules })).toEqual({ + applied, + crossed, + }); + }, + ); +}); + +describe("thresholdSuspensionEnd", () => { + it.each([ + ["after its duration", rule(1, 8, { durationHours: 24 }), at(24)], + [ + "never for an indefinite one", + rule(2, 10, { action: "suspend_indefinite", durationHours: null }), + null, + ], + ])("ends a suspension %s", (_name, threshold, end) => { + expect(thresholdSuspensionEnd(threshold, NOW)).toEqual(end); + }); +}); + +describe("isSanctionActiveAt", () => { + it.each([ + ["active and temporary", { endsAt: at(1), revokedAt: null }, true], + ["indefinite", { endsAt: null, revokedAt: null }, true], + ["revoked", { endsAt: null, revokedAt: at(-1) }, false], + ["ended", { endsAt: NOW, revokedAt: null }, false], + ["not started", { endsAt: null, revokedAt: null, startsAt: at(1) }, false], + ])("treats a %s sanction as active: %s", (_name, sanction, active) => { + expect(isSanctionActiveAt({ startsAt: at(-2), ...sanction }, NOW)).toBe( + active, + ); + }); +}); diff --git a/packages/vitnode/src/tanstack/comments/use-content-comments.test.tsx b/packages/vitnode/src/tanstack/comments/use-content-comments.test.tsx index 78ca94350f..71f2134c53 100644 --- a/packages/vitnode/src/tanstack/comments/use-content-comments.test.tsx +++ b/packages/vitnode/src/tanstack/comments/use-content-comments.test.tsx @@ -154,6 +154,7 @@ const NONE = { canEdit: false, canHide: false, canReport: false, + canWarn: false, }; type Comments = ReturnType< diff --git a/packages/vitnode/src/tanstack/content/permissions.test.tsx b/packages/vitnode/src/tanstack/content/permissions.test.tsx index 53a7db1c81..4cccee504a 100644 --- a/packages/vitnode/src/tanstack/content/permissions.test.tsx +++ b/packages/vitnode/src/tanstack/content/permissions.test.tsx @@ -84,6 +84,7 @@ describe("useContentPermissions", () => { canEdit: false, canHide: false, canReport: false, + canWarn: false, }); for (let settled = 0; settled < 5; settled++) { @@ -106,10 +107,34 @@ describe("useContentPermissions", () => { ).toStrictEqual(ids); expect(result.current.isError).toBe(false); expect([2, 3, 228, 229].map(id => result.current.of(id))).toStrictEqual([ - { canDelete: false, canEdit: true, canHide: false, canReport: true }, - { canDelete: false, canEdit: false, canHide: false, canReport: true }, - { canDelete: false, canEdit: true, canHide: false, canReport: true }, - { canDelete: false, canEdit: false, canHide: false, canReport: true }, + { + canDelete: false, + canEdit: true, + canHide: false, + canReport: true, + canWarn: false, + }, + { + canDelete: false, + canEdit: false, + canHide: false, + canReport: true, + canWarn: false, + }, + { + canDelete: false, + canEdit: true, + canHide: false, + canReport: true, + canWarn: false, + }, + { + canDelete: false, + canEdit: false, + canHide: false, + canReport: true, + canWarn: false, + }, ]); }); @@ -137,12 +162,14 @@ describe("useContentPermissions", () => { canEdit: false, canHide: false, canReport: false, + canWarn: false, }); expect(result.current.of(answered)).toStrictEqual({ canDelete: false, canEdit: true, canHide: false, canReport: true, + canWarn: false, }); }); }); diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.test.ts b/packages/vitnode/src/views/auth/settings/settings-nav.test.ts index de283924b2..c6588dfdf2 100644 --- a/packages/vitnode/src/views/auth/settings/settings-nav.test.ts +++ b/packages/vitnode/src/views/auth/settings/settings-nav.test.ts @@ -6,13 +6,13 @@ describe("visibleSettingsNavItems", () => { it("shows Security while passkeys are on", () => { expect( visibleSettingsNavItems({ passkeys: true }).map(item => item.key), - ).toEqual(["overview", "notifications", "devices", "security"]); + ).toEqual(["overview", "notifications", "devices", "security", "standing"]); }); it("hides Security when passkeys are switched off", () => { expect( visibleSettingsNavItems({ passkeys: false }).map(item => item.key), - ).toEqual(["overview", "notifications", "devices"]); + ).toEqual(["overview", "notifications", "devices", "standing"]); }); it("shows Connected accounts only while an SSO provider is configured", () => { @@ -21,12 +21,12 @@ describe("visibleSettingsNavItems", () => { passkeys: false, sso: [{ id: "google", name: "Google" }], }).map(item => item.key), - ).toEqual(["overview", "notifications", "devices", "sso"]); + ).toEqual(["overview", "notifications", "devices", "sso", "standing"]); expect( visibleSettingsNavItems({ passkeys: false, sso: [] }).map( item => item.key, ), - ).toEqual(["overview", "notifications", "devices"]); + ).toEqual(["overview", "notifications", "devices", "standing"]); }); it("shows AI usage only while the site has an AI model configured", () => { @@ -35,12 +35,12 @@ describe("visibleSettingsNavItems", () => { ai: { models: [{ id: "fast" }] }, passkeys: false, }).map(item => item.key), - ).toEqual(["overview", "notifications", "devices", "ai"]); + ).toEqual(["overview", "notifications", "devices", "ai", "standing"]); expect( visibleSettingsNavItems({ ai: { models: [] }, passkeys: false }).map( item => item.key, ), - ).toEqual(["overview", "notifications", "devices"]); + ).toEqual(["overview", "notifications", "devices", "standing"]); }); it("hides AI usage from a member without any AI feature", () => { @@ -50,6 +50,6 @@ describe("visibleSettingsNavItems", () => { aiFeatures: false, passkeys: false, }).map(item => item.key), - ).toEqual(["overview", "notifications", "devices"]); + ).toEqual(["overview", "notifications", "devices", "standing"]); }); }); diff --git a/packages/vitnode/src/views/moderation/warnings/account-status-content.tsx b/packages/vitnode/src/views/moderation/warnings/account-status-content.tsx index c4a73dcf5b..464d30c928 100644 --- a/packages/vitnode/src/views/moderation/warnings/account-status-content.tsx +++ b/packages/vitnode/src/views/moderation/warnings/account-status-content.tsx @@ -1,10 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { - ExternalLinkIcon, - LogInIcon, - ShieldAlertIcon, -} from "lucide-react"; +import { ExternalLinkIcon, LogInIcon, ShieldAlertIcon } from "lucide-react"; import React from "react"; import { useFormatter, useLocale, useTranslations } from "use-intl"; diff --git a/packages/vitnode/vitest.config.ts b/packages/vitnode/vitest.config.ts index bdf4a8a79c..f6fdc75e1b 100644 --- a/packages/vitnode/vitest.config.ts +++ b/packages/vitnode/vitest.config.ts @@ -44,6 +44,7 @@ const INTEGRATION = [ "scripts/cli/dev/package-watch.test.ts", "scripts/watch-plugin-manifest.test.ts", "src/api/lib/updates/lock.postgres.test.ts", + "src/api/modules/moderation/warnings.postgres.test.ts", "src/content/approvals/server/approval-notifications.postgres.test.ts", "src/content/server/notifications/content-notifications.postgres.test.ts", ]; From 1eca40711c79768099dc203662f8ade597cb3365 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:41:44 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(moderation):=20=E2=9C=A8=20warnings=20?= =?UTF-8?q?in=20the=20Activity=20Logs=20and=20a=20member=20moderation=20lo?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Record warning.issued, warning.revoked, sanction.applied and sanction.revoked in the unified Activity Logs (moderators' log), in the same transaction as the change; warning reason and threshold changes go to the administrators' log with their before and after values. Replaces the separate core_moderation_audit table. - GET /moderation/members/{id}/log and a Moderation log section on the ModCP member page, for moderators with `can_view`; suspension entries also need `can_view_sanctions`. No staff notes, override explanations or revocation reasons are ever recorded. - Regenerate the warnings migration on top of canary's migrations. - Reuse resolveLangValue and MultiLangValue instead of local copies, drop an unused helper, a needless cast and closure fallbacks. - Docs: where warning history lives, the member log, the log actions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY --- .../migration.sql | 90 + .../snapshot.json | 24672 ++++++++++++++++ .../web/content/docs/dev/audit-logs/index.mdx | 4 +- .../dev/moderation/configure-warnings.mdx | 2 + .../docs/dev/moderation/issue-warnings.mdx | 12 +- .../content/docs/dev/moderation/reference.mdx | 20 +- .../dev/moderation/warning-member-log.webp | Bin 0 -> 49988 bytes .../content/docs/dev/moderation/warnings.mdx | 7 +- .../src/api/lib/moderation/warnings/audit.ts | 47 + .../api/lib/moderation/warnings/reasons.ts | 33 +- .../api/lib/moderation/warnings/suspension.ts | 2 +- .../src/api/lib/notifications/core-types.ts | 13 +- .../warnings/routes/warning-reasons.routes.ts | 80 +- .../routes/warning-thresholds.routes.ts | 70 +- .../moderation/lib/warnings-service.ts | 125 +- .../modules/moderation/moderation.module.ts | 2 + .../moderation/routes/account-status.route.ts | 3 +- .../moderation/routes/warnings.routes.ts | 87 + .../api/modules/moderation/warnings.test.ts | 110 +- packages/vitnode/src/database/warnings.ts | 37 - packages/vitnode/src/lib/audit/actions.ts | 10 + .../vitnode/src/lib/moderation/warnings.ts | 34 +- packages/vitnode/src/locales/en.json | 42 +- .../moderation/warnings/warnings-admin-api.ts | 4 +- .../views/moderation/warnings/member-log.tsx | 215 + .../warnings/modcp-member-content.tsx | 3 + .../moderation/warnings/warning-list.tsx | 12 +- .../views/moderation/warnings/warnings-api.ts | 39 +- 28 files changed, 25529 insertions(+), 246 deletions(-) create mode 100644 apps/api/migrations/20261030193055_add_moderation_warnings/migration.sql create mode 100644 apps/api/migrations/20261030193055_add_moderation_warnings/snapshot.json create mode 100644 apps/web/content/docs/dev/moderation/warning-member-log.webp create mode 100644 packages/vitnode/src/api/lib/moderation/warnings/audit.ts create mode 100644 packages/vitnode/src/views/moderation/warnings/member-log.tsx diff --git a/apps/api/migrations/20261030193055_add_moderation_warnings/migration.sql b/apps/api/migrations/20261030193055_add_moderation_warnings/migration.sql new file mode 100644 index 0000000000..cf29b46171 --- /dev/null +++ b/apps/api/migrations/20261030193055_add_moderation_warnings/migration.sql @@ -0,0 +1,90 @@ +CREATE TABLE "core_moderation_sanctions" ( + "id" serial PRIMARY KEY, + "userId" integer NOT NULL, + "type" varchar(32) NOT NULL, + "source" varchar(32) NOT NULL, + "startsAt" timestamp DEFAULT now() NOT NULL, + "endsAt" timestamp, + "warningId" integer UNIQUE, + "thresholdId" integer, + "thresholdSnapshot" jsonb, + "createdById" integer, + "createdAt" timestamp DEFAULT now() NOT NULL, + "revokedAt" timestamp, + "revokedById" integer, + "revokeReason" text +); +--> statement-breakpoint +ALTER TABLE "core_moderation_sanctions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_moderation_warning_reasons" ( + "id" serial PRIMARY KEY, + "points" integer DEFAULT 0 NOT NULL, + "expiresAfterHours" integer, + "enabled" boolean DEFAULT true NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_moderation_warning_reasons" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_moderation_warning_thresholds" ( + "id" serial PRIMARY KEY, + "points" integer NOT NULL, + "action" varchar(32) NOT NULL, + "durationHours" integer, + "enabled" boolean DEFAULT true NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_moderation_warning_thresholds" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_moderation_warnings" ( + "id" serial PRIMARY KEY, + "userId" integer NOT NULL, + "issuedById" integer, + "reasonId" integer, + "reasonSnapshot" jsonb, + "points" integer NOT NULL, + "defaultPoints" integer, + "overrideNote" text, + "message" text NOT NULL, + "staffNote" text, + "issuedAt" timestamp DEFAULT now() NOT NULL, + "expiresAt" timestamp, + "pointsBefore" integer DEFAULT 0 NOT NULL, + "pointsAfter" integer DEFAULT 0 NOT NULL, + "pluginId" varchar(100), + "targetType" varchar(100), + "targetId" varchar(64), + "scopeFamily" varchar(100), + "scopeId" varchar(64), + "contentSnapshot" jsonb, + "reportId" integer, + "idempotencyKey" varchar(64), + "revokedAt" timestamp, + "revokedById" integer, + "revokeReason" text, + CONSTRAINT "core_moderation_warnings_idempotency_key" UNIQUE("issuedById","idempotencyKey") +); +--> statement-breakpoint +ALTER TABLE "core_moderation_warnings" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX "core_moderation_sanctions_user_idx" ON "core_moderation_sanctions" ("userId","revokedAt","endsAt");--> statement-breakpoint +CREATE INDEX "core_moderation_sanctions_created_at_id_idx" ON "core_moderation_sanctions" ("createdAt","id");--> statement-breakpoint +CREATE INDEX "core_moderation_warning_reasons_position_idx" ON "core_moderation_warning_reasons" ("position");--> statement-breakpoint +CREATE UNIQUE INDEX "core_moderation_warning_thresholds_enabled_points_idx" ON "core_moderation_warning_thresholds" ("points") WHERE "enabled" = true;--> statement-breakpoint +CREATE INDEX "core_moderation_warnings_user_idx" ON "core_moderation_warnings" ("userId","issuedAt");--> statement-breakpoint +CREATE INDEX "core_moderation_warnings_issued_at_id_idx" ON "core_moderation_warnings" ("issuedAt","id");--> statement-breakpoint +CREATE INDEX "core_moderation_warnings_issued_by_idx" ON "core_moderation_warnings" ("issuedById");--> statement-breakpoint +CREATE INDEX "core_moderation_warnings_reason_idx" ON "core_moderation_warnings" ("reasonId");--> statement-breakpoint +CREATE INDEX "core_moderation_warnings_target_idx" ON "core_moderation_warnings" ("pluginId","targetType","targetId");--> statement-breakpoint +CREATE INDEX "core_moderation_warnings_report_idx" ON "core_moderation_warnings" ("reportId");--> statement-breakpoint +ALTER TABLE "core_moderation_sanctions" ADD CONSTRAINT "core_moderation_sanctions_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_moderation_sanctions" ADD CONSTRAINT "core_moderation_sanctions_rlFetAHphSkc_fkey" FOREIGN KEY ("warningId") REFERENCES "core_moderation_warnings"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_sanctions" ADD CONSTRAINT "core_moderation_sanctions_ejX3lH3GN7oz_fkey" FOREIGN KEY ("thresholdId") REFERENCES "core_moderation_warning_thresholds"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_sanctions" ADD CONSTRAINT "core_moderation_sanctions_createdById_core_users_id_fkey" FOREIGN KEY ("createdById") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_sanctions" ADD CONSTRAINT "core_moderation_sanctions_revokedById_core_users_id_fkey" FOREIGN KEY ("revokedById") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_warnings" ADD CONSTRAINT "core_moderation_warnings_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_moderation_warnings" ADD CONSTRAINT "core_moderation_warnings_issuedById_core_users_id_fkey" FOREIGN KEY ("issuedById") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_warnings" ADD CONSTRAINT "core_moderation_warnings_J1JvecLI20Z2_fkey" FOREIGN KEY ("reasonId") REFERENCES "core_moderation_warning_reasons"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_warnings" ADD CONSTRAINT "core_moderation_warnings_0vlCswFpT46n_fkey" FOREIGN KEY ("reportId") REFERENCES "core_moderation_reports"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_warnings" ADD CONSTRAINT "core_moderation_warnings_revokedById_core_users_id_fkey" FOREIGN KEY ("revokedById") REFERENCES "core_users"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/apps/api/migrations/20261030193055_add_moderation_warnings/snapshot.json b/apps/api/migrations/20261030193055_add_moderation_warnings/snapshot.json new file mode 100644 index 0000000000..226fce40f4 --- /dev/null +++ b/apps/api/migrations/20261030193055_add_moderation_warnings/snapshot.json @@ -0,0 +1,24672 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "d0a2bdea-582a-4e74-831e-21a73f67bd7b", + "prevIds": [ + "ac6bc2f3-4afc-4768-be17-a5b36fd6180e" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_audit_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_audit_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_containers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_submissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_reasons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_payments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_payments_webhook_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_popularity", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_lock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_results", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sso_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sso_pending", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_sanctions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_warning_reasons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_warning_thresholds", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_warnings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_related_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_letters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "payments_demo_purchases", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "area", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(150)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorName", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetLabel", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changes", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "correlationId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "retentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "definitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "policies", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerDefinitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseValues", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseUpdatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "decidedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "requiresDetails", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporterId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetTitle", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'new'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigneeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolution", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolutionNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reference", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerType", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "currency", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerStatus", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checkoutId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checkoutUrl", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "livemode", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "syncedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "paymentId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "receivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowBrowseAnonymously", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "forceBrowseAnonymously", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editOwnContentMinutes", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowDeleteOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowHideOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "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" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logScore", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "clicks", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "comments", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastManualAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(214)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "componentId", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "versions", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasUnknownVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coreVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "result", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "etag", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastModified", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stateHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bindingHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeVerifier", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nonce", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "confirmSends", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmSentAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSeenAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hideOnlineStatus", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeVerifier", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nonce", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endsAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "warningId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thresholdId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thresholdSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revokedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revokedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revokeReason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "points", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAfterHours", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_reasons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_reasons" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_thresholds" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "points", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_thresholds" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_thresholds" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "durationHours", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_thresholds" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_thresholds" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_thresholds" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warning_thresholds" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issuedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasonId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasonSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "points", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "defaultPoints", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "overrideNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "staffNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "issuedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "pointsBefore", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "pointsAfter", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revokedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revokedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revokeReason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "forum", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(80)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "newsroom_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "desk", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_plugin_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "area", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_area_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_parent_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_pending_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_queue_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reportId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_events_report_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_reasons_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigneeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reporterId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_reporter_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "eventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"eventId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reference", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_open_reference_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "checkoutId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"checkoutId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_checkout_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reference", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_reference_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_open_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "receivedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_webhook_events_received_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "completedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"completedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_completed_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "to_tsvector('simple'::regconfig, \"title\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_title_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "imageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_image_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "containerType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_container_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "logScore", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_popularity_log_score_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_popularity" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sso_attempts_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sso_pending_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sso_pending" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revokedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "endsAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_sanctions_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_sanctions_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_warning_reasons_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_warning_reasons" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "points", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"enabled\" = true", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_warning_thresholds_enabled_points_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_warning_thresholds" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issuedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_warnings_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "issuedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_warnings_issued_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "issuedById", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_warnings_issued_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reasonId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_warnings_reason_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_warnings_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reportId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_warnings_report_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_article_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "forum", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_forum_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "desk", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_desk_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_code_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "payments_demo_purchases_user_item_unique", + "entityType": "indexes", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_audit_logs_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_content_containers_q6QIucJ4HJ7B_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_authorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "reviewerId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_reviewerId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "reportId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_report_events_4CqHOxfheaUm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_report_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "reporterId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_reporterId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "assigneeId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_assigneeId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_resolvedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_payments_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": false, + "columns": [ + "paymentId" + ], + "schemaTo": "public", + "tableTo": "core_payments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_payments_webhook_events_paymentId_core_payments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_sanctions_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "nameExplicit": false, + "columns": [ + "warningId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_warnings", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_sanctions_rlFetAHphSkc_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "nameExplicit": false, + "columns": [ + "thresholdId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_warning_thresholds", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_sanctions_ejX3lH3GN7oz_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "nameExplicit": false, + "columns": [ + "createdById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_sanctions_createdById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "nameExplicit": false, + "columns": [ + "revokedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_sanctions_revokedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_sanctions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_warnings_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": false, + "columns": [ + "issuedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_warnings_issuedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": false, + "columns": [ + "reasonId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_warning_reasons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_warnings_J1JvecLI20Z2_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": false, + "columns": [ + "reportId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_warnings_0vlCswFpT46n_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": false, + "columns": [ + "revokedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_warnings_revokedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_comments_article_blog_posts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "blog_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_parent_blog_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "topic" + ], + "schemaTo": "public", + "tableTo": "forum_topics", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "forum_replies_topic_forum_topics_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "forum_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_parent_forum_replies_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "forum" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "forum_topics_forum_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_bulletins", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_bulletins_translations_kgMZwrpzwwU7_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_bulletins_translations_rkkiMJQiOrEo_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "newsroom_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_comments_article_newsroom_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "newsroom_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_parent_newsroom_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_primaryTag_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_gallery_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_0xHNSLM3JAJj_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_sessions_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_tags_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_translations_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_categories_9dOmLv3k6xDX_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_categories_mnxKvproEUm5_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_faq_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_TFvIU3TogSGy_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_74JhaXF5PsDV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_tags_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_translations_hcPB0jdTm8MV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_translations_qqggtF6jhcsZ_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "desk" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_letters_desk_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_stories_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_stories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_stories_gallery_itemId_newsroom_stories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_translations_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "payments_demo_purchases_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "providerId", + "eventId" + ], + "nameExplicit": false, + "name": "core_payments_webhook_events_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "columns": [ + "itemType", + "itemId" + ], + "nameExplicit": false, + "name": "core_search_popularity_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_search_popularity" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_bulletins_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_related_explainers_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_stories_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_audit_logs_pkey", + "schema": "public", + "table": "core_audit_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_audit_settings_pkey", + "schema": "public", + "table": "core_audit_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_containers_pkey", + "schema": "public", + "table": "core_content_containers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_submissions_pkey", + "schema": "public", + "table": "core_content_submissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_events_pkey", + "schema": "public", + "table": "core_moderation_report_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_reasons_pkey", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_reports_pkey", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_payments_pkey", + "schema": "public", + "table": "core_payments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_update_lock_pkey", + "schema": "public", + "table": "core_update_lock", + "entityType": "pks" + }, + { + "columns": [ + "componentId" + ], + "nameExplicit": false, + "name": "core_update_results_pkey", + "schema": "public", + "table": "core_update_results", + "entityType": "pks" + }, + { + "columns": [ + "url" + ], + "nameExplicit": false, + "name": "core_update_sources_pkey", + "schema": "public", + "table": "core_update_sources", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sso_attempts_pkey", + "schema": "public", + "table": "core_sso_attempts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sso_pending_pkey", + "schema": "public", + "table": "core_sso_pending", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_sanctions_pkey", + "schema": "public", + "table": "core_moderation_sanctions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_warning_reasons_pkey", + "schema": "public", + "table": "core_moderation_warning_reasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_warning_thresholds_pkey", + "schema": "public", + "table": "core_moderation_warning_thresholds", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_warnings_pkey", + "schema": "public", + "table": "core_moderation_warnings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_comments_pkey", + "schema": "public", + "table": "blog_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_replies_pkey", + "schema": "public", + "table": "forum_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_topics_pkey", + "schema": "public", + "table": "forum_topics", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_articles_pkey", + "schema": "public", + "table": "newsroom_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_bulletins_pkey", + "schema": "public", + "table": "newsroom_bulletins", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_categories_pkey", + "schema": "public", + "table": "newsroom_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_comments_pkey", + "schema": "public", + "table": "newsroom_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_pkey", + "schema": "public", + "table": "newsroom_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_sessions_pkey", + "schema": "public", + "table": "newsroom_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_pkey", + "schema": "public", + "table": "newsroom_explainers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_faq_pkey", + "schema": "public", + "table": "newsroom_explainers_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_letters_pkey", + "schema": "public", + "table": "newsroom_letters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_pages_pkey", + "schema": "public", + "table": "newsroom_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_stories_pkey", + "schema": "public", + "table": "newsroom_stories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_tags_pkey", + "schema": "public", + "table": "newsroom_tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "payments_demo_purchases_pkey", + "schema": "public", + "table": "payments_demo_purchases", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "issuedById", + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_moderation_warnings_idempotency_key", + "entityType": "uniques", + "schema": "public", + "table": "core_moderation_warnings" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_moderation_report_reasons_code_key", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "openKey" + ], + "nullsNotDistinct": false, + "name": "core_moderation_reports_openKey_key", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "stateHash" + ], + "nullsNotDistinct": false, + "name": "core_sso_attempts_stateHash_key", + "schema": "public", + "table": "core_sso_attempts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_sso_pending_tokenHash_key", + "schema": "public", + "table": "core_sso_pending", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "warningId" + ], + "nullsNotDistinct": false, + "name": "core_moderation_sanctions_warningId_key", + "schema": "public", + "table": "core_moderation_sanctions", + "entityType": "uniques" + }, + { + "value": "\"amount\" > 0", + "name": "core_payments_amount_positive", + "entityType": "checks", + "schema": "public", + "table": "core_payments" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/content/docs/dev/audit-logs/index.mdx b/apps/web/content/docs/dev/audit-logs/index.mdx index bb84690923..e0c539ccdd 100644 --- a/apps/web/content/docs/dev/audit-logs/index.mdx +++ b/apps/web/content/docs/dev/audit-logs/index.mdx @@ -50,8 +50,8 @@ after, an **Open** link to the target and **Show this person's activity**. | Content Engine | Create, edit, delete, duplicate, publish, unpublish, hide, unhide, restore, schedule and every translation change of every content type | | Roles and staff | Roles created, edited and deleted; administrator and moderator permissions added, changed and removed | | Users | Profile and role changes made in the AdminCP, password resets, email verification, signed-out devices | -| Settings | AI settings and access, notification types and danger-zone actions, report reasons, log retention and log exports | -| Moderation | Taking, assigning, resolving and reopening reports, report notes, acting on reported content, approving and rejecting submissions | +| Settings | AI settings and access, notification types and danger-zone actions, report reasons, warning reasons and thresholds, log retention and log exports | +| Moderation | Taking, assigning, resolving and reopening reports, report notes, acting on reported content, approving and rejecting submissions, [warnings and suspensions](/docs/dev/moderation/warnings) | | Background work | Scheduled publishing, cron jobs and queue tasks, attributed to the system; manual cron runs, attributed to the admin who started them | | Payments | Checkouts started by members, and every status change the provider reports (processing, succeeded, failed, expired) | diff --git a/apps/web/content/docs/dev/moderation/configure-warnings.mdx b/apps/web/content/docs/dev/moderation/configure-warnings.mdx index 21a429e79e..44579cc823 100644 --- a/apps/web/content/docs/dev/moderation/configure-warnings.mdx +++ b/apps/web/content/docs/dev/moderation/configure-warnings.mdx @@ -74,6 +74,8 @@ Warn a test member twice with **Spam** (4 points), then preview a third time in Changes apply to warnings issued from now on. Saving a lower threshold does not suspend members who are already above it, and raising one does not lift anything. +Every change to a reason or threshold, with its values before and after, is recorded in **AdminCP → Staff → Administrators → Logs**. + ## Related - [Warnings](/docs/dev/moderation/warnings) diff --git a/apps/web/content/docs/dev/moderation/issue-warnings.mdx b/apps/web/content/docs/dev/moderation/issue-warnings.mdx index 5ac4ed8563..32fc659091 100644 --- a/apps/web/content/docs/dev/moderation/issue-warnings.mdx +++ b/apps/web/content/docs/dev/moderation/issue-warnings.mdx @@ -7,6 +7,7 @@ icon: TriangleAlert import { ImgDocs } from "@/components/fumadocs/img"; import form from "./warning-form.webp"; import member from "./warning-member.webp"; +import memberLog from "./warning-member-log.webp"; Moderators warn members with one shared form, opened from wherever the problem is. This guide warns a member for a blog comment, then revokes the warning and lifts the suspension it caused. @@ -19,7 +20,7 @@ Warnings need [warning reasons](/docs/dev/moderation/configure-warnings) and a m | **Warn authors of this content** (`can_warn`) | The content type's module, for example **Comment** in the blog. Can be [scoped](/docs/dev/moderation/scoped-permissions) | Warn the author of that content | | **Warn members directly, without content** | Core → **Warnings** | Warn from a profile or the ModCP without content | | **Change a warning's points** / **Change when a warning's points expire** | Core → **Warnings** | Override the reason's defaults, with an explanation | -| **View every member's warnings** | Core → **Warnings** | See all warnings, not only those in their scope | +| **View every member's warnings** | Core → **Warnings** | See all warnings, not only those in their scope, and a member's **Moderation log** | | **Read staff notes on warnings** | Core → **Warnings** | Read private notes and override explanations | | **Revoke warnings** | Core → **Warnings** | Revoke a warning | | **View suspensions** / **Lift suspensions** | Core → **Warnings** | See and lift suspensions | @@ -83,6 +84,15 @@ Open the member in **Moderator CP → Warnings** (or **Warnings** on their profi Revoking a warning never lifts the suspension it caused. Lift it separately with **Lift** on the suspension. Other active suspensions keep applying. +The **Moderation log** at the bottom of the page lists who warned the member, revoked a warning or applied and lifted a suspension, newest first, with points before and after. It needs **View every member's warnings**, and suspension entries also need **View suspensions**. + + + 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 literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/moderation/warnings.mdx b/apps/web/content/docs/dev/moderation/warnings.mdx index 6384b95078..a4019e96e2 100644 --- a/apps/web/content/docs/dev/moderation/warnings.mdx +++ b/apps/web/content/docs/dev/moderation/warnings.mdx @@ -27,7 +27,7 @@ A **warning** records a moderator's decision about a member. It can carry **poin 1. A moderator opens the warn form. It previews the member's active points before and after, and the suspension a threshold would apply. 2. On submit, the server checks every permission again and locks that member's warnings for the length of one transaction. Two moderators warning the same member at once are handled one after the other, so the points add up and each threshold fires once. 3. A threshold applies when the warning moves active points from below its number to its number or above. When one warning crosses several, only the highest applies: 6 → 11 points with thresholds at 8 and 10 gives only the 10-point suspension. -4. The warning, the threshold decision, the suspension, the audit entries and the member's notifications commit together or not at all. +4. The warning, the threshold decision, the suspension, the [activity log](/docs/dev/audit-logs) entries and the member's notifications commit together or not at all. A temporary suspension ends at its own end time, even when the member still has the points. Expired points or a changed threshold never create a new suspension on their own; only a new warning does. Points that later fall below a threshold let it fire again on a future crossing. @@ -37,6 +37,10 @@ The server checks suspensions when it reads the session, for every request: core The member keeps their session. A strip under the header says the account is suspended and links to **Account status** at `/account-status`, which shows the suspension end time, active points and the warning history with each message. Members reach the same page from **Settings → Account status** at any time. It never shows staff notes or which moderator issued a warning. +## Where the history lives + +Every warning, revocation and suspension is written to the [Activity Logs](/docs/dev/audit-logs), in the same transaction as the change. Administrators see them in **AdminCP → Staff → Moderators → Logs**. Moderators with **View every member's warnings** see one member's entries as the **Moderation log** on that member's ModCP page. The entries hold points, threshold decisions and links to reports, never staff notes, override explanations or revocation reasons. Changes to warning reasons and thresholds land in the administrators' log. + ## Who is protected Nobody can warn themselves. A member who holds a root role cannot be warned at all, and other staff (admins and moderators) can only be warned by someone who holds a root role. These checks run before a threshold is evaluated, so a threshold can never suspend a protected account. @@ -45,7 +49,6 @@ Nobody can warn themselves. A member who holds a root role cannot be warned at a - Suspensions are the only sanction. There is no manual "suspend without a warning" action, no appeal workflow and no posting restriction per plugin yet; the data model leaves room for them. - Suspension state is cached for up to a minute per member. Applying or lifting a suspension clears that cache, and with Redis every instance sees it at once. Without Redis, other instances may take up to a minute. -- The audit trail is stored in `core_moderation_audit` but has no screen yet. ## Related diff --git a/packages/vitnode/src/api/lib/moderation/warnings/audit.ts b/packages/vitnode/src/api/lib/moderation/warnings/audit.ts new file mode 100644 index 0000000000..66e728d9d1 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/warnings/audit.ts @@ -0,0 +1,47 @@ +import type { Context } from "hono"; + +import type { AuditDatabase } from "@/api/lib/audit/write"; +import type { AuditScope } from "@/lib/audit/const"; + +import { writeAuditLog } from "@/api/lib/audit/write"; + +/** The audit actions of warnings and sanctions, all about the warned member. */ +export type WarningAuditAction = + | "sanction.applied" + | "sanction.revoked" + | "warning.issued" + | "warning.revoked"; + +/** + * Writes one warning or sanction entry to the central audit log, in the + * moderators' log and in the same transaction as the change. The target is + * the warned member, so their history reads as one moderation log. Never pass + * staff notes, override explanations or revocation reasons in `metadata`. + */ +export const recordWarningAudit = async ( + c: Context, + tx: AuditDatabase, + { + action, + metadata, + scope = null, + user, + }: { + action: WarningAuditAction; + metadata: Record; + scope?: AuditScope | null; + user: { id: number; name: null | string }; + }, +): Promise => { + await writeAuditLog( + c, + { + action, + area: "moderator", + metadata, + scope, + target: { id: user.id, label: user.name, type: "user" }, + }, + tx, + ); +}; diff --git a/packages/vitnode/src/api/lib/moderation/warnings/reasons.ts b/packages/vitnode/src/api/lib/moderation/warnings/reasons.ts index 62e9d28878..f021c42223 100644 --- a/packages/vitnode/src/api/lib/moderation/warnings/reasons.ts +++ b/packages/vitnode/src/api/lib/moderation/warnings/reasons.ts @@ -3,14 +3,8 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; import { and, asc, eq, inArray } from "drizzle-orm"; -import type { ContentDatabase } from "@/content/server/service"; -import type { ModerationAuditType } from "@/lib/moderation/warnings"; - import { core_languages_words } from "@/database/languages"; -import { - core_moderation_audit, - core_moderation_warning_reasons, -} from "@/database/warnings"; +import { core_moderation_warning_reasons } from "@/database/warnings"; export const WARNING_REASON_WORDS = { pluginCode: "core", @@ -92,28 +86,3 @@ export const listWarningReasons = async ( title: pick(row.id, "title"), })); }; - -/** - * Appends one entry to the warning system's audit trail. Never pass staff - * notes or override explanations in `data`. - */ -export const appendModerationAudit = async ( - db: ContentDatabase, - entry: { - actorId: null | number; - data?: Record; - sanctionId?: null | number; - type: ModerationAuditType; - userId?: null | number; - warningId?: null | number; - }, -): Promise => { - await db.insert(core_moderation_audit).values({ - actorId: entry.actorId, - data: entry.data ?? {}, - sanctionId: entry.sanctionId ?? null, - type: entry.type, - userId: entry.userId ?? null, - warningId: entry.warningId ?? null, - }); -}; diff --git a/packages/vitnode/src/api/lib/moderation/warnings/suspension.ts b/packages/vitnode/src/api/lib/moderation/warnings/suspension.ts index fb18b1211d..a126bb12bc 100644 --- a/packages/vitnode/src/api/lib/moderation/warnings/suspension.ts +++ b/packages/vitnode/src/api/lib/moderation/warnings/suspension.ts @@ -110,5 +110,5 @@ export const forgetSuspension = async ( { disconnect }: { disconnect: boolean }, ): Promise => { await c.get("cache").deleteSystem(suspensionCacheKey(userId)); - if (disconnect) c.get("realtime")?.closeUserConnections(userId); + if (disconnect) c.get("realtime").closeUserConnections(userId); }; diff --git a/packages/vitnode/src/api/lib/notifications/core-types.ts b/packages/vitnode/src/api/lib/notifications/core-types.ts index 7a6c5937f0..2a1a835944 100644 --- a/packages/vitnode/src/api/lib/notifications/core-types.ts +++ b/packages/vitnode/src/api/lib/notifications/core-types.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { resolveLangValue } from "@/lib/helpers/multi-lang"; + import { buildNotificationType } from "./registry"; export const adminMessageNotification = buildNotificationType({ @@ -20,15 +22,6 @@ const reasonTextSchema = z .array(z.object({ languageCode: z.string(), value: z.string() })) .nullable(); -const pickText = ( - values: null | { languageCode: string; value: string }[], - locale: string, -): string => - values?.find(item => item.languageCode === locale)?.value ?? - values?.find(item => item.languageCode === "en")?.value ?? - values?.[0]?.value ?? - ""; - const ACCOUNT_STATUS_PATH = "/account-status"; /** Sent to a member when a moderator warns them. Never carries staff notes. */ @@ -48,7 +41,7 @@ export const warningIssuedNotification = buildNotificationType({ email: true, mandatory: true, present: ({ data, locale, t }) => { - const reason = pickText(data.reason, locale); + const reason = data.reason ? resolveLangValue(data.reason, { locale }) : ""; return { body: data.message, diff --git a/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts index 10f41be05d..ed36aabb72 100644 --- a/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts +++ b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts @@ -4,8 +4,9 @@ import { z } from "@hono/zod-openapi"; import { count, eq, inArray, max, sql } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { multiLangAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { - appendModerationAudit, listWarningReasons, WARNING_REASON_WORDS, warningReasonSchema, @@ -13,6 +14,7 @@ import { import { saveLanguageWords } from "@/api/lib/save-language-words"; import { errorSchema, json } from "@/api/modules/moderation/lib/schema"; import { core_moderation_warning_reasons } from "@/database/warnings"; +import { auditDiff } from "@/lib/audit/diff"; import { WARNING_LIMITS } from "@/lib/moderation/warnings"; const wordsSchema = (maxLength: number) => @@ -138,11 +140,19 @@ export const createWarningReasonAdminRoute = buildRoute({ position: (lastPosition ?? -1) + 1, }) .returning({ id: core_moderation_warning_reasons.id }); - await appendModerationAudit(tx, { - actorId: c.get("admin")?.user.id ?? null, - data: { reasonId: row.id, ...auditedSettings(body) }, - type: "reason_created", - }); + const label = multiLangAuditLabel(body.title); + await writeAuditLog( + c, + { + action: "warning_reason.created", + changes: auditDiff({ + after: { ...auditedSettings(body), title: label }, + before: null, + }), + target: { id: row.id, label, type: "warning_reason" }, + }, + tx, + ); return row; }); @@ -190,7 +200,12 @@ export const updateWarningReasonAdminRoute = buildRoute({ const found = await c.get("db").transaction(async tx => { const [row] = await tx - .select({ id: core_moderation_warning_reasons.id }) + .select({ + enabled: core_moderation_warning_reasons.enabled, + expiresAfterHours: core_moderation_warning_reasons.expiresAfterHours, + id: core_moderation_warning_reasons.id, + points: core_moderation_warning_reasons.points, + }) .from(core_moderation_warning_reasons) .where(eq(core_moderation_warning_reasons.id, id)) .limit(1); @@ -201,15 +216,31 @@ export const updateWarningReasonAdminRoute = buildRoute({ .set(patch) .where(eq(core_moderation_warning_reasons.id, id)); } - await appendModerationAudit(tx, { - actorId: c.get("admin")?.user.id ?? null, - data: { - reasonId: id, - ...patch, - wordsChanged: body.title !== undefined, + const [stored] = await listWarningReasons(c, { ids: [id] }); + const label = multiLangAuditLabel(body.title ?? stored?.title); + await writeAuditLog( + c, + { + action: "warning_reason.updated", + changes: auditDiff({ + after: { + ...row, + ...patch, + ...(body.title === undefined ? {} : { title: label }), + }, + before: row, + exclude: ["id"], + }), + metadata: { + ...(body.description === undefined + ? {} + : { descriptionChanged: true }), + ...(body.message === undefined ? {} : { messageChanged: true }), + }, + target: { id, label, type: "warning_reason" }, }, - type: "reason_updated", - }); + tx, + ); return true; }); @@ -272,10 +303,21 @@ export const reorderWarningReasonsAdminRoute = buildRoute({ ), sql` `, ); - await db - .update(core_moderation_warning_reasons) - .set({ position: sql`case ${positionById} end` }) - .where(inArray(core_moderation_warning_reasons.id, ids)); + await db.transaction(async tx => { + await tx + .update(core_moderation_warning_reasons) + .set({ position: sql`case ${positionById} end` }) + .where(inArray(core_moderation_warning_reasons.id, ids)); + await writeAuditLog( + c, + { + action: "warning_reason.reordered", + metadata: { order: ids }, + target: { type: "warning_reason" }, + }, + tx, + ); + }); return c.body(null, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/warnings/routes/warning-thresholds.routes.ts b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-thresholds.routes.ts index 92ec137bea..0558d75524 100644 --- a/packages/vitnode/src/api/modules/admin/warnings/routes/warning-thresholds.routes.ts +++ b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-thresholds.routes.ts @@ -1,14 +1,13 @@ -import type { Context } from "hono"; - import { z } from "@hono/zod-openapi"; import { and, asc, count, eq, ne, sql } from "drizzle-orm"; import type { ContentDatabase } from "@/content/server/service"; import { buildRoute } from "@/api/builders"; -import { appendModerationAudit } from "@/api/lib/moderation/warnings/reasons"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { errorSchema, json } from "@/api/modules/moderation/lib/schema"; import { core_moderation_warning_thresholds } from "@/database/warnings"; +import { auditDiff } from "@/lib/audit/diff"; import { WARNING_LIMITS, WARNING_THRESHOLD_ACTIONS, @@ -72,7 +71,11 @@ const pointsTaken = async ( return row !== undefined; }; -const actorOf = (c: Context) => c.get("admin")?.user.id ?? null; +const TARGET = "warning_threshold"; + +/** A threshold has no name; its points are what admins tell it apart by. */ +const thresholdLabel = ({ points }: { points: number }): string => + String(points); const responses = { 400: json(errorSchema, "A temporary suspension needs a duration"), @@ -154,11 +157,15 @@ export const createWarningThresholdAdminRoute = buildRoute({ .insert(table) .values(body) .returning({ id: table.id }); - await appendModerationAudit(tx, { - actorId: actorOf(c), - data: { thresholdId: row.id, ...body }, - type: "threshold_created", - }); + await writeAuditLog( + c, + { + action: "warning_threshold.created", + changes: auditDiff({ after: body, before: null }), + target: { id: row.id, label: thresholdLabel(body), type: TARGET }, + }, + tx, + ); return { id: row.id }; }); @@ -202,7 +209,12 @@ export const updateWarningThresholdAdminRoute = buildRoute({ const result = await c.get("db").transaction(async tx => { await tx.execute(LOCK_THRESHOLDS); const [existing] = await tx - .select({ id: table.id }) + .select({ + action: table.action, + durationHours: table.durationHours, + enabled: table.enabled, + points: table.points, + }) .from(table) .where(eq(table.id, id)) .limit(1); @@ -212,11 +224,15 @@ export const updateWarningThresholdAdminRoute = buildRoute({ } await tx.update(table).set(body).where(eq(table.id, id)); - await appendModerationAudit(tx, { - actorId: actorOf(c), - data: { thresholdId: id, ...body }, - type: "threshold_updated", - }); + await writeAuditLog( + c, + { + action: "warning_threshold.updated", + changes: auditDiff({ after: body, before: existing }), + target: { id, label: thresholdLabel(body), type: TARGET }, + }, + tx, + ); return { id }; }); @@ -255,16 +271,22 @@ export const deleteWarningThresholdAdminRoute = buildRoute({ const { id } = c.req.valid("param"); const table = core_moderation_warning_thresholds; const deleted = await c.get("db").transaction(async tx => { - const [row] = await tx - .delete(table) - .where(eq(table.id, id)) - .returning({ id: table.id, points: table.points }); - if (!row) return false; - await appendModerationAudit(tx, { - actorId: actorOf(c), - data: { points: row.points, thresholdId: id }, - type: "threshold_deleted", + const [row] = await tx.delete(table).where(eq(table.id, id)).returning({ + action: table.action, + durationHours: table.durationHours, + enabled: table.enabled, + points: table.points, }); + if (!row) return false; + await writeAuditLog( + c, + { + action: "warning_threshold.deleted", + changes: auditDiff({ after: null, before: row }), + target: { id, label: thresholdLabel(row), type: TARGET }, + }, + tx, + ); return true; }); diff --git a/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts b/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts index 0dbf51326b..b448da35ed 100644 --- a/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts +++ b/packages/vitnode/src/api/modules/moderation/lib/warnings-service.ts @@ -11,6 +11,7 @@ import type { WarningThresholdAction, } from "@/lib/moderation/warnings"; +import { userAuditLabel } from "@/api/lib/audit/labels"; import { isUniqueViolation } from "@/api/lib/db-errors"; import { canOnReport } from "@/api/lib/moderation/access"; import { findReportTarget } from "@/api/lib/moderation/registry"; @@ -20,10 +21,8 @@ import { hasWarningPermission, warningTargetRefusal, } from "@/api/lib/moderation/warnings/access"; -import { - appendModerationAudit, - listWarningReasons, -} from "@/api/lib/moderation/warnings/reasons"; +import { recordWarningAudit } from "@/api/lib/moderation/warnings/audit"; +import { listWarningReasons } from "@/api/lib/moderation/warnings/reasons"; import { forgetSuspension } from "@/api/lib/moderation/warnings/suspension"; import { accountSuspendedNotification, @@ -576,59 +575,38 @@ export const issueWarning = async ( }) .returning({ id: core_moderation_warnings.id }); - await appendModerationAudit(tx, { - actorId: moderator.user.id, - data: { - content: draft.content - ? { - pluginId: draft.content.pluginId, - targetId: draft.content.targetId, - targetType: draft.content.targetType, - } - : null, - expiresAt: expiresAt?.toISOString() ?? null, - points: draft.points, - reasonId: draft.reason?.id ?? null, - reportId: report?.id ?? null, - }, - type: "warning_issued", - userId: draft.user.id, - warningId: warning.id, - }); - if (overridden) { - await appendModerationAudit(tx, { - actorId: moderator.user.id, - data: { - defaultExpiresAfterHours: draft.defaultExpiresAfterHours, - defaultPoints: draft.defaultPoints, - expiresAt: expiresAt?.toISOString() ?? null, - expiration: draft.overrides.expiration, - points: draft.points, - pointsOverridden: draft.overrides.points, - }, - type: "warning_overridden", - userId: draft.user.id, - warningId: warning.id, - }); - } - const rules = await loadEnabledThresholds(tx); const { applied, crossed } = evaluateWarningThresholds({ after, before, rules, }); - await appendModerationAudit(tx, { - actorId: moderator.user.id, - data: { - after, + const scope = + draft.content?.scopeFamily && draft.content.scopeId + ? { family: draft.content.scopeFamily, id: draft.content.scopeId } + : null; + await recordWarningAudit(c, tx, { + action: "warning.issued", + metadata: { appliedThresholdId: applied?.id ?? null, - before, + contentId: draft.content?.targetId ?? null, + contentPluginId: draft.content?.pluginId ?? null, + contentType: draft.content?.targetType ?? null, crossedThresholdIds: crossed.map(rule => rule.id), + defaultExpiresAfterHours: draft.defaultExpiresAfterHours, + defaultPoints: draft.defaultPoints, + expirationOverridden: draft.overrides.expiration, + expiresAt: expiresAt?.toISOString() ?? null, + points: draft.points, + pointsAfter: after, + pointsBefore: before, + pointsOverridden: draft.overrides.points, + reasonId: draft.reason?.id ?? null, + reportId: report?.id ?? null, + warningId: warning.id, }, - type: "threshold_evaluated", - userId: draft.user.id, - warningId: warning.id, + scope, + user: draft.user, }); let sanction: IssuedWarning["sanction"] = null; @@ -653,19 +631,19 @@ export const issueWarning = async ( }) .returning({ id: core_moderation_sanctions.id }); sanction = { endsAt, id: row.id, thresholdId: applied.id }; - await appendModerationAudit(tx, { - actorId: moderator.user.id, - data: { - action: applied.action, + await recordWarningAudit(c, tx, { + action: "sanction.applied", + metadata: { endsAt: endsAt?.toISOString() ?? null, + sanctionId: row.id, source: "threshold", + thresholdAction: applied.action, thresholdId: applied.id, thresholdPoints: applied.points, + warningId: warning.id, }, - sanctionId: row.id, - type: "sanction_applied", - userId: draft.user.id, - warningId: warning.id, + scope, + user: draft.user, }); } @@ -823,12 +801,17 @@ export const revokeWarning = async ( .returning({ id: core_moderation_warnings.id }); if (!row) return false; - await appendModerationAudit(tx, { - actorId: moderator.user.id, - data: { points: warning.points }, - type: "warning_revoked", - userId: warning.userId, - warningId: id, + await recordWarningAudit(c, tx, { + action: "warning.revoked", + metadata: { points: warning.points, warningId: id }, + scope: + warning.scopeFamily && warning.scopeId + ? { family: warning.scopeFamily, id: warning.scopeId } + : null, + user: { + id: warning.userId, + name: await userAuditLabel(c, warning.userId), + }, }); await c.get("notifications").publish({ actorId: null, @@ -895,13 +878,17 @@ export const revokeSanction = async ( .returning({ id: core_moderation_sanctions.id }); if (!row) return false; - await appendModerationAudit(tx, { - actorId: moderator.user.id, - data: { type: sanction.type }, - sanctionId: id, - type: "sanction_revoked", - userId: sanction.userId, - warningId: sanction.warningId, + await recordWarningAudit(c, tx, { + action: "sanction.revoked", + metadata: { + sanctionId: id, + type: sanction.type, + warningId: sanction.warningId, + }, + user: { + id: sanction.userId, + name: await userAuditLabel(c, sanction.userId), + }, }); await c.get("notifications").publish({ actorId: null, diff --git a/packages/vitnode/src/api/modules/moderation/moderation.module.ts b/packages/vitnode/src/api/modules/moderation/moderation.module.ts index afc46dc58f..f5517fb831 100644 --- a/packages/vitnode/src/api/modules/moderation/moderation.module.ts +++ b/packages/vitnode/src/api/modules/moderation/moderation.module.ts @@ -21,6 +21,7 @@ import { issueWarningRoute, listWarningReasonsRoute, listWarningsRoute, + memberLogRoute, memberStandingRoute, previewWarningRoute, revokeSanctionRoute, @@ -67,6 +68,7 @@ export const moderationModule = buildModule({ revokeSanctionRoute, searchMembersRoute, memberStandingRoute, + memberLogRoute, accountStatusRoute, ], }); diff --git a/packages/vitnode/src/api/modules/moderation/routes/account-status.route.ts b/packages/vitnode/src/api/modules/moderation/routes/account-status.route.ts index daf65a9522..320219770c 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/account-status.route.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/account-status.route.ts @@ -82,7 +82,8 @@ export const accountStatusRoute = buildRoute({ pluginId: row.pluginId, type: row.targetType, }); - // Link only to content that is still there and not hidden. + // Link only to content that is still there and not hidden. A plugin that + // fails to answer costs the member the link, never the page. const current = target ? await target.definition .current(c, { locale: null, targetId: row.targetId }) diff --git a/packages/vitnode/src/api/modules/moderation/routes/warnings.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/warnings.routes.ts index a565b927b6..1ad469db2e 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/warnings.routes.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/warnings.routes.ts @@ -19,6 +19,7 @@ import { HTTPException } from "hono/http-exception"; import type { ModeratorContext } from "@/api/lib/moderation/access"; import { buildRoute } from "@/api/builders"; +import { listAuditLogsWithDetails } from "@/api/lib/audit/query"; import { requireModerator } from "@/api/lib/moderation/access"; import { canWarnAnywhere, @@ -33,6 +34,7 @@ import { findActiveSuspension } from "@/api/lib/moderation/warnings/suspension"; import { core_users } from "@/database/users"; import { core_moderation_warnings } from "@/database/warnings"; import { containsLikePattern } from "@/lib/api/like-pattern"; +import { AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const"; import { WARNING_LIMITS, WARNING_PERMISSIONS, @@ -579,6 +581,7 @@ export const memberStandingRoute = buildRoute({ revoke: z.boolean(), viewSanctions: z.boolean(), manageSanctions: z.boolean(), + viewLog: z.boolean(), }), }), "The member's standing", @@ -625,9 +628,93 @@ export const memberStandingRoute = buildRoute({ revoke: has(WARNING_PERMISSIONS.revoke), viewSanctions, manageSanctions: has(WARNING_PERMISSIONS.manageSanctions), + viewLog: has(WARNING_PERMISSIONS.view), }, }, 200, ); }, }); + +const LOG_PAGE_SIZE = 20; + +export const memberLogRoute = buildRoute({ + route: { + method: "get", + path: "/members/{id}/log", + description: + "A member's moderation log for the ModCP, newest first: warnings issued and revoked and, with `can_view_sanctions`, suspensions applied and lifted. Read from the central activity log; needs `can_view`. Never includes staff notes", + request: { + params: idParams, + query: z.object({ cursor: z.string().max(200).optional() }), + }, + responses: { + 200: json( + z.object({ + edges: z.array( + z.object({ + id: z.number(), + action: z.string(), + actor: reportPersonSchema.nullable(), + actorName: z.string().nullable(), + createdAt: z.date(), + metadata: z.record(z.string(), z.unknown()), + }), + ), + pageInfo: z.object({ + endCursor: z.string().nullable(), + hasNextPage: z.boolean(), + }), + }), + "One page of the member's moderation log", + ), + 401: commonResponses[401], + 403: { description: "Needs `can_view` warnings" }, + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + if (!hasWarningPermission(moderator.set, WARNING_PERMISSIONS.view)) { + throw new HTTPException(403, { message: "Forbidden" }); + } + const { id } = c.req.valid("param"); + const { cursor } = c.req.valid("query"); + const viewSanctions = hasWarningPermission( + moderator.set, + WARNING_PERMISSIONS.viewSanctions, + ); + + const { edges, pageInfo } = await listAuditLogsWithDetails(c, { + cursor, + filters: { + area: "moderator", + pluginId: AUDIT_CORE_PLUGIN_ID, + targetId: String(id), + targetType: "user", + }, + first: LOG_PAGE_SIZE, + }); + const visible = edges.filter( + entry => viewSanctions || !entry.action.startsWith("sanction."), + ); + const people = await loadReportPeople( + c, + visible.map(entry => entry.actor?.id), + ); + + return c.json( + { + edges: visible.map(entry => ({ + id: entry.id, + action: entry.action, + actor: entry.actor ? (people.get(entry.actor.id) ?? null) : null, + actorName: entry.actorName, + createdAt: entry.createdAt, + metadata: entry.metadata, + })), + pageInfo, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/moderation/warnings.test.ts b/packages/vitnode/src/api/modules/moderation/warnings.test.ts index 866870beb8..d043384733 100644 --- a/packages/vitnode/src/api/modules/moderation/warnings.test.ts +++ b/packages/vitnode/src/api/modules/moderation/warnings.test.ts @@ -15,6 +15,7 @@ import { warningIssuedNotification, } from "@/api/lib/notifications/core-types"; import { defineModerationScope, defineReportTarget } from "@/api/moderation"; +import { core_audit_logs } from "@/database/audit"; import { core_languages_words } from "@/database/languages"; import { core_moderation_report_events, @@ -24,7 +25,6 @@ import { import { core_roles } from "@/database/roles"; import { core_users } from "@/database/users"; import { - core_moderation_audit, core_moderation_sanctions, core_moderation_warning_reasons, core_moderation_warning_thresholds, @@ -408,9 +408,19 @@ interface StaffWarning { interface Standing { activePoints: number; hiddenWarnings: number; + permissions: { viewLog: boolean }; warnings: StaffWarning[]; } +interface MemberLog { + edges: { + action: string; + actor: null | { id: number }; + metadata: Record; + }[]; + pageInfo: { endCursor: null | string; hasNextPage: boolean }; +} + interface AccountStatus { activePoints: number; sanctions: { active: boolean; id: number }[]; @@ -500,9 +510,15 @@ describe("issuing a warning", () => { expect( (row.expiresAt as Date).getTime() - (row.issuedAt as Date).getTime(), ).toBe(48 * HOUR); - expect(h.memory.rows(core_moderation_audit).map(e => e.type)).toContain( - "warning_overridden", - ); + expect( + h.memory + .rows(core_audit_logs) + .find(entry => entry.action === "warning.issued"), + ).toMatchObject({ + area: "moderator", + metadata: { defaultPoints: 2, points: 4, pointsOverridden: true }, + targetType: "user", + }); }); it("lets a root moderator warn other staff", async () => { @@ -810,6 +826,80 @@ describe("revoking", () => { }); }); +describe("member moderation log", () => { + it.each([ + ["with can_view_sanctions", SENIOR, true], + ["without can_view_sanctions", VIEWER, false], + ])( + "lists the member's warning activity newest first %s", + async (_name, viewer, viewSanctions) => { + const h = await harness(); + await h.seedPoints(MEMBER, 6); + const { sanction, warningId } = await h.issued(SENIOR); + await h.issued(SENIOR, { reasonId: REASON_ZERO, userId: OTHER_MEMBER }); + expect((await h.revoke(SENIOR, "warnings", warningId)).status).toBe(200); + const sanctionId = sanction?.id ?? 0; + expect((await h.revoke(SENIOR, "sanctions", sanctionId)).status).toBe( + 200, + ); + + const res = await h.call(viewer, "GET", `/members/${MEMBER}/log`); + + expect(res.status).toBe(200); + const log = (await res.json()) as MemberLog; + expect(log.edges).toEqual( + [ + { action: "sanction.revoked", metadata: { sanctionId, warningId } }, + { action: "warning.revoked", metadata: { points: 2, warningId } }, + { + action: "sanction.applied", + metadata: { sanctionId, thresholdId: THRESHOLD_EIGHT, warningId }, + }, + { + action: "warning.issued", + metadata: { + appliedThresholdId: THRESHOLD_EIGHT, + points: 2, + pointsAfter: 8, + pointsBefore: 6, + }, + }, + ] + .filter(entry => viewSanctions || entry.action.startsWith("warning.")) + .map(({ action, metadata }) => + expect.objectContaining({ + action, + actor: expect.objectContaining({ id: SENIOR }), + metadata: expect.objectContaining(metadata), + }), + ), + ); + expect(log.pageInfo).toEqual({ + endCursor: expect.any(String), + hasNextPage: false, + }); + }, + ); + + it.each([ + ["can_view", 200, VIEWER], + ["only core can_warn", 403, BASIC], + ["only content-scoped can_warn", 403, SCOPED], + ])( + "answers a moderator with %s with %i and says so in the standing", + async (_name, status, moderator) => { + const h = await harness(); + + const res = await h.call(moderator, "GET", `/members/${MEMBER}/log`); + + expect(res.status).toBe(status); + expect((await h.standing(moderator)).permissions.viewLog).toBe( + status === 200, + ); + }, + ); +}); + describe("reports", () => { it("links a warning to a report and resolves it only when asked", async () => { const h = await harness(); @@ -911,7 +1001,7 @@ describe("staff notes", () => { expect(basic.hiddenWarnings).toBe(1); }); - it("never reach the member's own status, notifications, events or audit trail", async () => { + it("never reach the member's own status, notifications, events, audit trail or moderation log", async () => { const h = await harness(); await h.seedPoints(MEMBER, 6); const { sanction, warningId } = await h.issued(SENIOR, { @@ -935,11 +1025,19 @@ describe("staff notes", () => { "moderation.warning.revoked", "moderation.sanction.revoked", ]); + const log = await h.call(SENIOR, "GET", `/members/${MEMBER}/log`); + expect(log.status).toBe(200); + const trail = JSON.stringify([ + await log.text(), + h.memory.rows(core_audit_logs), + ]); + expect(trail).not.toContain("Issued by mistake"); + const leaked = JSON.stringify([ status, h.emit.mock.calls, h.publish.mock.calls.map(([args]) => args.data), - h.memory.rows(core_moderation_audit), + trail, ]); expect(leaked).not.toContain(STAFF_NOTE); expect(leaked).not.toContain(OVERRIDE_NOTE); diff --git a/packages/vitnode/src/database/warnings.ts b/packages/vitnode/src/database/warnings.ts index 6712101838..a32d195320 100644 --- a/packages/vitnode/src/database/warnings.ts +++ b/packages/vitnode/src/database/warnings.ts @@ -2,7 +2,6 @@ import { sql } from "drizzle-orm"; import { camelCase, index, unique, uniqueIndex } from "drizzle-orm/pg-core"; import type { - ModerationAuditType, SanctionSource, SanctionThresholdSnapshot, SanctionType, @@ -12,7 +11,6 @@ import type { } from "@/lib/moderation/warnings"; import { - MODERATION_AUDIT_TYPES, SANCTION_SOURCES, SANCTION_TYPES, WARNING_THRESHOLD_ACTIONS, @@ -181,38 +179,3 @@ export const core_moderation_sanctions = camelCase.table.withRLS( index("core_moderation_sanctions_created_at_id_idx").on(t.createdAt, t.id), ], ); - -/** - * The warning system's audit trail. Never holds staff notes or override - * explanations - those stay on the warning, behind `can_view_notes`. - */ -export const core_moderation_audit = camelCase.table.withRLS( - "core_moderation_audit", - t => ({ - id: t.serial().primaryKey(), - type: t - .varchar({ enum: MODERATION_AUDIT_TYPES, length: 40 }) - .$type() - .notNull(), - actorId: t.integer().references(() => core_users.id, { - onDelete: "set null", - }), - userId: t.integer().references(() => core_users.id, { - onDelete: "set null", - }), - warningId: t.integer().references(() => core_moderation_warnings.id, { - onDelete: "set null", - }), - sanctionId: t.integer().references(() => core_moderation_sanctions.id, { - onDelete: "set null", - }), - data: t.jsonb().$type>().notNull().default({}), - createdAt: t.timestamp().notNull().defaultNow(), - }), - t => [ - index("core_moderation_audit_user_idx").on(t.userId, t.createdAt), - index("core_moderation_audit_created_at_id_idx").on(t.createdAt, t.id), - index("core_moderation_audit_warning_idx").on(t.warningId), - index("core_moderation_audit_sanction_idx").on(t.sanctionId), - ], -); diff --git a/packages/vitnode/src/lib/audit/actions.ts b/packages/vitnode/src/lib/audit/actions.ts index c9a0664183..65e405d0a4 100644 --- a/packages/vitnode/src/lib/audit/actions.ts +++ b/packages/vitnode/src/lib/audit/actions.ts @@ -135,6 +135,16 @@ export const CORE_AUDIT_ACTIONS = defineAuditActions([ { action: "report.resolved", targetType: "report" }, { action: "report.reopened", targetType: "report" }, { action: "report.action_taken", targetType: "report" }, + { action: "warning.issued", targetType: "user" }, + { action: "warning.revoked", targetType: "user" }, + { action: "sanction.applied", targetType: "user" }, + { action: "sanction.revoked", targetType: "user" }, + { action: "warning_reason.created", targetType: "warning_reason" }, + { action: "warning_reason.updated", targetType: "warning_reason" }, + { action: "warning_reason.reordered", targetType: "warning_reason" }, + { action: "warning_threshold.created", targetType: "warning_threshold" }, + { action: "warning_threshold.updated", targetType: "warning_threshold" }, + { action: "warning_threshold.deleted", targetType: "warning_threshold" }, { action: "submission.approved", targetType: "submission" }, { action: "submission.rejected", targetType: "submission" }, { action: "cron.run", targetType: "cron_job" }, diff --git a/packages/vitnode/src/lib/moderation/warnings.ts b/packages/vitnode/src/lib/moderation/warnings.ts index 83cafdf343..92da40eb2e 100644 --- a/packages/vitnode/src/lib/moderation/warnings.ts +++ b/packages/vitnode/src/lib/moderation/warnings.ts @@ -1,3 +1,5 @@ +import type { MultiLangValue } from "@/lib/helpers/multi-lang"; + /** Moderator permissions of core's `warnings` module. */ export const WARNING_PERMISSIONS = { view: "can_view", @@ -46,33 +48,13 @@ export const SANCTION_TYPES = ["suspension"] as const; export type SanctionType = (typeof SANCTION_TYPES)[number]; -/** What created a sanction. */ export const SANCTION_SOURCES = ["threshold", "manual"] as const; export type SanctionSource = (typeof SANCTION_SOURCES)[number]; -export const MODERATION_AUDIT_TYPES = [ - "warning_issued", - "warning_overridden", - "warning_revoked", - "threshold_evaluated", - "sanction_applied", - "sanction_revoked", - "reason_created", - "reason_updated", - "threshold_created", - "threshold_updated", - "threshold_deleted", -] as const; - -export type ModerationAuditType = (typeof MODERATION_AUDIT_TYPES)[number]; - -/** A multi-language value, as stored in `core_languages_words`. */ -export type WarningReasonText = { languageCode: string; value: string }[]; - /** What a warning remembers about its reason, so renaming the reason never rewrites history. */ export interface WarningReasonSnapshot { - title: WarningReasonText; + title: MultiLangValue; } /** What a warning remembers about the content it was issued for. */ @@ -181,13 +163,3 @@ export const isSanctionActiveAt = ( !sanction.revokedAt && sanction.startsAt.getTime() <= now.getTime() && (sanction.endsAt === null || sanction.endsAt.getTime() > now.getTime()); - -/** Picks the reason text in `locale`, else English, else the first one. Pure. */ -export const warningReasonText = ( - values: null | undefined | WarningReasonText, - locale: string, -): string => - values?.find(item => item.languageCode === locale)?.value ?? - values?.find(item => item.languageCode === "en")?.value ?? - values?.[0]?.value ?? - ""; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 2e57f3bb63..96ba0e3f5f 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -3049,6 +3049,23 @@ "source": "Source", "from_threshold": "Reached {points, plural, one {# point} other {# points}}", "manual": "Applied by staff" + }, + "log": { + "title": "Moderation log", + "loading": "Loading the moderation log", + "error": "Could not load the moderation log.", + "empty": "Nothing has happened to this account yet.", + "load_more": "Show older", + "someone": "Someone", + "warning_issued": " warned them for {points, plural, one {# point} other {# points}} ({before} → {after} active)", + "warning_revoked": " revoked a warning of {points, plural, one {# point} other {# points}}", + "sanction_applied_until": "'s warning suspended the account until {endsAt} (the {threshold}-point threshold)", + "sanction_applied_indefinite": "'s warning suspended the account indefinitely (the {threshold}-point threshold)", + "sanction_revoked": " lifted a suspension", + "other": ": {action}", + "points_overridden": "Points changed from the reason's {points, plural, one {# point} other {# points}}", + "expiration_overridden": "Expiry changed", + "report": "From report #{id}" } }, "account_status": { @@ -3261,6 +3278,24 @@ "succeeded": "Payment succeeded", "failed": "Payment failed", "expired": "Payment expired" + }, + "warning": { + "issued": "Warned member", + "revoked": "Revoked a warning" + }, + "sanction": { + "applied": "Suspended account", + "revoked": "Lifted a suspension" + }, + "warning_reason": { + "created": "Created warning reason", + "updated": "Updated warning reason", + "reordered": "Reordered warning reasons" + }, + "warning_threshold": { + "created": "Created warning threshold", + "updated": "Updated warning threshold", + "deleted": "Deleted warning threshold" } }, "fields": { @@ -3284,7 +3319,12 @@ "provider": "Provider", "reference": "Reference", "customerId": "Customer id", - "errorCode": "Error code" + "errorCode": "Error code", + "points": "Points", + "expiresAfterHours": "Points expire after (hours)", + "enabled": "Enabled", + "action": "Action", + "durationHours": "Duration (hours)" } } }, diff --git a/packages/vitnode/src/views/admin/views/core/moderation/warnings/warnings-admin-api.ts b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warnings-admin-api.ts index 6948a85208..79b9968935 100644 --- a/packages/vitnode/src/views/admin/views/core/moderation/warnings/warnings-admin-api.ts +++ b/packages/vitnode/src/views/admin/views/core/moderation/warnings/warnings-admin-api.ts @@ -94,7 +94,7 @@ export const saveAdminWarningReason = async ({ values, }: { id?: number; - values: Partial; + values: AdminWarningReasonInput; }): Promise> => await runAdminApiMutation({ expected: id === undefined ? 201 : 200, @@ -103,7 +103,7 @@ export const saveAdminWarningReason = async ({ id === undefined ? await fetcherClient({ plugin: manifest.id, - args: { body: values as AdminWarningReasonInput }, + args: { body: values }, method: "post", module: "admin/warnings", options, diff --git a/packages/vitnode/src/views/moderation/warnings/member-log.tsx b/packages/vitnode/src/views/moderation/warnings/member-log.tsx new file mode 100644 index 0000000000..e76590a8ec --- /dev/null +++ b/packages/vitnode/src/views/moderation/warnings/member-log.tsx @@ -0,0 +1,215 @@ +import type { LucideIcon } from "lucide-react"; + +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { + ChevronDownIcon, + HistoryIcon, + ShieldAlertIcon, + ShieldOffIcon, + TriangleAlertIcon, + Undo2Icon, +} from "lucide-react"; +import React from "react"; +import { useFormatter, useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +import type { ModcpMemberLogEntry } from "./warnings-api"; + +import { PersonName } from "../modcp/report-tone"; +import { memberLogQueryOptions } from "./warnings-api"; + +const ICONS: Record = { + "sanction.applied": ShieldAlertIcon, + "sanction.revoked": ShieldOffIcon, + "warning.issued": TriangleAlertIcon, + "warning.revoked": Undo2Icon, +}; + +const numberOf = (value: unknown): null | number => + typeof value === "number" ? value : null; + +const EntryText = ({ + entry, +}: { + entry: ModcpMemberLogEntry; +}): React.ReactNode => { + const t = useTranslations("core.moderation.warn.log"); + const format = useFormatter(); + const { metadata } = entry; + const actor = () => + entry.actor ? ( + + ) : ( + (entry.actorName ?? t("someone")) + ); + const points = numberOf(metadata.points) ?? 0; + + switch (entry.action) { + case "sanction.applied": { + const endsAt = + typeof metadata.endsAt === "string" ? new Date(metadata.endsAt) : null; + const values = { + actor, + threshold: numberOf(metadata.thresholdPoints) ?? 0, + }; + + return endsAt + ? t.rich("sanction_applied_until", { + ...values, + endsAt: format.dateTime(endsAt, { + dateStyle: "medium", + timeStyle: "short", + }), + }) + : t.rich("sanction_applied_indefinite", values); + } + case "sanction.revoked": + return t.rich("sanction_revoked", { actor }); + case "warning.issued": + return t.rich("warning_issued", { + actor, + after: numberOf(metadata.pointsAfter) ?? points, + before: numberOf(metadata.pointsBefore) ?? 0, + points, + }); + case "warning.revoked": + return t.rich("warning_revoked", { actor, points }); + default: + return t.rich("other", { action: entry.action, actor }); + } +}; + +const EntryDetails = ({ entry }: { entry: ModcpMemberLogEntry }) => { + const t = useTranslations("core.moderation.warn.log"); + const { metadata } = entry; + const reportId = numberOf(metadata.reportId); + const details = [ + metadata.pointsOverridden === true + ? t("points_overridden", { + points: numberOf(metadata.defaultPoints) ?? 0, + }) + : null, + metadata.expirationOverridden === true ? t("expiration_overridden") : null, + ].filter((detail): detail is string => detail !== null); + + if (details.length === 0 && reportId === null) return null; + + return ( +

+ {details.map(detail => ( + {detail} + ))} + {reportId === null ? null : ( + + {t("report", { id: reportId })} + + )} +

+ ); +}; + +const PendingRows = () => ( + +); + +/** + * The member's moderation log: who warned them, revoked a warning, or applied + * and lifted a suspension, from the central activity log. Never shows staff + * notes. Only for moderators with `can_view`. + */ +export const MemberLog = ({ userId }: { userId: number }) => { + const t = useTranslations("core.moderation.warn.log"); + const titleId = React.useId(); + const query = useInfiniteQuery(memberLogQueryOptions(userId)); + const entries = query.data?.pages.flatMap(page => page.edges) ?? []; + + return ( +
+

+ {t("title")} +

+ +
+ {query.isPending ? ( + <> + + {t("loading")} + + + + ) : query.isError ? ( +

+ {t("error")} +

+ ) : entries.length === 0 ? ( +

+

+ ) : ( +
    + {entries.map(entry => { + const Icon = ICONS[entry.action] ?? HistoryIcon; + + return ( +
  1. + + +
    +

    + +

    + +

    + +

    +
    +
  2. + ); + })} +
+ )} + + {query.hasNextPage ? ( + + ) : null} +
+
+ ); +}; diff --git a/packages/vitnode/src/views/moderation/warnings/modcp-member-content.tsx b/packages/vitnode/src/views/moderation/warnings/modcp-member-content.tsx index 7c767c2b57..c0c52cad93 100644 --- a/packages/vitnode/src/views/moderation/warnings/modcp-member-content.tsx +++ b/packages/vitnode/src/views/moderation/warnings/modcp-member-content.tsx @@ -8,6 +8,7 @@ import { PageTitle } from "@/components/ui/page-title"; import { WarnDialog } from "@/tanstack/moderation/warn-button"; import { Person } from "../modcp/report-tone"; +import { MemberLog } from "./member-log"; import { SanctionCard, StaffWarningCard } from "./warning-list"; import { memberStandingQueryOptions } from "./warnings-api"; @@ -132,6 +133,8 @@ export const ModcpMemberContent = ({ userId }: { userId: number }) => { )}
+ {permissions.viewLog ? : null} + ); diff --git a/packages/vitnode/src/views/moderation/warnings/warning-list.tsx b/packages/vitnode/src/views/moderation/warnings/warning-list.tsx index 07e2134f20..05c96f1387 100644 --- a/packages/vitnode/src/views/moderation/warnings/warning-list.tsx +++ b/packages/vitnode/src/views/moderation/warnings/warning-list.tsx @@ -177,6 +177,7 @@ export const StaffWarningCard = ({ ? resolveReasonText(warning.reason, locale) : t("custom_reason"); const titleId = React.useId(); + const { revokedAt } = warning; return (
) : null} - {warning.revokedAt ? ( + {revokedAt ? (

{t.rich("list.revoked", { by: () => , - date: () => , + date: () => , })} {warning.revokeReason ? ` ${warning.revokeReason}` : null}

) : null} - {warning.sanctionId && warning.revokedAt ? ( + {warning.sanctionId && revokedAt ? (

{t("list.sanction_stays")}

@@ -343,6 +344,7 @@ export const SanctionCard = ({ }) => { const t = useTranslations("core.moderation.warn"); const [lifting, setLifting] = React.useState(false); + const { revokedAt } = sanction; return (
@@ -410,11 +412,11 @@ export const SanctionCard = ({ ) : null} - {sanction.revokedAt ? ( + {revokedAt ? (

{t.rich("list.lifted", { by: () => , - date: () => , + date: () => , })} {sanction.revokeReason ? ` ${sanction.revokeReason}` : null}

diff --git a/packages/vitnode/src/views/moderation/warnings/warnings-api.ts b/packages/vitnode/src/views/moderation/warnings/warnings-api.ts index 9ea7595444..cc715a7ede 100644 --- a/packages/vitnode/src/views/moderation/warnings/warnings-api.ts +++ b/packages/vitnode/src/views/moderation/warnings/warnings-api.ts @@ -1,4 +1,8 @@ -import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { + infiniteQueryOptions, + keepPreviousData, + queryOptions, +} from "@tanstack/react-query"; import { fetcherClient } from "@/lib/fetcher-client"; import { manifest } from "@/manifest"; @@ -78,6 +82,39 @@ export const memberStandingQueryOptions = (userId: number) => staleTime: 10_000, }); +const fetchMemberLog = async (userId: number, cursor: null | string) => { + const response = await fetcher({ + plugin: manifest.id, + args: { + params: { id: userId }, + query: cursor ? { cursor } : {}, + }, + method: "get", + module: "moderation", + path: "/members/{id}/log", + }); + if (!response.ok) { + throw new ModcpRequestError(response.status, "the member's log"); + } + + return await response.json(); +}; + +export type ModcpMemberLogEntry = Awaited< + ReturnType +>["edges"][number]; + +export const memberLogQueryOptions = (userId: number) => + infiniteQueryOptions({ + getNextPageParam: (last: Awaited>) => + last.pageInfo.hasNextPage ? last.pageInfo.endCursor : undefined, + initialPageParam: null as null | string, + queryFn: async ({ pageParam }) => await fetchMemberLog(userId, pageParam), + queryKey: [...WARNINGS_QUERY_ROOT, "member-log", userId], + retry: false, + staleTime: 10_000, + }); + export interface ModcpWarningsFilter { issuedById?: number; page: number; From d2145764a0eecff6e672f4001774c70537a423b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:50:00 +0000 Subject: [PATCH 4/4] =?UTF-8?q?refactor(moderation):=20=E2=99=BB=EF=B8=8F?= =?UTF-8?q?=20tighten=20warning=20copy=20and=20React=20Doctor=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Plainer UI strings and docs sentences: no spaced dashes, a named actor instead of passive voice, no pep-talk on an empty warning history. - Cache the UTC date formatter suspension notifications use, save a reason's translations in parallel, and split the staff warning card into WarningFacts and StaffNotes. - Give the Postgres warnings test the realtime model every request has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY --- .../dev/moderation/configure-warnings.mdx | 2 +- .../content/docs/dev/moderation/warnings.mdx | 2 +- .../src/api/lib/notifications/core-types.ts | 23 ++- .../warnings/routes/warning-reasons.routes.ts | 22 ++- .../moderation/warnings.postgres.test.ts | 3 + packages/vitnode/src/locales/en.json | 6 +- .../moderation/warnings/warning-list.tsx | 177 ++++++++++-------- 7 files changed, 137 insertions(+), 98 deletions(-) diff --git a/apps/web/content/docs/dev/moderation/configure-warnings.mdx b/apps/web/content/docs/dev/moderation/configure-warnings.mdx index 44579cc823..3780b7f4c4 100644 --- a/apps/web/content/docs/dev/moderation/configure-warnings.mdx +++ b/apps/web/content/docs/dev/moderation/configure-warnings.mdx @@ -53,7 +53,7 @@ Open **AdminCP → Moderation → Warning thresholds** and choose **Add threshol | Active points | Action | Duration | | --- | --- | --- | | 8 | Temporary suspension | 24 hours | -| 10 | Indefinite suspension | - | +| 10 | Indefinite suspension | None | (); + +/** Emails have no viewer time zone, so suspension ends are written in UTC. */ +const utcDateTimeFormat = (locale: string): Intl.DateTimeFormat => { + let format = utcDateTimeFormats.get(locale); + if (!format) { + format = new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }); + utcDateTimeFormats.set(locale, format); + } + + return format; +}; + /** Sent to a member when a moderator warns them. Never carries staff notes. */ export const warningIssuedNotification = buildNotificationType({ id: "core.warning_issued", @@ -99,11 +116,7 @@ export const accountSuspendedNotification = buildNotificationType({ present: ({ data, locale, t }) => ({ body: data.endsAt ? t("core.notifications.types.account_suspended.body_until", { - endsAt: new Intl.DateTimeFormat(locale, { - dateStyle: "medium", - timeStyle: "short", - timeZone: "UTC", - }).format(new Date(data.endsAt)), + endsAt: utcDateTimeFormat(locale).format(new Date(data.endsAt)), }) : t("core.notifications.types.account_suspended.body_indefinite"), target: ACCOUNT_STATUS_PATH, diff --git a/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts index ed36aabb72..97acbfc00f 100644 --- a/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts +++ b/packages/vitnode/src/api/modules/admin/warnings/routes/warning-reasons.routes.ts @@ -61,16 +61,18 @@ const saveReasonWords = async ( reasonId: number, body: Partial>, ) => { - for (const variable of ["title", "description", "message"] as const) { - const values = body[variable]; - if (values === undefined) continue; - await saveLanguageWords(c, { - ...WARNING_REASON_WORDS, - itemId: reasonId, - values: values.filter(item => item.value.length > 0), - variable, - }); - } + await Promise.all( + (["title", "description", "message"] as const).map(async variable => { + const values = body[variable]; + if (values === undefined) return; + await saveLanguageWords(c, { + ...WARNING_REASON_WORDS, + itemId: reasonId, + values: values.filter(item => item.value.length > 0), + variable, + }); + }), + ); }; /** What an audit entry keeps of a reason: settings, never the words. */ diff --git a/packages/vitnode/src/api/modules/moderation/warnings.postgres.test.ts b/packages/vitnode/src/api/modules/moderation/warnings.postgres.test.ts index 20a80c2a6e..3ba0f92380 100644 --- a/packages/vitnode/src/api/modules/moderation/warnings.postgres.test.ts +++ b/packages/vitnode/src/api/modules/moderation/warnings.postgres.test.ts @@ -59,6 +59,9 @@ describe.skipIf(!POSTGRES_TEST_URL)("issuing warnings on PostgreSQL", () => { c.set("notifications", { publish, } as unknown as Context["var"]["notifications"]); + c.set("realtime", { + closeUserConnections: () => undefined, + } as unknown as Context["var"]["realtime"]); c.set("core", { moderation: { scopes: [], targets: [] }, permissionStaff: [], diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 96ba0e3f5f..500bb9eec0 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -2883,7 +2883,7 @@ "title": "Warn {name}", "title_loading": "Warn member", "desc": "The member gets your message in a notification. Points count across the whole site.", - "no_reasons": "No warning reasons are set up yet - an administrator can add them in the AdminCP.", + "no_reasons": "No warning reasons are set up yet. An administrator can add them in the AdminCP.", "cancel": "Cancel", "submit": "Issue warning", "points": "{count, plural, one {# point} other {# points}}", @@ -2942,7 +2942,7 @@ "suspended_until": "Their account is suspended until {endsAt}.", "suspended_indefinitely": "Their account is suspended indefinitely.", "report_resolved": "The report is closed.", - "differs": "That's different from the preview - their points changed in the meantime." + "differs": "Their points changed after you opened the form, so this differs from the preview." }, "errors": { "title": "Could not issue the warning", @@ -3084,7 +3084,7 @@ "points_title": "Active points", "points_desc": "Points from warnings that haven't expired or been revoked. Reaching a limit can suspend your account.", "warnings_title": "Your warnings", - "no_warnings": "You haven't received any warnings. Keep it up!", + "no_warnings": "You haven't received any warnings.", "expires": "Points expire:", "never": "Never", "content": "About:", diff --git a/packages/vitnode/src/views/moderation/warnings/warning-list.tsx b/packages/vitnode/src/views/moderation/warnings/warning-list.tsx index 05c96f1387..00597f9317 100644 --- a/packages/vitnode/src/views/moderation/warnings/warning-list.tsx +++ b/packages/vitnode/src/views/moderation/warnings/warning-list.tsx @@ -159,6 +159,103 @@ const PersonOrDeleted = ({ person }: { person: null | ReportPerson }) => { return person ? : <>{t("deleted_user")}; }; +/** Who, when, what for: the facts of one warning. */ +const WarningFacts = ({ + member, + warning, +}: { + member?: null | ReportPerson; + warning: ModcpStaffWarning; +}) => { + const t = useTranslations("core.moderation.warn"); + + return ( +
+ {member !== undefined ? ( + + {member ? ( + + + + ) : ( + + )} + + ) : null} + + + + + {warning.expiresAt ? ( + + ) : ( + t("list.never") + )} + + {warning.defaultPoints !== null && + warning.defaultPoints !== warning.points ? ( + + {t("points", { count: warning.defaultPoints })} + + ) : null} + {warning.content ? ( + + {warning.content.url ? ( + + {warning.content.title ?? t("list.untitled")} + + ) : ( + (warning.content.title ?? t("list.untitled")) + )} + + ) : null} + {warning.reportId ? ( + + + + ) : null} +
+ ); +}; + +/** Staff notes and override explanations - `null` from the API unless the moderator may read them. */ +const StaffNotes = ({ warning }: { warning: ModcpStaffWarning }) => { + const t = useTranslations("core.moderation.warn"); + if (!warning.staffNote && !warning.overrideNote) return null; + + return ( +
+

+

+ {warning.staffNote ? ( + + {warning.staffNote} + + ) : null} + {warning.overrideNote ? ( + + {warning.overrideNote} + + ) : null} +
+ ); +}; + /** One warning as staff see it, with Revoke when allowed. */ export const StaffWarningCard = ({ canRevoke, @@ -216,89 +313,13 @@ export const StaffWarningCard = ({
-
- {member !== undefined ? ( - - {member ? ( - - - - ) : ( - - )} - - ) : null} - - - - - {warning.expiresAt ? ( - - ) : ( - t("list.never") - )} - - {warning.defaultPoints !== null && - warning.defaultPoints !== warning.points ? ( - - {t("points", { count: warning.defaultPoints })} - - ) : null} - {warning.content ? ( - - {warning.content.url ? ( - - {warning.content.title ?? t("list.untitled")} - - ) : ( - (warning.content.title ?? t("list.untitled")) - )} - - ) : null} - {warning.reportId ? ( - - - - ) : null} -
+ {warning.message} - {warning.staffNote || warning.overrideNote ? ( -
-

-

- {warning.staffNote ? ( - - {warning.staffNote} - - ) : null} - {warning.overrideNote ? ( - - - {warning.overrideNote} - - - ) : null} -
- ) : null} + {revokedAt ? (