mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 05:49:12 +02:00
* feat(autopilot): add View/Write permission layer Autopilot write and execute operations were gated only by workspace membership, so any member could edit, delete, trigger, or rotate the webhook of any autopilot, and GetAutopilot returned webhook tokens to every member (a token alone can trigger the autopilot). - Add canWriteAutopilot / requireAutopilotWrite: update, delete, trigger, replay-delivery, and all trigger/secret management now require the autopilot creator or a workspace owner/admin. - Redact webhook_token/path/url in GetAutopilot for callers without write access; trigger metadata otherwise stays visible (View default = all members). Creating an autopilot stays open to any member. - ANDs with the existing private-assignee-agent dispatch gate. MUL-3807 Co-authored-by: multica-agent <github@multica.ai> * feat(autopilot): delegate write access via collaborators + manage-access UI Adds an explicit grant primitive so an autopilot's creator/admin can authorize specific workspace members to manage it, with a frontend entry point — beyond the implicit creator/owner-admin set from the prior commit. Backend: - New autopilot_collaborator table (migration 128, members-only, app-layer cleanup, no FK) + sqlc queries. - memberCanWriteAutopilot now also honors explicit collaborators; the write gate, webhook-secret redaction, and a new per-caller can_write flag (on list + detail) all flow through it. - POST/DELETE /api/autopilots/{id}/collaborators (writer-gated); GetAutopilot embeds the collaborators list. Delete cleans up grants in its transaction. - Tests: grant->write->revoke flow, non-writer can't grant, non-member rejected. Frontend (web + desktop via packages/views): - ManageAccessDialog: member picker to grant/revoke, current list with remove. - 'Manage access' entry in the autopilot detail header; edit/run/add-trigger/ delete and the list-row kebab + per-trigger rotate/delete now gate on can_write (absent => allowed, server stays the gate). - can_write wired through types/schema/api client/mutations; en + zh-Hans copy. MUL-3807 Co-authored-by: multica-agent <github@multica.ai> * fix(autopilot): add manage-access i18n keys to ja/ko locales The locale parity test requires every non-EN bundle to cover every EN key. The prior commit added detail.manage_access + the access.* block to en and zh-Hans only, failing parity for ja and ko. Add the translated keys to both. Co-authored-by: multica-agent <github@multica.ai> * fix(autopilot): restrict access-list management to creator/admin only Final-review fix: AddAutopilotCollaborator/RemoveAutopilotCollaborator used requireAutopilotWrite, which counts granted collaborators as writers — so a collaborator could in turn grant/revoke others, a privilege escalation contradicting the 'collaborators cannot re-grant' design. - New requireAutopilotAccessManagement guard uses the narrower autopilotWriteByOwnership predicate (creator or workspace owner/admin only); swapped into both collaborator endpoints. Collaborators keep their edit/trigger/secret write-execute rights. - GetAutopilot now also stamps can_manage_access (narrower than can_write); the detail page gates the 'Manage access' button on it so collaborators no longer see an entry that would 403. - Tests: collaborator grant-others -> 403, revoke-peer -> 403, while retaining edit; can_manage_access true for owner, false for collaborator. MUL-3807 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
455 lines
17 KiB
SQL
455 lines
17 KiB
SQL
-- =====================
|
|
-- Autopilot CRUD
|
|
-- =====================
|
|
|
|
-- name: ListAutopilots :many
|
|
-- List rows carry three derived columns the list UI needs (trigger badges,
|
|
-- next run, last-run outcome) so the page never has to N+1 into the detail
|
|
-- endpoint. trigger_kinds/next_run_at only consider ENABLED triggers — the
|
|
-- columns answer "how does this fire today", not "what is configured".
|
|
-- last_run_status is COALESCEd to '' (never ran) because sqlc cannot infer
|
|
-- nullability through a scalar subquery; the handler maps '' back to omitted.
|
|
SELECT
|
|
sqlc.embed(a),
|
|
(
|
|
SELECT array_agg(DISTINCT t.kind ORDER BY t.kind)
|
|
FROM autopilot_trigger t
|
|
WHERE t.autopilot_id = a.id AND t.enabled
|
|
)::text[] AS trigger_kinds,
|
|
(
|
|
SELECT min(t.next_run_at)
|
|
FROM autopilot_trigger t
|
|
WHERE t.autopilot_id = a.id AND t.enabled AND t.kind = 'schedule'
|
|
)::timestamptz AS next_run_at,
|
|
COALESCE((
|
|
SELECT r.status
|
|
FROM autopilot_run r
|
|
WHERE r.autopilot_id = a.id
|
|
ORDER BY r.triggered_at DESC
|
|
LIMIT 1
|
|
), '')::text AS last_run_status
|
|
FROM autopilot a
|
|
WHERE a.workspace_id = $1
|
|
AND (sqlc.narg('status')::text IS NULL OR a.status = sqlc.narg('status'))
|
|
ORDER BY a.created_at DESC;
|
|
|
|
-- name: GetAutopilot :one
|
|
SELECT * FROM autopilot
|
|
WHERE id = $1;
|
|
|
|
-- name: GetAutopilotInWorkspace :one
|
|
SELECT * FROM autopilot
|
|
WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: CreateAutopilot :one
|
|
INSERT INTO autopilot (
|
|
workspace_id, title, description, assignee_type, assignee_id,
|
|
status, execution_mode, issue_title_template, project_id,
|
|
created_by_type, created_by_id
|
|
) VALUES (
|
|
$1, $2, sqlc.narg('description'), $3, $4,
|
|
$5, $6, sqlc.narg('issue_title_template'), sqlc.narg('project_id'),
|
|
$7, $8
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateAutopilot :one
|
|
UPDATE autopilot SET
|
|
title = COALESCE(sqlc.narg('title'), title),
|
|
description = COALESCE(sqlc.narg('description'), description),
|
|
assignee_type = COALESCE(sqlc.narg('assignee_type'), assignee_type),
|
|
assignee_id = COALESCE(sqlc.narg('assignee_id')::uuid, assignee_id),
|
|
status = COALESCE(sqlc.narg('status'), status),
|
|
execution_mode = COALESCE(sqlc.narg('execution_mode'), execution_mode),
|
|
issue_title_template = sqlc.narg('issue_title_template'),
|
|
project_id = sqlc.narg('project_id'),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteAutopilot :exec
|
|
DELETE FROM autopilot WHERE id = $1;
|
|
|
|
-- name: UpdateAutopilotLastRunAt :exec
|
|
UPDATE autopilot SET last_run_at = now(), updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- =====================
|
|
-- Autopilot Trigger CRUD
|
|
-- =====================
|
|
|
|
-- name: ListAutopilotTriggers :many
|
|
SELECT * FROM autopilot_trigger
|
|
WHERE autopilot_id = $1
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: GetAutopilotTrigger :one
|
|
SELECT * FROM autopilot_trigger
|
|
WHERE id = $1;
|
|
|
|
-- name: CreateAutopilotTrigger :one
|
|
INSERT INTO autopilot_trigger (
|
|
autopilot_id, kind, enabled, cron_expression, timezone,
|
|
next_run_at, webhook_token, label, provider, event_filters
|
|
) VALUES (
|
|
$1, $2, $3, sqlc.narg('cron_expression'), sqlc.narg('timezone'),
|
|
sqlc.narg('next_run_at'), sqlc.narg('webhook_token'), sqlc.narg('label'),
|
|
COALESCE(sqlc.narg('provider')::text, 'generic'),
|
|
sqlc.narg('event_filters')
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateAutopilotTrigger :one
|
|
UPDATE autopilot_trigger SET
|
|
enabled = COALESCE(sqlc.narg('enabled')::boolean, enabled),
|
|
cron_expression = COALESCE(sqlc.narg('cron_expression'), cron_expression),
|
|
timezone = COALESCE(sqlc.narg('timezone'), timezone),
|
|
next_run_at = sqlc.narg('next_run_at'),
|
|
label = COALESCE(sqlc.narg('label'), label),
|
|
event_filters = COALESCE(sqlc.narg('event_filters'), event_filters),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteAutopilotTrigger :exec
|
|
DELETE FROM autopilot_trigger WHERE id = $1;
|
|
|
|
-- name: AdvanceTriggerNextRun :exec
|
|
UPDATE autopilot_trigger
|
|
SET next_run_at = sqlc.narg('next_run_at'),
|
|
last_fired_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: GetWebhookTriggerByToken :one
|
|
-- Look up a webhook trigger by its public bearer token. Joined to autopilot
|
|
-- so the webhook handler can derive the workspace from the trigger's parent
|
|
-- without trusting any request header. The handler still re-loads the
|
|
-- Autopilot via GetAutopilot and cross-checks WorkspaceID matches the row's
|
|
-- autopilot_workspace_id.
|
|
SELECT t.*, a.workspace_id AS autopilot_workspace_id
|
|
FROM autopilot_trigger t
|
|
JOIN autopilot a ON a.id = t.autopilot_id
|
|
WHERE t.kind = 'webhook'
|
|
AND t.webhook_token = $1;
|
|
|
|
-- name: TouchAutopilotTriggerFiredAt :exec
|
|
-- Bumps last_fired_at after a webhook fires, regardless of whether the
|
|
-- dispatch succeeded, was admission-skipped, or even if Autopilot status
|
|
-- transitioned to paused/disabled at exactly the wrong moment. Disabled /
|
|
-- paused early-return paths in the handler never call this.
|
|
UPDATE autopilot_trigger
|
|
SET last_fired_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: RotateAutopilotTriggerWebhookToken :one
|
|
-- Rotates the bearer token for a webhook trigger. Restricted to kind='webhook'
|
|
-- so an accidental call against a schedule/api trigger is a no-op (returns no
|
|
-- rows) rather than corrupting unrelated state.
|
|
UPDATE autopilot_trigger
|
|
SET webhook_token = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
AND kind = 'webhook'
|
|
RETURNING *;
|
|
|
|
-- name: SetAutopilotTriggerWebhookToken :one
|
|
-- Sets the webhook token at creation time. CreateAutopilotTrigger inserts the
|
|
-- row first (using its full 8-arg signature), then this query attaches the
|
|
-- token. Splitting the create + token-set keeps the existing CreateAutopilotTrigger
|
|
-- query usable by the schedule path without forcing every caller to think
|
|
-- about webhook_token.
|
|
UPDATE autopilot_trigger
|
|
SET webhook_token = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: SetAutopilotTriggerSigningSecret :one
|
|
-- Writes the signing secret for a webhook trigger. Kept as a dedicated query
|
|
-- (not a field on UpdateAutopilotTrigger) so the request body for the
|
|
-- write-only endpoint only ever carries the secret value, with no risk of an
|
|
-- accidental log line leaking it alongside other fields. Restricted to
|
|
-- webhook triggers to avoid corrupting unrelated state.
|
|
UPDATE autopilot_trigger
|
|
SET signing_secret = sqlc.narg('signing_secret'),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
AND kind = 'webhook'
|
|
RETURNING *;
|
|
|
|
-- =====================
|
|
-- Autopilot Run Management
|
|
-- =====================
|
|
|
|
-- name: CreateAutopilotRun :one
|
|
-- squad_id is an attribution hook: set to the assignee squad when the
|
|
-- parent autopilot has assignee_type='squad', NULL otherwise. The executing
|
|
-- agent_id on agent_task_queue still records who actually ran the work
|
|
-- (the squad leader); squad_id lets reports group by squad without a join.
|
|
--
|
|
-- planned_at carries the canonical UTC fire time for scheduled triggers
|
|
-- (source='schedule'); it stays NULL for manual / webhook / api sources
|
|
-- which have no canonical occurrence. Combined with the partial unique
|
|
-- index uq_autopilot_run_trigger_planned, this gives dispatch-layer
|
|
-- idempotency: a stale-steal retry at the same plan_time cannot create
|
|
-- a second run for the same (trigger_id, planned_at) pair (MUL-3551).
|
|
INSERT INTO autopilot_run (
|
|
autopilot_id, trigger_id, source, status, trigger_payload, squad_id, planned_at
|
|
) VALUES (
|
|
$1, sqlc.narg('trigger_id'), $2, $3, sqlc.narg('trigger_payload'),
|
|
sqlc.narg('squad_id'), sqlc.narg('planned_at')
|
|
) RETURNING *;
|
|
|
|
-- name: GetAutopilotRunByTriggerAndPlanned :one
|
|
-- Idempotent lookup used by DispatchAutopilotForPlan to detect a
|
|
-- crash-during-dispatch retry: if a row already exists for this
|
|
-- (trigger_id, planned_at), the caller reuses it instead of creating a
|
|
-- duplicate. The partial unique index covers the same key, so a race
|
|
-- between "look up then insert" still resolves to a single row — this
|
|
-- query is just the fast path that lets us skip the INSERT when we
|
|
-- can see the prior row clearly. Returns no rows for the (much more
|
|
-- common) first-time dispatch.
|
|
SELECT * FROM autopilot_run
|
|
WHERE trigger_id = $1
|
|
AND planned_at = $2
|
|
LIMIT 1;
|
|
|
|
-- name: RecoverPartialAutopilotRun :exec
|
|
-- Recovers a partial-state autopilot_run from a crashed first attempt
|
|
-- (the runner wrote the run row but died before creating the downstream
|
|
-- issue/task) so that a subsequent DispatchAutopilotForPlan call can
|
|
-- create a fresh run at the same (trigger_id, planned_at).
|
|
--
|
|
-- Setting planned_at = NULL clears the partial-unique slot held by
|
|
-- uq_autopilot_run_trigger_planned, letting the new INSERT proceed.
|
|
-- The row stays in autopilot_run as a FAILED record (with a recovery
|
|
-- reason) so ops still see the abandoned attempt in the run history —
|
|
-- it is not silently deleted.
|
|
UPDATE autopilot_run
|
|
SET status = 'failed',
|
|
completed_at = now(),
|
|
failure_reason = 'recovered partial dispatch (crashed before downstream creation)',
|
|
planned_at = NULL
|
|
WHERE id = $1;
|
|
|
|
-- name: GetAutopilotRun :one
|
|
SELECT * FROM autopilot_run
|
|
WHERE id = $1;
|
|
|
|
-- name: ListAutopilotRuns :many
|
|
SELECT * FROM autopilot_run
|
|
WHERE autopilot_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT $2 OFFSET $3;
|
|
|
|
-- name: UpdateAutopilotRunIssueCreated :one
|
|
UPDATE autopilot_run
|
|
SET status = 'issue_created', issue_id = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunRunning :one
|
|
UPDATE autopilot_run
|
|
SET status = 'running', task_id = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunCompleted :one
|
|
UPDATE autopilot_run
|
|
SET status = 'completed', completed_at = now(), result = sqlc.narg('result')
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunFailed :one
|
|
UPDATE autopilot_run
|
|
SET status = 'failed', completed_at = now(), failure_reason = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunSkipped :one
|
|
-- Marks an autopilot_run as skipped without enqueueing any task. Used by the
|
|
-- pre-flight admission check when the assignee agent's runtime is offline:
|
|
-- creating an issue / task in that state would just pile a doomed job onto
|
|
-- agent_task_queue (the canonical "持续给离线 local agent 入队" symptom from
|
|
-- MUL-1899). Recording the skip + reason gives the UI / failure monitor / ops
|
|
-- a paper trail without polluting the failure ratio.
|
|
UPDATE autopilot_run
|
|
SET status = 'skipped', completed_at = now(), failure_reason = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunSkippedWithResult :one
|
|
UPDATE autopilot_run
|
|
SET status = 'skipped',
|
|
completed_at = now(),
|
|
failure_reason = $2,
|
|
result = sqlc.narg('result')
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- =====================
|
|
-- Scheduler Queries
|
|
-- =====================
|
|
|
|
-- name: ListSchedulableAutopilotTriggers :many
|
|
-- Lists every schedule trigger the autopilot_schedule_dispatch JobSpec
|
|
-- should consider this tick. Returns just the columns the scheduler's
|
|
-- scope provider + PlansForScope hook need; the full trigger row is
|
|
-- re-loaded by the handler so a trigger update between scope-list and
|
|
-- handler-run sees the latest enabled / cron values.
|
|
--
|
|
-- last_fired_at is read so the planner hook can anchor cold-start
|
|
-- enumeration on the most recent successful fire (set by either the
|
|
-- legacy goroutine before the new scheduler took over, or the new
|
|
-- scheduler's own post-dispatch advance — AdvanceTriggerNextRun, falling
|
|
-- back to TouchAutopilotTriggerFiredAt on a cron parse error). Without it,
|
|
-- a trigger that was created days ago and fired by the legacy code
|
|
-- looks like a brand-new trigger to the new scheduler on first tick
|
|
-- and the half-open `(created_at, now]` enumeration replays the most
|
|
-- recent already-fired occurrence — exactly the post-deploy
|
|
-- spurious-fire reported on MUL-3551 dev.
|
|
--
|
|
-- Filters out webhook / api triggers, disabled triggers, paused/archived
|
|
-- autopilots, and any trigger missing its cron expression. ORDER BY id
|
|
-- keeps the per-tick scope list stable across replicas.
|
|
SELECT t.id, t.autopilot_id, t.cron_expression, t.timezone, t.created_at, t.last_fired_at
|
|
FROM autopilot_trigger t
|
|
JOIN autopilot a ON a.id = t.autopilot_id
|
|
WHERE t.kind = 'schedule'
|
|
AND t.enabled = TRUE
|
|
AND a.status = 'active'
|
|
AND t.cron_expression IS NOT NULL
|
|
AND t.cron_expression <> ''
|
|
ORDER BY t.id;
|
|
|
|
-- =====================
|
|
-- Task Queue (run_only mode)
|
|
-- =====================
|
|
|
|
-- name: CreateAutopilotTask :one
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, autopilot_run_id, trigger_summary)
|
|
VALUES ($1, $2, NULL, 'queued', $3, $4, sqlc.narg(trigger_summary))
|
|
RETURNING *;
|
|
|
|
-- =====================
|
|
-- Run lookup by linked entities
|
|
-- =====================
|
|
|
|
-- name: GetAutopilotRunByIssue :one
|
|
SELECT * FROM autopilot_run
|
|
WHERE issue_id = $1 AND status IN ('issue_created', 'running')
|
|
LIMIT 1;
|
|
|
|
-- name: FailAutopilotRunsByIssue :exec
|
|
-- Fails active autopilot runs linked to a given issue.
|
|
-- Must be called BEFORE issue deletion (ON DELETE SET NULL clears issue_id).
|
|
UPDATE autopilot_run
|
|
SET status = 'failed', completed_at = now(), failure_reason = 'linked issue was deleted'
|
|
WHERE issue_id = $1
|
|
AND status IN ('issue_created', 'running');
|
|
|
|
-- =====================
|
|
-- Failure-rate auto-pause
|
|
-- =====================
|
|
|
|
-- name: SelectAutopilotsExceedingFailureThreshold :many
|
|
-- Find active autopilots whose recent run failure rate exceeds the threshold.
|
|
-- Counts only "real" terminal runs (completed | failed). 'skipped' is
|
|
-- excluded from BOTH numerator and denominator: an admission-skipped run
|
|
-- (e.g. assignee runtime offline at dispatch time, MUL-1899) is neither a
|
|
-- success nor a failure, so it must not dilute the failure ratio (which
|
|
-- would let a 100%-failing autopilot mask itself behind a wall of skips)
|
|
-- nor inflate it. issue_created/running are still excluded so in-flight
|
|
-- work isn't penalised.
|
|
-- Used by the failure monitor to auto-pause sustained-failure autopilots
|
|
-- (the canonical example from MUL-1336 was an autopilot scheduled every 5 min
|
|
-- that 100% failed for days, burning ~1.5k useless tasks per week).
|
|
WITH stats AS (
|
|
SELECT autopilot_id,
|
|
count(*) FILTER (WHERE status IN ('completed', 'failed')) AS total,
|
|
count(*) FILTER (WHERE status = 'failed') AS failed
|
|
FROM autopilot_run
|
|
WHERE created_at >= sqlc.arg('since')::timestamptz
|
|
GROUP BY autopilot_id
|
|
)
|
|
SELECT a.id, a.workspace_id, a.title, a.assignee_id,
|
|
a.created_by_type, a.created_by_id,
|
|
s.total::bigint AS total_runs,
|
|
s.failed::bigint AS failed_runs
|
|
FROM autopilot a
|
|
JOIN stats s ON s.autopilot_id = a.id
|
|
WHERE a.status = 'active'
|
|
AND s.total >= sqlc.arg('min_runs')::bigint
|
|
AND s.failed::float8 / NULLIF(s.total, 0)::float8 >= sqlc.arg('fail_ratio_threshold')::float8
|
|
ORDER BY s.failed DESC, a.id ASC;
|
|
|
|
-- name: SystemPauseAutopilot :one
|
|
-- Atomically pauses an autopilot only if it is currently active. Returns no
|
|
-- rows when the autopilot was already paused/archived (or another worker
|
|
-- raced first), letting the caller treat that as a benign no-op rather than
|
|
-- an error.
|
|
UPDATE autopilot
|
|
SET status = 'paused', updated_at = now()
|
|
WHERE id = $1 AND status = 'active'
|
|
RETURNING *;
|
|
|
|
-- =====================
|
|
-- Autopilot Subscribers
|
|
-- =====================
|
|
|
|
-- name: ListAutopilotSubscribers :many
|
|
-- ORDER BY created_at keeps chip rendering stable across refreshes.
|
|
SELECT * FROM autopilot_subscriber
|
|
WHERE autopilot_id = $1
|
|
ORDER BY created_at ASC, user_id ASC;
|
|
|
|
-- name: AddAutopilotSubscriber :exec
|
|
INSERT INTO autopilot_subscriber (autopilot_id, user_type, user_id)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (autopilot_id, user_type, user_id) DO NOTHING;
|
|
|
|
-- name: DeleteAutopilotSubscribersForAutopilot :exec
|
|
-- Paired with a re-insert loop to implement full-replace PATCH semantics.
|
|
DELETE FROM autopilot_subscriber
|
|
WHERE autopilot_id = $1;
|
|
|
|
-- =====================
|
|
-- Autopilot Collaborators
|
|
-- =====================
|
|
|
|
-- name: ListAutopilotCollaborators :many
|
|
-- ORDER BY created_at keeps row rendering stable across refreshes.
|
|
SELECT * FROM autopilot_collaborator
|
|
WHERE autopilot_id = $1
|
|
ORDER BY created_at ASC, user_id ASC;
|
|
|
|
-- name: AddAutopilotCollaborator :one
|
|
-- Re-granting an existing collaborator is a no-op that refreshes granted_by,
|
|
-- so the call is idempotent from the API boundary.
|
|
INSERT INTO autopilot_collaborator (autopilot_id, user_type, user_id, granted_by)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (autopilot_id, user_type, user_id)
|
|
DO UPDATE SET granted_by = EXCLUDED.granted_by
|
|
RETURNING *;
|
|
|
|
-- name: DeleteAutopilotCollaborator :exec
|
|
DELETE FROM autopilot_collaborator
|
|
WHERE autopilot_id = $1 AND user_type = $2 AND user_id = $3;
|
|
|
|
-- name: DeleteAutopilotCollaboratorsForAutopilot :exec
|
|
-- Application-layer cleanup run inside the autopilot delete transaction.
|
|
DELETE FROM autopilot_collaborator
|
|
WHERE autopilot_id = $1;
|
|
|
|
-- name: IsAutopilotCollaborator :one
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM autopilot_collaborator
|
|
WHERE autopilot_id = $1 AND user_type = 'member' AND user_id = $2
|
|
) AS is_collaborator;
|
|
|
|
-- name: ListAutopilotIDsForCollaborator :many
|
|
-- Powers the per-row can_write flag on the list endpoint without an N+1.
|
|
SELECT autopilot_id FROM autopilot_collaborator
|
|
WHERE user_type = 'member' AND user_id = $1;
|
|
|