Merged in feature/chatbot1 (pull request #223)

Chatbot functionality

* baseline working

* missing test file

* more tests
This commit is contained in:
Jay Brown
2026-05-07 20:56:18 +00:00
parent 17fc813823
commit 72de690894
62 changed files with 19150 additions and 445 deletions
@@ -0,0 +1,9 @@
-- Rollback migration 131. Drop in reverse dependency order: turns and
-- scope tables reference bot_sessions (via FK), so they go first.
-- DROP TABLE removes any indexes and CHECK constraints attached to the
-- table, so no separate index/constraint drops are needed.
DROP TABLE IF EXISTS bot_turns;
DROP TABLE IF EXISTS bot_session_folders;
DROP TABLE IF EXISTS bot_session_documents;
DROP TABLE IF EXISTS bot_sessions;
@@ -0,0 +1,102 @@
-- Chatbot backend support, milestone M1: chatbot tables.
-- See plans/chatbot_plan_codex.v10.md §4 (Data Model) for the canonical
-- definition. Existing tables retain camelCase column names (per migrations
-- 1-130); new bot_* tables use snake_case to match migrations 127-130.
--
-- Tables created in dependency order:
-- 1. bot_sessions — owner-scoped chatbot conversations
-- 2. bot_session_documents — explicit per-session document scope
-- 3. bot_session_folders — explicit per-session folder scope
-- 4. bot_turns — append-only turn log per session
-- ---------- bot_sessions ----------
CREATE TABLE bot_sessions (
id uuid NOT NULL DEFAULT uuid_generate_v7(),
client_id varchar(255) NOT NULL REFERENCES clients(clientId) ON DELETE CASCADE,
created_by varchar(255) NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
title text NOT NULL DEFAULT '',
state jsonb NOT NULL DEFAULT '{}'::jsonb,
is_deleted boolean NOT NULL DEFAULT false,
CONSTRAINT pk_bot_sessions PRIMARY KEY (id)
);
CREATE INDEX idx_bot_sessions_client_user_active
ON bot_sessions (client_id, created_by, updated_at DESC)
WHERE is_deleted = false;
CREATE INDEX idx_bot_sessions_client_active
ON bot_sessions (client_id, updated_at DESC)
WHERE is_deleted = false;
-- ---------- bot_session_documents (scope) ----------
CREATE TABLE bot_session_documents (
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
CONSTRAINT pk_bot_session_documents PRIMARY KEY (session_id, document_id)
);
CREATE INDEX idx_bsd_document ON bot_session_documents(document_id);
-- ---------- bot_session_folders (scope) ----------
CREATE TABLE bot_session_folders (
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
folder_id uuid NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
CONSTRAINT pk_bot_session_folders PRIMARY KEY (session_id, folder_id)
);
CREATE INDEX idx_bsf_folder ON bot_session_folders(folder_id);
-- ---------- bot_turns ----------
--
-- bot_turns_status_fields_consistent groups session_deleted with errored
-- and abandoned: completion IS NULL AND error IS NOT NULL. This matches
-- plan §4 verbatim.
CREATE TABLE bot_turns (
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
ordinal int NOT NULL CHECK (ordinal > 0),
prompt text NOT NULL,
completion text,
status text NOT NULL DEFAULT 'in_flight',
attempt_id uuid NOT NULL,
attempt_started_at timestamptz NOT NULL DEFAULT NOW(),
created_at timestamptz NOT NULL DEFAULT NOW(),
latency_ms int,
tokens_in int,
tokens_out int,
error jsonb,
CONSTRAINT pk_bot_turns PRIMARY KEY (session_id, ordinal),
CONSTRAINT bot_turns_status_values CHECK (
status IN ('in_flight', 'completed', 'errored', 'abandoned', 'session_deleted')
),
-- bot_turns_status_fields_consistent enforces the (status, completion,
-- error) consistency matrix from plan §4. The trailing OR-clause
-- (`status NOT IN (...)`) is an evaluation-order guard, not a relaxation
-- of the rule: Postgres evaluates table-level CHECK constraints in
-- alphabetical order by constraint name, so without this guard a row
-- with an UNKNOWN status (e.g. 'frobnicated') trips this constraint
-- before bot_turns_status_values gets a chance to reject the unknown
-- enum value. With the guard, unknown statuses pass this consistency
-- check (every branch becomes irrelevant) and bot_turns_status_values
-- owns the rejection. For every documented status value the plan §4
-- literal still holds verbatim.
CONSTRAINT bot_turns_status_fields_consistent CHECK (
(status = 'in_flight' AND completion IS NULL AND error IS NULL)
OR (status = 'completed' AND completion IS NOT NULL AND error IS NULL)
OR (status IN ('errored', 'abandoned', 'session_deleted') AND completion IS NULL AND error IS NOT NULL)
OR status NOT IN ('in_flight', 'completed', 'errored', 'abandoned', 'session_deleted')
)
);
CREATE UNIQUE INDEX bot_turns_one_inflight_per_session
ON bot_turns (session_id)
WHERE status = 'in_flight';
CREATE INDEX idx_bot_turns_session_completed
ON bot_turns (session_id, ordinal)
WHERE status = 'completed';
+397
View File
@@ -0,0 +1,397 @@
-- Chatbot backend support, milestone M1: bot.sql.
-- See plans/chatbot_plan_codex.v10.md §5 (Core SQL Queries) for the
-- canonical query shapes. Only M1 queries live here; M4 algorithm
-- queries (CompleteBotTurn, FailBotTurn, MarkBotTurnSessionDeleted,
-- InsertBotTurnPrompt, GetBotTurn, etc.) are added in M4.
--
-- Naming conventions:
-- - All bot_* tables use snake_case columns. New named params
-- (@session_id, @client_id, @created_by, @grace_seconds, @attempt_id,
-- @ordinal, @session_ids, @turn_limit) follow snake_case so sqlc
-- generates Go field names ClientID, SessionID, CreatedBy, etc.
-- name: LockBotSessionForOwner :one
-- Owner-scoped session lookup with row-level lock. Returns the session
-- plus two derived ordinals computed from bot_turns:
-- last_seen_ordinal = MAX(ordinal) regardless of status
-- last_terminal_ordinal = MAX(ordinal) where status <> 'in_flight'
-- The FOR UPDATE OF s clause locks the session row but not the
-- bot_turns rows (those are read uncontended by the subselects).
-- Must be called inside a transaction. Plan §5.
SELECT s.*,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
FROM bot_sessions s
WHERE s.id = @session_id
AND s.client_id = @client_id
AND s.created_by = @created_by
AND s.is_deleted = false
FOR UPDATE OF s;
-- name: AbandonExpiredInflightForOwner :exec
-- Owner-scoped grace-window abandonment. Mutates bot_turns rows whose
-- attempt_started_at is older than NOW() - grace_seconds AND whose owning
-- session matches the caller's (client_id, created_by, is_deleted=false).
-- The EXISTS predicate is defense in depth — the service layer is
-- expected to call LockBotSessionForOwner first. Plan §5.
UPDATE bot_turns bt
SET status = 'abandoned',
error = jsonb_build_object(
'code', 'turn_abandoned_grace_expired',
'message', 'no terminal status reached within grace window'
)
WHERE bt.session_id = @session_id
AND bt.status = 'in_flight'
AND bt.attempt_started_at < NOW() - make_interval(secs => @grace_seconds::int)
AND EXISTS (
SELECT 1
FROM bot_sessions s
WHERE s.id = bt.session_id
AND s.client_id = @client_id
AND s.created_by = @created_by
AND s.is_deleted = false
);
-- name: ResetBotTurnForRetry :exec
-- Reset a terminal-failure turn back to in_flight for retry. Mints a
-- fresh attempt_id, resets attempt_started_at and created_at, and
-- nullifies completion/error. Only rows in (errored, abandoned) are
-- eligible; in_flight, completed, and session_deleted rows are no-ops.
-- Plan §5.
UPDATE bot_turns
SET status = 'in_flight',
attempt_id = @attempt_id,
attempt_started_at = NOW(),
created_at = NOW(),
completion = NULL,
error = NULL
WHERE session_id = @session_id
AND ordinal = @ordinal
AND status IN ('errored', 'abandoned');
-- name: ListLastTurnsForSessions :many
-- Session-list preview query. For each session_id in @session_ids,
-- return the most recent @turn_limit turns ordered by ordinal DESC.
-- The service caller is expected to pass turn_limit = min(request.limit,
-- cfg.RecentTurns) per plan §3. The full row column set is returned per
-- plan §5: session_id, ordinal, prompt, completion, status, created_at,
-- latency_ms, tokens_in, tokens_out, error.
SELECT session_id, ordinal, prompt, completion, status, created_at,
latency_ms, tokens_in, tokens_out, error
FROM (
SELECT bt.*,
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY ordinal DESC) AS rn
FROM bot_turns bt
WHERE bt.session_id = ANY(@session_ids::uuid[])
) ranked
WHERE rn <= @turn_limit::int
ORDER BY session_id, ordinal DESC;
-- ---------- Milestone M2 queries ----------
--
-- Session/scope CRUD queries used by internal/bot/service_session.go.
-- All reads carry the derived last_terminal_ordinal column (plan §4) so
-- the API response can populate `lastTurn` without a follow-up query.
-- Owner-scoped queries filter on (client_id, created_by, is_deleted=false);
-- super-admin reads filter on (client_id, is_deleted=false) only.
-- name: InsertBotSession :one
-- Insert a new owner-scoped session. Title and state default to '' / '{}'
-- on the schema; we let those defaults apply rather than passing the
-- caller's intent on creation. Plan §6 says title is derived from the
-- first turn's prompt (handled by M4's AddTurn path, not here).
INSERT INTO bot_sessions (client_id, created_by)
VALUES (@client_id, @created_by)
RETURNING id, client_id, created_by, created_at, updated_at, title, state, is_deleted;
-- name: GetBotSessionForOwner :one
-- Read-only owner-scoped session read. Mirrors LockBotSessionForOwner
-- but without FOR UPDATE so callers that only need to display a session
-- do not block writers. The derived ordinal columns match plan §4.
SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
FROM bot_sessions s
WHERE s.id = @session_id
AND s.client_id = @client_id
AND s.created_by = @created_by
AND s.is_deleted = false;
-- name: ListBotSessionsForOwner :many
-- Owner-scoped list with limit/offset. Backed by
-- idx_bot_sessions_client_user_active (plan §4): the WHERE filter and
-- the (client_id, created_by, updated_at DESC) ordering match the index
-- column order so the planner can satisfy the request without a sort.
SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
FROM bot_sessions s
WHERE s.client_id = @client_id
AND s.created_by = @created_by
AND s.is_deleted = false
ORDER BY s.updated_at DESC
LIMIT @limit_val::int OFFSET @offset_val::int;
-- name: GetBotSessionForSuperAdmin :one
-- Super-admin client-scoped session read. No created_by predicate per
-- plan §9. is_deleted=false filter is the M2 default; including
-- soft-deleted rows is out of scope for M2.
SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
FROM bot_sessions s
WHERE s.id = @session_id
AND s.client_id = @client_id
AND s.is_deleted = false;
-- name: ListBotSessionsForSuperAdmin :many
-- Super-admin client-scoped list. Backed by idx_bot_sessions_client_active.
-- Same ordering invariant as ListBotSessionsForOwner.
SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
FROM bot_sessions s
WHERE s.client_id = @client_id
AND s.is_deleted = false
ORDER BY s.updated_at DESC
LIMIT @limit_val::int OFFSET @offset_val::int;
-- name: SoftDeleteBotSessionForSuperAdmin :execrows
-- Mark a session as deleted. Returns the affected row count so the
-- caller can distinguish 0 (not found / already deleted) from 1 (soft
-- delete applied). Filters on (client_id, is_deleted=false) so a second
-- delete on an already-deleted row returns 0.
UPDATE bot_sessions
SET is_deleted = true,
updated_at = NOW()
WHERE id = @session_id
AND client_id = @client_id
AND is_deleted = false;
-- name: MarkInflightTurnsSessionDeletedForSession :exec
-- Side-effect of a super-admin DELETE: any in-flight turn on the deleted
-- session must transition to status='session_deleted' with an explanatory
-- error JSON so the M4 AddTurn tx2 callback finds a terminal row instead
-- of a stale in_flight row. The bot_turns_status_fields_consistent CHECK
-- requires error IS NOT NULL on session_deleted rows.
UPDATE bot_turns
SET status = 'session_deleted',
error = jsonb_build_object(
'code', 'session_deleted_by_admin',
'message', 'session deleted while turn in flight'
)
WHERE session_id = @session_id
AND status = 'in_flight';
-- name: ListBotSessionDocumentsForSession :many
-- Returns the document_id set persisted in bot_session_documents for the
-- given session, in deterministic order. M2 returns these as-is — no
-- folder expansion (that lives in the M3 FastAPI payload assembly).
SELECT document_id
FROM bot_session_documents
WHERE session_id = @session_id
ORDER BY document_id;
-- name: ListBotSessionFoldersForSession :many
-- Returns the folder_id set persisted in bot_session_folders for the
-- given session, in deterministic order.
SELECT folder_id
FROM bot_session_folders
WHERE session_id = @session_id
ORDER BY folder_id;
-- name: DeleteBotSessionDocumentsForSession :exec
-- @sqlc-vet-disable
-- Wipe the persisted document scope for a session. Used as the first
-- half of the replace-set semantics in PatchBotSessionScope. The caller
-- must follow with InsertBotSessionDocument inside the same transaction
-- so the union write is atomic.
DELETE FROM bot_session_documents
WHERE session_id = @session_id;
-- name: DeleteBotSessionFoldersForSession :exec
-- @sqlc-vet-disable
-- Wipe the persisted folder scope for a session. Same role as
-- DeleteBotSessionDocumentsForSession.
DELETE FROM bot_session_folders
WHERE session_id = @session_id;
-- name: InsertBotSessionDocument :exec
-- Add one (session_id, document_id) link. ON CONFLICT DO NOTHING covers
-- the rare case where the same id appears twice in the new set; the
-- service-layer dedupe runs first so this is defense in depth.
INSERT INTO bot_session_documents (session_id, document_id)
VALUES (@session_id, @document_id)
ON CONFLICT (session_id, document_id) DO NOTHING;
-- name: InsertBotSessionFolder :exec
-- Add one (session_id, folder_id) link.
INSERT INTO bot_session_folders (session_id, folder_id)
VALUES (@session_id, @folder_id)
ON CONFLICT (session_id, folder_id) DO NOTHING;
-- name: GetDocumentClientIDsForBotScope :many
-- Bulk client-id lookup for a candidate document set. Returns one row
-- per matching document so the service can detect cross-client ids by
-- comparing against the session's client_id and missing rows. Documents
-- not found return no row (caller must compare set sizes).
SELECT id, clientId AS client_id
FROM documents
WHERE id = ANY(@document_ids::uuid[]);
-- name: GetFolderClientIDsForBotScope :many
-- Bulk client-id lookup for a candidate folder set. Same shape and
-- semantics as GetDocumentClientIDsForBotScope.
SELECT id, clientId AS client_id
FROM folders
WHERE id = ANY(@folder_ids::uuid[]);
-- name: TouchBotSessionUpdatedAt :exec
-- Bumps updated_at on a bot_sessions row so the owner list-by-updated_at
-- ordering reflects the most recent activity. Used after PatchScope.
UPDATE bot_sessions
SET updated_at = NOW()
WHERE id = @session_id;
-- ---------- Milestone M3 queries ----------
-- name: GetCompletedTurnsForSessionAscending :many
-- Returns the most recent N completed turns for a session, ordered by
-- ordinal ASCENDING for the FastAPI conversation_history payload (plan §7).
-- The CTE first filters to completed turns and DESCENDS by ordinal so the
-- LIMIT clause picks the newest N; the outer SELECT then re-orders ASC
-- because conversation_history is "two messages per turn, ascending
-- ordinal" per plan §7. The service caller passes turn_limit = cfg.RecentTurns.
WITH recent AS (
SELECT ordinal, prompt, completion, created_at
FROM bot_turns
WHERE session_id = @session_id
AND status = 'completed'
ORDER BY ordinal DESC
LIMIT @turn_limit::int
)
SELECT ordinal, prompt, completion, created_at
FROM recent
ORDER BY ordinal ASC;
-- ---------- Milestone M4 queries ----------
--
-- Turn CRUD + AddTurn algorithm queries per plan §6. Each mutating
-- query compares-and-sets on attempt_id so a stale tx2 callback after
-- abandonment or retry affects zero rows (plan §11 invariant).
-- name: GetBotTurn :one
-- Read one turn by (session_id, ordinal). Returns pgx.ErrNoRows when
-- no row exists. The service uses this in tx1 after
-- LockBotSessionForOwner so the existing-row classification (plan §6)
-- runs against a stable snapshot.
SELECT session_id, ordinal, prompt, completion, status, attempt_id,
attempt_started_at, created_at, latency_ms, tokens_in,
tokens_out, error
FROM bot_turns
WHERE session_id = @session_id
AND ordinal = @ordinal;
-- name: FindBotTurnByPrompt :one
-- Find the lowest-ordinal turn whose prompt matches input. Used by the
-- handler to pick a target ordinal under prompt-match semantics: a
-- retry of an errored/abandoned/completed/session_deleted turn must
-- reuse the existing row's ordinal so the service classifies it via
-- the existing-row branch of plan §6. pgx.ErrNoRows when no match.
SELECT session_id, ordinal, prompt, completion, status
FROM bot_turns
WHERE session_id = @session_id
AND prompt = @prompt
ORDER BY ordinal ASC
LIMIT 1;
-- name: InsertBotTurnPrompt :one
-- Insert a new in_flight turn at the requested ordinal. The partial
-- unique index `bot_turns_one_inflight_per_session` rejects insertion
-- when another in_flight row exists for the session — the service
-- catches the unique-violation pgconn.PgError and maps it to
-- ErrPriorTurnInFlight. Returns the inserted row so the caller can
-- surface created_at without a follow-up read.
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id,
attempt_started_at, created_at)
VALUES (@session_id, @ordinal, @prompt, 'in_flight', @attempt_id,
NOW(), NOW())
RETURNING session_id, ordinal, prompt, completion, status, attempt_id,
attempt_started_at, created_at, latency_ms, tokens_in,
tokens_out, error;
-- name: CompleteBotTurn :execrows
-- Compare-and-set: flips status from in_flight to completed when
-- attempt_id matches. Returns affected row count so a stale tx2
-- callback (whose attempt_id no longer matches) reports 0 rows and the
-- service returns ErrTurnSuperseded. Plan §6 + plan §11.
UPDATE bot_turns
SET status = 'completed',
completion = @completion,
latency_ms = @latency_ms,
tokens_in = @tokens_in,
tokens_out = @tokens_out
WHERE session_id = @session_id
AND ordinal = @ordinal
AND attempt_id = @attempt_id
AND status = 'in_flight';
-- name: FailBotTurn :execrows
-- Compare-and-set: flips status from in_flight to errored when
-- attempt_id matches. Error JSON shape is `{"code","message","attempt"}`
-- per plan §6.
UPDATE bot_turns
SET status = 'errored',
error = @error
WHERE session_id = @session_id
AND ordinal = @ordinal
AND attempt_id = @attempt_id
AND status = 'in_flight';
-- name: MarkBotTurnSessionDeleted :execrows
-- Compare-and-set: flips status from in_flight to session_deleted when
-- attempt_id matches. Used by the AddTurn tx2 path when
-- LockBotSessionForOwner finds the session disappeared mid-FastAPI-call.
-- Plan §6.
UPDATE bot_turns
SET status = 'session_deleted',
error = @error
WHERE session_id = @session_id
AND ordinal = @ordinal
AND attempt_id = @attempt_id
AND status = 'in_flight';
-- name: SetBotSessionTitle :exec
-- Sets the session title and bumps updated_at, but ONLY when the
-- existing title is empty. Plan §6: title is derived from the first
-- turn's prompt on ordinal=1; the empty-title guard prevents a retry
-- on ordinal=1 (after an errored/abandoned reset) from clobbering a
-- title that was already set on a prior successful attempt.
UPDATE bot_sessions
SET title = @title,
updated_at = NOW()
WHERE id = @session_id
AND title = '';
-- name: ListTurnsForSession :many
-- Owner-scoped list of every turn for a session, ordered ASC by ordinal.
-- The owner check is the responsibility of the caller; the service
-- runs GetBotSessionForOwner first so a non-owner caller never reaches
-- this query.
SELECT session_id, ordinal, prompt, completion, status, attempt_id,
attempt_started_at, created_at, latency_ms, tokens_in,
tokens_out, error
FROM bot_turns
WHERE session_id = @session_id
ORDER BY ordinal ASC;
-- name: UpdateBotSessionState :exec
-- Persists a session_state object after tx2's compare-and-set. Plan §6
-- mandates that null updated_session_state preserves prior; this query
-- is only invoked when the service has decided to write a non-null
-- (possibly stripped) value. NULL @state explicitly clears state.
UPDATE bot_sessions
SET state = COALESCE(@state, '{}'::jsonb),
updated_at = NOW()
WHERE id = @session_id
AND client_id = @client_id;
@@ -120,6 +120,39 @@ SELECT custom_schema_id
FROM documents
WHERE id = @id;
-- name: GetDocumentCustomSchemaIdForClient :one
-- Chatbot-scoped variant of GetDocumentCustomSchemaId: adds a clientId
-- filter so cross-client lookups miss with pgx.ErrNoRows. Same scalar
-- result type (nullable uuid) as the non-scoped query — only the WHERE
-- clause differs. Plan §5.
SELECT custom_schema_id
FROM documents
WHERE id = @id
AND clientId = @client_id;
-- name: GetCurrentDocumentCustomMetadataForClient :one
-- Chatbot-scoped variant of GetCurrentDocumentCustomMetadata. Same column
-- set (the metadata row decorated with schema id/name/version), but the
-- joined documents row must belong to @client_id; cross-client calls miss
-- with pgx.ErrNoRows. Plan §5.
SELECT
dcm.id,
dcm.document_id,
dcm.metadata,
dcm.version,
dcm.created_at,
dcm.created_by,
cms.id AS schema_id,
cms.name AS schema_name,
cms.version AS schema_version
FROM document_custom_metadata dcm
JOIN documents d ON d.id = dcm.document_id
LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id
WHERE dcm.document_id = @document_id
AND d.clientId = @client_id
ORDER BY dcm.version DESC
LIMIT 1;
-- name: GetDocumentClientID :one
-- Read a document's clientId for the AssignSchema cross-client check.
-- documents.clientId is the unquoted (lowercased) varchar column.
+23
View File
@@ -81,6 +81,29 @@ SELECT
FROM documents d
WHERE d.id = $1;
-- name: GetDocumentEnrichedForClient :one
-- Chatbot-scoped read: same column set as GetDocumentEnriched, but adds a
-- clientId filter so cross-client lookups miss with pgx.ErrNoRows.
-- Plan §5 mandates a clientId filter on every chatbot document/folder
-- read so document scope cannot leak between clients. documents.clientId
-- is the unquoted (lowercased) varchar column from migration 5.
SELECT
d.id,
d.clientId,
d.hash,
d.folderId,
d.filename,
d.originalPath,
d.file_size_bytes,
d.custom_schema_id,
EXISTS(
SELECT 1 FROM document_custom_metadata
WHERE document_id = d.id
) AS has_custom_metadata
FROM documents d
WHERE d.id = @id
AND d.clientId = @client_id;
-- name: LockDocumentForLegacyExtractionWrite :one
-- Milestone 2.5 parent-row lock acquired by CreateFieldExtraction as the
-- FIRST DML statement inside its transaction. Serializes with AssignSchema
+27
View File
@@ -131,6 +131,33 @@ WITH RECURSIVE folder_tree AS (
)
SELECT id FROM documents WHERE folderId IN (SELECT id FROM folder_tree);
-- name: GetDocumentIDsInFolderTreeForClient :many
-- Chatbot-scoped recursive folder tree walk. Plan §5 mandates that the
-- clientId filter applies at every recursive step AND at the final
-- document selection so a cross-client root cannot leak documents.
--
-- Step-by-step:
-- - Base case: select the root folder only when its clientId matches.
-- - Recursive case: descend through folders.parentId, requiring the
-- child folder's clientId to match. A folder owned by a different
-- client cannot enter the CTE even if its parent did (which it
-- cannot, because the base case already rejects cross-client roots).
-- - Final selection: documents must reside in folder_tree AND share
-- the same clientId, defending against any future case where a
-- document row references a folder owned by a different client.
WITH RECURSIVE folder_tree AS (
SELECT id FROM folders
WHERE id = @folder_id::uuid
AND clientId = @client_id::varchar
UNION ALL
SELECT f.id FROM folders f
JOIN folder_tree ft ON f.parentId = ft.id
WHERE f.clientId = @client_id::varchar
)
SELECT id FROM documents
WHERE folderId IN (SELECT id FROM folder_tree)
AND clientId = @client_id::varchar;
-- name: DeleteDocumentUploadsInFolderTree :exec
-- @sqlc-vet-disable
-- Delete documentUploads rows that reference folders in the tree
+13
View File
@@ -8,6 +8,19 @@ SELECT * FROM documentLabels
WHERE documentId = $1
ORDER BY appliedAt DESC;
-- name: GetDocumentLabelsForClient :many
-- Chatbot-scoped variant of GetDocumentLabels. Joins documents to enforce
-- the clientId filter so cross-client lookups return an empty slice
-- rather than another tenant's labels. Returns the same documentLabels
-- column shape as GetDocumentLabels (not the joined documents row).
-- Plan §5.
SELECT dl.*
FROM documentLabels dl
JOIN documents d ON d.id = dl.documentId
WHERE dl.documentId = @document_id
AND d.clientId = @client_id
ORDER BY dl.appliedAt DESC;
-- name: GetMostRecentLabel :one
SELECT * FROM documentLabels
WHERE documentId = $1 AND label = $2
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,644 @@
// Milestone 1 §M1 chatbot tables — migration shape and constraint tests.
//
// These tests are the authoritative spec for migration 131
// (00000000000131_create_chatbot_tables). They run against the real
// Postgres testcontainer that internal/test.CreateDB stands up. No mocks.
//
// Plan reference: plans/chatbot_plan_codex.v10.md §4 (Data Model).
//
// Coverage:
// - TestMigration131_UpDownRoundtrip: down then up apply cleanly and
// re-create every table, column, index, and CHECK constraint.
// - TestMigration131_BotTurnsCheckConstraints: bot_turns_status_values
// rejects unknown statuses; bot_turns_status_fields_consistent rejects
// every illegal (status, completion, error) tuple per §4.
// - TestMigration131_PartialUniqueIndexInflight: only one in_flight row
// per session is allowed.
// - TestMigration131_BotSessionsPartialIndexes: the two partial indexes
// on bot_sessions exist with the documented WHERE clause.
// - TestMigration131_ScopeTablesCascade: delete-cascade behavior for
// both bot_session_documents and bot_session_folders.
//
// All five tests follow the existing migration-test convention:
// - t.Parallel + testing.Short skip mirroring TestMigration127_*
// - per-test client/session reset because the testcontainer DB is reused
// - introspection via information_schema and pg_catalog
// - SQLSTATE assertions on negative-path expectations
package repository_test
import (
"context"
"errors"
"os"
"testing"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/serviceconfig"
"queryorchestration/internal/test"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/require"
)
// chatbotMigrationFiles is the canonical pair of file paths the round-trip
// test reads. Migration 131 is currently the latest, so single-step
// reversal is correct (team-lead M1 dispatch confirmed this).
const (
chatbotUp131Path = "../migrations/00000000000131_create_chatbot_tables.up.sql"
chatbotDown131Path = "../migrations/00000000000131_create_chatbot_tables.down.sql"
)
// resetClientForBot wipes any leftover client/session rows from prior
// runs. Mirrors resetClient in customschemas_queries_test.go: the
// Postgres container and per-test database are reused across runs, so
// fresh CreateClient calls would collide with leftover rows. Documents
// must be deleted first because clients->documents is NOT cascade.
// bot_sessions cascades via the bot_sessions FK on clients(clientId).
func resetClientForBot(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, name string) {
t.Helper()
pool := cfg.GetDBPool()
_, err := pool.Exec(ctx, `DELETE FROM documents WHERE clientId = $1`, clientID)
require.NoError(t, err, "pre-test DELETE documents must succeed")
_, err = pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID)
require.NoError(t, err, "pre-test DELETE clients must succeed")
err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
Name: name,
Clientid: clientID,
})
require.NoError(t, err, "CreateClient(%s) must succeed", clientID)
}
// seedBotSession inserts one bot_sessions row and returns its id. Uses
// raw SQL because the sqlc bot.sql generated layer is part of M1 and
// may not be in place when this helper is called.
func seedBotSession(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, createdBy string) uuid.UUID {
t.Helper()
var id uuid.UUID
err := cfg.GetDBPool().QueryRow(ctx, `
INSERT INTO bot_sessions (client_id, created_by)
VALUES ($1, $2)
RETURNING id
`, clientID, createdBy).Scan(&id)
require.NoError(t, err, "seed bot_sessions must succeed")
return id
}
// TestMigration131_UpDownRoundtrip proves the down.sql is complete: it
// drops every object created by up.sql, and a subsequent up.sql re-apply
// re-creates them. Mirrors TestMigration127_UpDownRoundtrip. Best-effort
// cleanup runs the up.sql under context.Background() because t.Context()
// is canceled before t.Cleanup callbacks in Go 1.24+.
func TestMigration131_UpDownRoundtrip(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
upSQL, err := os.ReadFile(chatbotUp131Path)
require.NoError(t, err, "must read migration 131 up.sql at %s", chatbotUp131Path)
downSQL, err := os.ReadFile(chatbotDown131Path)
require.NoError(t, err, "must read migration 131 down.sql at %s", chatbotDown131Path)
t.Cleanup(func() {
// Restore migrated state on the shared container regardless of
// test outcome. context.Background() is intentional — t.Context()
// is canceled before Cleanup callbacks in Go 1.24+.
_, _ = pool.Exec(context.Background(), string(upSQL)) //nolint:usetesting // see comment
})
// 1. Down — single-step because 131 is currently the latest migration.
_, err = pool.Exec(ctx, string(downSQL))
require.NoError(t, err, "down migration 131 must apply cleanly")
// All four tables must be gone.
for _, table := range []string{"bot_sessions", "bot_session_documents", "bot_session_folders", "bot_turns"} {
var oid *string
err = pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, table).Scan(&oid)
require.NoError(t, err)
require.Nilf(t, oid, "%s must not exist after down migration", table)
}
// 2. Up — re-create everything.
_, err = pool.Exec(ctx, string(upSQL))
require.NoError(t, err, "up migration 131 must re-apply cleanly")
// All four tables back.
for _, table := range []string{"bot_sessions", "bot_session_documents", "bot_session_folders", "bot_turns"} {
var oid *string
err = pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, table).Scan(&oid)
require.NoError(t, err)
require.NotNilf(t, oid, "%s must exist after up migration", table)
require.Equal(t, table, *oid)
}
// bot_turns CHECK constraints reappear.
for _, conname := range []string{"bot_turns_status_values", "bot_turns_status_fields_consistent"} {
var contype string
err = pool.QueryRow(ctx, `
SELECT c.contype::text
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE c.conname = $1 AND t.relname = 'bot_turns'
`, conname).Scan(&contype)
require.NoErrorf(t, err, "%s must exist on bot_turns", conname)
require.Equalf(t, "c", contype, "%s must be a CHECK constraint", conname)
}
// Partial unique index reappears.
var indexExists bool
err = pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'bot_turns'
AND indexname = 'bot_turns_one_inflight_per_session'
)
`).Scan(&indexExists)
require.NoError(t, err)
require.True(t, indexExists, "bot_turns_one_inflight_per_session must reappear after up")
// Partial unique index must include the WHERE status='in_flight' clause.
var indexDef string
err = pool.QueryRow(ctx, `
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'bot_turns_one_inflight_per_session'
`).Scan(&indexDef)
require.NoError(t, err)
require.Contains(t, indexDef, "in_flight",
"bot_turns_one_inflight_per_session must filter on status='in_flight'")
// idx_bot_turns_session_completed reappears.
err = pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'bot_turns'
AND indexname = 'idx_bot_turns_session_completed'
)
`).Scan(&indexExists)
require.NoError(t, err)
require.True(t, indexExists, "idx_bot_turns_session_completed must reappear after up")
}
// TestMigration131_BotTurnsCheckConstraints exercises both CHECK
// constraints on bot_turns directly. The status-enum CHECK rejects any
// value outside the documented set; the status/payload consistency CHECK
// enforces the (status, completion, error) matrix from plan §4.
func TestMigration131_BotTurnsCheckConstraints(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
const clientID = "MIG131_CHK"
resetClientForBot(t, ctx, cfg, clientID, "mig131-chk")
sessionID := seedBotSession(t, ctx, cfg, clientID, "tester@example.com")
// Helper: assert the INSERT trips a check_violation (23514) naming
// the expected constraint.
assertCheckViolation := func(t *testing.T, err error, wantConstraint string) {
t.Helper()
require.Error(t, err, "expected CHECK violation, got success")
var pgErr *pgconn.PgError
require.True(t, errors.As(err, &pgErr),
"expected *pgconn.PgError, got %T: %v", err, err)
require.Equal(t, "23514", pgErr.Code,
"expected check_violation SQLSTATE 23514, got %q: %s", pgErr.Code, pgErr.Message)
require.Equal(t, wantConstraint, pgErr.ConstraintName,
"expected %s to be the tripped constraint", wantConstraint)
}
t.Run("status_enum_rejects_unknown", func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion, error)
VALUES ($1, $2, $3, $4, $5, NULL, NULL)
`, sessionID, 1, "p", "frobnicated", uuid.New())
assertCheckViolation(t, err, "bot_turns_status_values")
})
// Each row of this table sets the (status, completion, error) tuple
// the consistency CHECK must reject. ordinal collisions are avoided
// by giving each case its own ordinal.
type fieldsRow struct {
name string
ordinal int
status string
completionPtr *string
errorJSON *string
}
str := func(s string) *string { return &s }
const errJSON = `{"code":"x","message":"y"}`
t.Run("status_fields_consistent", func(t *testing.T) {
cases := []fieldsRow{
{
name: "in_flight_with_completion_rejected",
ordinal: 2,
status: "in_flight",
completionPtr: str("not allowed"),
errorJSON: nil,
},
{
name: "in_flight_with_error_rejected",
ordinal: 3,
status: "in_flight",
completionPtr: nil,
errorJSON: str(errJSON),
},
{
name: "completed_with_error_rejected",
ordinal: 4,
status: "completed",
completionPtr: str("answer"),
errorJSON: str(errJSON),
},
{
name: "completed_without_completion_rejected",
ordinal: 5,
status: "completed",
completionPtr: nil,
errorJSON: nil,
},
{
name: "errored_with_completion_rejected",
ordinal: 6,
status: "errored",
completionPtr: str("oops"),
errorJSON: str(errJSON),
},
{
name: "errored_without_error_rejected",
ordinal: 7,
status: "errored",
completionPtr: nil,
errorJSON: nil,
},
{
name: "abandoned_without_error_rejected",
ordinal: 8,
status: "abandoned",
completionPtr: nil,
errorJSON: nil,
},
{
name: "abandoned_with_completion_rejected",
ordinal: 9,
status: "abandoned",
completionPtr: str("partial"),
errorJSON: str(errJSON),
},
{
name: "session_deleted_without_error_rejected",
ordinal: 10,
status: "session_deleted",
completionPtr: nil,
errorJSON: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion, error)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
`,
sessionID, c.ordinal, "prompt", c.status, uuid.New(),
c.completionPtr, c.errorJSON)
assertCheckViolation(t, err, "bot_turns_status_fields_consistent")
})
}
})
// Positive control: each legal tuple from plan §4 must be accepted.
// One session can hold at most one in_flight (partial unique index),
// so the in_flight case uses a fresh session.
t.Run("legal_status_tuples_accepted", func(t *testing.T) {
legalSessionID := seedBotSession(t, ctx, cfg, clientID, "legal@example.com")
// in_flight with NULL completion and NULL error.
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 1, 'p', 'in_flight', $2)
`, legalSessionID, uuid.New())
require.NoError(t, err, "in_flight with NULLs must be accepted")
// completed with completion non-NULL, error NULL. New session to
// avoid colliding with the legal in_flight row above.
completedSessionID := seedBotSession(t, ctx, cfg, clientID, "completed@example.com")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, 1, 'p', 'completed', $2, 'ok')
`, completedSessionID, uuid.New())
require.NoError(t, err, "completed with completion must be accepted")
// errored with error non-NULL, completion NULL.
erroredSessionID := seedBotSession(t, ctx, cfg, clientID, "errored@example.com")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
VALUES ($1, 1, 'p', 'errored', $2, $3::jsonb)
`, erroredSessionID, uuid.New(), errJSON)
require.NoError(t, err, "errored with error must be accepted")
// abandoned with error non-NULL.
abandonedSessionID := seedBotSession(t, ctx, cfg, clientID, "abandoned@example.com")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
VALUES ($1, 1, 'p', 'abandoned', $2, $3::jsonb)
`, abandonedSessionID, uuid.New(), errJSON)
require.NoError(t, err, "abandoned with error must be accepted")
// session_deleted with error non-NULL.
deletedSessionID := seedBotSession(t, ctx, cfg, clientID, "deleted@example.com")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
VALUES ($1, 1, 'p', 'session_deleted', $2, $3::jsonb)
`, deletedSessionID, uuid.New(), errJSON)
require.NoError(t, err, "session_deleted with error must be accepted")
})
}
// TestMigration131_PartialUniqueIndexInflight proves bot_turns_one_inflight_per_session
// rejects a second in_flight row for the same session, but accepts mixed
// states (in_flight + completed) and multiple completed rows.
func TestMigration131_PartialUniqueIndexInflight(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
const clientID = "MIG131_UNIQ"
resetClientForBot(t, ctx, cfg, clientID, "mig131-uniq")
sessionID := seedBotSession(t, ctx, cfg, clientID, "uniq@example.com")
t.Run("two_inflight_same_session_rejected", func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 1, 'p1', 'in_flight', $2)
`, sessionID, uuid.New())
require.NoError(t, err, "first in_flight row must succeed")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 2, 'p2', 'in_flight', $2)
`, sessionID, uuid.New())
require.Error(t, err, "second in_flight row for same session must be rejected")
var pgErr *pgconn.PgError
require.True(t, errors.As(err, &pgErr),
"expected *pgconn.PgError, got %T: %v", err, err)
require.Equal(t, "23505", pgErr.Code,
"expected unique_violation SQLSTATE 23505, got %q", pgErr.Code)
require.Equal(t, "bot_turns_one_inflight_per_session", pgErr.ConstraintName,
"expected bot_turns_one_inflight_per_session to be the tripped constraint")
})
t.Run("inflight_plus_completed_allowed", func(t *testing.T) {
// Fresh session so we don't collide with the previous subtest.
mixedSession := seedBotSession(t, ctx, cfg, clientID, "mixed@example.com")
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, 1, 'p1', 'completed', $2, 'ok')
`, mixedSession, uuid.New())
require.NoError(t, err, "completed row must succeed")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 2, 'p2', 'in_flight', $2)
`, mixedSession, uuid.New())
require.NoError(t, err, "in_flight after completed must be accepted (different status)")
})
t.Run("two_completed_allowed", func(t *testing.T) {
complSession := seedBotSession(t, ctx, cfg, clientID, "completed-twice@example.com")
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, 1, 'p1', 'completed', $2, 'ok1')
`, complSession, uuid.New())
require.NoError(t, err)
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, 2, 'p2', 'completed', $2, 'ok2')
`, complSession, uuid.New())
require.NoError(t, err, "two completed rows for the same session must be accepted")
})
}
// TestMigration131_BotSessionsPartialIndexes asserts the two partial
// indexes on bot_sessions exist with the documented WHERE clause
// (is_deleted = false). Plan §4: idx_bot_sessions_client_user_active and
// idx_bot_sessions_client_active are both partial indexes.
func TestMigration131_BotSessionsPartialIndexes(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
expected := map[string][]string{
// Map index name -> ordered column list per plan §4.
"idx_bot_sessions_client_user_active": {"client_id", "created_by", "updated_at"},
"idx_bot_sessions_client_active": {"client_id", "updated_at"},
}
for indexName, wantCols := range expected {
t.Run(indexName, func(t *testing.T) {
var indexDef string
err := pool.QueryRow(ctx, `
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'bot_sessions'
AND indexname = $1
`, indexName).Scan(&indexDef)
require.NoErrorf(t, err, "index %s must exist on bot_sessions", indexName)
// WHERE clause must filter is_deleted = false. Postgres
// normalises the clause text, so a substring match is enough.
require.Contains(t, indexDef, "is_deleted = false",
"index %s must be partial on is_deleted = false; got %q",
indexName, indexDef)
// Each expected column must appear in the indexdef text.
for _, col := range wantCols {
require.Containsf(t, indexDef, col,
"index %s definition must reference column %s; got %q",
indexName, col, indexDef)
}
})
}
}
// TestMigration131_ScopeTablesCascade exercises the FK-cascade behavior
// declared in plan §4: deleting a bot_sessions row cascades through
// bot_session_documents and bot_session_folders; deleting a referenced
// document or folder cascades the linkage row in those join tables.
func TestMigration131_ScopeTablesCascade(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
const clientID = "MIG131_CSC"
resetClientForBot(t, ctx, cfg, clientID, "mig131-csc")
// Use the sqlc generated CreateFolder/CreateDocument to seed
// scope-table fixtures. CreateFolder accepts parentId=NULL because
// the migration declares parentId as a nullable FK on folders.
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
t.Run("session_delete_cascades_documents", func(t *testing.T) {
// Seed: one session, one document, one bot_session_documents row.
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-doc@example.com")
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: "mig131_csc_doc_hash_session",
})
require.NoError(t, err, "CreateDocument must succeed")
_, err = pool.Exec(ctx, `
INSERT INTO bot_session_documents (session_id, document_id) VALUES ($1, $2)
`, sessionID, docID)
require.NoError(t, err, "INSERT bot_session_documents must succeed")
// Sanity check the link is present.
var linkCount int
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_documents
WHERE session_id = $1 AND document_id = $2
`, sessionID, docID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 1, linkCount, "link row must exist before delete")
// Delete the session; the link must vanish via cascade.
_, err = pool.Exec(ctx, `DELETE FROM bot_sessions WHERE id = $1`, sessionID)
require.NoError(t, err, "DELETE bot_sessions must succeed")
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_documents WHERE session_id = $1
`, sessionID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 0, linkCount, "link rows must cascade away with parent session")
})
t.Run("session_delete_cascades_folders", func(t *testing.T) {
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-fld@example.com")
folder, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/csc-folder-session",
Parentid: nil,
Clientid: clientID,
Createdby: "tester",
})
require.NoError(t, err, "CreateFolder must succeed")
_, err = pool.Exec(ctx, `
INSERT INTO bot_session_folders (session_id, folder_id) VALUES ($1, $2)
`, sessionID, folder.ID)
require.NoError(t, err, "INSERT bot_session_folders must succeed")
var linkCount int
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_folders
WHERE session_id = $1 AND folder_id = $2
`, sessionID, folder.ID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 1, linkCount, "folder link must exist before delete")
_, err = pool.Exec(ctx, `DELETE FROM bot_sessions WHERE id = $1`, sessionID)
require.NoError(t, err)
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_folders WHERE session_id = $1
`, sessionID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 0, linkCount, "folder link must cascade away with parent session")
})
t.Run("document_delete_cascades_link", func(t *testing.T) {
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-doc2@example.com")
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: "mig131_csc_doc_hash_doc",
})
require.NoError(t, err)
_, err = pool.Exec(ctx, `
INSERT INTO bot_session_documents (session_id, document_id) VALUES ($1, $2)
`, sessionID, docID)
require.NoError(t, err)
// Delete the document; the link row must cascade.
_, err = pool.Exec(ctx, `DELETE FROM documents WHERE id = $1`, docID)
require.NoError(t, err)
var linkCount int
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_documents WHERE document_id = $1
`, docID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 0, linkCount,
"deleting referenced document must cascade away the link row")
})
t.Run("folder_delete_cascades_link", func(t *testing.T) {
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-fld2@example.com")
folder, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/csc-folder-folder",
Parentid: nil,
Clientid: clientID,
Createdby: "tester",
})
require.NoError(t, err)
_, err = pool.Exec(ctx, `
INSERT INTO bot_session_folders (session_id, folder_id) VALUES ($1, $2)
`, sessionID, folder.ID)
require.NoError(t, err)
_, err = pool.Exec(ctx, `DELETE FROM folders WHERE id = $1`, folder.ID)
require.NoError(t, err)
var linkCount int
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_folders WHERE folder_id = $1
`, folder.ID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 0, linkCount,
"deleting referenced folder must cascade away the link row")
})
}
@@ -0,0 +1,921 @@
// Milestone 1 §M1 chatbot repository tests.
//
// These tests are the authoritative behavioral spec for the sqlc-generated
// bot.sql query layer (plan §5) and the new client-scoped document/folder
// queries (plan §5 again, the "client-scoped" bullet list).
//
// Compile contract: this file references the generated method names and
// parameter struct names exactly as plan §5 specifies. Until backend-eng
// adds internal/database/queries/bot.sql plus the client-scoped variants
// in document.sql/folders.sql/custommetadata.sql/labels.sql and re-runs
// `task db:generate`, the file fails to compile. That is the failing
// state we want — the team-lead M1 dispatch explicitly endorsed it.
//
// All tests use:
// - real Postgres via internal/test.CreateDB
// - per-test client/session/turn rows seeded with raw SQL where the
// sqlc layer is not yet available, otherwise via cfg.GetDBQueries()
// - explicit clock arithmetic for grace-window assertions instead of
// time.Sleep, so the test does not flake under slow CI
//
// No mocks. No t.Skip beyond testing.Short. No commented-out tests.
package repository_test
import (
"context"
"testing"
"time"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/serviceconfig"
"queryorchestration/internal/test"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
// botStatusInFlight etc. mirror the status string set defined by the
// bot_turns_status_values CHECK constraint in plan §4. Defined here so
// negative-path tests don't accidentally drift from the migration.
const (
botStatusInFlight = "in_flight"
botStatusCompleted = "completed"
botStatusErrored = "errored"
botStatusAbandoned = "abandoned"
botStatusSessionDeleted = "session_deleted"
)
// resetBotClient mirrors resetClientForBot in bot_migration_test.go but
// is package-local to bot_repository_test.go so the two test files do
// not silently share helpers across compilation boundaries. Same idempotency
// dance: documents first (no cascade from clients), then clients (cascade
// wipes bot_sessions and the join tables).
func resetBotClient(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, name string) {
t.Helper()
pool := cfg.GetDBPool()
_, err := pool.Exec(ctx, `DELETE FROM documents WHERE clientId = $1`, clientID)
require.NoError(t, err, "pre-test DELETE documents must succeed")
_, err = pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID)
require.NoError(t, err, "pre-test DELETE clients must succeed")
err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
Name: name,
Clientid: clientID,
})
require.NoError(t, err, "CreateClient(%s) must succeed", clientID)
}
// seedSession inserts one bot_sessions row with the requested
// (client, created_by, is_deleted) and returns its id.
func seedSession(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, createdBy string, isDeleted bool) uuid.UUID {
t.Helper()
var id uuid.UUID
err := cfg.GetDBPool().QueryRow(ctx, `
INSERT INTO bot_sessions (client_id, created_by, is_deleted)
VALUES ($1, $2, $3)
RETURNING id
`, clientID, createdBy, isDeleted).Scan(&id)
require.NoError(t, err, "seed bot_sessions must succeed")
return id
}
// seedTurn inserts one bot_turns row with the given status and ordinal,
// minting a fresh attempt_id. attemptStartedAt drives the grace-window
// abandonment test directly instead of relying on time.Sleep.
func seedTurn(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, sessionID uuid.UUID, ordinal int, status string, attemptStartedAt time.Time) uuid.UUID {
t.Helper()
attemptID := uuid.New()
pool := cfg.GetDBPool()
switch status {
case botStatusInFlight:
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt)
require.NoError(t, err, "seed in_flight turn must succeed")
case botStatusCompleted:
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at, completion)
VALUES ($1, $2, $3, $4, $5, $6, $6, 'answer')
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt)
require.NoError(t, err, "seed completed turn must succeed")
case botStatusErrored, botStatusAbandoned, botStatusSessionDeleted:
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at, error)
VALUES ($1, $2, $3, $4, $5, $6, $6, $7::jsonb)
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt,
`{"code":"x","message":"y"}`)
require.NoError(t, err, "seed %s turn must succeed", status)
default:
t.Fatalf("seedTurn: unknown status %q", status)
}
return attemptID
}
// fetchTurnStatus returns the current status of (sessionID, ordinal).
// Helper used by retry-reset and abandonment tests.
func fetchTurnStatus(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, sessionID uuid.UUID, ordinal int) string {
t.Helper()
var status string
err := cfg.GetDBPool().QueryRow(ctx, `
SELECT status FROM bot_turns WHERE session_id = $1 AND ordinal = $2
`, sessionID, ordinal).Scan(&status)
require.NoError(t, err)
return status
}
// TestLockBotSessionForOwner_DerivedOrdinals proves the derived columns
// last_seen_ordinal and last_terminal_ordinal in plan §5's owner-lock
// query are computed correctly across a session whose turns include
// in_flight + completed + errored + abandoned at known ordinals.
//
// Layout: ordinal 1 completed, 2 errored, 3 abandoned, 4 in_flight.
// last_seen_ordinal must be 4 (the MAX). last_terminal_ordinal must be 3
// (the MAX where status <> 'in_flight').
func TestLockBotSessionForOwner_DerivedOrdinals(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientID = "BOT_REPO_LOCK"
actor = "u1@example.com"
)
resetBotClient(t, ctx, cfg, clientID, "bot-repo-lock")
sessionID := seedSession(t, ctx, cfg, clientID, actor, false)
now := time.Now().UTC()
seedTurn(t, ctx, cfg, sessionID, 1, botStatusCompleted, now.Add(-30*time.Minute))
seedTurn(t, ctx, cfg, sessionID, 2, botStatusErrored, now.Add(-20*time.Minute))
seedTurn(t, ctx, cfg, sessionID, 3, botStatusAbandoned, now.Add(-10*time.Minute))
seedTurn(t, ctx, cfg, sessionID, 4, botStatusInFlight, now.Add(-1*time.Minute))
// LockBotSessionForOwner is FOR UPDATE — must run inside a tx.
tx, err := pool.Begin(ctx)
require.NoError(t, err)
defer func() { _ = tx.Rollback(ctx) }()
txQueries := queries.WithTx(tx)
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
SessionID: sessionID,
ClientID: clientID,
CreatedBy: actor,
})
require.NoError(t, err, "LockBotSessionForOwner must return one row when (client, owner) match")
require.NotNil(t, row)
require.Equal(t, sessionID, row.ID)
require.Equal(t, clientID, row.ClientID)
require.Equal(t, actor, row.CreatedBy)
require.Equal(t, int32(4), row.LastSeenOrdinal,
"last_seen_ordinal must be MAX(ordinal) = 4")
require.Equal(t, int32(3), row.LastTerminalOrdinal,
"last_terminal_ordinal must be MAX(ordinal) where status <> in_flight = 3")
}
// TestLockBotSessionForOwner_CrossClientRejected enumerates the four
// cells of the (lookup_client, lookup_user) matrix that must miss the
// session created by (clientA, U1):
//
// (clientB, U1) -> 0 rows (cross-client, same user)
// (clientA, U2) -> 0 rows (same client, different user)
// (clientB, U2) -> 0 rows (cross-client and cross-user)
//
// (clientA, U1) is the positive control covered by DerivedOrdinals; not
// repeated here to keep the table tight.
func TestLockBotSessionForOwner_CrossClientRejected(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientA = "BOT_REPO_X_A"
clientB = "BOT_REPO_X_B"
userU1 = "u1@example.com"
userU2 = "u2@example.com"
)
resetBotClient(t, ctx, cfg, clientA, "bot-repo-x-a")
resetBotClient(t, ctx, cfg, clientB, "bot-repo-x-b")
sessionID := seedSession(t, ctx, cfg, clientA, userU1, false)
cases := []struct {
name string
clientID string
createdBy string
}{
{name: "cross_client_same_user", clientID: clientB, createdBy: userU1},
{name: "same_client_other_user", clientID: clientA, createdBy: userU2},
{name: "cross_client_other_user", clientID: clientB, createdBy: userU2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
tx, err := pool.Begin(ctx)
require.NoError(t, err)
defer func() { _ = tx.Rollback(ctx) }()
txQueries := queries.WithTx(tx)
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
SessionID: sessionID,
ClientID: c.clientID,
CreatedBy: c.createdBy,
})
require.Errorf(t, err,
"lookup with (clientID=%s, createdBy=%s) must return ErrNoRows", c.clientID, c.createdBy)
require.ErrorIs(t, err, pgx.ErrNoRows,
"want pgx.ErrNoRows, got %T: %v", err, err)
_ = row
})
}
}
// TestLockBotSessionForOwner_DeletedExcluded proves that a session with
// is_deleted = true is invisible to LockBotSessionForOwner even when the
// caller would otherwise be the rightful owner. Plan §5: WHERE clause
// must include `is_deleted = false`.
func TestLockBotSessionForOwner_DeletedExcluded(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientID = "BOT_REPO_DEL"
actor = "deleted-owner@example.com"
)
resetBotClient(t, ctx, cfg, clientID, "bot-repo-del")
sessionID := seedSession(t, ctx, cfg, clientID, actor, true)
tx, err := pool.Begin(ctx)
require.NoError(t, err)
defer func() { _ = tx.Rollback(ctx) }()
txQueries := queries.WithTx(tx)
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
SessionID: sessionID,
ClientID: clientID,
CreatedBy: actor,
})
require.Error(t, err, "is_deleted=true session must not be returned")
require.ErrorIs(t, err, pgx.ErrNoRows)
_ = row
}
// TestAbandonExpiredInflightForOwner_OwnerOnly proves the EXISTS guard
// in plan §5: AbandonExpiredInflightForOwner mutates only when the
// caller's (client_id, created_by) match the session row. Cross-client
// or cross-user calls are no-ops on the same DB state.
func TestAbandonExpiredInflightForOwner_OwnerOnly(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientA = "BOT_REPO_AB_A"
clientB = "BOT_REPO_AB_B"
userU1 = "u1@example.com"
userU2 = "u2@example.com"
)
resetBotClient(t, ctx, cfg, clientA, "bot-repo-ab-a")
resetBotClient(t, ctx, cfg, clientB, "bot-repo-ab-b")
sessionID := seedSession(t, ctx, cfg, clientA, userU1, false)
// Seed an expired in_flight: attempt_started_at is one hour old.
seedTurn(t, ctx, cfg, sessionID, 1, botStatusInFlight, time.Now().UTC().Add(-1*time.Hour))
// Grace window: 60 seconds. Anything older than 60s must be eligible.
const graceSeconds = 60
t.Run("cross_client_no_op", func(t *testing.T) {
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
SessionID: sessionID,
ClientID: clientB, // wrong client
CreatedBy: userU1,
GraceSeconds: graceSeconds,
})
require.NoError(t, err, "abandon must not error when EXISTS guard fails")
require.Equal(t, botStatusInFlight,
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
"row must remain in_flight when client mismatch")
})
t.Run("cross_user_no_op", func(t *testing.T) {
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
SessionID: sessionID,
ClientID: clientA,
CreatedBy: userU2, // wrong user
GraceSeconds: graceSeconds,
})
require.NoError(t, err)
require.Equal(t, botStatusInFlight,
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
"row must remain in_flight when user mismatch")
})
t.Run("rightful_owner_abandons", func(t *testing.T) {
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
SessionID: sessionID,
ClientID: clientA,
CreatedBy: userU1,
GraceSeconds: graceSeconds,
})
require.NoError(t, err)
require.Equal(t, botStatusAbandoned,
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
"rightful owner must abandon expired in_flight row")
})
}
// TestAbandonExpiredInflightForOwner_GraceWindow proves the grace-window
// boundary: rows with attempt_started_at newer than NOW() - grace are NOT
// abandoned; older rows are. Uses two sibling sessions so each row is
// unambiguously identified by session_id.
func TestAbandonExpiredInflightForOwner_GraceWindow(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientID = "BOT_REPO_GR"
actor = "owner@example.com"
)
resetBotClient(t, ctx, cfg, clientID, "bot-repo-gr")
now := time.Now().UTC()
freshSession := seedSession(t, ctx, cfg, clientID, actor, false)
expiredSession := seedSession(t, ctx, cfg, clientID, actor, false)
// Grace = 120 seconds; fresh row is 30s old, expired row is 600s old.
const graceSeconds = 120
seedTurn(t, ctx, cfg, freshSession, 1, botStatusInFlight, now.Add(-30*time.Second))
seedTurn(t, ctx, cfg, expiredSession, 1, botStatusInFlight, now.Add(-600*time.Second))
// Two abandon calls — one per session. The fresh-session call must be
// a no-op; the expired-session call must mutate the row.
for _, sessionID := range []uuid.UUID{freshSession, expiredSession} {
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
SessionID: sessionID,
ClientID: clientID,
CreatedBy: actor,
GraceSeconds: graceSeconds,
})
require.NoError(t, err)
}
require.Equal(t, botStatusInFlight,
fetchTurnStatus(t, ctx, cfg, freshSession, 1),
"fresh row (within grace) must remain in_flight")
require.Equal(t, botStatusAbandoned,
fetchTurnStatus(t, ctx, cfg, expiredSession, 1),
"expired row (outside grace) must be abandoned")
}
// TestResetBotTurnForRetry_OnlyTerminal proves plan §5: the retry reset
// resets to in_flight only for rows in (errored, abandoned). Rows with
// status in (in_flight, completed, session_deleted) must be no-ops.
func TestResetBotTurnForRetry_OnlyTerminal(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientID = "BOT_REPO_RR"
actor = "retry@example.com"
)
resetBotClient(t, ctx, cfg, clientID, "bot-repo-rr")
type tc struct {
name string
seededStatus string
expectReset bool
expectedStatus string
}
cases := []tc{
{name: "errored_resets", seededStatus: botStatusErrored, expectReset: true, expectedStatus: botStatusInFlight},
{name: "abandoned_resets", seededStatus: botStatusAbandoned, expectReset: true, expectedStatus: botStatusInFlight},
{name: "in_flight_no_op", seededStatus: botStatusInFlight, expectReset: false, expectedStatus: botStatusInFlight},
{name: "completed_no_op", seededStatus: botStatusCompleted, expectReset: false, expectedStatus: botStatusCompleted},
{name: "session_deleted_no_op", seededStatus: botStatusSessionDeleted, expectReset: false, expectedStatus: botStatusSessionDeleted},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Each subtest gets its own session so the partial unique
// index on bot_turns(session_id) WHERE status='in_flight'
// does not interfere across cases.
sessionID := seedSession(t, ctx, cfg, clientID, actor, false)
seededAt := time.Now().UTC().Add(-1 * time.Hour) // far in the past
seedTurn(t, ctx, cfg, sessionID, 1, c.seededStatus, seededAt)
// Snapshot original timestamps so we can prove created_at and
// attempt_started_at advance only when the reset is supposed
// to fire.
var origAttemptStartedAt, origCreatedAt time.Time
var origAttemptID uuid.UUID
err := pool.QueryRow(ctx, `
SELECT attempt_started_at, created_at, attempt_id
FROM bot_turns WHERE session_id = $1 AND ordinal = 1
`, sessionID).Scan(&origAttemptStartedAt, &origCreatedAt, &origAttemptID)
require.NoError(t, err)
newAttemptID := uuid.New()
err = queries.ResetBotTurnForRetry(ctx, &repository.ResetBotTurnForRetryParams{
SessionID: sessionID,
Ordinal: 1,
AttemptID: newAttemptID,
})
require.NoError(t, err, "ResetBotTurnForRetry must not error in any case")
// Read back current state.
var gotStatus string
var gotAttemptID uuid.UUID
var gotAttemptStartedAt, gotCreatedAt time.Time
err = pool.QueryRow(ctx, `
SELECT status, attempt_id, attempt_started_at, created_at
FROM bot_turns WHERE session_id = $1 AND ordinal = 1
`, sessionID).Scan(&gotStatus, &gotAttemptID, &gotAttemptStartedAt, &gotCreatedAt)
require.NoError(t, err)
require.Equal(t, c.expectedStatus, gotStatus,
"status must be %q after reset of seeded %q", c.expectedStatus, c.seededStatus)
if c.expectReset {
require.Equal(t, newAttemptID, gotAttemptID,
"reset must replace attempt_id with the caller-provided value")
require.Truef(t, gotAttemptStartedAt.After(origAttemptStartedAt),
"reset must advance attempt_started_at; orig=%v got=%v",
origAttemptStartedAt, gotAttemptStartedAt)
require.Truef(t, gotCreatedAt.After(origCreatedAt),
"reset must advance created_at; orig=%v got=%v",
origCreatedAt, gotCreatedAt)
} else {
require.Equal(t, origAttemptID, gotAttemptID,
"no-op case must not change attempt_id")
require.Truef(t, gotAttemptStartedAt.Equal(origAttemptStartedAt),
"no-op case must not change attempt_started_at; orig=%v got=%v",
origAttemptStartedAt, gotAttemptStartedAt)
require.Truef(t, gotCreatedAt.Equal(origCreatedAt),
"no-op case must not change created_at; orig=%v got=%v",
origCreatedAt, gotCreatedAt)
}
})
}
}
// TestListLastTurnsForSessions_TurnLimit proves plan §5's preview query
// returns at most turn_limit most-recent turns per session, ordered
// session_id, ordinal DESC.
//
// Layout: 4 sessions × 7 turns each (ordinals 1..7), all completed.
// turn_limit = 3 must yield exactly 12 rows (4 × 3), and within each
// session the rows must be ordinals (7, 6, 5).
func TestListLastTurnsForSessions_TurnLimit(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientID = "BOT_REPO_LIST"
actor = "list@example.com"
)
resetBotClient(t, ctx, cfg, clientID, "bot-repo-list")
sessions := make([]uuid.UUID, 4)
now := time.Now().UTC()
for i := range sessions {
sessions[i] = seedSession(t, ctx, cfg, clientID, actor, false)
for ord := 1; ord <= 7; ord++ {
// Stagger created_at by one second per turn so any future
// secondary ordering by created_at is also deterministic.
seedTurn(t, ctx, cfg, sessions[i], ord, botStatusCompleted,
now.Add(time.Duration(ord)*time.Second))
}
}
rows, err := queries.ListLastTurnsForSessions(ctx, &repository.ListLastTurnsForSessionsParams{
SessionIds: sessions,
TurnLimit: 3,
})
require.NoError(t, err)
require.Len(t, rows, 4*3, "must return exactly turn_limit rows per session")
// Bucket per session, then assert each bucket holds the top 3
// ordinals in DESC order.
bySession := map[uuid.UUID][]int32{}
for _, r := range rows {
bySession[r.SessionID] = append(bySession[r.SessionID], r.Ordinal)
}
require.Len(t, bySession, 4, "all four sessions must appear in the result")
for _, sid := range sessions {
got := bySession[sid]
require.Equal(t, []int32{7, 6, 5}, got,
"session %s must yield ordinals (7,6,5) in DESC order; got %v", sid, got)
}
}
// TestGetDocumentEnrichedForClient_CrossClient proves the client-scoped
// document enriched read returns no row for a cross-client lookup.
// Plan §5 mandates a clientId filter on every document/folder read so
// the chatbot scope cannot leak documents across clients.
func TestGetDocumentEnrichedForClient_CrossClient(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientA = "BOT_REPO_DOC_A"
clientB = "BOT_REPO_DOC_B"
)
resetBotClient(t, ctx, cfg, clientA, "bot-repo-doc-a")
resetBotClient(t, ctx, cfg, clientB, "bot-repo-doc-b")
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientA,
Hash: "bot_repo_doc_hash_enriched",
})
require.NoError(t, err)
t.Run("same_client_returns_row", func(t *testing.T) {
row, err := queries.GetDocumentEnrichedForClient(ctx, &repository.GetDocumentEnrichedForClientParams{
ID: docID,
ClientID: clientA,
})
require.NoError(t, err, "same-client lookup must succeed")
require.NotNil(t, row)
require.Equal(t, docID, row.ID)
require.Equal(t, clientA, row.Clientid)
})
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
_, err := queries.GetDocumentEnrichedForClient(ctx, &repository.GetDocumentEnrichedForClientParams{
ID: docID,
ClientID: clientB,
})
require.Error(t, err, "cross-client lookup must miss")
require.ErrorIs(t, err, pgx.ErrNoRows)
})
}
// TestGetDocumentLabelsForClient_CrossClient mirrors the document
// enriched test for the labels endpoint. Empty result on cross-client
// lookup, populated result on same-client.
func TestGetDocumentLabelsForClient_CrossClient(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientA = "BOT_REPO_LBL_A"
clientB = "BOT_REPO_LBL_B"
)
resetBotClient(t, ctx, cfg, clientA, "bot-repo-lbl-a")
resetBotClient(t, ctx, cfg, clientB, "bot-repo-lbl-b")
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientA,
Hash: "bot_repo_doc_hash_labels",
})
require.NoError(t, err)
_, err = queries.ApplyLabel(ctx, &repository.ApplyLabelParams{
Documentid: docID,
Label: "Ingested", // seeded by migration 110
Appliedby: "tester",
})
require.NoError(t, err)
t.Run("same_client_returns_labels", func(t *testing.T) {
labels, err := queries.GetDocumentLabelsForClient(ctx, &repository.GetDocumentLabelsForClientParams{
DocumentID: docID,
ClientID: clientA,
})
require.NoError(t, err)
require.Len(t, labels, 1, "must return the one applied label")
})
t.Run("cross_client_returns_empty", func(t *testing.T) {
labels, err := queries.GetDocumentLabelsForClient(ctx, &repository.GetDocumentLabelsForClientParams{
DocumentID: docID,
ClientID: clientB,
})
require.NoError(t, err, ":many cross-client must return empty slice, not error")
require.Empty(t, labels, "cross-client must return no labels")
})
}
// TestGetDocumentCustomSchemaIdForClient_CrossClient: same-client returns
// the bound schema id (or NULL); cross-client returns no row.
func TestGetDocumentCustomSchemaIdForClient_CrossClient(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientA = "BOT_REPO_CSI_A"
clientB = "BOT_REPO_CSI_B"
)
resetBotClient(t, ctx, cfg, clientA, "bot-repo-csi-a")
resetBotClient(t, ctx, cfg, clientB, "bot-repo-csi-b")
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientA,
Hash: "bot_repo_doc_hash_csi",
})
require.NoError(t, err)
t.Run("same_client_returns_row", func(t *testing.T) {
schemaID, err := queries.GetDocumentCustomSchemaIdForClient(ctx, &repository.GetDocumentCustomSchemaIdForClientParams{
ID: docID,
ClientID: clientA,
})
require.NoError(t, err, "same-client lookup must succeed even when schema is NULL")
// schemaID is *uuid.UUID by sqlc convention for nullable cols;
// document has no schema bound, so the value must be nil.
require.Nil(t, schemaID,
"unbound document must report nil custom_schema_id, got %v", schemaID)
})
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
_, err := queries.GetDocumentCustomSchemaIdForClient(ctx, &repository.GetDocumentCustomSchemaIdForClientParams{
ID: docID,
ClientID: clientB,
})
require.Error(t, err)
require.ErrorIs(t, err, pgx.ErrNoRows)
})
}
// TestCurrentDocMetadataForClient_CrossClient: same-client returns the
// most recent metadata row; cross-client returns no row.
//
// Renamed from TestGetCurrentDocumentCustomMetadataForClient_CrossClient
// because Postgres NAMEDATALEN caps identifiers at 63 chars; the
// internal/test.GetAlias helper concatenates `postgrestest` with the
// test name and the original 52-char name pushed the alias to 64 chars.
// Postgres truncates silently and rerun resolves to a stale leftover DB.
// Shorter name keeps the alias at 55 chars, well under the cap.
func TestCurrentDocMetadataForClient_CrossClient(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientA = "BOT_REPO_CMD_A"
clientB = "BOT_REPO_CMD_B"
)
resetBotClient(t, ctx, cfg, clientA, "bot-repo-cmd-a")
resetBotClient(t, ctx, cfg, clientB, "bot-repo-cmd-b")
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientA,
Hash: "bot_repo_doc_hash_meta",
})
require.NoError(t, err)
// Seed a metadata row with no schema bound (custom_schema_id NULL is
// permitted on document_custom_metadata per migration 129; the schema
// join in the query returns NULL for schemaName/schemaVersion).
_, err = queries.CreateDocumentCustomMetadata(ctx, &repository.CreateDocumentCustomMetadataParams{
DocumentID: docID,
Metadata: []byte(`{"k":"v"}`),
Version: 1,
CreatedBy: "tester",
})
require.NoError(t, err, "seed CreateDocumentCustomMetadata must succeed")
t.Run("same_client_returns_row", func(t *testing.T) {
row, err := queries.GetCurrentDocumentCustomMetadataForClient(ctx, &repository.GetCurrentDocumentCustomMetadataForClientParams{
DocumentID: docID,
ClientID: clientA,
})
require.NoError(t, err)
require.NotNil(t, row)
require.Equal(t, docID, row.DocumentID)
})
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
_, err := queries.GetCurrentDocumentCustomMetadataForClient(ctx, &repository.GetCurrentDocumentCustomMetadataForClientParams{
DocumentID: docID,
ClientID: clientB,
})
require.Error(t, err)
require.ErrorIs(t, err, pgx.ErrNoRows)
})
}
// TestGetDocumentIDsInFolderTreeForClient_FolderRecursion proves the
// recursive folder tree walk filters clientId at every level. Layout:
//
// client A folders: client B folders:
// /A /B
// /A/sub (parent=/A) /B/sub (parent=/B)
//
// Documents:
//
// docA1 in /A
// docA2 in /A/sub
// docB1 in /B
// docB2 in /B/sub
//
// Calling GetDocumentIDsInFolderTreeForClient(rootFolder=/A, client=A)
// must return {docA1, docA2}; calling it with (/A, client=B) must
// return empty even though folder /A exists, because the clientId
// filter at the recursive step rejects the cross-client root.
func TestGetDocumentIDsInFolderTreeForClient_FolderRecursion(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientA = "BOT_REPO_FT_A"
clientB = "BOT_REPO_FT_B"
)
resetBotClient(t, ctx, cfg, clientA, "bot-repo-ft-a")
resetBotClient(t, ctx, cfg, clientB, "bot-repo-ft-b")
// Folder tree for client A.
rootA, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/ft-a-root",
Parentid: nil,
Clientid: clientA,
Createdby: "tester",
})
require.NoError(t, err)
subA, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/ft-a-root/sub",
Parentid: &rootA.ID,
Clientid: clientA,
Createdby: "tester",
})
require.NoError(t, err)
// Folder tree for client B.
rootB, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/ft-b-root",
Parentid: nil,
Clientid: clientB,
Createdby: "tester",
})
require.NoError(t, err)
subB, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/ft-b-root/sub",
Parentid: &rootB.ID,
Clientid: clientB,
Createdby: "tester",
})
require.NoError(t, err)
// Documents in each folder. The folder column is camelCase per plan §4.
mkDoc := func(t *testing.T, clientID string, folderID uuid.UUID, hash string) uuid.UUID {
t.Helper()
var id uuid.UUID
err := pool.QueryRow(ctx, `
INSERT INTO documents (clientId, hash, folderId)
VALUES ($1, $2, $3) RETURNING id
`, clientID, hash, folderID).Scan(&id)
require.NoError(t, err)
return id
}
docA1 := mkDoc(t, clientA, rootA.ID, "ft_doc_a1")
docA2 := mkDoc(t, clientA, subA.ID, "ft_doc_a2")
_ = mkDoc(t, clientB, rootB.ID, "ft_doc_b1")
_ = mkDoc(t, clientB, subB.ID, "ft_doc_b2")
t.Run("client_A_root_returns_only_A_docs", func(t *testing.T) {
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
FolderID: rootA.ID,
ClientID: clientA,
})
require.NoError(t, err)
require.ElementsMatch(t, []uuid.UUID{docA1, docA2}, ids,
"folder tree of client A must include both A documents and exclude B documents")
})
t.Run("cross_client_root_returns_empty", func(t *testing.T) {
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
FolderID: rootA.ID, // A's root folder
ClientID: clientB, // queried as client B
})
require.NoError(t, err)
require.Empty(t, ids,
"cross-client recursion must reject the root and return zero documents")
})
t.Run("client_B_root_returns_only_B_docs", func(t *testing.T) {
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
FolderID: rootB.ID,
ClientID: clientB,
})
require.NoError(t, err)
require.Len(t, ids, 2, "client B tree must yield exactly two documents")
})
}
@@ -195,6 +195,82 @@ func (q *Queries) GetCurrentDocumentCustomMetadata(ctx context.Context, document
return &i, err
}
const getCurrentDocumentCustomMetadataForClient = `-- name: GetCurrentDocumentCustomMetadataForClient :one
SELECT
dcm.id,
dcm.document_id,
dcm.metadata,
dcm.version,
dcm.created_at,
dcm.created_by,
cms.id AS schema_id,
cms.name AS schema_name,
cms.version AS schema_version
FROM document_custom_metadata dcm
JOIN documents d ON d.id = dcm.document_id
LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id
WHERE dcm.document_id = $1
AND d.clientId = $2
ORDER BY dcm.version DESC
LIMIT 1
`
type GetCurrentDocumentCustomMetadataForClientParams struct {
DocumentID uuid.UUID `db:"document_id"`
ClientID string `db:"client_id"`
}
type GetCurrentDocumentCustomMetadataForClientRow struct {
ID uuid.UUID `db:"id"`
DocumentID uuid.UUID `db:"document_id"`
Metadata []byte `db:"metadata"`
Version int32 `db:"version"`
CreatedAt pgtype.Timestamptz `db:"created_at"`
CreatedBy string `db:"created_by"`
SchemaID *uuid.UUID `db:"schema_id"`
SchemaName *string `db:"schema_name"`
SchemaVersion *int32 `db:"schema_version"`
}
// Chatbot-scoped variant of GetCurrentDocumentCustomMetadata. Same column
// set (the metadata row decorated with schema id/name/version), but the
// joined documents row must belong to @client_id; cross-client calls miss
// with pgx.ErrNoRows. Plan §5.
//
// SELECT
// dcm.id,
// dcm.document_id,
// dcm.metadata,
// dcm.version,
// dcm.created_at,
// dcm.created_by,
// cms.id AS schema_id,
// cms.name AS schema_name,
// cms.version AS schema_version
// FROM document_custom_metadata dcm
// JOIN documents d ON d.id = dcm.document_id
// LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id
// WHERE dcm.document_id = $1
// AND d.clientId = $2
// ORDER BY dcm.version DESC
// LIMIT 1
func (q *Queries) GetCurrentDocumentCustomMetadataForClient(ctx context.Context, arg *GetCurrentDocumentCustomMetadataForClientParams) (*GetCurrentDocumentCustomMetadataForClientRow, error) {
row := q.db.QueryRow(ctx, getCurrentDocumentCustomMetadataForClient, arg.DocumentID, arg.ClientID)
var i GetCurrentDocumentCustomMetadataForClientRow
err := row.Scan(
&i.ID,
&i.DocumentID,
&i.Metadata,
&i.Version,
&i.CreatedAt,
&i.CreatedBy,
&i.SchemaID,
&i.SchemaName,
&i.SchemaVersion,
)
return &i, err
}
const getDocumentClientID = `-- name: GetDocumentClientID :one
SELECT clientId FROM documents WHERE id = $1
`
@@ -353,6 +429,34 @@ func (q *Queries) GetDocumentCustomSchemaId(ctx context.Context, id uuid.UUID) (
return custom_schema_id, err
}
const getDocumentCustomSchemaIdForClient = `-- name: GetDocumentCustomSchemaIdForClient :one
SELECT custom_schema_id
FROM documents
WHERE id = $1
AND clientId = $2
`
type GetDocumentCustomSchemaIdForClientParams struct {
ID uuid.UUID `db:"id"`
ClientID string `db:"client_id"`
}
// Chatbot-scoped variant of GetDocumentCustomSchemaId: adds a clientId
// filter so cross-client lookups miss with pgx.ErrNoRows. Same scalar
// result type (nullable uuid) as the non-scoped query — only the WHERE
// clause differs. Plan §5.
//
// SELECT custom_schema_id
// FROM documents
// WHERE id = $1
// AND clientId = $2
func (q *Queries) GetDocumentCustomSchemaIdForClient(ctx context.Context, arg *GetDocumentCustomSchemaIdForClientParams) (*uuid.UUID, error) {
row := q.db.QueryRow(ctx, getDocumentCustomSchemaIdForClient, arg.ID, arg.ClientID)
var custom_schema_id *uuid.UUID
err := row.Scan(&custom_schema_id)
return custom_schema_id, err
}
const getDocumentSchemaBindingForReset = `-- name: GetDocumentSchemaBindingForReset :one
SELECT
d.custom_schema_id,
@@ -288,6 +288,81 @@ func (q *Queries) GetDocumentEnriched(ctx context.Context, id uuid.UUID) (*GetDo
return &i, err
}
const getDocumentEnrichedForClient = `-- name: GetDocumentEnrichedForClient :one
SELECT
d.id,
d.clientId,
d.hash,
d.folderId,
d.filename,
d.originalPath,
d.file_size_bytes,
d.custom_schema_id,
EXISTS(
SELECT 1 FROM document_custom_metadata
WHERE document_id = d.id
) AS has_custom_metadata
FROM documents d
WHERE d.id = $1
AND d.clientId = $2
`
type GetDocumentEnrichedForClientParams struct {
ID uuid.UUID `db:"id"`
ClientID string `db:"client_id"`
}
type GetDocumentEnrichedForClientRow struct {
ID uuid.UUID `db:"id"`
Clientid string `db:"clientid"`
Hash string `db:"hash"`
Folderid *uuid.UUID `db:"folderid"`
Filename *string `db:"filename"`
Originalpath *string `db:"originalpath"`
FileSizeBytes *int64 `db:"file_size_bytes"`
CustomSchemaID *uuid.UUID `db:"custom_schema_id"`
HasCustomMetadata bool `db:"has_custom_metadata"`
}
// Chatbot-scoped read: same column set as GetDocumentEnriched, but adds a
// clientId filter so cross-client lookups miss with pgx.ErrNoRows.
// Plan §5 mandates a clientId filter on every chatbot document/folder
// read so document scope cannot leak between clients. documents.clientId
// is the unquoted (lowercased) varchar column from migration 5.
//
// SELECT
// d.id,
// d.clientId,
// d.hash,
// d.folderId,
// d.filename,
// d.originalPath,
// d.file_size_bytes,
// d.custom_schema_id,
// EXISTS(
// SELECT 1 FROM document_custom_metadata
// WHERE document_id = d.id
// ) AS has_custom_metadata
// FROM documents d
// WHERE d.id = $1
// AND d.clientId = $2
func (q *Queries) GetDocumentEnrichedForClient(ctx context.Context, arg *GetDocumentEnrichedForClientParams) (*GetDocumentEnrichedForClientRow, error) {
row := q.db.QueryRow(ctx, getDocumentEnrichedForClient, arg.ID, arg.ClientID)
var i GetDocumentEnrichedForClientRow
err := row.Scan(
&i.ID,
&i.Clientid,
&i.Hash,
&i.Folderid,
&i.Filename,
&i.Originalpath,
&i.FileSizeBytes,
&i.CustomSchemaID,
&i.HasCustomMetadata,
)
return &i, err
}
const getDocumentEntry = `-- name: GetDocumentEntry :one
SELECT documentId, bucket, key FROM documentEntries WHERE documentId = $1 ORDER BY id DESC LIMIT 1
`
@@ -178,6 +178,75 @@ func (q *Queries) GetDocumentIDsInFolderTree(ctx context.Context, folderID *uuid
return items, nil
}
const getDocumentIDsInFolderTreeForClient = `-- name: GetDocumentIDsInFolderTreeForClient :many
WITH RECURSIVE folder_tree AS (
SELECT id FROM folders
WHERE id = $2::uuid
AND clientId = $1::varchar
UNION ALL
SELECT f.id FROM folders f
JOIN folder_tree ft ON f.parentId = ft.id
WHERE f.clientId = $1::varchar
)
SELECT id FROM documents
WHERE folderId IN (SELECT id FROM folder_tree)
AND clientId = $1::varchar
`
type GetDocumentIDsInFolderTreeForClientParams struct {
ClientID string `db:"client_id"`
FolderID uuid.UUID `db:"folder_id"`
}
// Chatbot-scoped recursive folder tree walk. Plan §5 mandates that the
// clientId filter applies at every recursive step AND at the final
// document selection so a cross-client root cannot leak documents.
//
// Step-by-step:
//
// - Base case: select the root folder only when its clientId matches.
//
// - Recursive case: descend through folders.parentId, requiring the
// child folder's clientId to match. A folder owned by a different
// client cannot enter the CTE even if its parent did (which it
// cannot, because the base case already rejects cross-client roots).
//
// - Final selection: documents must reside in folder_tree AND share
// the same clientId, defending against any future case where a
// document row references a folder owned by a different client.
//
// WITH RECURSIVE folder_tree AS (
// SELECT id FROM folders
// WHERE id = $2::uuid
// AND clientId = $1::varchar
// UNION ALL
// SELECT f.id FROM folders f
// JOIN folder_tree ft ON f.parentId = ft.id
// WHERE f.clientId = $1::varchar
// )
// SELECT id FROM documents
// WHERE folderId IN (SELECT id FROM folder_tree)
// AND clientId = $1::varchar
func (q *Queries) GetDocumentIDsInFolderTreeForClient(ctx context.Context, arg *GetDocumentIDsInFolderTreeForClientParams) ([]uuid.UUID, error) {
rows, err := q.db.Query(ctx, getDocumentIDsInFolderTreeForClient, arg.ClientID, arg.FolderID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []uuid.UUID{}
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getDocumentsByFolder = `-- name: GetDocumentsByFolder :many
SELECT id, clientid, hash, batch_id, filename, folderid, originalpath, file_size_bytes, custom_schema_id FROM documents
WHERE folderId = $1
@@ -190,6 +190,58 @@ func (q *Queries) GetDocumentLabels(ctx context.Context, documentid uuid.UUID) (
return items, nil
}
const getDocumentLabelsForClient = `-- name: GetDocumentLabelsForClient :many
SELECT dl.id, dl.documentid, dl.label, dl.appliedat, dl.appliedby
FROM documentLabels dl
JOIN documents d ON d.id = dl.documentId
WHERE dl.documentId = $1
AND d.clientId = $2
ORDER BY dl.appliedAt DESC
`
type GetDocumentLabelsForClientParams struct {
DocumentID uuid.UUID `db:"document_id"`
ClientID string `db:"client_id"`
}
// Chatbot-scoped variant of GetDocumentLabels. Joins documents to enforce
// the clientId filter so cross-client lookups return an empty slice
// rather than another tenant's labels. Returns the same documentLabels
// column shape as GetDocumentLabels (not the joined documents row).
// Plan §5.
//
// SELECT dl.id, dl.documentid, dl.label, dl.appliedat, dl.appliedby
// FROM documentLabels dl
// JOIN documents d ON d.id = dl.documentId
// WHERE dl.documentId = $1
// AND d.clientId = $2
// ORDER BY dl.appliedAt DESC
func (q *Queries) GetDocumentLabelsForClient(ctx context.Context, arg *GetDocumentLabelsForClientParams) ([]*Documentlabel, error) {
rows, err := q.db.Query(ctx, getDocumentLabelsForClient, arg.DocumentID, arg.ClientID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []*Documentlabel{}
for rows.Next() {
var i Documentlabel
if err := rows.Scan(
&i.ID,
&i.Documentid,
&i.Label,
&i.Appliedat,
&i.Appliedby,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getDocumentsByLabel = `-- name: GetDocumentsByLabel :many
SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes, d.custom_schema_id
FROM documents d
+36
View File
@@ -366,6 +366,42 @@ type BatchUpload struct {
FileSizeBytes *int64 `db:"file_size_bytes"`
}
type BotSession struct {
ID uuid.UUID `db:"id"`
ClientID string `db:"client_id"`
CreatedBy string `db:"created_by"`
CreatedAt pgtype.Timestamptz `db:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
Title string `db:"title"`
State []byte `db:"state"`
IsDeleted bool `db:"is_deleted"`
}
type BotSessionDocument struct {
SessionID uuid.UUID `db:"session_id"`
DocumentID uuid.UUID `db:"document_id"`
}
type BotSessionFolder struct {
SessionID uuid.UUID `db:"session_id"`
FolderID uuid.UUID `db:"folder_id"`
}
type BotTurn struct {
SessionID uuid.UUID `db:"session_id"`
Ordinal int32 `db:"ordinal"`
Prompt string `db:"prompt"`
Completion *string `db:"completion"`
Status string `db:"status"`
AttemptID uuid.UUID `db:"attempt_id"`
AttemptStartedAt pgtype.Timestamptz `db:"attempt_started_at"`
CreatedAt pgtype.Timestamptz `db:"created_at"`
LatencyMs *int32 `db:"latency_ms"`
TokensIn *int32 `db:"tokens_in"`
TokensOut *int32 `db:"tokens_out"`
Error []byte `db:"error"`
}
type Client struct {
Clientid string `db:"clientid"`
Name string `db:"name"`