diff --git a/CHANGELOG.md b/CHANGELOG.md index 41eec28b..3f5d61ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ _Reverse-chronological record of shipped work — features, fixes, and chores. N # 2026-07-05 +- Prompt library — saved, reusable prompt templates inserted in the composer by typing `/` (the roadmap v0.5 slash-commands seed, in its genuinely-useful form; validated by Open WebUI, which has the same `/` prompt menu). A `prompts` table (owner-scoped, FORCE RLS, `UNIQUE(user_id, name)`, migration 0021) + `/api/v1/me/prompts` CRUD (GET/POST/PATCH/DELETE), mirroring the memories pattern — the `name` is a slug (`^[A-Za-z0-9_-]+$`, DB CHECK) so `/` is unambiguous, and a duplicate name is a real 409 (DB unique + a `23505`-walking catch, stricter than the memories pre-check). Manage prompts in Settings; insert them in a chat via a `/` autocomplete menu. A two-reviewer round hardened the composer: the trigger is `^/(\S+)$` — bare `/` never opens the menu, so a literal `/` message still sends (the adversarial reviewer's literal-send trap); prefix matching is case-insensitive; only BARE Enter selects (Shift+Enter falls through to a newline); the menu list comes from a single shared `usePromptsQuery()` (fetched once, filtered per keystroke, invalidated by a settings edit). The core-composer change is minimal + backward-compatible: `PromptInputTextarea` now calls a passed `onKeyDown` first and bails if it `preventDefault`ed (before that, a passed handler silently clobbered Enter-to-send). Verified: 7 prompts RLS integration cases (owner CRUD, cross-tenant denied, per-user unique name but same-name-across-users allowed, a case-insensitive name conflict for the same user, slug + content CHECKs, and a concurrent-create race proving the per-user cap holds under `pg_advisory_xact_lock`), plus pure-trigger and web service cases, prompts route in openapi.json, api + web build/lint/tsc clean. +- Prompt templating — saved prompts can now carry `{{placeholder}}` variables, completing the prompt library into genuinely reusable, parameterized prompts. When you insert a prompt that has placeholders (via `/name`), a small fill-in dialog collects a value per unique placeholder and substitutes them into the composer; a plain prompt inserts directly as before. Client-only, no schema change (placeholders are plain text in the prompt body). Chosen a fill DIALOG over inline cursor-jump templating deliberately — it sidesteps textarea DOM-ref/selection machinery and is complete for any number of variables + fully unit-testable via pure `extractPlaceholders`/`fillPlaceholders`. A two-reviewer round hardened it: substitution is a SINGLE `String.replace(regex, callback)` pass over the original body (so a value that itself contains `{{x}}` is never re-expanded); the `/` menu is dismissed via `setDismissedFor(input)` when the dialog opens (no menu-behind-dialog glitch or cancel-reopen loop); the regex is ReDoS-safe (`{{([^{}]*?)}}`, no overlapping quantifiers); and the dialog field ids are INDEX-based, because a placeholder name can contain spaces (`{{target language}}`) which is invalid in a DOM id and would break `label[for]`. Verified: 8 pure templating cases (unique/ordered extraction, dedupe, empty `{{}}` ignored, substitution, duplicate-fill, unfilled→empty, no-double-expansion), web build/lint/tsc clean; no api/schema touched. - Background run-completion notifications. Runs survive navigation and refresh (the durable worker, #50, keeps generating), but that was structurally invisible — you had to sit and watch the tab. Now when a run you started finishes while you're on a different chat (or the tab is backgrounded), you get a clickable toast, an unseen-reply dot on that chat in the sidebar, and (opt-in) a desktop notification when the tab is hidden. Client-only: a global `ActiveRunsProvider` (mounted in the `(chat)` layout so it survives chat→chat navigation) tracks runs started this session and polls the existing owner-scoped `GET /runs/:id` until terminal — no backend change. `cancelled` runs are always silent (a stopped/regenerated reply never toasts "ready"); the chat page untracks a run on `onFinish`/`onError` so a reply you just watched can't fire a stale toast after you navigate away; `expired` (a reaped/hung run) surfaces as a failure rather than being swallowed; a 404 (deleted chat) drops silently. Verified with unit coverage for the tracking/decision logic; web build/lint/typecheck clean. - Run-completion notifications now survive a page reload. The tracker above was purely in-memory — a refresh dropped every in-flight run, so its completion went un-notified (the exact walked-away-and-came-back case). Now the client re-hydrates on load via a new owner-scoped `GET /api/v1/me/runs?status=active` (the caller's non-terminal runs, with each chat's title; `RunsRepository.findActiveByUser`, doubly owner-scoped — `runs_owner` RLS on `user_id` plus an explicit filter, and the chats join is itself owner-scoped, independent of chat visibility, so a public chat's run stays with its owner, never a viewer), re-tracking them so the existing poll loop still notifies — no new notification machinery. The re-hydration effect uses a plain fetch (not a cached React Query snapshot): because the provider lives in the `(chat)` layout rather than the app root, it remounts on every chat→chat navigation, and a frozen cache would replay its stale snapshot and re-notify already-completed runs. Verified: 2 RLS integration cases (owner active runs with title, terminal excluded; cross-tenant sees none, run against a live Postgres via `scripts/rls-test.sh`) plus a pure-mapper unit case, `/api/v1/me/runs` in openapi.json, api + web build/lint/typecheck clean. - Foundational identity & org model (#44 — v0.3 opens): nested `org_units` (org → team → project, arbitrary depth) with an **id-based materialized path** — renames never rebuild paths, a subtree move is one prefix-rewrite UPDATE, and the ancestor set is embedded in the path itself, which lets every RLS policy check "membership on unit or any ancestor" with a single memberships scan (no self-join — Postgres rejects self-referential policies as infinite recursion). `memberships` carry the full SPEC §7.3 role set per (user, unit); inherited roles are **computed along the ancestor path, nearest node wins** (a subtree can demote as well as promote) rather than materialized as rows. `external_identities` establishes the canonical `(provider, external_subject) → user` map for future channels — reference research confirmed no OSS comp (open-webui, opencode, hermes-agent) actually has nested groups with per-membership roles or cross-channel identity, so this is original design closest in spirit to open-webui's `group_member` join table. All three tables ship RLS `ENABLE`+`FORCE` (creator-bootstrap policy solves the fresh-root chicken-egg) with a 12-test integration suite: cross-tenant invisibility, self-grant escalation denied, forged-path insert denied, subtree visibility following a move. diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index 22d0484c..04f67688 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -10,8 +10,8 @@ NestJS 11 backend: API + services, and owner of the database schema/migrations. ## Structure -- `src/` — one directory per feature, each a NestJS module (`chats/`, `runs/`, `compaction/`, `titles/`, `queue/`, `models/`, `auth/`, `users/`, `db/`); a feature another feature consumes exports its service from its own module, never re-provided elsewhere. Boundary rules: `queue/` is consumed ONLY by `runs/` (chats dispatches runs via `RunDispatchService` and never sees queue names/payloads); `runs/` hosts the whole execution domain (executor, worker consumers, dispatch, stream bridge — `RunWorkerModule` is what the dedicated worker entrypoint (#116) will boot); `db/DbModule` is the single global `TenantDbService` provider -- `src/db/` — `schema/` (`auth.ts`, `chats.ts`), `migrations/` (+ `meta/` journal), `migrate.ts` +- `src/` — one directory per feature, each a NestJS module (`chats/`, `runs/`, `compaction/`, `titles/`, `queue/`, `models/`, `auth/`, `users/`, `prompts/`, `db/`); a feature another feature consumes exports its service from its own module, never re-provided elsewhere. Boundary rules: `queue/` is consumed ONLY by `runs/` (chats dispatches runs via `RunDispatchService` and never sees queue names/payloads); `runs/` hosts the whole execution domain (executor, worker consumers, dispatch, stream bridge — `RunWorkerModule` is what the dedicated worker entrypoint (#116) will boot); `db/DbModule` is the single global `TenantDbService` provider +- `src/db/` — `schema/` (`auth.ts`, `chats.ts`, `prompts.ts`), `migrations/` (+ `meta/` journal), `migrate.ts` - `src/main.ts`, `src/app.module.ts` ## Commands @@ -126,7 +126,7 @@ this split exists to avoid. ## Conventions - One NestJS module per feature (controller / service / module); wire via DI and register in `app.module.ts`. -- Schema lives in `src/db/schema`; change it, then `db:generate`. Don't hand-edit generated migration SQL or `meta/_journal.json` — the exceptions (`0004`, `0006`, `0010`, `0011`, `0012`, `0013`, `0018`, `0019`, `0020`) are documented in Gotchas. +- Schema lives in `src/db/schema`; change it, then `db:generate`. Don't hand-edit generated migration SQL or `meta/_journal.json` — the exceptions (`0004`, `0006`, `0010`, `0011`, `0012`, `0013`, `0018`, `0019`, `0020`, `0021`) are documented in Gotchas. - **API contract — code-first OpenAPI** (decision + rationale: SPEC §22.0; established by #60). Every `/auth/v1`·`/api/v1` endpoint takes a class-validator **DTO** behind the global `ValidationPipe` and returns an **explicit response type** (never an ad-hoc object — mirror the `toPublicUser` egress allowlist), so `@nestjs/swagger` can emit a complete `openapi.json`. Add a DTO + response type with every new endpoint. Client/SDK codegen is **deferred** (post-v0.1) — don't hand-write or generate an API client yet; the spec is the source of truth. The live spec is served at `/docs` (UI), `/docs/json`, `/docs/yaml`. - **RESTful resource design — design the surface deliberately.** Model the API as resources + standard verbs (`GET`/`POST`/`PATCH`/`DELETE`), JSON:API-ish. Partial updates are `PATCH /resource/:id` — **not** RPC-style verb handles (`/chats/:id/title`, `/x/rename`). Nullable response fields are modeled explicitly (`@ApiProperty({ type, nullable: true })`, required-not-optional). Path ids backed by a typed DB column get `ParseUUIDPipe` + `@ApiParam`. Think about the resource model before adding a handle; don't bolt on verbs. @@ -134,4 +134,4 @@ this split exists to avoid. - `apps/api/src/db` is the **sole** schema; `apps/web` owns no database. - Linting is oxlint with type-aware rules (`.oxlintrc.json`, `options.typeAware`) running on **tsgo** (TypeScript 7). tsgo rejects `baseUrl`, so `tsconfig.json` must not reintroduce it, and global test/node types are declared explicitly via `"types": ["node", "jest"]` (tsgo does not auto-include `@types/*` under pnpm the way tsc does). Formatting is prettier (`pnpm format`), checked in CI via the root `format:check` — it is no longer an ESLint rule. -- Migrations are `drizzle-kit`-generated (`0005`+). Hand-authored exceptions: `0004` (the PoC → multi-tenant transition — drizzle-kit's interactive column-rename can't be driven non-interactively; `FORCE ROW LEVEL SECURITY` is hand-maintained here too, Drizzle can't express it), `0006` (the sessions hashing migration carries a manual `DELETE FROM sessions` — raw tokens can't be carried into the hashed-at-rest model), `0010` (the nullable-title migration carries a manual `UPDATE` backfilling old default-literal titles to NULL, and drops a spurious generated DROP/CREATE of the unchanged `sessions_user_created_idx`), `0011` (the durable-runs migration hand-appends `FORCE ROW LEVEL SECURITY` for `runs`/`run_events` — Drizzle emits ENABLE only — and hand-reorders the composite-key unique indexes before the FKs that reference them), `0012` (the single-flight migration carries a manual `UPDATE` cancelling all but the newest non-terminal run per chat — the partial unique index cannot be created over duplicates — plus matching `run.cancelled` events, applied inside a NO FORCE RLS window since migrations run as the owning role), `0013` (the `in_reply_to` reply-integrity trigger, #73 — Drizzle can't express triggers), `0018` (the identity/org-units migration hand-appends `FORCE ROW LEVEL SECURITY` for `org_units`/`memberships`/`external_identities`, same as `0004`/`0011`), and `0019` (org-units production-grade invariants — hand-appends the `llame_role_on_unit_path` `SECURITY DEFINER` function (owned by `app` until the separate `pnpm db:provision-rls` step reassigns it to `app_rls` — see "`app_rls` (BYPASSRLS)" above) + `GRANT SELECT ... TO app_rls`, the deferred path-integrity constraint trigger on `org_units` (+ a `DO`-block assertion that pre-existing rows already satisfy it), and the last-owner `BEFORE UPDATE OR DELETE` trigger on `memberships` — Drizzle can express none of CREATE FUNCTION or CREATE [CONSTRAINT] TRIGGER), and `0020` (the `runs.model_id` migration carries a manual `UPDATE` backfilling existing rows to the canonical default `system:openai:gpt-5.4-mini` before `SET NOT NULL` — drizzle-kit emits only `ADD COLUMN` + `SET NOT NULL` — inside a NO FORCE RLS window, same as `0012`, since migrations run as the owning `app` role with no `app.current_user_id` and FORCE would silently no-op the update). `drizzle-kit check` passes for all. Re-add the manual steps if you ever regenerate these. +- Migrations are `drizzle-kit`-generated (`0005`+). Hand-authored exceptions: `0004` (the PoC → multi-tenant transition — drizzle-kit's interactive column-rename can't be driven non-interactively; `FORCE ROW LEVEL SECURITY` is hand-maintained here too, Drizzle can't express it), `0006` (the sessions hashing migration carries a manual `DELETE FROM sessions` — raw tokens can't be carried into the hashed-at-rest model), `0010` (the nullable-title migration carries a manual `UPDATE` backfilling old default-literal titles to NULL, and drops a spurious generated DROP/CREATE of the unchanged `sessions_user_created_idx`), `0011` (the durable-runs migration hand-appends `FORCE ROW LEVEL SECURITY` for `runs`/`run_events` — Drizzle emits ENABLE only — and hand-reorders the composite-key unique indexes before the FKs that reference them), `0012` (the single-flight migration carries a manual `UPDATE` cancelling all but the newest non-terminal run per chat — the partial unique index cannot be created over duplicates — plus matching `run.cancelled` events, applied inside a NO FORCE RLS window since migrations run as the owning role), `0013` (the `in_reply_to` reply-integrity trigger, #73 — Drizzle can't express triggers), `0018` (the identity/org-units migration hand-appends `FORCE ROW LEVEL SECURITY` for `org_units`/`memberships`/`external_identities`, same as `0004`/`0011`), `0019` (org-units production-grade invariants — hand-appends the `llame_role_on_unit_path` `SECURITY DEFINER` function (owned by `app` until the separate `pnpm db:provision-rls` step reassigns it to `app_rls` — see "`app_rls` (BYPASSRLS)" above) + `GRANT SELECT ... TO app_rls`, the deferred path-integrity constraint trigger on `org_units` (+ a `DO`-block assertion that pre-existing rows already satisfy it), and the last-owner `BEFORE UPDATE OR DELETE` trigger on `memberships` — Drizzle can express none of CREATE FUNCTION or CREATE [CONSTRAINT] TRIGGER), `0020` (the `runs.model_id` migration carries a manual `UPDATE` backfilling existing rows to the canonical default `system:openai:gpt-5.4-mini` before `SET NOT NULL` — drizzle-kit emits only `ADD COLUMN` + `SET NOT NULL` — inside a NO FORCE RLS window, same as `0012`, since migrations run as the owning `app` role with no `app.current_user_id` and FORCE would silently no-op the update), and `0021` (the prompt-library migration hand-appends `FORCE ROW LEVEL SECURITY` for `prompts`, same pattern as `0004`/`0011`; renumbered repeatedly — `0015`→`0017`→`0018`→`0021` — as sibling PRs merged to master first; regenerated fresh via `db:generate` each time, never hand-renamed). `drizzle-kit check` passes for all. Re-add the manual steps if you ever regenerate these. diff --git a/apps/api/openapi.json b/apps/api/openapi.json index df4e8540..9cc1f02c 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1121,6 +1121,178 @@ ] } }, + "/api/v1/me/prompts": { + "get": { + "operationId": "MePromptsController_list", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptResponse" + } + } + } + } + }, + "401": { + "description": "" + } + }, + "security": [ + { + "cookie": [] + }, + { + "bearer": [] + } + ], + "tags": [ + "me" + ] + }, + "post": { + "operationId": "MePromptsController_create", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePromptDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromptResponse" + } + } + } + }, + "401": { + "description": "" + }, + "409": { + "description": "Duplicate name, or at the per-user prompt cap" + } + }, + "security": [ + { + "cookie": [] + }, + { + "bearer": [] + } + ], + "tags": [ + "me" + ] + } + }, + "/api/v1/me/prompts/{id}": { + "patch": { + "operationId": "MePromptsController_update", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePromptDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromptResponse" + } + } + } + }, + "401": { + "description": "" + }, + "404": { + "description": "Unknown or cross-tenant prompt" + }, + "409": { + "description": "Rename collides with an existing prompt" + } + }, + "security": [ + { + "cookie": [] + }, + { + "bearer": [] + } + ], + "tags": [ + "me" + ] + }, + "delete": { + "operationId": "MePromptsController_remove", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "description": "" + }, + "404": { + "description": "Unknown or cross-tenant prompt" + } + }, + "security": [ + { + "cookie": [] + }, + { + "bearer": [] + } + ], + "tags": [ + "me" + ] + } + }, "/api/v1/org-units": { "post": { "operationId": "IdentityController_createRootOrg", @@ -2526,6 +2698,72 @@ "status" ] }, + "PromptResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "maxLength": 64 + }, + "content": { + "type": "string", + "maxLength": 8000 + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "name", + "content", + "createdAt", + "updatedAt" + ] + }, + "CreatePromptDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 64, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + } + }, + "required": [ + "name", + "content" + ] + }, + "UpdatePromptDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 64, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + } + } + }, "CreateOrgUnitDto": { "type": "object", "properties": { diff --git a/apps/api/scripts/rls-test.sh b/apps/api/scripts/rls-test.sh index 8d59cb4b..8cca1ac9 100755 --- a/apps/api/scripts/rls-test.sh +++ b/apps/api/scripts/rls-test.sh @@ -94,8 +94,9 @@ docker exec -e PGPASSWORD=postgres -i "$CONTAINER" \ echo "▶ running RLS + queue integration suites as 'app' (the '.integration' glob" echo " covers every *.integration.spec.ts — chats-rls, compaction-surfacing," echo " chats-search, chat-sharing, chats-delete, chat-pinning, fork-chat," -echo " identity-rls, identity-admin, identity-invariants, queue — no separate" -echo " per-suite steps needed; keeps new integration specs covered automatically)" +echo " identity-rls, identity-admin, identity-invariants, prompts-rls, queue —" +echo " no separate per-suite steps needed; keeps new integration specs covered" +echo " automatically)" # --runInBand: every suite opens its own pool against the ONE throwaway # database; parallel workers contend on it (same class as the documented e2e # flake) and a real RLS regression could be misread as "the known flake". diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 571b8b20..e7f9414b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,7 @@ import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { ChatsModule } from './chats/chats.module'; import { DbModule } from './db/db.module'; +import { PromptsModule } from './prompts/prompts.module'; import { InstanceConfigModule } from './instance-config/instance-config.module'; import { RunsModule } from './runs/runs.module'; import { IdentityModule } from './identity/identity.module'; @@ -49,6 +50,7 @@ import * as schema from './db/schema'; UsersModule, DbModule, ChatsModule, + PromptsModule, RunsModule, IdentityModule, ], diff --git a/apps/api/src/db/migrations/0021_dark_lady_mastermind.sql b/apps/api/src/db/migrations/0021_dark_lady_mastermind.sql new file mode 100644 index 00000000..cf7b612c --- /dev/null +++ b/apps/api/src/db/migrations/0021_dark_lady_mastermind.sql @@ -0,0 +1,16 @@ +CREATE TABLE "prompts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "name" text NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "prompts_name_slug" CHECK ("prompts"."name" ~ '^[A-Za-z0-9_-]{1,64}$'), + CONSTRAINT "prompts_content_len" CHECK (char_length("prompts"."content") BETWEEN 1 AND 8000) +); +--> statement-breakpoint +ALTER TABLE "prompts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "prompts" FORCE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "prompts" ADD CONSTRAINT "prompts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "prompts_user_name_idx" ON "prompts" USING btree ("user_id",lower("name"));--> statement-breakpoint +CREATE POLICY "prompts_owner" ON "prompts" AS PERMISSIVE FOR ALL TO public USING (user_id = current_setting('app.current_user_id', true)); \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0021_snapshot.json b/apps/api/src/db/migrations/meta/0021_snapshot.json new file mode 100644 index 00000000..3670d28d --- /dev/null +++ b/apps/api/src/db/migrations/meta/0021_snapshot.json @@ -0,0 +1,1952 @@ +{ + "id": "48509664-237e-48e7-94ef-1316352f3b80", + "prevId": "ca69ecd1-c9fe-4131-b423-9be73f570e4a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authenticators": { + "name": "authenticators", + "schema": "", + "columns": { + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_public_key": { + "name": "credential_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credential_device_type": { + "name": "credential_device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_backed_up": { + "name": "credential_backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "authenticators_user_id_users_id_fk": { + "name": "authenticators_user_id_users_id_fk", + "tableFrom": "authenticators", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "authenticators_credential_id_unique": { + "name": "authenticators_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "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()" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_created_idx": { + "name": "sessions_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_expires_idx": { + "name": "sessions_user_expires_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_idx": { + "name": "sessions_expires_idx", + "columns": [ + { + "expression": "expires", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_last_seen_at_idx": { + "name": "sessions_last_seen_at_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "chat_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "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()" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chats_owner_updated_idx": { + "name": "chats_owner_updated_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chats_owner_pinned_updated_idx": { + "name": "chats_owner_pinned_updated_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chats_id_owner_user_id_unique_idx": { + "name": "chats_id_owner_user_id_unique_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chats_owner_user_id_users_id_fk": { + "name": "chats_owner_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "chats_owner": { + "name": "chats_owner", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "owner_user_id = current_setting('app.current_user_id', true)" + }, + "chats_public_read": { + "name": "chats_public_read", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "visibility = 'public' AND current_setting('app.current_user_id', true) = ''" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.compactions": { + "name": "compactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upto_seq": { + "name": "upto_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage": { + "name": "usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compactions_chat_upto_seq_idx": { + "name": "compactions_chat_upto_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upto_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compactions_id_chat_id_unique_idx": { + "name": "compactions_id_chat_id_unique_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compactions_chat_id_chats_id_fk": { + "name": "compactions_chat_id_chats_id_fk", + "tableFrom": "compactions", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compactions_parent_id_compactions_id_fk": { + "name": "compactions_parent_id_compactions_id_fk", + "tableFrom": "compactions", + "tableTo": "compactions", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compactions_parent_id_chat_id_fk": { + "name": "compactions_parent_id_chat_id_fk", + "tableFrom": "compactions", + "tableTo": "compactions", + "columnsFrom": [ + "parent_id", + "chat_id" + ], + "columnsTo": [ + "id", + "chat_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "compactions_owner": { + "name": "compactions_owner", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "chat_id IN (\n SELECT id FROM chats\n WHERE owner_user_id = current_setting('app.current_user_id', true)\n )" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "messages_seq_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "role": { + "name": "role", + "type": "message_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "usage": { + "name": "usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_chat_created_idx": { + "name": "messages_chat_created_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_chat_seq_idx": { + "name": "messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_in_reply_to_unique_idx": { + "name": "messages_in_reply_to_unique_idx", + "columns": [ + { + "expression": "in_reply_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_id_chat_id_unique_idx": { + "name": "messages_id_chat_id_unique_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_user_id_users_id_fk": { + "name": "messages_sender_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_in_reply_to_messages_id_fk": { + "name": "messages_in_reply_to_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "in_reply_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "messages_owner": { + "name": "messages_owner", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "chat_id IN (\n SELECT id FROM chats\n WHERE owner_user_id = current_setting('app.current_user_id', true)\n )" + }, + "messages_public_read": { + "name": "messages_public_read", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "current_setting('app.current_user_id', true) = '' AND chat_id IN (SELECT id FROM chats WHERE visibility = 'public')" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.run_events": { + "name": "run_events", + "schema": "", + "columns": { + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "run_events_sequence_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_events_run_sequence_idx": { + "name": "run_events_run_sequence_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_events_run_id_runs_id_fk": { + "name": "run_events_run_id_runs_id_fk", + "tableFrom": "run_events", + "tableTo": "runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "run_events_owner_select": { + "name": "run_events_owner_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "run_id IN (\n SELECT runs.id FROM runs\n INNER JOIN chats ON chats.id = runs.chat_id\n WHERE chats.owner_user_id = current_setting('app.current_user_id', true)\n )" + }, + "run_events_owner_insert": { + "name": "run_events_owner_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "run_id IN (\n SELECT runs.id FROM runs\n INNER JOIN chats ON chats.id = runs.chat_id\n WHERE chats.owner_user_id = current_setting('app.current_user_id', true)\n )" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "runs_chat_created_idx": { + "name": "runs_chat_created_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_user_status_idx": { + "name": "runs_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_chat_inflight_unique": { + "name": "runs_chat_inflight_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status NOT IN ('completed', 'failed', 'cancelled', 'expired')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "runs_chat_id_chats_id_fk": { + "name": "runs_chat_id_chats_id_fk", + "tableFrom": "runs", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "runs_message_id_messages_id_fk": { + "name": "runs_message_id_messages_id_fk", + "tableFrom": "runs", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "runs_user_id_users_id_fk": { + "name": "runs_user_id_users_id_fk", + "tableFrom": "runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "runs_chat_id_user_id_fk": { + "name": "runs_chat_id_user_id_fk", + "tableFrom": "runs", + "tableTo": "chats", + "columnsFrom": [ + "chat_id", + "user_id" + ], + "columnsTo": [ + "id", + "owner_user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_message_id_chat_id_fk": { + "name": "runs_message_id_chat_id_fk", + "tableFrom": "runs", + "tableTo": "messages", + "columnsFrom": [ + "message_id", + "chat_id" + ], + "columnsTo": [ + "id", + "chat_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "runs_owner": { + "name": "runs_owner", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "chat_id IN (\n SELECT id FROM chats\n WHERE owner_user_id = current_setting('app.current_user_id', true)\n )" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.external_identities": { + "name": "external_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_subject": { + "name": "external_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_identities_provider_subject_unique": { + "name": "external_identities_provider_subject_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_identities_user_idx": { + "name": "external_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_identities_user_id_users_id_fk": { + "name": "external_identities_user_id_users_id_fk", + "tableFrom": "external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "external_identities_owner": { + "name": "external_identities_owner", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "user_id = current_setting('app.current_user_id', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_unit_id": { + "name": "org_unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_unit_unique": { + "name": "memberships_user_unit_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "org_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_unit_idx": { + "name": "memberships_unit_idx", + "columns": [ + { + "expression": "org_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_org_unit_id_org_units_id_fk": { + "name": "memberships_org_unit_id_org_units_id_fk", + "tableFrom": "memberships", + "tableTo": "org_units", + "columnsFrom": [ + "org_unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "memberships_select": { + "name": "memberships_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "user_id = current_setting('app.current_user_id', true) OR llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner','admin','maintainer','member','viewer','guest','service_account']::org_role[])" + }, + "memberships_trigger_read": { + "name": "memberships_trigger_read", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "pg_trigger_depth() > 0" + }, + "memberships_insert": { + "name": "memberships_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(\n user_id = current_setting('app.current_user_id', true)\n AND role = 'owner'\n AND EXISTS (\n SELECT 1 FROM org_units u\n WHERE u.id = memberships.org_unit_id\n AND u.parent_id IS NULL\n AND u.created_by = current_setting('app.current_user_id', true)\n )\n ) OR (\n llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner']::org_role[])\n ) OR (\n memberships.role <> 'owner'\n AND llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner','admin']::org_role[])\n )" + }, + "memberships_update": { + "name": "memberships_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(\n memberships.role <> 'owner' AND llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner','admin']::org_role[])\n ) OR llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner']::org_role[])", + "withCheck": "(\n memberships.role <> 'owner' AND llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner','admin']::org_role[])\n ) OR (\n memberships.role = 'owner' AND llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner']::org_role[])\n )" + }, + "memberships_delete": { + "name": "memberships_delete", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "\n user_id = current_setting('app.current_user_id', true)\n OR (memberships.role <> 'owner' AND llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner','admin']::org_role[]))\n OR llame_role_on_unit_path(memberships.org_unit_id, ARRAY['owner']::org_role[])\n " + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.org_units": { + "name": "org_units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "org_unit_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'group'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "org_units_path_unique": { + "name": "org_units_path_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_units_parent_idx": { + "name": "org_units_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_units_parent_id_org_units_id_fk": { + "name": "org_units_parent_id_org_units_id_fk", + "tableFrom": "org_units", + "tableTo": "org_units", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "org_units_created_by_users_id_fk": { + "name": "org_units_created_by_users_id_fk", + "tableFrom": "org_units", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "org_units_select": { + "name": "org_units_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "EXISTS (\n SELECT 1 FROM memberships m\n WHERE m.user_id = current_setting('app.current_user_id', true)\n AND m.role IN ('owner','admin','maintainer','member','viewer','guest','service_account')\n AND m.org_unit_id::text = ANY(string_to_array(org_units.path, '/'))\n ) OR created_by = current_setting('app.current_user_id', true)" + }, + "org_units_trigger_read": { + "name": "org_units_trigger_read", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "pg_trigger_depth() > 0" + }, + "org_units_insert": { + "name": "org_units_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "created_by = current_setting('app.current_user_id', true) AND (parent_id IS NULL OR EXISTS (\n SELECT 1 FROM memberships m\n WHERE m.user_id = current_setting('app.current_user_id', true)\n AND m.role IN ('owner','admin')\n AND m.org_unit_id::text = ANY(string_to_array(org_units.path, '/'))\n ))" + }, + "org_units_update": { + "name": "org_units_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "EXISTS (\n SELECT 1 FROM memberships m\n WHERE m.user_id = current_setting('app.current_user_id', true)\n AND m.role IN ('owner','admin')\n AND m.org_unit_id::text = ANY(string_to_array(org_units.path, '/'))\n )", + "withCheck": "EXISTS (\n SELECT 1 FROM memberships m\n WHERE m.user_id = current_setting('app.current_user_id', true)\n AND m.role IN ('owner','admin')\n AND m.org_unit_id::text = ANY(string_to_array(org_units.path, '/'))\n )" + }, + "org_units_delete": { + "name": "org_units_delete", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "EXISTS (\n SELECT 1 FROM memberships m\n WHERE m.user_id = current_setting('app.current_user_id', true)\n AND m.role IN ('owner')\n AND m.org_unit_id::text = ANY(string_to_array(org_units.path, '/'))\n )" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prompts": { + "name": "prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "prompts_user_name_idx": { + "name": "prompts_user_name_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prompts_user_id_users_id_fk": { + "name": "prompts_user_id_users_id_fk", + "tableFrom": "prompts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "prompts_owner": { + "name": "prompts_owner", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "user_id = current_setting('app.current_user_id', true)" + } + }, + "checkConstraints": { + "prompts_name_slug": { + "name": "prompts_name_slug", + "value": "\"prompts\".\"name\" ~ '^[A-Za-z0-9_-]{1,64}$'" + }, + "prompts_content_len": { + "name": "prompts_content_len", + "value": "char_length(\"prompts\".\"content\") BETWEEN 1 AND 8000" + } + }, + "isRLSEnabled": true + } + }, + "enums": { + "public.chat_visibility": { + "name": "chat_visibility", + "schema": "public", + "values": [ + "private", + "public" + ] + }, + "public.message_role": { + "name": "message_role", + "schema": "public", + "values": [ + "user", + "assistant", + "system", + "tool" + ] + }, + "public.run_status": { + "name": "run_status", + "schema": "public", + "values": [ + "queued", + "resolving_config", + "retrieving_context", + "planning", + "waiting_for_approval", + "running_model", + "running_tool", + "running_sandbox", + "updating_artifact", + "summarizing", + "completed", + "failed", + "cancelled", + "expired" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "maintainer", + "member", + "viewer", + "guest", + "service_account" + ] + }, + "public.org_unit_type": { + "name": "org_unit_type", + "schema": "public", + "values": [ + "organization", + "group", + "team", + "department", + "project" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 6dcd8ebb..2586cdd7 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1783613237724, "tag": "0020_open_chameleon", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1783686787615, + "tag": "0021_dark_lady_mastermind", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index 3d24d767..ff4c5e16 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -1,3 +1,4 @@ export * from './auth'; export * from './chats'; export * from './identity'; +export * from './prompts'; diff --git a/apps/api/src/db/schema/prompts.ts b/apps/api/src/db/schema/prompts.ts new file mode 100644 index 00000000..5a68e527 --- /dev/null +++ b/apps/api/src/db/schema/prompts.ts @@ -0,0 +1,71 @@ +import { InferSelectModel } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; +import { + check, + pgPolicy, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; + +import { users } from './auth'; + +/** + * A saved prompt template — the user's reusable prompt, inserted in the + * composer by typing `/` (roadmap v0.5 slash-commands seed). User-scoped: + * the tenant boundary, like `memories.user_id`. + * + * RLS: `prompts_owner` (`user_id = current_setting('app.current_user_id')`). + * `.enableRLS()` emits only ENABLE; the migration ALSO hand-issues + * `FORCE ROW LEVEL SECURITY` (Drizzle can't express FORCE — the documented + * pattern shared with chats/messages/memories; re-add it if regenerating). + * + * `name` is the slash trigger, so it is a slug (no whitespace/slashes) — + * `/` must be unambiguous; UNIQUE per user, case-INsensitively (the + * composer's `/` menu matches names case-insensitively — see matching.ts — + * so "Standup" and "standup" must not coexist as two distinct prompts for + * the same user). Bounds are DB CHECKs (defense-in-depth beyond the DTO). + */ +export const PROMPT_NAME_MAX = 64; +export const PROMPT_CONTENT_MAX = 8000; + +export const prompts = pgTable( + 'prompts', + { + id: uuid('id').primaryKey().defaultRandom(), + // text — FK to users.id which is text (NextAuth convention). + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + content: text('content').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + // `/` must be unambiguous per user, case-insensitively (functional + // index on lower(name) — the `name` column itself keeps the user's + // chosen casing for display). + uniqueIndex('prompts_user_name_idx').on(t.userId, sql`lower(${t.name})`), + check( + 'prompts_name_slug', + // Slug only — no whitespace/slashes, so `/` matching is exact. + sql`${t.name} ~ '^[A-Za-z0-9_-]{1,${sql.raw(String(PROMPT_NAME_MAX))}}$'`, + ), + check( + 'prompts_content_len', + sql`char_length(${t.content}) BETWEEN 1 AND ${sql.raw(String(PROMPT_CONTENT_MAX))}`, + ), + pgPolicy('prompts_owner', { + using: sql`user_id = current_setting('app.current_user_id', true)`, + }), + ], +).enableRLS(); + +export type Prompt = InferSelectModel; diff --git a/apps/api/src/prompts/dto/prompts.dto.spec.ts b/apps/api/src/prompts/dto/prompts.dto.spec.ts new file mode 100644 index 00000000..0862e4c7 --- /dev/null +++ b/apps/api/src/prompts/dto/prompts.dto.spec.ts @@ -0,0 +1,61 @@ +import { ArgumentMetadata, ValidationPipe } from '@nestjs/common'; +import { CreatePromptDto, UpdatePromptDto } from './prompts.dto'; + +// Whitespace-only content must be rejected as a clean 400 at the DTO layer. +// Without the trim-before-validate transform, `MinLength(1)` sees the RAW +// (untrimmed) string, a whitespace-only body passes it, the controller then +// trims to an empty string, and the DB CHECK (char_length BETWEEN 1 AND 8000) +// rejects the insert/update as an unhandled 500 instead of a 400. +describe('CreatePromptDto', () => { + const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }); + const metadata: ArgumentMetadata = { + type: 'body', + metatype: CreatePromptDto, + }; + + it('trims content before length validation', async () => { + await expect( + pipe.transform({ name: 'ok', content: ' hi ' }, metadata), + ).resolves.toMatchObject({ content: 'hi' }); + }); + + it('rejects whitespace-only content (would otherwise become an empty string post-trim)', async () => { + await expect( + pipe.transform({ name: 'ok', content: ' ' }, metadata), + ).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe('UpdatePromptDto', () => { + const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }); + const metadata: ArgumentMetadata = { + type: 'body', + metatype: UpdatePromptDto, + }; + + it('trims content before length validation', async () => { + await expect( + pipe.transform({ content: ' hi ' }, metadata), + ).resolves.toMatchObject({ content: 'hi' }); + }); + + it('rejects whitespace-only content on a content-only update', async () => { + await expect( + pipe.transform({ content: ' ' }, metadata), + ).rejects.toMatchObject({ status: 400 }); + }); + + it('accepts a name-only update with no content field', async () => { + await expect( + pipe.transform({ name: 'renamed' }, metadata), + ).resolves.toEqual({ name: 'renamed' }); + }); +}); diff --git a/apps/api/src/prompts/dto/prompts.dto.ts b/apps/api/src/prompts/dto/prompts.dto.ts new file mode 100644 index 00000000..fed4573c --- /dev/null +++ b/apps/api/src/prompts/dto/prompts.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +// Trims before length validation so a whitespace-only body (which would pass +// MinLength(1) untrimmed, then fail the DB CHECK as an empty string post-trim) +// is rejected as a clean 400 instead of surfacing as an unhandled 500. +const trim = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +import { + PROMPT_CONTENT_MAX, + PROMPT_NAME_MAX, + type Prompt, +} from '../../db/schema'; + +// The slash trigger: a slug (no whitespace/slashes) so `/` is exact. +const NAME_PATTERN = /^[A-Za-z0-9_-]+$/; +const NAME_MESSAGE = + 'name must be a slug: letters, digits, underscore or hyphen (no spaces)'; + +export class CreatePromptDto { + @ApiProperty({ maxLength: PROMPT_NAME_MAX, pattern: NAME_PATTERN.source }) + @IsString() + @Matches(NAME_PATTERN, { message: NAME_MESSAGE }) + @MaxLength(PROMPT_NAME_MAX) + name!: string; + + @ApiProperty({ minLength: 1, maxLength: PROMPT_CONTENT_MAX }) + @Transform(trim) + @IsString() + @MinLength(1) + @MaxLength(PROMPT_CONTENT_MAX) + content!: string; +} + +export class UpdatePromptDto { + @ApiPropertyOptional({ + maxLength: PROMPT_NAME_MAX, + pattern: NAME_PATTERN.source, + }) + @IsOptional() + @IsString() + @Matches(NAME_PATTERN, { message: NAME_MESSAGE }) + @MaxLength(PROMPT_NAME_MAX) + name?: string; + + @ApiPropertyOptional({ minLength: 1, maxLength: PROMPT_CONTENT_MAX }) + @IsOptional() + @Transform(trim) + @IsString() + @MinLength(1) + @MaxLength(PROMPT_CONTENT_MAX) + content?: string; +} + +export class PromptResponse { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ maxLength: PROMPT_NAME_MAX }) + name!: string; + + @ApiProperty({ maxLength: PROMPT_CONTENT_MAX }) + content!: string; + + @ApiProperty({ format: 'date-time' }) + createdAt!: Date; + + @ApiProperty({ format: 'date-time' }) + updatedAt!: Date; +} + +export function toPromptResponse(prompt: Prompt): PromptResponse { + return { + id: prompt.id, + name: prompt.name, + content: prompt.content, + createdAt: prompt.createdAt, + updatedAt: prompt.updatedAt, + }; +} diff --git a/apps/api/src/prompts/me-prompts.controller.ts b/apps/api/src/prompts/me-prompts.controller.ts new file mode 100644 index 00000000..b497fa5f --- /dev/null +++ b/apps/api/src/prompts/me-prompts.controller.ts @@ -0,0 +1,151 @@ +import { + Body, + ConflictException, + Controller, + Delete, + Get, + HttpCode, + NotFoundException, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiConflictResponse, + ApiCookieAuth, + ApiCreatedResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiParam, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/auth-context'; +import { TenantDbService } from '../db/tenant-db.service'; +import { + PROMPT_MAX_PER_USER, + PromptsRepository, + isPromptNameConflict, +} from './prompts-repository'; +import { + CreatePromptDto, + PromptResponse, + UpdatePromptDto, + toPromptResponse, +} from './dto/prompts.dto'; + +/** + * User-facing management of saved prompts — the caller's reusable `/` + * templates. Own-scope only (RLS `prompts_owner` scopes every op to the caller; + * `user_id` is the seatbelt). A duplicate `/name` (per user) is a 409. + */ +@ApiTags('me') +@ApiBearerAuth('bearer') +@ApiCookieAuth('cookie') +@Controller('api/v1/me/prompts') +export class MePromptsController { + constructor(private readonly tenantDb: TenantDbService) {} + + @Get() + @ApiOkResponse({ type: [PromptResponse] }) + @ApiUnauthorizedResponse() + async list(@CurrentUser() userId: string): Promise { + const rows = await this.tenantDb.runAs(userId, (tx) => + new PromptsRepository(tx).list(userId), + ); + return rows.map(toPromptResponse); + } + + @Post() + @ApiCreatedResponse({ type: PromptResponse }) + @ApiConflictResponse({ + description: 'Duplicate name, or at the per-user prompt cap', + }) + @ApiUnauthorizedResponse() + async create( + @CurrentUser() userId: string, + @Body() dto: CreatePromptDto, + ): Promise { + const content = dto.content.trim(); + try { + const created = await this.tenantDb.runAs(userId, async (tx) => { + const repo = new PromptsRepository(tx); + // Serializes concurrent creates for this user so the cap check below + // can't race with another in-flight create (would otherwise let + // PROMPT_MAX_PER_USER be overshot under concurrent requests). + await repo.lockUserForCreate(userId); + if ((await repo.countByUser(userId)) >= PROMPT_MAX_PER_USER) { + throw new ConflictException( + `Prompt limit reached (${PROMPT_MAX_PER_USER}).`, + ); + } + return repo.create(userId, dto.name, content); + }); + return toPromptResponse(created); + } catch (error) { + if (isPromptNameConflict(error)) { + throw new ConflictException( + `A prompt named "${dto.name}" already exists.`, + ); + } + throw error; + } + } + + @Patch(':id') + @ApiParam({ name: 'id', format: 'uuid' }) + @ApiOkResponse({ type: PromptResponse }) + @ApiConflictResponse({ + description: 'Rename collides with an existing prompt', + }) + @ApiNotFoundResponse({ description: 'Unknown or cross-tenant prompt' }) + @ApiUnauthorizedResponse() + async update( + @CurrentUser() userId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePromptDto, + ): Promise { + const patch = { + ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.content !== undefined ? { content: dto.content.trim() } : {}), + }; + try { + const updated = await this.tenantDb.runAs(userId, (tx) => + new PromptsRepository(tx).update(id, userId, patch), + ); + if (!updated) { + throw new NotFoundException(`Prompt ${id} not found`); + } + return toPromptResponse(updated); + } catch (error) { + if (isPromptNameConflict(error)) { + throw new ConflictException( + `A prompt named "${dto.name}" already exists.`, + ); + } + throw error; + } + } + + @Delete(':id') + @HttpCode(204) + @ApiParam({ name: 'id', format: 'uuid' }) + @ApiNoContentResponse() + @ApiNotFoundResponse({ description: 'Unknown or cross-tenant prompt' }) + @ApiUnauthorizedResponse() + async remove( + @CurrentUser() userId: string, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + const deleted = await this.tenantDb.runAs(userId, (tx) => + new PromptsRepository(tx).delete(id, userId), + ); + if (!deleted) { + throw new NotFoundException(`Prompt ${id} not found`); + } + } +} diff --git a/apps/api/src/prompts/prompts-repository.ts b/apps/api/src/prompts/prompts-repository.ts new file mode 100644 index 00000000..463844b5 --- /dev/null +++ b/apps/api/src/prompts/prompts-repository.ts @@ -0,0 +1,116 @@ +import { and, asc, eq, sql } from 'drizzle-orm'; +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; + +import * as schema from '../db/schema'; +import { prompts, type Prompt } from '../db/schema'; + +type Db = PostgresJsDatabase; + +/** Upper bound on saved prompts per user — a library, not a dumping ground. */ +export const PROMPT_MAX_PER_USER = 100; + +// Arbitrary namespace/seed id for the prompt-cap advisory lock, folded into a +// 64-bit key via hashtextextended (see lockUserForCreate) rather than the +// 2-int-arg pg_advisory_xact_lock(int, int) overload — that overload's second +// key is a plain `hashtext()` int4 (32-bit), so two unrelated userIds could +// collide and briefly serialize against each other under load. No other code +// in this codebase takes an advisory lock, so there's nothing else this +// namespace/seed could collide with. +const PROMPT_CAP_LOCK_CLASSID = 411_001; + +/** + * A Postgres unique_violation (23505) — the `prompts_user_name_idx` collision + * (a duplicate `/name` for the same user). Walks the cause chain (drizzle wraps + * the postgres.js error). Mapped to 409 by the controller. + */ +export function isPromptNameConflict(error: unknown): boolean { + let cursor: unknown = error; + while (cursor instanceof Error || (cursor && typeof cursor === 'object')) { + if ((cursor as { code?: unknown }).code === '23505') { + return true; + } + cursor = (cursor as { cause?: unknown }).cause; + if (cursor === undefined || cursor === null) { + break; + } + } + return false; +} + +/** Owner-scoped access to a user's saved prompts (RLS is the primary guard). */ +export class PromptsRepository { + constructor(private readonly db: Db) {} + + /** The user's prompts, name-ordered. */ + async list(userId: string): Promise { + return this.db + .select() + .from(prompts) + .where(eq(prompts.userId, userId)) + .orderBy(asc(prompts.name)); + } + + /** + * Serializes concurrent create() calls for the SAME user for the rest of + * this transaction (released automatically on commit/rollback), so the + * cap check in countByUser() + the following create() can't race under + * concurrent requests from the same user and overshoot PROMPT_MAX_PER_USER. + * Different users never block each other (namespaced by userId). + */ + async lockUserForCreate(userId: string): Promise { + await this.db.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${userId}, ${PROMPT_CAP_LOCK_CLASSID}))`, + ); + } + + async countByUser(userId: string): Promise { + const [row] = await this.db + .select({ n: sql`count(*)::int` }) + .from(prompts) + .where(eq(prompts.userId, userId)); + return row?.n ?? 0; + } + + async create(userId: string, name: string, content: string): Promise { + const [created] = await this.db + .insert(prompts) + .values({ userId, name, content }) + .returning(); + return created; + } + + /** Patch name/content, owner-scoped. Undefined when not found / not owned. */ + async update( + id: string, + userId: string, + patch: { name?: string; content?: string }, + ): Promise { + const fields = { + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.content !== undefined ? { content: patch.content } : {}), + }; + if (Object.keys(fields).length === 0) { + const [row] = await this.db + .select() + .from(prompts) + .where(and(eq(prompts.id, id), eq(prompts.userId, userId))) + .limit(1); + return row; + } + const [updated] = await this.db + .update(prompts) + .set({ ...fields, updatedAt: new Date() }) + .where(and(eq(prompts.id, id), eq(prompts.userId, userId))) + .returning(); + return updated; + } + + /** Delete by id, owner-scoped. True iff a row was removed (→ 404). */ + async delete(id: string, userId: string): Promise { + const deleted = await this.db + .delete(prompts) + .where(and(eq(prompts.id, id), eq(prompts.userId, userId))) + .returning({ id: prompts.id }); + return deleted.length > 0; + } +} diff --git a/apps/api/src/prompts/prompts-rls.integration.spec.ts b/apps/api/src/prompts/prompts-rls.integration.spec.ts new file mode 100644 index 00000000..3e610846 --- /dev/null +++ b/apps/api/src/prompts/prompts-rls.integration.spec.ts @@ -0,0 +1,194 @@ +/** + * Prompt library RLS integration test — owner-scoped CRUD under FORCE RLS: + * - a user CRUDs their own prompts; a cross-tenant read/update/delete is denied; + * - UNIQUE(user_id, name) rejects a per-user duplicate but ALLOWS the same name + * across users; the name-slug + content CHECKs hold. + * + * TEST_DATABASE_URL-gated; run by scripts/rls-test.sh. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { drizzle } from 'drizzle-orm/postgres-js'; + +import * as schema from '../db/schema'; +import { TenantDbService, type Db } from '../db/tenant-db.service'; +import { + PROMPT_MAX_PER_USER, + PromptsRepository, + isPromptNameConflict, +} from './prompts-repository'; + +const TEST_DB_URL = process.env['TEST_DATABASE_URL']; +const describeIfDb = TEST_DB_URL ? describe : describe.skip; +type SqlClient = any; + +describeIfDb('prompt library RLS + constraints', () => { + let sql: SqlClient; + let db: Db; + let tenantDb: TenantDbService; + let a: string; + let b: string; + let c: string; + + beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const postgres = require('postgres'); + const connect = postgres.default ?? postgres; + const ssl = /sslmode=require/.test(TEST_DB_URL!) ? 'require' : false; + sql = connect(TEST_DB_URL!, { ssl, max: 5 }); + db = drizzle(sql, { schema }); + tenantDb = new TenantDbService(db); + a = crypto.randomUUID(); + b = crypto.randomUUID(); + c = crypto.randomUUID(); + for (const id of [a, b, c]) { + await sql`INSERT INTO users (id, name, email) VALUES (${id}, 'P', ${`p-${id}@t.com`})`; + } + }); + + afterAll(async () => { + if (sql) { + await sql`DELETE FROM users WHERE id IN (${a}, ${b}, ${c})`; + await sql.end(); + } + }); + + it('owner CRUD round-trips (create, list, update, delete)', async () => { + const created = await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).create(a, 'summarize', 'Summarize: '), + ); + expect(created.name).toBe('summarize'); + + const listed = await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).list(a), + ); + expect(listed.map((p) => p.name)).toContain('summarize'); + + const updated = await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).update(created.id, a, { + content: 'Summarize concisely: ', + }), + ); + expect(updated?.content).toBe('Summarize concisely: '); + + expect( + await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).delete(created.id, a), + ), + ).toBe(true); + }); + + it('a duplicate name for the SAME user is rejected; the SAME name for a DIFFERENT user is allowed', async () => { + await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).create(a, 'dup', 'A body'), + ); + // Same user, same name → unique violation. + let conflict: unknown; + try { + await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).create(a, 'dup', 'A body 2'), + ); + } catch (error) { + conflict = error; + } + expect(isPromptNameConflict(conflict)).toBe(true); + // Different user, same name → fine (namespaced per user). + const bPrompt = await tenantDb.runAs(b, (tx) => + new PromptsRepository(tx).create(b, 'dup', 'B body'), + ); + expect(bPrompt.name).toBe('dup'); + }); + + it('a name differing only by case for the SAME user is also rejected (the composer menu matches case-insensitively)', async () => { + await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).create(a, 'Standup', 'A body'), + ); + let conflict: unknown; + try { + await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).create(a, 'standup', 'A body 2'), + ); + } catch (error) { + conflict = error; + } + expect(isPromptNameConflict(conflict)).toBe(true); + }); + + it('a cross-tenant read / update / delete is denied (RLS)', async () => { + const mine = await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).create(a, 'private', 'my body'), + ); + // B lists → does not see A's prompt. + const bList = await tenantDb.runAs(b, (tx) => + new PromptsRepository(tx).list(b), + ); + expect(bList.some((p) => p.id === mine.id)).toBe(false); + // B update / delete → no-op (RLS scopes to owner). + expect( + await tenantDb.runAs(b, (tx) => + new PromptsRepository(tx).update(mine.id, b, { content: 'hacked' }), + ), + ).toBeUndefined(); + expect( + await tenantDb.runAs(b, (tx) => + new PromptsRepository(tx).delete(mine.id, b), + ), + ).toBe(false); + // A's prompt survives unchanged. + const survivor = await tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).list(a), + ); + expect(survivor.find((p) => p.id === mine.id)?.content).toBe('my body'); + }); + + it('the name-slug CHECK rejects whitespace/slashes (so /name matching is exact)', async () => { + await expect( + tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).create(a, 'bad name', 'body'), + ), + ).rejects.toThrow(); + }); + + it('the content CHECK rejects an oversized body', async () => { + await expect( + tenantDb.runAs(a, (tx) => + new PromptsRepository(tx).create(a, 'toobig', 'x'.repeat(8001)), + ), + ).rejects.toThrow(); + }); + + it('the per-user cap holds under concurrent creates (lockUserForCreate serializes the check + insert)', async () => { + // Fill user c to one below the cap, then fire several concurrent creates — + // without the advisory lock, each request's countByUser() could read the + // same pre-insert count and let more than one through, overshooting the cap. + for (let i = 0; i < PROMPT_MAX_PER_USER - 1; i++) { + await tenantDb.runAs(c, (tx) => + new PromptsRepository(tx).create(c, `filler-${i}`, 'body'), + ); + } + const attempt = (name: string) => + tenantDb.runAs(c, async (tx) => { + const repo = new PromptsRepository(tx); + await repo.lockUserForCreate(c); + if ((await repo.countByUser(c)) >= PROMPT_MAX_PER_USER) { + throw new Error('cap reached'); + } + return repo.create(c, name, 'body'); + }); + const results = await Promise.allSettled([ + attempt('race-1'), + attempt('race-2'), + attempt('race-3'), + ]); + const succeeded = results.filter((r) => r.status === 'fulfilled').length; + expect(succeeded).toBe(1); + const finalCount = await tenantDb.runAs(c, (tx) => + new PromptsRepository(tx).countByUser(c), + ); + expect(finalCount).toBe(PROMPT_MAX_PER_USER); + }); +}); diff --git a/apps/api/src/prompts/prompts.module.ts b/apps/api/src/prompts/prompts.module.ts new file mode 100644 index 00000000..f6f00e0f --- /dev/null +++ b/apps/api/src/prompts/prompts.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MePromptsController } from './me-prompts.controller'; + +// One feature, one module (AGENTS.md convention). Needs nothing beyond the +// globally-provided TenantDbService (DbModule is @Global()) — prompts are a +// standalone resource, not part of the chats domain. +@Module({ + controllers: [MePromptsController], +}) +export class PromptsModule {} diff --git a/apps/web/app/(chat)/components/chat-page.tsx b/apps/web/app/(chat)/components/chat-page.tsx index a5565fef..ef7bb1ff 100644 --- a/apps/web/app/(chat)/components/chat-page.tsx +++ b/apps/web/app/(chat)/components/chat-page.tsx @@ -63,6 +63,7 @@ import { cancelRun, runIdToCancel } from "@/lib/services/chat/runs"; import { toast } from "@workspace/ui/components/sonner"; import { safeRandomUUID } from "@/lib/uuid"; import { useQueryClient } from "@tanstack/react-query"; +import { usePromptMenu } from "./prompt-command-menu"; import { compactionBoundaryIndex } from "@/lib/services/chat/compaction"; import type { ChatHistory, Compaction } from "@/lib/services/chat/history"; import { CompactionBoundary } from "./compaction-boundary"; @@ -207,6 +208,12 @@ function ChatSessionContent({ const [input, setInput] = useState(""); const [sendError, setSendError] = useState(null); + // `/`-triggered saved-prompt menu for the composer (roadmap v0.5 slash seed). + const { onKeyDown: promptMenuKeyDown, menu: promptMenu } = usePromptMenu({ + input, + onInsert: setInput, + }); + const router = useRouter(); const queryClient = useQueryClient(); const { @@ -582,6 +589,7 @@ function ChatSessionContent({
+ {promptMenu} {modelSendUnavailableReason && (

{modelSendUnavailableReason} @@ -592,6 +600,7 @@ function ChatSessionContent({ name="message" value={input} onChange={(e) => setInput(e.target.value)} + onKeyDown={promptMenuKeyDown} placeholder="What would you like to know?" autoFocus /> diff --git a/apps/web/app/(chat)/components/prompt-command-menu.tsx b/apps/web/app/(chat)/components/prompt-command-menu.tsx new file mode 100644 index 00000000..34df8206 --- /dev/null +++ b/apps/web/app/(chat)/components/prompt-command-menu.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { + useEffect, + useMemo, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; + +import { Button } from "@workspace/ui/components/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog"; +import { Input } from "@workspace/ui/components/input"; + +import { usePromptsQuery } from "@/lib/services/prompts/queries"; +import { matchingPrompts } from "@/lib/services/prompts/matching"; +import { + extractPlaceholders, + fillPlaceholders, +} from "@/lib/services/prompts/templating"; + +/** + * The `/`-triggered prompt menu for the composer. Returns an `onKeyDown` to pass + * to the textarea (intercepts Arrow/Enter/Escape ONLY while the menu is open — + * bare Enter selects, Shift+Enter is left alone for a newline) and the rendered + * menu (or null). Selecting a prompt replaces the composer input with its body. + */ +export function usePromptMenu({ + input, + onInsert, +}: { + input: string; + onInsert: (content: string) => void; +}): { onKeyDown: (e: KeyboardEvent) => void; menu: ReactNode } { + const { data: prompts = [] } = usePromptsQuery(); + const [highlighted, setHighlighted] = useState(0); + // Escape dismisses the menu for the CURRENT input; any edit reopens it. + const [dismissedFor, setDismissedFor] = useState(null); + // A selected prompt whose body has {{placeholders}} → the fill-in dialog. + const [fillContent, setFillContent] = useState(null); + + const matches = matchingPrompts(input, prompts); + const open = matches !== null && input !== dismissedFor; + const list = open ? matches : null; + + useEffect(() => { + setHighlighted(0); + }, [input]); + + const select = (content: string) => { + // A prompt with {{placeholders}} opens a fill dialog; dismiss the `/` menu + // for the current token (so cancelling doesn't reopen it in a loop). A + // plain prompt inserts directly, as before. + if (extractPlaceholders(content).length > 0) { + setDismissedFor(input); + setFillContent(content); + } else { + onInsert(content); + setDismissedFor(null); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!list) return; + // Real prompt names are ASCII slugs (DTO-enforced), but the query itself + // can transiently match one mid-IME-composition (e.g. romaji "su" toward + // a kana conversion can match a saved "/summarize" prompt). Don't hijack + // the Enter that CONFIRMS the composition — let it through undisturbed. + if (e.nativeEvent.isComposing) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlighted((i) => Math.min(i + 1, list.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlighted((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" && !e.shiftKey) { + // Only BARE Enter selects; Shift+Enter falls through to a newline. + e.preventDefault(); + const chosen = list[highlighted] ?? list[0]; + if (chosen) select(chosen.content); + } else if (e.key === "Escape") { + e.preventDefault(); + setDismissedFor(input); + } + }; + + const menu = ( + <> + {list && ( +

+ {list.map((prompt, i) => ( + + ))} +
+ )} + {fillContent !== null && ( + { + onInsert(filled); + setFillContent(null); + }} + onClose={() => setFillContent(null)} + /> + )} + + ); + + return { onKeyDown, menu }; +} + +/** Collects a value for each `{{placeholder}}`, then substitutes + inserts. */ +function FillPromptDialog({ + content, + onSubmit, + onClose, +}: { + content: string; + onSubmit: (filled: string) => void; + onClose: () => void; +}) { + const placeholders = useMemo(() => extractPlaceholders(content), [content]); + const [values, setValues] = useState>({}); + + const submit = () => onSubmit(fillPlaceholders(content, values)); + + return ( + !open && onClose()}> + + + Fill in the prompt + +
+ {placeholders.map((name, i) => ( +
+ {/* index-based id — a placeholder NAME can contain spaces/punct + ({{target language}}), which is invalid in a DOM id. */} + + + setValues((v) => ({ ...v, [name]: e.target.value })) + } + onKeyDown={(e) => { + // Don't hijack the Enter that confirms an IME composition + // (values can be any text, unlike the ASCII-slug `/` menu). + if (e.nativeEvent.isComposing) return; + if (e.key !== "Enter") return; + e.preventDefault(); + if (i === placeholders.length - 1) { + submit(); + } else { + document.getElementById(`ph-${i + 1}`)?.focus(); + } + }} + /> +
+ ))} +
+ + + + +
+
+ ); +} diff --git a/apps/web/app/(chat)/settings/components/prompts-section.tsx b/apps/web/app/(chat)/settings/components/prompts-section.tsx new file mode 100644 index 00000000..7d6f65a3 --- /dev/null +++ b/apps/web/app/(chat)/settings/components/prompts-section.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@workspace/ui/components/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@workspace/ui/components/card"; +import { Input } from "@workspace/ui/components/input"; +import { Skeleton } from "@workspace/ui/components/skeleton"; +import { Textarea } from "@workspace/ui/components/textarea"; +import { toast } from "sonner"; +import { HTTPError } from "ky"; +import { PencilIcon, Trash2Icon } from "lucide-react"; + +import { + PROMPT_CONTENT_MAX, + PROMPT_NAME_MAX, + useCreatePrompt, + useDeletePrompt, + usePromptsQuery, + useUpdatePrompt, +} from "@/lib/services/prompts/queries"; + +const NAME_OK = /^[A-Za-z0-9_-]+$/; + +/** + * Manage saved prompts — reusable templates inserted in the composer by typing + * `/`. Name is a slug (no spaces) so the trigger is unambiguous. + */ +export function PromptsSection() { + const { data: prompts, isLoading } = usePromptsQuery(); + const create = useCreatePrompt(); + const update = useUpdatePrompt(); + const remove = useDeletePrompt(); + + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [content, setContent] = useState(""); + + const nameOk = NAME_OK.test(name) && name.length <= PROMPT_NAME_MAX; + const contentOk = + content.trim().length > 0 && + content.trim().length <= PROMPT_CONTENT_MAX; + const pending = create.isPending || update.isPending; + + const reset = () => { + setEditingId(null); + setName(""); + setContent(""); + }; + + const startEdit = (p: { id: string; name: string; content: string }) => { + setEditingId(p.id); + setName(p.name); + setContent(p.content); + }; + + const onSave = () => { + if (!nameOk || !contentOk) return; + const onError = (error: unknown) => + toast.error( + error instanceof HTTPError && error.response.status === 409 + ? `Couldn't save "/${name}" — that name is already taken.` + : `Couldn't save "/${name}" — try again.`, + ); + if (editingId) { + update.mutate( + { id: editingId, patch: { name, content: content.trim() } }, + { onSuccess: reset, onError }, + ); + } else { + create.mutate( + { name, content: content.trim() }, + { onSuccess: reset, onError }, + ); + } + }; + + return ( + + + Prompts + + Reusable prompt templates. Insert one in a chat by typing{" "} + /name. Use {"{{variable}}"} in the body to be + prompted for values on insert. + + + +
+ setName(e.target.value)} + placeholder="name (e.g. summarize)" + aria-label="Prompt name" + /> + {name.length > 0 && !nameOk && ( +

+ Use letters, digits, underscore or hyphen — no spaces. +

+ )} +