-
+
{!state.found ? (
diff --git a/apps/admin/vitest.config.mts b/apps/admin/vitest.config.mts
index 6920ed80..8d773aa7 100644
--- a/apps/admin/vitest.config.mts
+++ b/apps/admin/vitest.config.mts
@@ -1,7 +1,44 @@
+import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
-import { defineConfig } from "vitest/config";
+import { defineConfig, type Plugin } from "vitest/config";
+
+/**
+ * Makes a static image import look the way `next build` makes it look.
+ *
+ * Next turns `import mark from "./mark.png"` into `{ src, width, height }`,
+ * and components use the dimensions — the brand mark derives its width from
+ * the artwork's ratio. Vite hands back a URL string instead, so those reads
+ * are `undefined`, the ratio is `NaN`, and `next/image` throws inside any test
+ * that renders a page with the logo on it.
+ *
+ * The size is read from the PNG header rather than faked, so a test is looking
+ * at the same aspect ratio the browser will.
+ */
+function nextStaticImages(): Plugin {
+ return {
+ name: "next-static-images",
+ enforce: "pre",
+ load(id) {
+ const path = id.split("?")[0];
+ if (!/\.(png|jpe?g|gif|webp|avif)$/i.test(path)) return null;
+
+ // PNG: 8-byte signature, then the IHDR chunk with width and height as
+ // big-endian 32-bit integers at byte 16 and byte 20.
+ let width = 1;
+ let height = 1;
+ if (path.toLowerCase().endsWith(".png")) {
+ const header = readFileSync(path).subarray(0, 24);
+ width = header.readUInt32BE(16);
+ height = header.readUInt32BE(20);
+ }
+
+ return `export default ${JSON.stringify({ src: path, width, height })};`;
+ },
+ };
+}
export default defineConfig({
+ plugins: [nextStaticImages()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
diff --git a/packages/db/migrations/0009_custom_roles.sql b/packages/db/migrations/0009_custom_roles.sql
new file mode 100644
index 00000000..2c46f5fe
--- /dev/null
+++ b/packages/db/migrations/0009_custom_roles.sql
@@ -0,0 +1,17 @@
+CREATE TYPE "public"."admin_permission" AS ENUM('posts.read', 'posts.write', 'posts.publish', 'stats.read', 'stats.write', 'subscribers.read', 'subscribers.write', 'broadcasts.send', 'mail.read', 'mail.send', 'mail.manage', 'settings.read', 'settings.write', 'members.read', 'members.manage');--> statement-breakpoint
+CREATE TABLE "admin_custom_roles" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "key" text NOT NULL,
+ "label" text NOT NULL,
+ "description" text,
+ "permissions" "admin_permission"[] DEFAULT '{}' NOT NULL,
+ "created_by" uuid,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "admin_custom_roles_key_unique" UNIQUE("key")
+);
+--> statement-breakpoint
+ALTER TABLE "admin_users" ADD COLUMN "custom_role_id" uuid;--> statement-breakpoint
+ALTER TABLE "admin_users" ADD COLUMN "extra_permissions" "admin_permission"[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
+ALTER TABLE "admin_users" ADD COLUMN "denied_permissions" "admin_permission"[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
+ALTER TABLE "admin_users" ADD CONSTRAINT "admin_users_custom_role_id_admin_custom_roles_id_fk" FOREIGN KEY ("custom_role_id") REFERENCES "public"."admin_custom_roles"("id") ON DELETE set null ON UPDATE no action;
\ No newline at end of file
diff --git a/packages/db/migrations/meta/0009_snapshot.json b/packages/db/migrations/meta/0009_snapshot.json
new file mode 100644
index 00000000..cba6c577
--- /dev/null
+++ b/packages/db/migrations/meta/0009_snapshot.json
@@ -0,0 +1,1941 @@
+{
+ "id": "cfc26b2d-2116-451c-bf1e-a65e4ff9b790",
+ "prevId": "0e4ec473-5918-4703-817b-d2e17c499485",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "academy.sample_requests": {
+ "name": "sample_requests",
+ "schema": "academy",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference": {
+ "name": "reference",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "sample_kind",
+ "typeSchema": "academy",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chapter_id": {
+ "name": "chapter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "request": {
+ "name": "request",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "list_value": {
+ "name": "list_value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sample_requests_created_idx": {
+ "name": "sample_requests_created_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sample_requests_email_key": {
+ "name": "sample_requests_email_key",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.admin_custom_roles": {
+ "name": "admin_custom_roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "admin_permission[]",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "admin_custom_roles_key_unique": {
+ "name": "admin_custom_roles_key_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.admin_users": {
+ "name": "admin_users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "github_id": {
+ "name": "github_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "login": {
+ "name": "login",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "admin_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'viewer'"
+ },
+ "custom_role_id": {
+ "name": "custom_role_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "extra_permissions": {
+ "name": "extra_permissions",
+ "type": "admin_permission[]",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "denied_permissions": {
+ "name": "denied_permissions",
+ "type": "admin_permission[]",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "status": {
+ "name": "status",
+ "type": "admin_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "mail_workspaces": {
+ "name": "mail_workspaces",
+ "type": "mail_workspace[]",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "admin_users_custom_role_id_admin_custom_roles_id_fk": {
+ "name": "admin_users_custom_role_id_admin_custom_roles_id_fk",
+ "tableFrom": "admin_users",
+ "tableTo": "admin_custom_roles",
+ "columnsFrom": ["custom_role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "admin_users_github_id_unique": {
+ "name": "admin_users_github_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["github_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip": {
+ "name": "ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sessions_expires_at_idx": {
+ "name": "sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_admin_users_id_fk": {
+ "name": "sessions_user_id_admin_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "admin_users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_hash_unique": {
+ "name": "sessions_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token_hash"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.broadcasts": {
+ "name": "broadcasts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body_markdown": {
+ "name": "body_markdown",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "recipient_count": {
+ "name": "recipient_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "broadcasts_status_idx": {
+ "name": "broadcasts_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "broadcasts_post_idx": {
+ "name": "broadcasts_post_idx",
+ "columns": [
+ {
+ "expression": "post_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"post_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "broadcasts_post_id_posts_id_fk": {
+ "name": "broadcasts_post_id_posts_id_fk",
+ "tableFrom": "broadcasts",
+ "tableTo": "posts",
+ "columnsFrom": ["post_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "broadcasts_status_check": {
+ "name": "broadcasts_status_check",
+ "value": "\"status\" in ('draft', 'sending', 'sent', 'failed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.email_attachments": {
+ "name": "email_attachments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'application/octet-stream'"
+ },
+ "byte_size": {
+ "name": "byte_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_attachments_scope_idx": {
+ "name": "email_attachments_scope_idx",
+ "columns": [
+ {
+ "expression": "scope",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"message_id\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_attachments_message_idx": {
+ "name": "email_attachments_message_idx",
+ "columns": [
+ {
+ "expression": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "email_attachments_message_id_outbound_messages_id_fk": {
+ "name": "email_attachments_message_id_outbound_messages_id_fk",
+ "tableFrom": "email_attachments",
+ "tableTo": "outbound_messages",
+ "columnsFrom": ["message_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_attachments_uploaded_by_admin_users_id_fk": {
+ "name": "email_attachments_uploaded_by_admin_users_id_fk",
+ "tableFrom": "email_attachments",
+ "tableTo": "admin_users",
+ "columnsFrom": ["uploaded_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_threads": {
+ "name": "email_threads",
+ "schema": "",
+ "columns": {
+ "thread_key": {
+ "name": "thread_key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "correspondent_email": {
+ "name": "correspondent_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "correspondent_name": {
+ "name": "correspondent_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mailbox": {
+ "name": "mailbox",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "workspace": {
+ "name": "workspace",
+ "type": "mail_workspace",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'byteveda'"
+ },
+ "preview": {
+ "name": "preview",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_inbound_at": {
+ "name": "last_inbound_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_outbound_at": {
+ "name": "last_outbound_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_threads_active_idx": {
+ "name": "email_threads_active_idx",
+ "columns": [
+ {
+ "expression": "workspace",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_message_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"archived_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_threads_archived_idx": {
+ "name": "email_threads_archived_idx",
+ "columns": [
+ {
+ "expression": "workspace",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_message_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"archived_at\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.inbound_messages": {
+ "name": "inbound_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "resend_id": {
+ "name": "resend_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_key": {
+ "name": "thread_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_email": {
+ "name": "from_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_email": {
+ "name": "to_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "html": {
+ "name": "html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "headers": {
+ "name": "headers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "inbound_messages_resend_idx": {
+ "name": "inbound_messages_resend_idx",
+ "columns": [
+ {
+ "expression": "resend_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "inbound_messages_thread_idx": {
+ "name": "inbound_messages_thread_idx",
+ "columns": [
+ {
+ "expression": "thread_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "received_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "inbound_messages_received_idx": {
+ "name": "inbound_messages_received_idx",
+ "columns": [
+ {
+ "expression": "received_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "inbound_messages_thread_key_email_threads_thread_key_fk": {
+ "name": "inbound_messages_thread_key_email_threads_thread_key_fk",
+ "tableFrom": "inbound_messages",
+ "tableTo": "email_threads",
+ "columnsFrom": ["thread_key"],
+ "columnsTo": ["thread_key"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbound_messages": {
+ "name": "outbound_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "resend_id": {
+ "name": "resend_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_email": {
+ "name": "to_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_email": {
+ "name": "from_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_key": {
+ "name": "thread_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body_text": {
+ "name": "body_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "body_html": {
+ "name": "body_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "broadcast_id": {
+ "name": "broadcast_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "outbound_messages_sent_idx": {
+ "name": "outbound_messages_sent_idx",
+ "columns": [
+ {
+ "expression": "sent_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "outbound_messages_broadcast_idx": {
+ "name": "outbound_messages_broadcast_idx",
+ "columns": [
+ {
+ "expression": "broadcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "outbound_messages_thread_idx": {
+ "name": "outbound_messages_thread_idx",
+ "columns": [
+ {
+ "expression": "thread_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sent_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"thread_key\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "outbound_messages_thread_key_email_threads_thread_key_fk": {
+ "name": "outbound_messages_thread_key_email_threads_thread_key_fk",
+ "tableFrom": "outbound_messages",
+ "tableTo": "email_threads",
+ "columnsFrom": ["thread_key"],
+ "columnsTo": ["thread_key"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "outbound_messages_broadcast_id_broadcasts_id_fk": {
+ "name": "outbound_messages_broadcast_id_broadcasts_id_fk",
+ "tableFrom": "outbound_messages",
+ "tableTo": "broadcasts",
+ "columnsFrom": ["broadcast_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "outbound_messages_kind_check": {
+ "name": "outbound_messages_kind_check",
+ "value": "\"kind\" in ('transactional', 'confirmation', 'broadcast', 'announcement', 'reply')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.subscribers": {
+ "name": "subscribers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "unsubscribed_at": {
+ "name": "unsubscribed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "subscribers_email_idx": {
+ "name": "subscribers_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "subscribers_token_idx": {
+ "name": "subscribers_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "subscribers_status_idx": {
+ "name": "subscribers_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "subscribers_status_check": {
+ "name": "subscribers_status_check",
+ "value": "\"status\" in ('pending', 'active', 'unsubscribed', 'bounced')"
+ },
+ "subscribers_email_lowercase_check": {
+ "name": "subscribers_email_lowercase_check",
+ "value": "\"email\" = lower(\"email\")"
+ },
+ "subscribers_email_shape_check": {
+ "name": "subscribers_email_shape_check",
+ "value": "\"email\" ~ '^[^@[:space:]]+@[^@[:space:]]+$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.post_revisions": {
+ "name": "post_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "body_mdx": {
+ "name": "body_mdx",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "body_format": {
+ "name": "body_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'richtext'"
+ },
+ "editor_json": {
+ "name": "editor_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "post_revisions_post_id_created_idx": {
+ "name": "post_revisions_post_id_created_idx",
+ "columns": [
+ {
+ "expression": "post_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "post_revisions_post_id_posts_id_fk": {
+ "name": "post_revisions_post_id_posts_id_fk",
+ "tableFrom": "post_revisions",
+ "tableTo": "posts",
+ "columnsFrom": ["post_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "post_revisions_author_id_admin_users_id_fk": {
+ "name": "post_revisions_author_id_admin_users_id_fk",
+ "tableFrom": "post_revisions",
+ "tableTo": "admin_users",
+ "columnsFrom": ["author_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.posts": {
+ "name": "posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "site": {
+ "name": "site",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'flexiq'"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "body_mdx": {
+ "name": "body_mdx",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "body_format": {
+ "name": "body_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'richtext'"
+ },
+ "editor_json": {
+ "name": "editor_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "seo": {
+ "name": "seo",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"keywords\":[]}'::jsonb"
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ByteVeda'"
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "posts_site_slug_idx": {
+ "name": "posts_site_slug_idx",
+ "columns": [
+ {
+ "expression": "site",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "posts_site_status_published_idx": {
+ "name": "posts_site_status_published_idx",
+ "columns": [
+ {
+ "expression": "site",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "published_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "posts_site_check": {
+ "name": "posts_site_check",
+ "value": "\"site\" in ('flexiq')"
+ },
+ "posts_status_check": {
+ "name": "posts_status_check",
+ "value": "\"status\" in ('draft', 'published', 'archived')"
+ },
+ "posts_body_format_check": {
+ "name": "posts_body_format_check",
+ "value": "\"body_format\" in ('richtext', 'mdx')"
+ },
+ "posts_slug_check": {
+ "name": "posts_slug_check",
+ "value": "\"slug\" ~ '^[a-z0-9]+(-[a-z0-9]+)*$'"
+ },
+ "posts_published_at_check": {
+ "name": "posts_published_at_check",
+ "value": "(\"status\" = 'published') = (\"published_at\" is not null)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.collection_runs": {
+ "name": "collection_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ok": {
+ "name": "ok",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "days_written": {
+ "name": "days_written",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "collection_runs_started_idx": {
+ "name": "collection_runs_started_idx",
+ "columns": [
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "collection_runs_package_id_project_packages_id_fk": {
+ "name": "collection_runs_package_id_project_packages_id_fk",
+ "tableFrom": "collection_runs",
+ "tableTo": "project_packages",
+ "columnsFrom": ["package_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.download_snapshots": {
+ "name": "download_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "day": {
+ "name": "day",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "downloads": {
+ "name": "downloads",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "download_snapshots_package_day_idx": {
+ "name": "download_snapshots_package_day_idx",
+ "columns": [
+ {
+ "expression": "package_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "day",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "download_snapshots_day_idx": {
+ "name": "download_snapshots_day_idx",
+ "columns": [
+ {
+ "expression": "day",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "download_snapshots_package_id_project_packages_id_fk": {
+ "name": "download_snapshots_package_id_project_packages_id_fk",
+ "tableFrom": "download_snapshots",
+ "tableTo": "project_packages",
+ "columnsFrom": ["package_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "download_snapshots_downloads_check": {
+ "name": "download_snapshots_downloads_check",
+ "value": "\"downloads\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.package_totals": {
+ "name": "package_totals",
+ "schema": "",
+ "columns": {
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "total": {
+ "name": "total",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "package_totals_package_id_project_packages_id_fk": {
+ "name": "package_totals_package_id_project_packages_id_fk",
+ "tableFrom": "package_totals",
+ "tableTo": "project_packages",
+ "columnsFrom": ["package_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_packages": {
+ "name": "project_packages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_slug": {
+ "name": "project_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ecosystem": {
+ "name": "ecosystem",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "package_name": {
+ "name": "package_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "project_packages_ecosystem_name_idx": {
+ "name": "project_packages_ecosystem_name_idx",
+ "columns": [
+ {
+ "expression": "ecosystem",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "package_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_packages_project_idx": {
+ "name": "project_packages_project_idx",
+ "columns": [
+ {
+ "expression": "project_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "project_packages_ecosystem_check": {
+ "name": "project_packages_ecosystem_check",
+ "value": "\"ecosystem\" in ('pypi', 'npm', 'crates', 'maven')"
+ }
+ },
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "academy.sample_kind": {
+ "name": "sample_kind",
+ "schema": "academy",
+ "values": ["chapter", "custom"]
+ },
+ "public.admin_permission": {
+ "name": "admin_permission",
+ "schema": "public",
+ "values": [
+ "posts.read",
+ "posts.write",
+ "posts.publish",
+ "stats.read",
+ "stats.write",
+ "subscribers.read",
+ "subscribers.write",
+ "broadcasts.send",
+ "mail.read",
+ "mail.send",
+ "mail.manage",
+ "settings.read",
+ "settings.write",
+ "members.read",
+ "members.manage"
+ ]
+ },
+ "public.admin_role": {
+ "name": "admin_role",
+ "schema": "public",
+ "values": ["admin", "editor", "support", "viewer"]
+ },
+ "public.admin_status": {
+ "name": "admin_status",
+ "schema": "public",
+ "values": ["active", "suspended"]
+ },
+ "public.mail_workspace": {
+ "name": "mail_workspace",
+ "schema": "public",
+ "values": ["byteveda", "academy"]
+ }
+ },
+ "schemas": {
+ "academy": "academy"
+ },
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
index 8766533d..bbdf3f44 100644
--- a/packages/db/migrations/meta/_journal.json
+++ b/packages/db/migrations/meta/_journal.json
@@ -64,6 +64,13 @@
"when": 1790050390750,
"tag": "0008_rbac_and_attachments",
"breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "7",
+ "when": 1790053866766,
+ "tag": "0009_custom_roles",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/constants.ts b/packages/db/src/constants.ts
index bbeeb888..1d3d32fe 100644
--- a/packages/db/src/constants.ts
+++ b/packages/db/src/constants.ts
@@ -56,13 +56,121 @@ export type SampleKind = (typeof SAMPLE_KINDS)[number];
* `apps/admin/src/lib/auth/roles.ts` — because a permission is a statement
* about the code, and the code is where it can be kept true.
*
- * "Super admin" is deliberately absent. It is not a row anyone can be given;
- * it is a hardcoded list of GitHub IDs, and no write to this database can
- * widen it.
+ * A super admin may also define roles of their own, which are rows in
+ * `admin_custom_roles` carrying an explicit permission list. Those cannot be
+ * in this enum — it is a column's type, and a new value would mean a migration
+ * per role — so a member is either on one of these four or on a custom one,
+ * and `custom_role_id` is what says which.
+ *
+ * "Super admin" is deliberately absent from both. It is not a row anyone can be
+ * given; it is a hardcoded list of GitHub IDs, and no write to this database
+ * can widen it.
*/
export const ADMIN_ROLES = ["admin", "editor", "support", "viewer"] as const;
export type AdminRole = (typeof ADMIN_ROLES)[number];
+/**
+ * Everything the console can be asked to allow, named `resource.verb`.
+ *
+ * Here rather than in the admin app because the set is now a Postgres enum:
+ * a custom role stores its permissions as a column, and the column's type
+ * should be the closed set rather than `text[]` that any typo fits into. The
+ * admin app re-exports this as `PERMISSIONS` and remains the only place that
+ * decides which of them each built-in role carries.
+ *
+ * Groups of two or three — read, write, and the irreversible one — because
+ * "may edit a post" and "may put it in front of the public" are genuinely
+ * different grants, and so are "may read the mail" and "may answer it as us".
+ */
+export const ADMIN_PERMISSIONS = [
+ "posts.read",
+ "posts.write",
+ "posts.publish",
+ "stats.read",
+ "stats.write",
+ "subscribers.read",
+ "subscribers.write",
+ "broadcasts.send",
+ "mail.read",
+ "mail.send",
+ "mail.manage",
+ "settings.read",
+ "settings.write",
+ "members.read",
+ "members.manage",
+] as const;
+
+export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number];
+
+/**
+ * The thing a permission is about, derived from the name rather than declared.
+ *
+ * Deriving it means a new permission cannot be added to a resource that does
+ * not exist, and cannot be forgotten by the screen that groups them.
+ */
+export type AdminResource = AdminPermission extends `${infer Resource}.${string}`
+ ? Resource
+ : never;
+
+/** The order resources are shown in, which is roughly the order of the rail. */
+export const ADMIN_RESOURCES = [
+ "posts",
+ "stats",
+ "subscribers",
+ "broadcasts",
+ "mail",
+ "settings",
+ "members",
+] as const satisfies readonly AdminResource[];
+
+export function resourceOf(permission: AdminPermission): AdminResource {
+ return permission.slice(0, permission.indexOf(".")) as AdminResource;
+}
+
+export const ADMIN_RESOURCE_LABELS: Record
= {
+ posts: "Posts",
+ stats: "Downloads",
+ subscribers: "Subscribers",
+ broadcasts: "Broadcasts",
+ mail: "Inbox",
+ settings: "Settings",
+ members: "Members",
+};
+
+/**
+ * What ticking one actually hands over, in the second person.
+ *
+ * Written out rather than generated from the verb: "manage" means archiving a
+ * conversation in one place and editing every grant in the console in another,
+ * and a screen that says "Manage" twice has told the person nothing.
+ */
+export const ADMIN_PERMISSION_LABELS: Record = {
+ "posts.read": { verb: "Read", hint: "See every post, draft ones included." },
+ "posts.write": { verb: "Write", hint: "Create and edit posts, and upload their images." },
+ "posts.publish": { verb: "Publish", hint: "Put a post in front of the public, or take it down." },
+ "stats.read": { verb: "Read", hint: "See download figures and the registries behind them." },
+ "stats.write": { verb: "Write", hint: "Add or remove a tracked package, and force a refresh." },
+ "subscribers.read": { verb: "Read", hint: "See the list and each address's state." },
+ "subscribers.write": { verb: "Write", hint: "Add, edit and unsubscribe addresses." },
+ "broadcasts.send": {
+ verb: "Send",
+ hint: "Mail the whole list. This one cannot be taken back.",
+ },
+ "mail.read": { verb: "Read", hint: "Open conversations in the granted inboxes." },
+ "mail.send": { verb: "Send", hint: "Reply as ByteVeda, with attachments." },
+ "mail.manage": { verb: "Manage", hint: "Archive, reopen and assign conversations." },
+ "settings.read": { verb: "Read", hint: "See the console's configuration." },
+ "settings.write": {
+ verb: "Write",
+ hint: "Change it, including anything integrations depend on.",
+ },
+ "members.read": { verb: "Read", hint: "See who has access and what it amounts to." },
+ "members.manage": {
+ verb: "Manage",
+ hint: "Grant and withdraw access. Reserved for super admins.",
+ },
+};
+
/** Suspended keeps the record and the history; only signing in stops. */
export const ADMIN_STATUSES = ["active", "suspended"] as const;
export type AdminStatus = (typeof ADMIN_STATUSES)[number];
diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts
index 09fe5e76..f7a2123a 100644
--- a/packages/db/src/schema/auth.ts
+++ b/packages/db/src/schema/auth.ts
@@ -1,22 +1,64 @@
import { relations } from "drizzle-orm";
import { bigint, index, inet, pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import {
+ ADMIN_PERMISSIONS,
ADMIN_ROLES,
ADMIN_STATUSES,
+ type AdminPermission,
type AdminRole,
type AdminStatus,
MAIL_WORKSPACES,
type MailWorkspace,
} from "../constants";
-export type { AdminRole, AdminStatus };
-export { ADMIN_ROLES, ADMIN_STATUSES };
+export type { AdminPermission, AdminRole, AdminStatus };
+export { ADMIN_PERMISSIONS, ADMIN_ROLES, ADMIN_STATUSES };
/** Real Postgres enums, so the closed set is the column's type. */
export const adminRole = pgEnum("admin_role", ADMIN_ROLES);
export const adminStatus = pgEnum("admin_status", ADMIN_STATUSES);
+export const adminPermission = pgEnum("admin_permission", ADMIN_PERMISSIONS);
export const mailWorkspace = pgEnum("mail_workspace", MAIL_WORKSPACES);
+/**
+ * A role a super admin wrote, rather than one the source ships.
+ *
+ * The four built-in roles are jobs — editor, support — and they are in the
+ * code because they are claims about what the code allows. This table is for
+ * the shape that does not have a name yet: "everything support can do, plus
+ * publishing", asked for once, on a Tuesday. Inventing a role in the source
+ * for it would mean a deploy; inventing one here means a row.
+ *
+ * What it can hold is still bounded. `permissions` is an array of the
+ * `admin_permission` enum, so nothing outside the catalogue in `constants.ts`
+ * can be stored at all — a custom role can only ever recombine grants the
+ * application already knows how to enforce. It cannot mint a new power, and it
+ * cannot carry `members.manage` either: that check is a super admin's, and
+ * `sanitisePermissions` in the admin app strips it on the way in.
+ *
+ * Scope is deliberately not here. Which inboxes somebody may read stays on
+ * their own row — a role says *what*, a scope says *where*, and folding them
+ * together would need a role per combination, which is the thing this exists
+ * to avoid.
+ */
+export const adminCustomRoles = pgTable("admin_custom_roles", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ /** Slug derived from the label. Stable across renames, and what a log reads. */
+ key: text("key").notNull().unique(),
+ label: text("label").notNull(),
+ /** What this role is for, in the words of whoever made it. */
+ description: text("description"),
+ permissions: adminPermission("permissions")
+ .array()
+ .notNull()
+ .$type()
+ .default([]),
+ /** Who made it. A plain id, like `invited_by` — deleting them keeps the role. */
+ createdBy: uuid("created_by"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
/**
* Everyone who may use the admin app, and what they may do in it.
*
@@ -46,6 +88,40 @@ export const adminUsers = pgTable("admin_users", {
* about what the code allows, and only the code can keep it honest.
*/
role: adminRole("role").notNull().default("viewer"),
+ /**
+ * A role a super admin wrote, which replaces `role` when it is set.
+ *
+ * `role` is kept rather than cleared, and that is what makes deleting a
+ * custom role safe: the foreign key nulls this column, and the member lands
+ * back on the built-in role they were on before. A grant that evaporated
+ * with the role would be a way to lock the console's last editor out by
+ * tidying up.
+ */
+ customRoleId: uuid("custom_role_id").references(() => adminCustomRoles.id, {
+ onDelete: "set null",
+ }),
+ /**
+ * One person's exceptions to whichever role they are on.
+ *
+ * The reason a console this size does not need twelve roles. "Support, but
+ * they also publish the release notes" is one permission in `extra`, not a
+ * role nobody else will ever hold; "editor, but not this quarter's launch
+ * posts" is one in `denied`. Both are computed against the role rather than
+ * replacing it, so changing the role still changes what they can do.
+ *
+ * Denied wins over extra and over the role — the only ordering that makes
+ * "take this away" mean it, whatever else is ticked.
+ */
+ extraPermissions: adminPermission("extra_permissions")
+ .array()
+ .notNull()
+ .$type()
+ .default([]),
+ deniedPermissions: adminPermission("denied_permissions")
+ .array()
+ .notNull()
+ .$type()
+ .default([]),
status: adminStatus("status").notNull().default("active"),
/**
* Which mail they may read, independent of the role.
@@ -92,8 +168,16 @@ export const sessions = pgTable(
],
);
-export const adminUsersRelations = relations(adminUsers, ({ many }) => ({
+export const adminUsersRelations = relations(adminUsers, ({ many, one }) => ({
sessions: many(sessions),
+ customRole: one(adminCustomRoles, {
+ fields: [adminUsers.customRoleId],
+ references: [adminCustomRoles.id],
+ }),
+}));
+
+export const adminCustomRolesRelations = relations(adminCustomRoles, ({ many }) => ({
+ members: many(adminUsers),
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
@@ -102,4 +186,6 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
export type AdminUser = typeof adminUsers.$inferSelect;
export type NewAdminUser = typeof adminUsers.$inferInsert;
+export type AdminCustomRole = typeof adminCustomRoles.$inferSelect;
+export type NewAdminCustomRole = typeof adminCustomRoles.$inferInsert;
export type Session = typeof sessions.$inferSelect;