From ea3ebac92552f6a3d5598f479460411b9b68f088 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 11:55:37 -0700 Subject: [PATCH 01/15] feat(api): one webhook runner for every trigger provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight inbound routes each hand-rolled the same steps — cap, verify, parse, find connections, record, dispatch, respond — and drifted on what to do when the QStash handoff failed. providerWebhook() owns those mechanics; a provider declares verify, connections and normalize and nothing else. The handoff is the one step neither the sender nor QStash retries, so the event row now stores its dispatch input and is marked dispatched_at after a successful publish; the evaluate tick re-dispatches unmarked rows. Sentry is migrated as the worked example; the other providers follow. --- .../src/app/api/automations/evaluate/route.ts | 6 +- ...tryEvent.ts => normalizeSentryDelivery.ts} | 38 +- .../api/integrations/sentry/webhook/route.ts | 168 +- .../automations/dispatchMatchingTriggers.ts | 15 + .../lib/automations/ingestAutomationEvent.ts | 65 + .../lib/automations/providerWebhook.test.ts | 149 + .../src/lib/automations/providerWebhook.ts | 134 + .../lib/automations/recordAutomationEvent.ts | 15 +- .../lib/automations/redispatchUndispatched.ts | 55 + .../0085_automation_events_dispatch_state.sql | 3 + packages/db/drizzle/meta/0085_snapshot.json | 7025 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 9 +- packages/db/src/schema/schema.ts | 15 + packages/db/src/schema/types.ts | 12 + 14 files changed, 7599 insertions(+), 110 deletions(-) rename apps/api/src/app/api/integrations/sentry/webhook/{recordSentryEvent.ts => normalizeSentryDelivery.ts} (78%) create mode 100644 apps/api/src/lib/automations/ingestAutomationEvent.ts create mode 100644 apps/api/src/lib/automations/providerWebhook.test.ts create mode 100644 apps/api/src/lib/automations/providerWebhook.ts create mode 100644 apps/api/src/lib/automations/redispatchUndispatched.ts create mode 100644 packages/db/drizzle/0085_automation_events_dispatch_state.sql create mode 100644 packages/db/drizzle/meta/0085_snapshot.json diff --git a/apps/api/src/app/api/automations/evaluate/route.ts b/apps/api/src/app/api/automations/evaluate/route.ts index 93a90fb2075..afe2f03f6b4 100644 --- a/apps/api/src/app/api/automations/evaluate/route.ts +++ b/apps/api/src/app/api/automations/evaluate/route.ts @@ -7,8 +7,8 @@ import { import { nextOccurrenceAfter } from "@superset/shared/rrule"; import { Client, Receiver } from "@upstash/qstash"; import { and, eq, lte } from "drizzle-orm"; - import { env } from "@/env"; +import { redispatchUndispatched } from "@/lib/automations/redispatchUndispatched"; export const dynamic = "force-dynamic"; @@ -175,9 +175,13 @@ export async function POST(request: Request): Promise { ); } + // The same tick retries event handoffs that never reached QStash. + const redispatched = await redispatchUndispatched(); + return Response.json({ enqueued: planned.length, advanceFailed: advanceFailures.length, unusable: unusable.length, + redispatched, }); } diff --git a/apps/api/src/app/api/integrations/sentry/webhook/recordSentryEvent.ts b/apps/api/src/app/api/integrations/sentry/webhook/normalizeSentryDelivery.ts similarity index 78% rename from apps/api/src/app/api/integrations/sentry/webhook/recordSentryEvent.ts rename to apps/api/src/app/api/integrations/sentry/webhook/normalizeSentryDelivery.ts index 8705c8a65ea..51700be16bd 100644 --- a/apps/api/src/app/api/integrations/sentry/webhook/recordSentryEvent.ts +++ b/apps/api/src/app/api/integrations/sentry/webhook/normalizeSentryDelivery.ts @@ -1,10 +1,9 @@ -import { db } from "@superset/db/client"; import { type SentryMatchableEvent, sentryEventNames, } from "@superset/shared/automation-matching"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; /** * Records a Sentry issue webhook as an `automation_events` row. @@ -83,25 +82,30 @@ function titleFor(payload: SentryIssuePayload, eventType: string): string { ); } -export async function recordSentryEvent(params: { +export function normalizeSentryDelivery(params: { organizationId: string; connectionId: string; event: SentryMatchableEvent; deliveryId: string; payload: SentryIssuePayload; -}): Promise<{ id: string } | null> { +}): NormalizedDelivery { const { payload, event } = params; - // A redelivery of the same Sentry request id is the same event. - return recordAutomationEvent(db, { - organizationId: params.organizationId, - integrationConnectionId: params.connectionId, - provider: "sentry", - eventType: event.eventType, - externalEventId: params.deliveryId, - resourceKey: resourceKeyFor(payload), - title: titleFor(payload, event.eventType), - url: payload.data?.issue?.web_url ?? payload.data?.issue?.permalink ?? null, - actorLogin: event.actorLogin, - payload, - }); + return { + event: { + organizationId: params.organizationId, + integrationConnectionId: params.connectionId, + provider: "sentry", + eventType: event.eventType, + // A redelivery of the same Sentry request id is the same event. + externalEventId: params.deliveryId, + resourceKey: resourceKeyFor(payload), + title: titleFor(payload, event.eventType), + url: + payload.data?.issue?.web_url ?? payload.data?.issue?.permalink ?? null, + actorLogin: event.actorLogin, + payload, + }, + // An action nothing in the product names is recorded, never matched. + dispatch: event.names.length > 0 ? { event } : null, + }; } diff --git a/apps/api/src/app/api/integrations/sentry/webhook/route.ts b/apps/api/src/app/api/integrations/sentry/webhook/route.ts index f5cee03131f..0269a1de5f4 100644 --- a/apps/api/src/app/api/integrations/sentry/webhook/route.ts +++ b/apps/api/src/app/api/integrations/sentry/webhook/route.ts @@ -5,12 +5,12 @@ import { disconnectSentry } from "@superset/trpc/integrations/sentry"; import { and, eq, sql } from "drizzle-orm"; import { env } from "@/env"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; +import { providerWebhook } from "@/lib/automations/providerWebhook"; import { matchableFrom, - recordSentryEvent, + normalizeSentryDelivery, type SentryIssuePayload, -} from "./recordSentryEvent"; +} from "./normalizeSentryDelivery"; /** * Webhooks from the public Sentry integration. @@ -57,97 +57,85 @@ async function connectionByInstallation(installationUuid: string) { }); } -export async function POST(request: Request) { - const body = await request.text(); - const signature = request.headers.get("sentry-hook-signature"); - const timestamp = request.headers.get("sentry-hook-timestamp"); - const resource = request.headers.get("sentry-hook-resource"); - const requestId = request.headers.get("request-id"); +type SentryConnection = NonNullable< + Awaited> +>; - const secret = env.SENTRY_CLIENT_SECRET; - if (!secret || !signature || !signatureMatches(body, signature, secret)) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } - if (!timestampFresh(timestamp)) { - return Response.json({ error: "Stale timestamp" }, { status: 401 }); - } - - let payload: SentryIssuePayload; - try { - payload = JSON.parse(body); - } catch { - return Response.json({ error: "Invalid JSON payload" }, { status: 400 }); - } - - const installationUuid = - payload.installation?.uuid ?? payload.data?.installation?.uuid ?? null; +function installationUuidOf(payload: SentryIssuePayload): string | null { + return payload.installation?.uuid ?? payload.data?.installation?.uuid ?? null; +} - // installation.deleted / .created only touch connection state; a deletion - // drops the token so a later issue delivery for the same install is refused. - if (resource === "installation") { - if (installationUuid && payload.action === "deleted") { - const connection = await connectionByInstallation(installationUuid); - if (connection) { - await disconnectSentry(connection.id, "Integration removed in Sentry"); +export const POST = providerWebhook({ + provider: "sentry", + database: db, + + verify: ({ rawBody, headers }) => { + const secret = env.SENTRY_CLIENT_SECRET; + const signature = headers.get("sentry-hook-signature"); + if ( + !secret || + !signature || + !signatureMatches(rawBody, signature, secret) + ) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + if (!timestampFresh(headers.get("sentry-hook-timestamp"))) { + return Response.json({ error: "Stale timestamp" }, { status: 401 }); + } + }, + + connections: async (payload, { headers }) => { + const resource = headers.get("sentry-hook-resource"); + const installationUuid = installationUuidOf(payload); + + // installation.deleted / .created only touch connection state; a + // deletion drops the token so a later issue delivery for the same + // install is refused. + if (resource === "installation") { + if (installationUuid && payload.action === "deleted") { + const connection = await connectionByInstallation(installationUuid); + if (connection) { + await disconnectSentry( + connection.id, + "Integration removed in Sentry", + ); + } } + // installation.created is a no-op: the callback, which alone knows + // the Superset org, is what writes the connection. + return Response.json({ success: true }); + } + if (resource !== "issue" || !payload.action) { + return Response.json({ success: true, message: "Ignored" }); } - // installation.created is a no-op: the callback, which alone knows the - // Superset org, is what writes the connection. - return Response.json({ success: true }); - } - - if (resource !== "issue" || !payload.action) { - return Response.json({ success: true, message: "Ignored" }); - } - - const connection = installationUuid - ? await connectionByInstallation(installationUuid) - : null; - - // No active connection for this install: nothing to attribute the event to. - // 200 so Sentry does not retry what can never resolve. - if (!connection || connection.disconnectedAt) { - return Response.json({ success: true, message: "No connection" }); - } - - const event = matchableFrom(payload, `issue.${payload.action}`); - const deliveryId = requestId ?? `sentry-${crypto.randomUUID()}`; - - const recorded = await recordSentryEvent({ - organizationId: connection.organizationId, - connectionId: connection.id, - event, - deliveryId, - payload, - }); - if (!recorded) { - console.log( - "[sentry/webhook] Not recorded as automation event (duplicate delivery):", - deliveryId, - ); - return Response.json({ success: true, message: "duplicate delivery" }); - } - - // Nothing in the product names this action, so there is nothing to match. - if (event.names.length === 0) { - console.log( - `[sentry/webhook] Unhandled action ${resource}.${payload.action}, recorded only:`, - deliveryId, - ); - return Response.json({ success: true, message: "Recorded" }); - } - const result = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: recorded.id, - event, - }); - if (result.matched > 0) { - console.log( - `[sentry/webhook] ${result.matched}/${result.considered} triggers matched:`, + const connection = installationUuid + ? await connectionByInstallation(installationUuid) + : null; + // No active connection for this install: nothing to attribute the + // event to, and nothing a retry could resolve. + if (!connection || connection.disconnectedAt) return []; + return [connection]; + }, + + normalize: (payload, connection, { headers }) => { + const event = matchableFrom(payload, `issue.${payload.action}`); + const deliveryId = + headers.get("request-id") ?? `sentry-${crypto.randomUUID()}`; + if (event.names.length === 0) { + console.log( + `[sentry/webhook] Unhandled action issue.${payload.action}, recorded only:`, + deliveryId, + ); + } + return normalizeSentryDelivery({ + organizationId: connection.organizationId, + connectionId: connection.id, + event, deliveryId, - ); - } + payload, + }); + }, - return Response.json({ success: true }); -} + connectionId: (connection) => connection.id, +}); diff --git a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts index 2cb9b5f106b..cf0abedbdb7 100644 --- a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts +++ b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts @@ -1,5 +1,6 @@ import { dbWs } from "@superset/db/client"; import { + automationEvents, automations, automationTriggers, userIdentities, @@ -118,6 +119,7 @@ export async function dispatchMatchingTriggers(params: { ); if (matched.length === 0) { + await markDispatched(params.eventId); return { matched: 0, considered: candidates.length }; } @@ -137,5 +139,18 @@ export async function dispatchMatchingTriggers(params: { })), ); + await markDispatched(params.eventId); return { matched: matched.length, considered: candidates.length }; } + +/** + * The handoff to QStash is the one step that cannot be retried by the sender + * or by QStash itself, so the row records that it happened. Rows left + * unmarked are picked up by the re-dispatch sweep. + */ +async function markDispatched(eventId: string) { + await dbWs + .update(automationEvents) + .set({ dispatchedAt: new Date() }) + .where(eq(automationEvents.id, eventId)); +} diff --git a/apps/api/src/lib/automations/ingestAutomationEvent.ts b/apps/api/src/lib/automations/ingestAutomationEvent.ts new file mode 100644 index 00000000000..7460c34af15 --- /dev/null +++ b/apps/api/src/lib/automations/ingestAutomationEvent.ts @@ -0,0 +1,65 @@ +import type { AutomationEventDispatchInput } from "@superset/db/schema"; +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; +import { dispatchMatchingTriggers } from "./dispatchMatchingTriggers"; +import { + type AutomationEventInput, + recordAutomationEvent, +} from "./recordAutomationEvent"; + +/** + * One provider delivery, normalized: the row to record and, when the delivery + * names a product event, what the dispatcher matches against. A delivery that + * names nothing (an action no trigger exists for) is recorded and never + * dispatched. + */ +export type NormalizedDelivery = { + event: Omit; + dispatch: AutomationEventDispatchInput | null; +}; + +export type IngestOutcome = + | { status: "duplicate" } + | { status: "recorded"; eventId: string } + | { + status: "dispatched"; + eventId: string; + matched: number; + considered: number; + } + | { status: "dispatch_failed"; eventId: string }; + +/** + * The write-and-trigger half every provider shares: record the event, hand + * the matching runs to QStash. A duplicate delivery records nothing and + * dispatches nothing — the first delivery did. A failed handoff leaves the + * row unmarked for the re-dispatch sweep and reports it; the route should + * still acknowledge the delivery, since a redelivery would only dedupe. + */ +export async function ingestAutomationEvent( + database: PgDatabase>, + delivery: NormalizedDelivery, +): Promise { + const inserted = await recordAutomationEvent(database, { + ...delivery.event, + dispatchInput: delivery.dispatch, + }); + if (!inserted) return { status: "duplicate" }; + if (!delivery.dispatch) return { status: "recorded", eventId: inserted.id }; + + try { + const result = await dispatchMatchingTriggers({ + organizationId: delivery.event.organizationId, + eventId: inserted.id, + event: delivery.dispatch.event, + automationId: delivery.dispatch.automationId, + triggerId: delivery.dispatch.triggerId, + }); + return { status: "dispatched", eventId: inserted.id, ...result }; + } catch (error) { + console.error( + `[automations] dispatch failed for event ${inserted.id}; left for the sweep:`, + error, + ); + return { status: "dispatch_failed", eventId: inserted.id }; + } +} diff --git a/apps/api/src/lib/automations/providerWebhook.test.ts b/apps/api/src/lib/automations/providerWebhook.test.ts new file mode 100644 index 00000000000..4f6eae426c7 --- /dev/null +++ b/apps/api/src/lib/automations/providerWebhook.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "bun:test"; +import { providerWebhook } from "./providerWebhook"; + +const post = ( + handler: (request: Request) => Promise, + body: string, + headers: Record = {}, +) => + handler( + new Request("https://api.test/webhook", { + method: "POST", + body, + headers, + }), + ); + +describe("providerWebhook", () => { + it("refuses before parsing when verify answers", async () => { + let parsed = false; + const handler = providerWebhook({ + provider: "test", + verify: ({ headers }) => + headers.get("x-sig") === "ok" + ? undefined + : Response.json({ error: "Invalid signature" }, { status: 401 }), + connections: () => { + parsed = true; + return []; + }, + normalize: () => null, + }); + const refused = await post(handler, "not even json"); + expect(refused.status).toBe(401); + expect(parsed).toBe(false); + }); + + it("rejects an unparseable body only after verification", async () => { + const handler = providerWebhook({ + provider: "test", + verify: () => undefined, + connections: () => [], + normalize: () => null, + }); + const response = await post(handler, "{nope"); + expect(response.status).toBe(400); + }); + + it("caps the body before reading it", async () => { + const handler = providerWebhook({ + provider: "test", + maxBodyBytes: 10, + verify: () => undefined, + connections: () => [], + normalize: () => null, + }); + const response = await post(handler, "x".repeat(11)); + expect(response.status).toBe(413); + }); + + it("lets connections answer the request outright", async () => { + const handler = providerWebhook({ + provider: "test", + verify: () => undefined, + connections: () => Response.json({ handshake: true }), + normalize: () => null, + }); + const response = await post(handler, "{}"); + expect(await response.json()).toEqual({ handshake: true }); + }); + + it("acknowledges a delivery nobody subscribes to", async () => { + const handler = providerWebhook({ + provider: "test", + verify: () => undefined, + connections: () => [], + normalize: () => null, + }); + const response = await post(handler, "{}"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, + status: "no_subscribers", + }); + }); + + it("reports ignored deliveries per connection without recording", async () => { + const handler = providerWebhook({ + provider: "test", + verify: () => undefined, + connections: () => [{ id: "a" }, { id: "b" }], + normalize: () => null, + connectionId: (c) => c.id, + }); + const response = await post(handler, "{}"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, + results: [ + { connectionId: "a", outcome: { status: "ignored" } }, + { connectionId: "b", outcome: { status: "ignored" } }, + ], + }); + }); + + it("keeps the automation event when a side effect throws", async () => { + let normalized = false; + const handler = providerWebhook({ + provider: "test", + verify: () => undefined, + connections: () => [{ id: "a" }], + sideEffects: () => { + throw new Error("mirror failed"); + }, + normalize: () => { + normalized = true; + return null; + }, + }); + const response = await post(handler, "{}"); + expect(response.status).toBe(200); + expect(normalized).toBe(true); + }); + + it("returns 500 only when every connection failed", async () => { + const handler = providerWebhook({ + provider: "test", + verify: () => undefined, + connections: () => [{ id: "a" }, { id: "b" }], + normalize: (_payload, connection) => { + if (connection.id === "a") throw new Error("boom"); + return null; + }, + connectionId: (c) => c.id, + }); + const partial = await post(handler, "{}"); + expect(partial.status).toBe(200); + + const allBad = providerWebhook({ + provider: "test", + verify: () => undefined, + connections: () => [{ id: "a" }], + normalize: () => { + throw new Error("boom"); + }, + }); + const failed = await post(allBad, "{}"); + expect(failed.status).toBe(500); + }); +}); diff --git a/apps/api/src/lib/automations/providerWebhook.ts b/apps/api/src/lib/automations/providerWebhook.ts new file mode 100644 index 00000000000..028197f1858 --- /dev/null +++ b/apps/api/src/lib/automations/providerWebhook.ts @@ -0,0 +1,134 @@ +import { dbWs } from "@superset/db/client"; +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; +import { + type IngestOutcome, + ingestAutomationEvent, + type NormalizedDelivery, +} from "./ingestAutomationEvent"; + +const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; + +export type DeliveryContext = { + rawBody: string; + headers: Headers; + url: URL; +}; + +/** + * What a provider has to say about its webhook, and nothing else. The runner + * owns the mechanics every provider used to reimplement — body cap, verify + * before parse, fan-out, record, dispatch, response codes, logging. + * + * `verify` looks at the raw request and either accepts it (returns nothing) + * or answers it (a Response: 401 for a bad signature, 200 for a handshake). + * `connections` names who receives the parsed payload — a Response here + * answers the request instead (an ignored resource type, a side effect that + * is the whole point of the delivery). `normalize` turns the payload into the + * event for one connection, or null when it names nothing an automation can + * see. `sideEffects` is for work that rides along with the delivery but is + * not the automation event (mirroring a PR, syncing a task); it runs first + * and its failure never costs the automation event. + */ +export type ProviderWebhookSpec = { + provider: string; + maxBodyBytes?: number; + database?: PgDatabase>; + verify: ( + context: DeliveryContext, + ) => Promise | Response | undefined; + parse?: (rawBody: string) => Payload; + connections: ( + payload: Payload, + context: DeliveryContext, + ) => Promise | Connection[] | Response; + sideEffects?: ( + payload: Payload, + connection: Connection, + context: DeliveryContext, + ) => Promise | void; + normalize: ( + payload: Payload, + connection: Connection, + context: DeliveryContext, + ) => Promise | NormalizedDelivery | null; + connectionId?: (connection: Connection) => string; +}; + +export type ProviderWebhookResult = { + connectionId: string | null; + outcome: IngestOutcome | { status: "ignored" } | { status: "failed" }; +}; + +export function providerWebhook( + spec: ProviderWebhookSpec, +): (request: Request) => Promise { + const maxBodyBytes = spec.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + const database = spec.database ?? dbWs; + const tag = `[${spec.provider}/webhook]`; + + return async (request: Request) => { + const contentLength = Number(request.headers.get("content-length")); + if (contentLength > maxBodyBytes) { + return Response.json({ error: "Body too large" }, { status: 413 }); + } + const rawBody = await request.text(); + if (Buffer.byteLength(rawBody) > maxBodyBytes) { + return Response.json({ error: "Body too large" }, { status: 413 }); + } + const context: DeliveryContext = { + rawBody, + headers: request.headers, + url: new URL(request.url), + }; + + const refused = await spec.verify(context); + if (refused) return refused; + + let payload: Payload; + try { + payload = spec.parse + ? spec.parse(rawBody) + : (JSON.parse(rawBody) as Payload); + } catch { + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + const connections = await spec.connections(payload, context); + if (connections instanceof Response) return connections; + if (connections.length === 0) { + return Response.json({ ok: true, status: "no_subscribers" }); + } + + const results: ProviderWebhookResult[] = await Promise.all( + connections.map(async (connection) => { + const connectionId = spec.connectionId?.(connection) ?? null; + try { + if (spec.sideEffects) { + try { + await spec.sideEffects(payload, connection, context); + } catch (error) { + console.error(`${tag} side effect failed:`, error); + } + } + const delivery = await spec.normalize(payload, connection, context); + if (!delivery) + return { connectionId, outcome: { status: "ignored" } }; + const outcome = await ingestAutomationEvent(database, delivery); + return { connectionId, outcome }; + } catch (error) { + console.error(`${tag} failed for connection ${connectionId}:`, error); + return { connectionId, outcome: { status: "failed" } }; + } + }), + ); + + // A failure to normalize or record is worth a retry from the sender; + // a failed QStash handoff is not, since the sweep owns that and a + // redelivery would only dedupe. + const allFailed = results.every((r) => r.outcome.status === "failed"); + return Response.json( + { ok: !allFailed, results }, + { status: allFailed ? 500 : 200 }, + ); + }; +} diff --git a/apps/api/src/lib/automations/recordAutomationEvent.ts b/apps/api/src/lib/automations/recordAutomationEvent.ts index 60de47d72ce..15576d181a2 100644 --- a/apps/api/src/lib/automations/recordAutomationEvent.ts +++ b/apps/api/src/lib/automations/recordAutomationEvent.ts @@ -1,4 +1,7 @@ -import { automationEvents } from "@superset/db/schema"; +import { + type AutomationEventDispatchInput, + automationEvents, +} from "@superset/db/schema"; import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; import { stripNullChars } from "@/lib/strip-null-chars"; @@ -18,6 +21,13 @@ export type AutomationEventInput = { actorIsExternal?: boolean | null; payload: unknown; webhookEventId?: string | null; + /** + * What matching needs, stored so a failed QStash handoff can be retried by + * the sweep. Null when the delivery names no product event: it is kept for + * the record and never dispatched. Callers that still dispatch on their own + * leave it unset; the sweep never touches those rows. + */ + dispatchInput?: AutomationEventDispatchInput | null; }; /** @@ -51,6 +61,9 @@ export async function recordAutomationEvent( // throw and lose the event. payload: stripNullChars(input.payload) as Record, webhookEventId: input.webhookEventId ?? null, + dispatchInput: input.dispatchInput ?? null, + // Nothing to dispatch is dispatched. + dispatchedAt: input.dispatchInput === null ? new Date() : null, }) .onConflictDoNothing({ target: [ diff --git a/apps/api/src/lib/automations/redispatchUndispatched.ts b/apps/api/src/lib/automations/redispatchUndispatched.ts new file mode 100644 index 00000000000..7ba2f946d87 --- /dev/null +++ b/apps/api/src/lib/automations/redispatchUndispatched.ts @@ -0,0 +1,55 @@ +import { dbWs } from "@superset/db/client"; +import { automationEvents } from "@superset/db/schema"; +import { and, asc, isNotNull, isNull, lt } from "drizzle-orm"; +import { dispatchMatchingTriggers } from "./dispatchMatchingTriggers"; + +/** Long enough that an in-flight delivery is not mistaken for a stuck one. */ +const GRACE_MS = 60_000; +const BATCH_SIZE = 200; + +/** + * Retries the one step neither the sender nor QStash retries: the handoff + * from a recorded event to QStash. Rows past the grace period with no + * `dispatchedAt` are re-run through the dispatcher from their stored input; + * `dispatchMatchingTriggers` marks them once the publish succeeds, and QStash + * dedupes on trigger+event so a row that half-published does not double-run. + */ +export async function redispatchUndispatched(): Promise<{ + attempted: number; + failed: number; +}> { + const stuck = await dbWs + .select({ + id: automationEvents.id, + organizationId: automationEvents.organizationId, + dispatchInput: automationEvents.dispatchInput, + }) + .from(automationEvents) + .where( + and( + isNull(automationEvents.dispatchedAt), + isNotNull(automationEvents.dispatchInput), + lt(automationEvents.receivedAt, new Date(Date.now() - GRACE_MS)), + ), + ) + .orderBy(asc(automationEvents.receivedAt)) + .limit(BATCH_SIZE); + + let failed = 0; + for (const row of stuck) { + if (!row.dispatchInput) continue; + try { + await dispatchMatchingTriggers({ + organizationId: row.organizationId, + eventId: row.id, + event: row.dispatchInput.event, + automationId: row.dispatchInput.automationId, + triggerId: row.dispatchInput.triggerId, + }); + } catch (error) { + failed++; + console.error(`[automations/redispatch] event ${row.id} failed:`, error); + } + } + return { attempted: stuck.length, failed }; +} diff --git a/packages/db/drizzle/0085_automation_events_dispatch_state.sql b/packages/db/drizzle/0085_automation_events_dispatch_state.sql new file mode 100644 index 00000000000..6348eee1893 --- /dev/null +++ b/packages/db/drizzle/0085_automation_events_dispatch_state.sql @@ -0,0 +1,3 @@ +ALTER TABLE "automation_events" ADD COLUMN "dispatch_input" jsonb;--> statement-breakpoint +ALTER TABLE "automation_events" ADD COLUMN "dispatched_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "automation_events_undispatched_idx" ON "automation_events" USING btree ("received_at") WHERE "automation_events"."dispatched_at" IS NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0085_snapshot.json b/packages/db/drizzle/meta/0085_snapshot.json new file mode 100644 index 00000000000..1639a22495c --- /dev/null +++ b/packages/db/drizzle/meta/0085_snapshot.json @@ -0,0 +1,7025 @@ +{ + "id": "cf1c8fe2-7680-4b18-87a0-4b9e24039f50", + "prevId": "d252288a-9737-4e04-896c-055a194f939b", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n\t\t\t\tWHEN metadata IS NULL OR metadata = '' THEN NULL\n\t\t\t\tWHEN NOT (metadata IS JSON OBJECT) THEN NULL\n\t\t\t\tWHEN (metadata::jsonb->>'organizationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'\n\t\t\t\t\tTHEN (metadata::jsonb->>'organizationId')::uuid\n\t\t\t\tELSE NULL\n\t\t\tEND", + "type": "stored" + } + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_organization_id_idx": { + "name": "apikeys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_metadata_trgm_idx": { + "name": "apikeys_metadata_trgm_idx", + "columns": [ + { + "expression": "\"metadata\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.team_members": { + "name": "team_members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_user_id_idx": { + "name": "team_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_organization_id_idx": { + "name": "team_members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_team_user_unique": { + "name": "team_members_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_organization_id_organizations_id_fk": { + "name": "team_members_organization_id_organizations_id_fk", + "tableFrom": "team_members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.teams": { + "name": "teams", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_org_slug_unique": { + "name": "teams_org_slug_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletion_requested_at": { + "name": "deletion_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_organization_ids_idx": { + "name": "users_organization_ids_idx", + "columns": [ + { + "expression": "organization_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_events": { + "name": "automation_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "integration_connection_id": { + "name": "integration_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_login": { + "name": "actor_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_is_external": { + "name": "actor_is_external", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "webhook_event_id": { + "name": "webhook_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatch_input": { + "name": "dispatch_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_events_undispatched_idx": { + "name": "automation_events_undispatched_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"automation_events\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_org_received_idx": { + "name": "automation_events_org_received_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_resource_idx": { + "name": "automation_events_resource_idx", + "columns": [ + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_events_organization_id_organizations_id_fk": { + "name": "automation_events_organization_id_organizations_id_fk", + "tableFrom": "automation_events", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_events_integration_connection_id_integration_connections_id_fk": { + "name": "automation_events_integration_connection_id_integration_connections_id_fk", + "tableFrom": "automation_events", + "tableTo": "integration_connections", + "columnsFrom": [ + "integration_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automation_events_dedup_unique": { + "name": "automation_events_dedup_unique", + "nullsNotDistinct": true, + "columns": [ + "integration_connection_id", + "provider", + "external_event_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_prompt_versions": { + "name": "automation_prompt_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_bucket": { + "name": "window_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "automation_prompt_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "restored_from_version_id": { + "name": "restored_from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_prompt_versions_bucket_uniq": { + "name": "automation_prompt_versions_bucket_uniq", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"automation_prompt_versions\".\"source\" <> 'restore'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_prompt_versions_automation_idx": { + "name": "automation_prompt_versions_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_prompt_versions_automation_id_automations_id_fk": { + "name": "automation_prompt_versions_automation_id_automations_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_author_user_id_users_id_fk": { + "name": "automation_prompt_versions_author_user_id_users_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_restored_from_version_id_fk": { + "name": "automation_prompt_versions_restored_from_version_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automation_prompt_versions", + "columnsFrom": [ + "restored_from_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_kind": { + "name": "session_kind", + "type": "automation_session_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terminal_session_id": { + "name": "terminal_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_runs_schedule_dedup_idx": { + "name": "automation_runs_schedule_dedup_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "scheduled_for IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_event_dedup_idx": { + "name": "automation_runs_event_dedup_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "event_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_inflight_resource_idx": { + "name": "automation_runs_inflight_resource_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status IN ('dispatching', 'dispatched')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_history_idx": { + "name": "automation_runs_history_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_status_idx": { + "name": "automation_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_workspace_idx": { + "name": "automation_runs_workspace_idx", + "columns": [ + { + "expression": "v2_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_organization_id_organizations_id_fk": { + "name": "automation_runs_organization_id_organizations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_trigger_id_automation_triggers_id_fk": { + "name": "automation_runs_trigger_id_automation_triggers_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automation_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_event_id_automation_events_id_fk": { + "name": "automation_runs_event_id_automation_events_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automation_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_chat_session_id_chat_sessions_id_fk": { + "name": "automation_runs_chat_session_id_chat_sessions_id_fk", + "tableFrom": "automation_runs", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_automation_org_fk": { + "name": "automation_runs_automation_org_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_triggers": { + "name": "automation_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "automation_trigger_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_prefix": { + "name": "secret_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_triggers_dispatcher_idx": { + "name": "automation_triggers_dispatcher_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "kind = 'schedule'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_matcher_idx": { + "name": "automation_triggers_matcher_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "enabled", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_automation_idx": { + "name": "automation_triggers_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_schedule_idx": { + "name": "automation_triggers_schedule_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "kind = 'schedule'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_triggers_organization_id_organizations_id_fk": { + "name": "automation_triggers_organization_id_organizations_id_fk", + "tableFrom": "automation_triggers", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_triggers_automation_org_fk": { + "name": "automation_triggers_automation_org_fk", + "tableFrom": "automation_triggers", + "tableTo": "automations", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "automation_triggers_kind_matches_config": { + "name": "automation_triggers_kind_matches_config", + "value": "config->>'kind' = kind::text" + } + }, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_host_id": { + "name": "target_host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_project_id": { + "name": "v2_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automations_owner_idx": { + "name": "automations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_organization_idx": { + "name": "automations_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_organization_id_organizations_id_fk": { + "name": "automations_organization_id_organizations_id_fk", + "tableFrom": "automations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_owner_user_id_users_id_fk": { + "name": "automations_owner_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automations_id_org_unique": { + "name": "automations_id_org_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blob_pathname": { + "name": "blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_attachments_session_idx": { + "name": "chat_attachments_session_idx", + "columns": [ + { + "expression": "chat_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_attachments_created_by_idx": { + "name": "chat_attachments_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_attachments_chat_session_id_chat_sessions_id_fk": { + "name": "chat_attachments_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_created_by_users_id_fk": { + "name": "chat_attachments_created_by_users_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspaces": { + "name": "cloud_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blaxel'" + }, + "provider_sandbox_id": { + "name": "provider_sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_url": { + "name": "sandbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "cloud_workspace_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloud_workspaces_organization_id_idx": { + "name": "cloud_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloud_workspaces_project_id_idx": { + "name": "cloud_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_workspaces_organization_id_organizations_id_fk": { + "name": "cloud_workspaces_organization_id_organizations_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspaces_project_id_v2_projects_id_fk": { + "name": "cloud_workspaces_project_id_v2_projects_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "v2_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspaces_created_by_user_id_users_id_fk": { + "name": "cloud_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cloud_workspaces_provider_sandbox_id_unique": { + "name": "cloud_workspaces_provider_sandbox_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_sandbox_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.desktop_notices": { + "name": "desktop_notices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "severity": { + "name": "severity", + "type": "desktop_notice_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "desktop_notice_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'immediate'" + }, + "min_version": { + "name": "min_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_version": { + "name": "max_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platforms": { + "name": "platforms", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "channels": { + "name": "channels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta_label": { + "name": "cta_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cta_action": { + "name": "cta_action", + "type": "desktop_notice_cta_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cta_url": { + "name": "cta_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissible": { + "name": "dismissible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "desktop_notices_active_idx": { + "name": "desktop_notices_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_provider_unique": { + "name": "integration_connections_org_provider_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" <> 'google'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_google_user_unique": { + "name": "integration_connections_google_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'google'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_slack_external_org_active_unique": { + "name": "integration_connections_slack_external_org_active_unique", + "columns": [ + { + "expression": "external_org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'slack' AND \"integration_connections\".\"disconnected_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submitted_prompts": { + "name": "submitted_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt_text": { + "name": "prompt_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submitter_name": { + "name": "submitter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submitted_prompts_user_id_idx": { + "name": "submitted_prompts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_organization_id_idx": { + "name": "submitted_prompts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_created_at_idx": { + "name": "submitted_prompts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submitted_prompts_user_id_users_id_fk": { + "name": "submitted_prompts_user_id_users_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submitted_prompts_organization_id_organizations_id_fk": { + "name": "submitted_prompts_organization_id_organizations_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_project_id": { + "name": "external_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_project_name": { + "name": "external_project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_id": { + "name": "external_cycle_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_name": { + "name": "external_cycle_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_project_id_idx": { + "name": "tasks_external_project_id_idx", + "columns": [ + { + "expression": "external_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_project_name_idx": { + "name": "tasks_external_project_name_idx", + "columns": [ + { + "expression": "external_project_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_cycle_id_idx": { + "name": "tasks_external_cycle_id_idx", + "columns": [ + { + "expression": "external_cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_scope_id": { + "name": "external_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_identities_user_idx": { + "name": "user_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_identities_org_provider_idx": { + "name": "user_identities_org_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_identities_organization_id_organizations_id_fk": { + "name": "user_identities_organization_id_organizations_id_fk", + "tableFrom": "user_identities", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_identities_account_unique": { + "name": "user_identities_account_unique", + "nullsNotDistinct": true, + "columns": [ + "organization_id", + "provider", + "external_scope_id", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_clients_organization_id_user_id_machine_id_pk": { + "name": "v2_clients_organization_id_user_id_machine_id_pk", + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_online": { + "name": "is_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "wake_command": { + "name": "wake_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_hosts_organization_id_machine_id_pk": { + "name": "v2_hosts_organization_id_machine_id_pk", + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_clone_url": { + "name": "repo_clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_host_fk": { + "name": "v2_users_hosts_host_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_users_hosts_organization_id_user_id_host_id_pk": { + "name": "v2_users_hosts_organization_id_user_id_host_id_pk", + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'worktree'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_task_id_idx": { + "name": "v2_workspaces_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_one_main_per_host": { + "name": "v2_workspaces_one_main_per_host", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"v2_workspaces\".\"type\" = 'main'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_task_id_tasks_id_fk": { + "name": "v2_workspaces_task_id_tasks_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_host_fk": { + "name": "v2_workspaces_host_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.automation_prompt_source": { + "name": "automation_prompt_source", + "schema": "public", + "values": [ + "human", + "agent", + "restore" + ] + }, + "public.automation_run_status": { + "name": "automation_run_status", + "schema": "public", + "values": [ + "dispatching", + "dispatched", + "skipped_offline", + "dispatch_failed", + "debounced", + "rejected" + ] + }, + "public.automation_session_kind": { + "name": "automation_session_kind", + "schema": "public", + "values": [ + "chat", + "terminal" + ] + }, + "public.automation_trigger_kind": { + "name": "automation_trigger_kind", + "schema": "public", + "values": [ + "schedule", + "webhook", + "github", + "slack", + "linear", + "sentry", + "microsoft_teams", + "google_calendar", + "gmail", + "notion", + "circleback" + ] + }, + "public.cloud_workspace_status": { + "name": "cloud_workspace_status", + "schema": "public", + "values": [ + "provisioning", + "ready", + "failed", + "deleted" + ] + }, + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.desktop_notice_cta_action": { + "name": "desktop_notice_cta_action", + "schema": "public", + "values": [ + "install-update", + "open-url" + ] + }, + "public.desktop_notice_severity": { + "name": "desktop_notice_severity", + "schema": "public", + "values": [ + "info", + "warning", + "blocking" + ] + }, + "public.desktop_notice_trigger": { + "name": "desktop_notice_trigger", + "schema": "public", + "values": [ + "immediate", + "pre-update", + "post-update" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack", + "sentry", + "microsoft_teams", + "google", + "notion" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.v2_workspace_type": { + "name": "v2_workspace_type", + "schema": "public", + "values": [ + "main", + "worktree" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 8678e9bea27..ab6a61f762c 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -596,6 +596,13 @@ "when": 1787028266768, "tag": "0084_per_user_google_connections", "breakpoints": true + }, + { + "idx": 85, + "version": "7", + "when": 1787079112721, + "tag": "0085_automation_events_dispatch_state", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index cfc77212dcf..7a657365a69 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -37,6 +37,7 @@ import { } from "./enums"; import { githubRepositories } from "./github"; import type { + AutomationEventDispatchInput, IntegrationConfig, TriggerConfig, UserIdentityMetadata, @@ -933,6 +934,16 @@ export const automationEvents = pgTable( receivedAt: timestamp("received_at", { withTimezone: true }) .notNull() .defaultNow(), + + // What the dispatcher needs to match this event again — the normalized + // matchable event plus any automation/trigger narrowing — so a delivery + // whose QStash publish failed can be re-dispatched without the provider's + // normalizer. Null only for rows written before this existed. + dispatchInput: jsonb("dispatch_input").$type(), + // Set once every matched run has been handed to QStash (or nothing + // matched). Null past a grace period means the handoff failed and the + // sweep should retry it. + dispatchedAt: timestamp("dispatched_at", { withTimezone: true }), }, (t) => [ // Connection-scoped: two orgs can legitimately receive the same @@ -940,6 +951,10 @@ export const automationEvents = pgTable( unique("automation_events_dedup_unique") .on(t.integrationConnectionId, t.provider, t.externalEventId) .nullsNotDistinct(), + // The re-dispatch sweep reads only undispatched rows. + index("automation_events_undispatched_idx") + .on(t.receivedAt) + .where(sql`${t.dispatchedAt} IS NULL`), index("automation_events_org_received_idx").on( t.organizationId, t.receivedAt, diff --git a/packages/db/src/schema/types.ts b/packages/db/src/schema/types.ts index 7d77cc80dd4..0b290e2298a 100644 --- a/packages/db/src/schema/types.ts +++ b/packages/db/src/schema/types.ts @@ -1,9 +1,21 @@ +import type { MatchableEvent } from "@superset/shared/automation-matching"; import type { TriggerActor as TriggerConfigActor, TriggerConfigInput, TriggerScope as TriggerConfigScope, } from "@superset/shared/automation-triggers"; +/** + * Everything `dispatchMatchingTriggers` needs for one automation_events row: + * the provider-normalized event, and the narrowing for inbound URLs that + * address one automation (raw webhook) or one trigger (Circleback). + */ +export type AutomationEventDispatchInput = { + event: MatchableEvent; + automationId?: string; + triggerId?: string; +}; + export type LinearConfig = { provider: "linear"; newTasksTeamId?: string; From 31bdc6e28c512d94fc0c7e51252cf9e7abae0f74 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 12:51:44 -0700 Subject: [PATCH 02/15] refactor(api,trpc,desktop): shared trigger tail, verify helpers, one options call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshapes the runner into what every provider actually shares: the tail (ingestAutomationEvent: record → dispatch → mark dispatched_at, with a skip outcome for permanent provider failures), plain verify/body helpers, one verifyQstash, and one integration.triggerOptions procedure backed by a per-group source map that the editor fetches lazily for the kinds on screen. Sentry is on all of it; the request-owning runner is gone. --- .../google/calendar/scheduled/route.ts | 2 +- .../google/jobs/renew-watches/route.ts | 2 +- .../google/jobs/sweep-schedules/route.ts | 2 +- .../api/integrations/sentry/webhook/route.ts | 164 ++++++++---------- .../lib/automations/ingestAutomationEvent.ts | 18 +- .../lib/automations/providerWebhook.test.ts | 149 ---------------- .../src/lib/automations/providerWebhook.ts | 134 -------------- .../google => }/lib/verifyQstash.ts | 2 +- apps/api/src/lib/webhooks/body.ts | 29 ++++ apps/api/src/lib/webhooks/verify.ts | 36 ++++ .../components/TriggersCard/TriggersCard.tsx | 4 +- .../TriggersEditor/TriggersEditor.tsx | 10 +- .../components/providers/github/github.tsx | 1 + .../providers/github/useGithubOptions.ts | 30 ---- .../components/providers/google/gmail.tsx | 1 + .../providers/google/googleCalendar.tsx | 1 + .../providers/google/useGoogleOptions.ts | 38 ---- .../components/providers/linear/linear.tsx | 1 + .../providers/linear/useLinearOptions.ts | 31 ---- .../microsoftTeams/microsoftTeams.tsx | 1 + .../microsoftTeams/useTeamsOptions.ts | 38 ---- .../components/providers/notion/notion.tsx | 1 + .../providers/notion/useNotionOptions.ts | 29 ---- .../components/providers/sentry/sentry.tsx | 1 + .../providers/sentry/useSentryOptions.ts | 27 --- .../components/providers/slack/slack.tsx | 1 + .../providers/slack/useSlackOptions.ts | 30 ---- .../automations/components/providers/types.ts | 7 + .../providers/useProviderOptions.ts | 73 ++++---- .../src/router/integration/integration.ts | 2 + .../src/router/integration/sentry/sentry.ts | 35 +--- .../integration/sentry/trigger-options.ts | 34 ++++ .../src/router/integration/trigger-options.ts | 68 ++++++++ 33 files changed, 322 insertions(+), 680 deletions(-) delete mode 100644 apps/api/src/lib/automations/providerWebhook.test.ts delete mode 100644 apps/api/src/lib/automations/providerWebhook.ts rename apps/api/src/{app/api/integrations/google => }/lib/verifyQstash.ts (94%) create mode 100644 apps/api/src/lib/webhooks/body.ts create mode 100644 apps/api/src/lib/webhooks/verify.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/useGithubOptions.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/useGoogleOptions.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/useLinearOptions.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/useTeamsOptions.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/useNotionOptions.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/useSentryOptions.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/useSlackOptions.ts create mode 100644 packages/trpc/src/router/integration/sentry/trigger-options.ts create mode 100644 packages/trpc/src/router/integration/trigger-options.ts diff --git a/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts b/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts index 701d1dbcb08..857afb35b76 100644 --- a/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts +++ b/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts @@ -8,6 +8,7 @@ import { import { z } from "zod"; import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { accountDomain, accountEmail, @@ -15,7 +16,6 @@ import { matchableCalendarEvent, resourceKeyFor, } from "../../lib/calendarEvents"; -import { verifyQstashRequest } from "../../lib/verifyQstash"; export const maxDuration = 60; export const dynamic = "force-dynamic"; diff --git a/apps/api/src/app/api/integrations/google/jobs/renew-watches/route.ts b/apps/api/src/app/api/integrations/google/jobs/renew-watches/route.ts index 658f7e3b225..6e313f938a8 100644 --- a/apps/api/src/app/api/integrations/google/jobs/renew-watches/route.ts +++ b/apps/api/src/app/api/integrations/google/jobs/renew-watches/route.ts @@ -2,8 +2,8 @@ import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { reconcileWatches } from "../../lib/reconcileWatches"; -import { verifyQstashRequest } from "../../lib/verifyQstash"; export const maxDuration = 300; export const dynamic = "force-dynamic"; diff --git a/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts b/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts index 06863fd33cb..b36f77bf5ec 100644 --- a/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts +++ b/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts @@ -6,12 +6,12 @@ import { listUpcomingInstances, } from "@superset/trpc/integrations/google"; import { and, eq, inArray, sql } from "drizzle-orm"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { loadFirePlan, scheduleFires, sweepWindow, } from "../../lib/scheduleCalendarFires"; -import { verifyQstashRequest } from "../../lib/verifyQstash"; export const maxDuration = 300; export const dynamic = "force-dynamic"; diff --git a/apps/api/src/app/api/integrations/sentry/webhook/route.ts b/apps/api/src/app/api/integrations/sentry/webhook/route.ts index 0269a1de5f4..b1c60416da5 100644 --- a/apps/api/src/app/api/integrations/sentry/webhook/route.ts +++ b/apps/api/src/app/api/integrations/sentry/webhook/route.ts @@ -1,11 +1,17 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { disconnectSentry } from "@superset/trpc/integrations/sentry"; import { and, eq, sql } from "drizzle-orm"; import { env } from "@/env"; -import { providerWebhook } from "@/lib/automations/providerWebhook"; +import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; +import { cappedBody, parseJson } from "@/lib/webhooks/body"; +import { + freshTimestamp, + hmacHex, + timingSafeHex, + unauthorized, +} from "@/lib/webhooks/verify"; import { matchableFrom, normalizeSentryDelivery, @@ -25,27 +31,6 @@ import { /** How far a delivery's `sentry-hook-timestamp` may sit from our clock. */ const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; -/** Sentry sends unix seconds; a stale or future timestamp is a replay. */ -function timestampFresh(header: string | null): boolean { - if (!header) return false; - const seconds = Number(header); - if (!Number.isFinite(seconds)) return false; - return Math.abs(Date.now() - seconds * 1000) <= TIMESTAMP_TOLERANCE_MS; -} - -/** Constant-time compare of Sentry's hex HMAC-SHA256 against ours. */ -function signatureMatches( - body: string, - signature: string, - secret: string, -): boolean { - const expected = createHmac("sha256", secret).update(body, "utf8").digest(); - const received = Buffer.from(signature, "hex"); - return ( - received.length === expected.length && timingSafeEqual(received, expected) - ); -} - /** The connection an installation uuid belongs to, active or not. */ async function connectionByInstallation(installationUuid: string) { return db.query.integrationConnections.findFirst({ @@ -57,85 +42,80 @@ async function connectionByInstallation(installationUuid: string) { }); } -type SentryConnection = NonNullable< - Awaited> ->; +export async function POST(request: Request) { + const body = await cappedBody(request); + if (body instanceof Response) return body; -function installationUuidOf(payload: SentryIssuePayload): string | null { - return payload.installation?.uuid ?? payload.data?.installation?.uuid ?? null; -} + const secret = env.SENTRY_CLIENT_SECRET; + const signature = request.headers.get("sentry-hook-signature"); + if ( + !secret || + !signature || + !timingSafeHex(signature, hmacHex(body, secret)) + ) { + return unauthorized("Invalid signature"); + } + if ( + !freshTimestamp( + request.headers.get("sentry-hook-timestamp"), + TIMESTAMP_TOLERANCE_MS, + ) + ) { + return unauthorized("Stale timestamp"); + } -export const POST = providerWebhook({ - provider: "sentry", - database: db, + const payload = parseJson(body); + if (payload instanceof Response) return payload; - verify: ({ rawBody, headers }) => { - const secret = env.SENTRY_CLIENT_SECRET; - const signature = headers.get("sentry-hook-signature"); - if ( - !secret || - !signature || - !signatureMatches(rawBody, signature, secret) - ) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } - if (!timestampFresh(headers.get("sentry-hook-timestamp"))) { - return Response.json({ error: "Stale timestamp" }, { status: 401 }); - } - }, + const resource = request.headers.get("sentry-hook-resource"); + const installationUuid = + payload.installation?.uuid ?? payload.data?.installation?.uuid ?? null; - connections: async (payload, { headers }) => { - const resource = headers.get("sentry-hook-resource"); - const installationUuid = installationUuidOf(payload); - - // installation.deleted / .created only touch connection state; a - // deletion drops the token so a later issue delivery for the same - // install is refused. - if (resource === "installation") { - if (installationUuid && payload.action === "deleted") { - const connection = await connectionByInstallation(installationUuid); - if (connection) { - await disconnectSentry( - connection.id, - "Integration removed in Sentry", - ); - } + // installation.deleted / .created only touch connection state; a deletion + // drops the token so a later issue delivery for the same install is refused. + if (resource === "installation") { + if (installationUuid && payload.action === "deleted") { + const connection = await connectionByInstallation(installationUuid); + if (connection) { + await disconnectSentry(connection.id, "Integration removed in Sentry"); } - // installation.created is a no-op: the callback, which alone knows - // the Superset org, is what writes the connection. - return Response.json({ success: true }); - } - if (resource !== "issue" || !payload.action) { - return Response.json({ success: true, message: "Ignored" }); } + // installation.created is a no-op: the callback, which alone knows the + // Superset org, is what writes the connection. + return Response.json({ success: true }); + } + if (resource !== "issue" || !payload.action) { + return Response.json({ success: true, message: "Ignored" }); + } - const connection = installationUuid - ? await connectionByInstallation(installationUuid) - : null; - // No active connection for this install: nothing to attribute the - // event to, and nothing a retry could resolve. - if (!connection || connection.disconnectedAt) return []; - return [connection]; - }, + const connection = installationUuid + ? await connectionByInstallation(installationUuid) + : null; + // No active connection for this install: nothing to attribute the event + // to, and nothing a retry could resolve. + if (!connection || connection.disconnectedAt) { + return Response.json({ success: true, message: "No connection" }); + } - normalize: (payload, connection, { headers }) => { - const event = matchableFrom(payload, `issue.${payload.action}`); - const deliveryId = - headers.get("request-id") ?? `sentry-${crypto.randomUUID()}`; - if (event.names.length === 0) { - console.log( - `[sentry/webhook] Unhandled action issue.${payload.action}, recorded only:`, - deliveryId, - ); - } - return normalizeSentryDelivery({ + const event = matchableFrom(payload, `issue.${payload.action}`); + const deliveryId = + request.headers.get("request-id") ?? `sentry-${crypto.randomUUID()}`; + if (event.names.length === 0) { + console.log( + `[sentry/webhook] Unhandled action issue.${payload.action}, recorded only:`, + deliveryId, + ); + } + + const outcome = await ingestAutomationEvent( + db, + normalizeSentryDelivery({ organizationId: connection.organizationId, connectionId: connection.id, event, deliveryId, payload, - }); - }, - - connectionId: (connection) => connection.id, -}); + }), + ); + return Response.json({ success: true, outcome }); +} diff --git a/apps/api/src/lib/automations/ingestAutomationEvent.ts b/apps/api/src/lib/automations/ingestAutomationEvent.ts index 7460c34af15..8b9495c8998 100644 --- a/apps/api/src/lib/automations/ingestAutomationEvent.ts +++ b/apps/api/src/lib/automations/ingestAutomationEvent.ts @@ -12,12 +12,21 @@ import { * names nothing (an action no trigger exists for) is recorded and never * dispatched. */ -export type NormalizedDelivery = { - event: Omit; - dispatch: AutomationEventDispatchInput | null; -}; +export type NormalizedDelivery = + | { + event: Omit; + dispatch: AutomationEventDispatchInput | null; + } + /** + * A delivery the provider could not turn into an event for a permanent + * reason (revoked token, entity not shared with the integration). Nothing + * is recorded and the sender is acknowledged; a retry would fail the same + * way. Transient failures throw instead, so the sender retries. + */ + | { skip: string }; export type IngestOutcome = + | { status: "skipped"; reason: string } | { status: "duplicate" } | { status: "recorded"; eventId: string } | { @@ -39,6 +48,7 @@ export async function ingestAutomationEvent( database: PgDatabase>, delivery: NormalizedDelivery, ): Promise { + if ("skip" in delivery) return { status: "skipped", reason: delivery.skip }; const inserted = await recordAutomationEvent(database, { ...delivery.event, dispatchInput: delivery.dispatch, diff --git a/apps/api/src/lib/automations/providerWebhook.test.ts b/apps/api/src/lib/automations/providerWebhook.test.ts deleted file mode 100644 index 4f6eae426c7..00000000000 --- a/apps/api/src/lib/automations/providerWebhook.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { providerWebhook } from "./providerWebhook"; - -const post = ( - handler: (request: Request) => Promise, - body: string, - headers: Record = {}, -) => - handler( - new Request("https://api.test/webhook", { - method: "POST", - body, - headers, - }), - ); - -describe("providerWebhook", () => { - it("refuses before parsing when verify answers", async () => { - let parsed = false; - const handler = providerWebhook({ - provider: "test", - verify: ({ headers }) => - headers.get("x-sig") === "ok" - ? undefined - : Response.json({ error: "Invalid signature" }, { status: 401 }), - connections: () => { - parsed = true; - return []; - }, - normalize: () => null, - }); - const refused = await post(handler, "not even json"); - expect(refused.status).toBe(401); - expect(parsed).toBe(false); - }); - - it("rejects an unparseable body only after verification", async () => { - const handler = providerWebhook({ - provider: "test", - verify: () => undefined, - connections: () => [], - normalize: () => null, - }); - const response = await post(handler, "{nope"); - expect(response.status).toBe(400); - }); - - it("caps the body before reading it", async () => { - const handler = providerWebhook({ - provider: "test", - maxBodyBytes: 10, - verify: () => undefined, - connections: () => [], - normalize: () => null, - }); - const response = await post(handler, "x".repeat(11)); - expect(response.status).toBe(413); - }); - - it("lets connections answer the request outright", async () => { - const handler = providerWebhook({ - provider: "test", - verify: () => undefined, - connections: () => Response.json({ handshake: true }), - normalize: () => null, - }); - const response = await post(handler, "{}"); - expect(await response.json()).toEqual({ handshake: true }); - }); - - it("acknowledges a delivery nobody subscribes to", async () => { - const handler = providerWebhook({ - provider: "test", - verify: () => undefined, - connections: () => [], - normalize: () => null, - }); - const response = await post(handler, "{}"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - ok: true, - status: "no_subscribers", - }); - }); - - it("reports ignored deliveries per connection without recording", async () => { - const handler = providerWebhook({ - provider: "test", - verify: () => undefined, - connections: () => [{ id: "a" }, { id: "b" }], - normalize: () => null, - connectionId: (c) => c.id, - }); - const response = await post(handler, "{}"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - ok: true, - results: [ - { connectionId: "a", outcome: { status: "ignored" } }, - { connectionId: "b", outcome: { status: "ignored" } }, - ], - }); - }); - - it("keeps the automation event when a side effect throws", async () => { - let normalized = false; - const handler = providerWebhook({ - provider: "test", - verify: () => undefined, - connections: () => [{ id: "a" }], - sideEffects: () => { - throw new Error("mirror failed"); - }, - normalize: () => { - normalized = true; - return null; - }, - }); - const response = await post(handler, "{}"); - expect(response.status).toBe(200); - expect(normalized).toBe(true); - }); - - it("returns 500 only when every connection failed", async () => { - const handler = providerWebhook({ - provider: "test", - verify: () => undefined, - connections: () => [{ id: "a" }, { id: "b" }], - normalize: (_payload, connection) => { - if (connection.id === "a") throw new Error("boom"); - return null; - }, - connectionId: (c) => c.id, - }); - const partial = await post(handler, "{}"); - expect(partial.status).toBe(200); - - const allBad = providerWebhook({ - provider: "test", - verify: () => undefined, - connections: () => [{ id: "a" }], - normalize: () => { - throw new Error("boom"); - }, - }); - const failed = await post(allBad, "{}"); - expect(failed.status).toBe(500); - }); -}); diff --git a/apps/api/src/lib/automations/providerWebhook.ts b/apps/api/src/lib/automations/providerWebhook.ts deleted file mode 100644 index 028197f1858..00000000000 --- a/apps/api/src/lib/automations/providerWebhook.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { dbWs } from "@superset/db/client"; -import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; -import { - type IngestOutcome, - ingestAutomationEvent, - type NormalizedDelivery, -} from "./ingestAutomationEvent"; - -const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; - -export type DeliveryContext = { - rawBody: string; - headers: Headers; - url: URL; -}; - -/** - * What a provider has to say about its webhook, and nothing else. The runner - * owns the mechanics every provider used to reimplement — body cap, verify - * before parse, fan-out, record, dispatch, response codes, logging. - * - * `verify` looks at the raw request and either accepts it (returns nothing) - * or answers it (a Response: 401 for a bad signature, 200 for a handshake). - * `connections` names who receives the parsed payload — a Response here - * answers the request instead (an ignored resource type, a side effect that - * is the whole point of the delivery). `normalize` turns the payload into the - * event for one connection, or null when it names nothing an automation can - * see. `sideEffects` is for work that rides along with the delivery but is - * not the automation event (mirroring a PR, syncing a task); it runs first - * and its failure never costs the automation event. - */ -export type ProviderWebhookSpec = { - provider: string; - maxBodyBytes?: number; - database?: PgDatabase>; - verify: ( - context: DeliveryContext, - ) => Promise | Response | undefined; - parse?: (rawBody: string) => Payload; - connections: ( - payload: Payload, - context: DeliveryContext, - ) => Promise | Connection[] | Response; - sideEffects?: ( - payload: Payload, - connection: Connection, - context: DeliveryContext, - ) => Promise | void; - normalize: ( - payload: Payload, - connection: Connection, - context: DeliveryContext, - ) => Promise | NormalizedDelivery | null; - connectionId?: (connection: Connection) => string; -}; - -export type ProviderWebhookResult = { - connectionId: string | null; - outcome: IngestOutcome | { status: "ignored" } | { status: "failed" }; -}; - -export function providerWebhook( - spec: ProviderWebhookSpec, -): (request: Request) => Promise { - const maxBodyBytes = spec.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; - const database = spec.database ?? dbWs; - const tag = `[${spec.provider}/webhook]`; - - return async (request: Request) => { - const contentLength = Number(request.headers.get("content-length")); - if (contentLength > maxBodyBytes) { - return Response.json({ error: "Body too large" }, { status: 413 }); - } - const rawBody = await request.text(); - if (Buffer.byteLength(rawBody) > maxBodyBytes) { - return Response.json({ error: "Body too large" }, { status: 413 }); - } - const context: DeliveryContext = { - rawBody, - headers: request.headers, - url: new URL(request.url), - }; - - const refused = await spec.verify(context); - if (refused) return refused; - - let payload: Payload; - try { - payload = spec.parse - ? spec.parse(rawBody) - : (JSON.parse(rawBody) as Payload); - } catch { - return Response.json({ error: "Invalid payload" }, { status: 400 }); - } - - const connections = await spec.connections(payload, context); - if (connections instanceof Response) return connections; - if (connections.length === 0) { - return Response.json({ ok: true, status: "no_subscribers" }); - } - - const results: ProviderWebhookResult[] = await Promise.all( - connections.map(async (connection) => { - const connectionId = spec.connectionId?.(connection) ?? null; - try { - if (spec.sideEffects) { - try { - await spec.sideEffects(payload, connection, context); - } catch (error) { - console.error(`${tag} side effect failed:`, error); - } - } - const delivery = await spec.normalize(payload, connection, context); - if (!delivery) - return { connectionId, outcome: { status: "ignored" } }; - const outcome = await ingestAutomationEvent(database, delivery); - return { connectionId, outcome }; - } catch (error) { - console.error(`${tag} failed for connection ${connectionId}:`, error); - return { connectionId, outcome: { status: "failed" } }; - } - }), - ); - - // A failure to normalize or record is worth a retry from the sender; - // a failed QStash handoff is not, since the sweep owns that and a - // redelivery would only dedupe. - const allFailed = results.every((r) => r.outcome.status === "failed"); - return Response.json( - { ok: !allFailed, results }, - { status: allFailed ? 500 : 200 }, - ); - }; -} diff --git a/apps/api/src/app/api/integrations/google/lib/verifyQstash.ts b/apps/api/src/lib/verifyQstash.ts similarity index 94% rename from apps/api/src/app/api/integrations/google/lib/verifyQstash.ts rename to apps/api/src/lib/verifyQstash.ts index e35fcd3cf45..02edc6135e0 100644 --- a/apps/api/src/app/api/integrations/google/lib/verifyQstash.ts +++ b/apps/api/src/lib/verifyQstash.ts @@ -31,7 +31,7 @@ export async function verifyQstashRequest( return Response.json({ error: "Invalid signature" }, { status: 401 }); } } catch (error) { - console.error(`[google${path}] signature verification failed:`, error); + console.error(`[qstash${path}] signature verification failed:`, error); return Response.json( { error: "Signature verification failed" }, { status: 401 }, diff --git a/apps/api/src/lib/webhooks/body.ts b/apps/api/src/lib/webhooks/body.ts new file mode 100644 index 00000000000..39c0040a954 --- /dev/null +++ b/apps/api/src/lib/webhooks/body.ts @@ -0,0 +1,29 @@ +const DEFAULT_MAX_BYTES = 1024 * 1024; + +/** + * Reads the raw body with a size cap. The Content-Length check is cheap and + * refuses before reading; the byte check after reading is the one that holds, + * since the header is sender-controlled. + */ +export async function cappedBody( + request: Request, + maxBytes = DEFAULT_MAX_BYTES, +): Promise { + if (Number(request.headers.get("content-length")) > maxBytes) { + return Response.json({ error: "Body too large" }, { status: 413 }); + } + const body = await request.text(); + if (Buffer.byteLength(body) > maxBytes) { + return Response.json({ error: "Body too large" }, { status: 413 }); + } + return body; +} + +/** JSON.parse that answers 400 instead of throwing. */ +export function parseJson(body: string): T | Response { + try { + return JSON.parse(body) as T; + } catch { + return Response.json({ error: "Invalid JSON payload" }, { status: 400 }); + } +} diff --git a/apps/api/src/lib/webhooks/verify.ts b/apps/api/src/lib/webhooks/verify.ts new file mode 100644 index 00000000000..50de259c3a1 --- /dev/null +++ b/apps/api/src/lib/webhooks/verify.ts @@ -0,0 +1,36 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** HMAC-SHA256 of `payload` as lowercase hex — what most providers sign with. */ +export function hmacHex(payload: string, secret: string): string { + return createHmac("sha256", secret).update(payload, "utf8").digest("hex"); +} + +/** + * Constant-time compare of two hex digests. A length mismatch is a mismatch, + * never a throw, so a malformed header cannot 500 the route. + */ +export function timingSafeHex(received: string, expected: string): boolean { + const a = Buffer.from(received.trim().toLowerCase(), "hex"); + const b = Buffer.from(expected, "hex"); + return a.length === b.length && a.length > 0 && timingSafeEqual(a, b); +} + +/** + * True when a sender-supplied timestamp is within `toleranceMs` of our clock, + * in either direction. Accepts unix seconds or milliseconds; a missing or + * unparseable value is stale. + */ +export function freshTimestamp( + value: string | number | null | undefined, + toleranceMs: number, +): boolean { + if (value === null || value === undefined) return false; + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return false; + const ms = n < 1e12 ? n * 1000 : n; + return Math.abs(Date.now() - ms) <= toleranceMs; +} + +export function unauthorized(error: string): Response { + return Response.json({ error }, { status: 401 }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx index 0ce3beb9ff3..8478e1c3051 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx @@ -11,7 +11,6 @@ import { useRecentProjects } from "renderer/hooks/host-projects/useRecentProject import type { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; import { ProjectPicker } from "../../../components/ProjectPicker"; -import { useProviderOptions } from "../../../components/providers/useProviderOptions"; import { RelayOfflineNotice } from "../../../components/RelayOfflineNotice"; import { TriggersEditor } from "../../../components/TriggersEditor"; import { WorkspacePicker } from "../../../components/WorkspacePicker"; @@ -43,7 +42,6 @@ export function TriggersCard({ onSaveTriggers, }: TriggersCardProps) { const recentProjects = useRecentProjects(); - const options = useProviderOptions(automation.organizationId); const selectedProject = recentProjects.find( (p) => p.id === automation.v2ProjectId, ); @@ -140,7 +138,7 @@ export function TriggersCard({ config: t.config as DraftTrigger["config"], }))} onChange={onSaveTriggers} - options={options} + organizationId={automation.organizationId} renderNextRun={renderNextRun} readOnly={readOnly} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx index 3fd51b5a4b6..e8ac7704e18 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx @@ -16,7 +16,8 @@ import { Separator } from "@superset/ui/separator"; import { useFeatureFlagPayload } from "posthog-js/react"; import { type ReactNode, useMemo, useState } from "react"; import { LuCirclePlus, LuTriangleAlert } from "react-icons/lu"; -import { type ProviderOptions, TRIGGER_PROVIDERS } from "../providers"; +import { TRIGGER_PROVIDERS } from "../providers"; +import { useProviderOptions } from "../providers/useProviderOptions"; import { TriggerSentence } from "../TriggerSentence"; import { TriggerMenuItems } from "./TriggerMenuItems"; import { flattenTriggerMenu, matchesQuery } from "./triggerMenu"; @@ -25,8 +26,8 @@ interface TriggersEditorProps { triggers: DraftTrigger[]; /** Resolves once the set is written; rejects if it was refused. */ onChange: (next: DraftTrigger[]) => undefined | Promise; - /** Pickable values per provider, fetched by the card. */ - options: ProviderOptions; + /** Whose integrations the pickable lists come from. */ + organizationId: string; /** Trailing "Next run ..." text for one schedule row, by trigger id. */ renderNextRun?: (triggerId?: string) => ReactNode; readOnly?: boolean; @@ -43,7 +44,7 @@ interface TriggersEditorProps { export function TriggersEditor({ triggers, onChange, - options, + organizationId, renderNextRun, readOnly, }: TriggersEditorProps) { @@ -55,6 +56,7 @@ export function TriggersEditor({ // silently once it happened to become valid is no better: nothing tells you // which edit crossed the line, or that anything was written at all. const [drafts, setDrafts] = useState(triggers); + const options = useProviderOptions(organizationId, drafts); const [dirty, setDirty] = useState(false); const savedKey = JSON.stringify(triggers); const [prevSavedKey, setPrevSavedKey] = useState(savedKey); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx index aec2aedbfbb..ab7834c9a89 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx @@ -108,6 +108,7 @@ function renderPart( export const githubProvider: TriggerProvider = { kind: "github", + optionGroup: "github", label: "GitHub", icon: FaGithub, menu: GITHUB_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/useGithubOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/useGithubOptions.ts deleted file mode 100644 index 832fae7761d..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/useGithubOptions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useMemo } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import type { ProviderOptions } from "../types"; - -/** The pickable values a GitHub sentence needs: repositories and the installation's people. */ -export function useGithubOptions(organizationId: string): ProviderOptions { - // repoId is GitHub's numeric id, which is what the matcher compares against — - // a full name would stop matching the moment someone renames the repo. - const repos = cloudTrpc.integration.github.listRepositories.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - const people = cloudTrpc.integration.github.listPeople.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - - return useMemo( - () => ({ - github: { - repositories: (repos.data ?? []).map((repo) => ({ - id: repo.repoId, - label: repo.fullName, - })), - people: people.data ?? [], - }, - }), - [repos.data, people.data], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx index cea8d9acac5..eb5c68b0c38 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx @@ -92,6 +92,7 @@ function renderPart( export const gmailProvider: TriggerProvider = { kind: "gmail", + optionGroup: "google", label: "Gmail", icon: SiGmail, menu: GMAIL_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx index 21bbbc93031..0b4fda0e3c7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx @@ -92,6 +92,7 @@ function renderPart( export const googleCalendarProvider: TriggerProvider = { kind: "google_calendar", + optionGroup: "google", label: "Google Calendar", icon: SiGooglecalendar, menu: CALENDAR_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/useGoogleOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/useGoogleOptions.ts deleted file mode 100644 index ac037b14dc9..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/useGoogleOptions.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useMemo } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import type { ProviderOptions } from "../types"; - -/** - * The pickable values the Google sentences need: the connected account's - * calendars and mail labels, and the org's linked Google addresses for the - * attendee filter. Empty until an account is connected. - */ -export function useGoogleOptions(organizationId: string): ProviderOptions { - // Calendars and labels are read live from Google, not from a synced table - // like GitHub's repositories, so a stale-time keeps opening a card from - // spending two Google calls every time. - const query = { enabled: Boolean(organizationId), staleTime: 5 * 60_000 }; - const calendars = cloudTrpc.integration.google.listCalendars.useQuery( - { organizationId }, - query, - ); - const labels = cloudTrpc.integration.google.listLabels.useQuery( - { organizationId }, - query, - ); - const people = cloudTrpc.integration.google.listLinkedPeople.useQuery( - { organizationId }, - query, - ); - - return useMemo( - () => ({ - google: { - calendars: calendars.data ?? [], - gmailLabels: labels.data ?? [], - people: people.data ?? [], - }, - }), - [calendars.data, labels.data, people.data], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx index d8e2049c58a..cfcbf683e25 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx @@ -91,6 +91,7 @@ function renderPart( export const linearProvider: TriggerProvider = { kind: "linear", + optionGroup: "linear", label: "Linear", icon: SiLinear, menu: LINEAR_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/useLinearOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/useLinearOptions.ts deleted file mode 100644 index f24fefecfd5..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/useLinearOptions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import type { ProviderOptions } from "../types"; - -/** - * The pickable values a Linear sentence needs: teams, projects, labels and - * workflow states and members, all from Linear. - */ -export function useLinearOptions(organizationId: string): ProviderOptions { - const options = cloudTrpc.integration.linear.getTriggerOptions.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - const people = cloudTrpc.integration.linear.listPeople.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - - return useMemo( - () => ({ - linear: { - teams: options.data?.teams ?? [], - projects: options.data?.projects ?? [], - labels: options.data?.labels ?? [], - statuses: options.data?.statuses ?? [], - people: people.data ?? [], - }, - }), - [options.data, people.data], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx index 5f9a40d18e9..3e5f7770e51 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx @@ -88,6 +88,7 @@ function renderPart( export const microsoftTeamsProvider: TriggerProvider = { kind: "microsoft_teams", + optionGroup: "microsoftTeams", label: "Microsoft Teams", icon: BsMicrosoftTeams, menu: TEAMS_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/useTeamsOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/useTeamsOptions.ts deleted file mode 100644 index 21bc7a50f90..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/useTeamsOptions.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useMemo } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import type { ProviderOptions } from "../types"; - -/** - * The pickable values a Teams sentence needs: the tenant's teams, every - * channel in them labelled "Team › Channel", and the tenant's people as Entra - * object ids. All come from Graph through the connection's app token; without - * a connection all are empty and the chips say so. - */ -export function useTeamsOptions(organizationId: string): ProviderOptions { - const teams = cloudTrpc.integration.microsoftTeams.listTeams.useQuery( - { organizationId }, - { enabled: Boolean(organizationId), staleTime: 5 * 60 * 1000 }, - ); - const channels = cloudTrpc.integration.microsoftTeams.listChannels.useQuery( - { organizationId }, - { enabled: Boolean(organizationId), staleTime: 5 * 60 * 1000 }, - ); - const people = cloudTrpc.integration.microsoftTeams.listPeople.useQuery( - { organizationId }, - { enabled: Boolean(organizationId), staleTime: 5 * 60 * 1000 }, - ); - - return useMemo( - () => ({ - microsoftTeams: { - teams: teams.data ?? [], - channels: (channels.data ?? []).map((channel) => ({ - id: channel.id, - label: channel.label, - })), - people: people.data ?? [], - }, - }), - [teams.data, channels.data, people.data], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx index 902943220f0..e225b4b669c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx @@ -79,6 +79,7 @@ function renderPart( export const notionProvider: TriggerProvider = { kind: "notion", + optionGroup: "notion", label: "Notion", icon: SiNotion, menu: NOTION_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/useNotionOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/useNotionOptions.ts deleted file mode 100644 index ad81f152dae..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/useNotionOptions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useMemo } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import type { ProviderOptions } from "../types"; - -/** - * The pickable values a Notion sentence needs: the data sources the connected - * workspace shares with the integration, and the workspace's people (Notion - * user ids — what a comment's author and mentions carry). - */ -export function useNotionOptions(organizationId: string): ProviderOptions { - const dataSources = cloudTrpc.integration.notion.listDataSources.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - const people = cloudTrpc.integration.notion.listPeople.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - - return useMemo( - () => ({ - notion: { - dataSources: dataSources.data ?? [], - people: people.data ?? [], - }, - }), - [dataSources.data, people.data], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx index 007f387ed98..289ebd630ce 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx @@ -54,6 +54,7 @@ function renderPart( export const sentryProvider: TriggerProvider = { kind: "sentry", + optionGroup: "sentry", label: "Sentry", icon: SiSentry, menu: SENTRY_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/useSentryOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/useSentryOptions.ts deleted file mode 100644 index 7fef65ccaca..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/useSentryOptions.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useMemo } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import type { ProviderOptions } from "../types"; -import { SENTRY_LEVELS } from "./grammar"; - -/** The pickable values a Sentry sentence needs: projects, and the fixed levels. */ -export function useSentryOptions(organizationId: string): ProviderOptions { - // The numeric project id is what the matcher compares against — a slug would - // stop matching the moment someone renames the project. - const projects = cloudTrpc.integration.sentry.listProjects.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - - return useMemo( - () => ({ - sentry: { - projects: (projects.data ?? []).map((project) => ({ - id: project.id, - label: project.slug, - })), - levels: SENTRY_LEVELS, - }, - }), - [projects.data], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx index b160eb56263..d130eeb85b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx @@ -136,6 +136,7 @@ function renderPart( export const slackProvider: TriggerProvider = { kind: "slack", + optionGroup: "slack", label: "Slack", icon: FaSlack, menu: SLACK_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/useSlackOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/useSlackOptions.ts deleted file mode 100644 index 24995e934c9..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/useSlackOptions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useMemo } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import type { ProviderOptions } from "../types"; - -/** The pickable values a Slack sentence needs: channels and workspace members. */ -export function useSlackOptions(organizationId: string): ProviderOptions { - // Channel ids, not names — a channel can be renamed and the trigger must - // keep matching it afterwards. Same for people: Slack user ids. - const channels = cloudTrpc.integration.slack.listChannels.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - const people = cloudTrpc.integration.slack.listPeople.useQuery( - { organizationId }, - { enabled: Boolean(organizationId) }, - ); - - return useMemo( - () => ({ - slack: { - channels: (channels.data ?? []).map((channel) => ({ - id: channel.id, - label: `#${channel.name}`, - })), - people: people.data ?? [], - }, - }), - [channels.data, people.data], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts index a0409c4d6fc..a2313a931f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts @@ -27,6 +27,13 @@ export type TriggerProvider< menu: TriggerMenuEntry[]; /** Renders the editable sentence for a config of this kind. */ renderSentence: (config: Config, ctx: SentenceContext) => ReactNode; + /** + * The key this provider's pickable lists live under (`options.slack`), + * fetched from `integration.triggerOptions` when a row of this kind is on + * screen. Absent for providers with nothing to fetch (Scheduled, Webhook). + * Two kinds can share one group — Calendar and Gmail both read `google`. + */ + optionGroup?: string; }; /** diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderOptions.ts index 5adc6c9f63c..caf9c327376 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderOptions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderOptions.ts @@ -1,39 +1,46 @@ +import type { DraftTrigger } from "@superset/shared/automation-triggers"; +import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; -import { useGithubOptions } from "./github/useGithubOptions"; -import { useGoogleOptions } from "./google/useGoogleOptions"; -import { useLinearOptions } from "./linear/useLinearOptions"; -import { useTeamsOptions } from "./microsoftTeams/useTeamsOptions"; -import { useNotionOptions } from "./notion/useNotionOptions"; -import { useSentryOptions } from "./sentry/useSentryOptions"; -import { useSlackOptions } from "./slack/useSlackOptions"; +import { cloudTrpc } from "renderer/lib/cloud-trpc"; +import { providerFor } from "./index"; import type { ProviderOptions } from "./types"; +const STALE_MS = 5 * 60_000; + /** - * Every provider's pickable values, merged into one map for the editor. - * - * Hooks cannot be called from a registry loop, so each provider's hook is - * called here by name. Adding a provider with options means adding its hook to - * this list. Each hook returns its own top-level key (`{ github: {...} }`), so - * the merge is a spread and cannot clobber. + * The pickable values for the providers that are actually on screen, one + * round trip per option group. A page holding a single schedule trigger asks + * for nothing; adding a Slack row asks for Slack's lists and nothing else. */ -export function useProviderOptions(organizationId: string): ProviderOptions { - const github = useGithubOptions(organizationId); - const sentry = useSentryOptions(organizationId); - const linear = useLinearOptions(organizationId); - const notion = useNotionOptions(organizationId); - const slack = useSlackOptions(organizationId); - const teams = useTeamsOptions(organizationId); - const google = useGoogleOptions(organizationId); - return useMemo( - () => ({ - ...github, - ...sentry, - ...linear, - ...notion, - ...slack, - ...teams, - ...google, - }), - [github, sentry, linear, notion, slack, teams, google], - ); +export function useProviderOptions( + organizationId: string, + drafts: DraftTrigger[], +): ProviderOptions { + const groups = useMemo(() => { + const seen = new Set(); + for (const draft of drafts) { + const provider = providerFor(draft.config); + if (provider.optionGroup) seen.add(provider.optionGroup); + } + return [...seen].sort(); + }, [drafts]); + + const trpc = cloudTrpc.useUtils(); + const results = useQueries({ + queries: groups.map((group) => ({ + queryKey: ["integration.triggerOptions", organizationId, group], + queryFn: () => + trpc.integration.triggerOptions.fetch({ organizationId, group }), + enabled: Boolean(organizationId), + staleTime: STALE_MS, + })), + }); + + return useMemo(() => { + const options: ProviderOptions = {}; + groups.forEach((group, index) => { + options[group] = results[index]?.data ?? {}; + }); + return options; + }, [groups, results]); } diff --git a/packages/trpc/src/router/integration/integration.ts b/packages/trpc/src/router/integration/integration.ts index 81437a2d42b..a653fbd9c6c 100644 --- a/packages/trpc/src/router/integration/integration.ts +++ b/packages/trpc/src/router/integration/integration.ts @@ -11,6 +11,7 @@ import { microsoftTeamsRouter } from "./microsoft-teams"; import { notionRouter } from "./notion"; import { sentryRouter } from "./sentry"; import { slackRouter } from "./slack"; +import { triggerOptionsRouter } from "./trigger-options"; import { verifyOrgMembership } from "./utils"; export const integrationRouter = { @@ -21,6 +22,7 @@ export const integrationRouter = { notion: notionRouter, sentry: sentryRouter, slack: slackRouter, + ...triggerOptionsRouter, list: protectedProcedure .input(z.object({ organizationId: z.uuid() })) diff --git a/packages/trpc/src/router/integration/sentry/sentry.ts b/packages/trpc/src/router/integration/sentry/sentry.ts index 694b112cf05..ae4423ccdbb 100644 --- a/packages/trpc/src/router/integration/sentry/sentry.ts +++ b/packages/trpc/src/router/integration/sentry/sentry.ts @@ -1,11 +1,10 @@ import { db } from "@superset/db/client"; -import { integrationConnections, type SentryConfig } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; -import { fetchSentryProjects, getSentryAccessToken, SENTRY_URL } from "./utils"; /** * A Sentry connection is a public integration the org installs through Sentry's @@ -64,36 +63,4 @@ export const sentryRouter = { return { success: true }; }), - - /** The org's Sentry projects, as the trigger editor's project chip lists them. */ - listProjects: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "sentry"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { id: true, externalOrgId: true, config: true }, - }); - if (!connection?.externalOrgId) return []; - - const token = await getSentryAccessToken(connection.id); - if (token.disconnected) return []; - - const config = connection.config as SentryConfig | null; - const projects = await fetchSentryProjects( - config?.regionUrl ?? SENTRY_URL, - connection.externalOrgId, - token.accessToken, - ); - return projects.map((project) => ({ - id: project.id, - slug: project.slug, - name: project.name, - })); - }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/sentry/trigger-options.ts b/packages/trpc/src/router/integration/sentry/trigger-options.ts new file mode 100644 index 00000000000..c97cd353dab --- /dev/null +++ b/packages/trpc/src/router/integration/sentry/trigger-options.ts @@ -0,0 +1,34 @@ +import { db } from "@superset/db/client"; +import { integrationConnections, type SentryConfig } from "@superset/db/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import type { TriggerOptionSource } from "../trigger-options"; +import { fetchSentryProjects, getSentryAccessToken, SENTRY_URL } from "./utils"; + +/** + * The numeric project id is what the matcher compares against — a slug would + * stop matching the moment someone renames the project. The slug is the label. + */ +const projects: TriggerOptionSource = async ({ organizationId }) => { + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, organizationId), + eq(integrationConnections.provider, "sentry"), + isNull(integrationConnections.disconnectedAt), + ), + columns: { id: true, externalOrgId: true, config: true }, + }); + if (!connection?.externalOrgId) return []; + + const token = await getSentryAccessToken(connection.id); + if (token.disconnected) return []; + + const config = connection.config as SentryConfig | null; + const list = await fetchSentryProjects( + config?.regionUrl ?? SENTRY_URL, + connection.externalOrgId, + token.accessToken, + ); + return list.map((project) => ({ id: project.id, label: project.slug })); +}; + +export const sentryTriggerOptions = { projects }; diff --git a/packages/trpc/src/router/integration/trigger-options.ts b/packages/trpc/src/router/integration/trigger-options.ts new file mode 100644 index 00000000000..2d2b5379197 --- /dev/null +++ b/packages/trpc/src/router/integration/trigger-options.ts @@ -0,0 +1,68 @@ +import type { TRPCRouterRecord } from "@trpc/server"; +import { z } from "zod"; +import { protectedProcedure } from "../../trpc"; +import { sentryTriggerOptions } from "./sentry/trigger-options"; +import { verifyOrgMembership } from "./utils"; + +/** One selectable value in a scope or actor chip. */ +export type TriggerOption = { id: string; label: string }; + +export type TriggerOptionContext = { + organizationId: string; + /** Who is asking — matters for per-user connections (Google). */ + userId: string; +}; + +export type TriggerOptionSource = ( + context: TriggerOptionContext, +) => Promise; + +/** + * Every pickable list the trigger editor can show, by option group then key: + * `sentry.projects`, `slack.channels`, `google.calendars`. The group is the + * key the editor reads under (`options.slack.channels`), and it is what a + * provider's sentence and its lists agree on. + * + * Adding a list means adding a source here. Labels are the display string, + * ids are what the matcher compares against — a source that returns slugs + * where the matcher wants numeric ids stops matching the moment something is + * renamed. + */ +export const triggerOptionSources: Record< + string, + Record +> = { + sentry: sentryTriggerOptions, +}; + +export const triggerOptionsRouter = { + /** + * All of one group's lists in one round trip. A source that fails (revoked + * token, provider down) yields an empty list rather than failing the rest — + * the editor stays usable and the sentence shows what it can. + */ + triggerOptions: protectedProcedure + .input(z.object({ organizationId: z.uuid(), group: z.string() })) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + const sources = triggerOptionSources[input.group] ?? {}; + const context: TriggerOptionContext = { + organizationId: input.organizationId, + userId: ctx.session.user.id, + }; + const entries = await Promise.all( + Object.entries(sources).map(async ([key, source]) => { + try { + return [key, await source(context)] as const; + } catch (error) { + console.error( + `[integration.triggerOptions] ${input.group}.${key} failed:`, + error, + ); + return [key, []] as const; + } + }), + ); + return Object.fromEntries(entries) as Record; + }), +} satisfies TRPCRouterRecord; From e05e2ddb833ec445336e97219a86819755c24d9e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 12:58:19 -0700 Subject: [PATCH 03/15] refactor(api): circleback + raw webhook on ingestAutomationEvent, verifyQstashRequest in every QStash route --- .../api/automations/dispatch/[id]/route.ts | 25 +-- .../src/app/api/automations/evaluate/route.ts | 25 +-- .../app/api/automations/run-failed/route.ts | 26 +--- .../normalizeWebhookDelivery.ts | 37 +++++ .../webhook/[automationId]/route.ts | 78 ++-------- .../api/cloud-workspaces/provision/route.ts | 37 +---- .../app/api/github/jobs/initial-sync/route.ts | 41 +---- .../app/api/hosts/jobs/sync-presence/route.ts | 34 +---- .../app/api/ingest/prune-payloads/route.ts | 26 +--- .../normalizeCirclebackDelivery.ts | 68 +++++++++ .../circleback/webhook/[triggerId]/route.ts | 143 +++--------------- .../linear/jobs/initial-sync/route.ts | 34 +---- .../linear/jobs/refresh-tokens/route.ts | 44 +----- .../linear/jobs/sync-task/route.ts | 37 +---- .../jobs/renew-subscriptions/route.ts | 25 +-- .../microsoft-teams/process/route.ts | 25 +-- .../jobs/process-assistant-message/route.ts | 28 +--- .../slack/jobs/process-mention/route.ts | 28 +--- .../stripe/jobs/notify-slack/route.ts | 26 +--- 19 files changed, 240 insertions(+), 547 deletions(-) create mode 100644 apps/api/src/app/api/automations/webhook/[automationId]/normalizeWebhookDelivery.ts create mode 100644 apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/normalizeCirclebackDelivery.ts diff --git a/apps/api/src/app/api/automations/dispatch/[id]/route.ts b/apps/api/src/app/api/automations/dispatch/[id]/route.ts index a8977dd0318..2f5efb737c0 100644 --- a/apps/api/src/app/api/automations/dispatch/[id]/route.ts +++ b/apps/api/src/app/api/automations/dispatch/[id]/route.ts @@ -1,39 +1,26 @@ import { dbWs } from "@superset/db/client"; import { automations } from "@superset/db/schema"; import { dispatchAutomation } from "@superset/trpc/automation-dispatch"; -import { Receiver } from "@upstash/qstash"; import { eq } from "drizzle-orm"; -import { env } from "@/env"; import { getRelayUrl } from "@/lib/relay-url"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { runPayloadSchema } from "../../runPayloadSchema"; export const maxDuration = 60; export const dynamic = "force-dynamic"; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - export async function POST( request: Request, { params }: { params: Promise<{ id: string }> }, ): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - const { id } = await params; - const valid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/automations/dispatch/${id}`, - }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + `/api/automations/dispatch/${id}`, + ); + if (rejected) return rejected; const parsed = runPayloadSchema.safeParse(JSON.parse(body)); if (!parsed.success) { diff --git a/apps/api/src/app/api/automations/evaluate/route.ts b/apps/api/src/app/api/automations/evaluate/route.ts index afe2f03f6b4..062be421e25 100644 --- a/apps/api/src/app/api/automations/evaluate/route.ts +++ b/apps/api/src/app/api/automations/evaluate/route.ts @@ -5,10 +5,11 @@ import { type TriggerConfig, } from "@superset/db/schema"; import { nextOccurrenceAfter } from "@superset/shared/rrule"; -import { Client, Receiver } from "@upstash/qstash"; +import { Client } from "@upstash/qstash"; import { and, eq, lte } from "drizzle-orm"; import { env } from "@/env"; import { redispatchUndispatched } from "@/lib/automations/redispatchUndispatched"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; export const dynamic = "force-dynamic"; @@ -16,11 +17,6 @@ const qstash = new Client({ token: env.QSTASH_TOKEN, baseUrl: env.QSTASH_URL, }); -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - const BATCH_SIZE = 2000; function bucketToMinute(d: Date): Date { @@ -45,19 +41,12 @@ function scheduleFromConfig( export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const valid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/automations/evaluate`, - }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + "/api/automations/evaluate", + ); + if (rejected) return rejected; const now = new Date(); diff --git a/apps/api/src/app/api/automations/run-failed/route.ts b/apps/api/src/app/api/automations/run-failed/route.ts index 267445e0a06..9e2e93e51b5 100644 --- a/apps/api/src/app/api/automations/run-failed/route.ts +++ b/apps/api/src/app/api/automations/run-failed/route.ts @@ -1,20 +1,13 @@ import * as Sentry from "@sentry/nextjs"; import { dbWs } from "@superset/db/client"; import { automationRuns, automations } from "@superset/db/schema"; -import { Receiver } from "@upstash/qstash"; import { eq, sql } from "drizzle-orm"; import { z } from "zod"; - -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { runPayloadSchema } from "../runPayloadSchema"; export const dynamic = "force-dynamic"; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - const failurePayloadSchema = z.object({ sourceMessageId: z.string(), sourceBody: z.string(), @@ -25,19 +18,12 @@ const failurePayloadSchema = z.object({ export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const valid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/automations/run-failed`, - }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + "/api/automations/run-failed", + ); + if (rejected) return rejected; let rawBody: unknown; try { diff --git a/apps/api/src/app/api/automations/webhook/[automationId]/normalizeWebhookDelivery.ts b/apps/api/src/app/api/automations/webhook/[automationId]/normalizeWebhookDelivery.ts new file mode 100644 index 00000000000..8dc3e0fb37e --- /dev/null +++ b/apps/api/src/app/api/automations/webhook/[automationId]/normalizeWebhookDelivery.ts @@ -0,0 +1,37 @@ +import { randomUUID } from "node:crypto"; + +import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; + +export const EVENT_TYPE = "webhook.received"; + +/** + * Every authenticated delivery is its own event — there is no dedupe — and + * fires every enabled webhook trigger on the one automation the URL names. + */ +export function normalizeWebhookDelivery(params: { + organizationId: string; + automationId: string; + payload: Record | unknown[]; +}): NormalizedDelivery { + return { + event: { + organizationId: params.organizationId, + integrationConnectionId: null, + provider: "webhook", + eventType: EVENT_TYPE, + externalEventId: randomUUID(), + title: "Webhook", + payload: params.payload, + }, + dispatch: { + automationId: params.automationId, + event: { + provider: "webhook", + eventType: EVENT_TYPE, + actorId: null, + actorLogin: null, + body: null, + }, + }, + }; +} diff --git a/apps/api/src/app/api/automations/webhook/[automationId]/route.ts b/apps/api/src/app/api/automations/webhook/[automationId]/route.ts index 6a7a60e851e..7d4ab044853 100644 --- a/apps/api/src/app/api/automations/webhook/[automationId]/route.ts +++ b/apps/api/src/app/api/automations/webhook/[automationId]/route.ts @@ -1,11 +1,6 @@ -import { createHash, randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; import { db } from "@superset/db/client"; -import { - automationEvents, - automations, - automationTriggers, -} from "@superset/db/schema"; -import type { WebhookMatchableEvent } from "@superset/shared/automation-matching"; +import { automations, automationTriggers } from "@superset/db/schema"; import { bearerToken, WEBHOOK_TOKEN_PREFIX, @@ -17,8 +12,9 @@ import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { env } from "@/env"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; +import { cappedBody } from "@/lib/webhooks/body"; +import { normalizeWebhookDelivery } from "./normalizeWebhookDelivery"; export const dynamic = "force-dynamic"; @@ -31,9 +27,6 @@ const rateLimit = new Ratelimit({ prefix: "ratelimit:automations:webhook", }); -const EVENT_TYPE = "webhook.received"; -const MAX_BODY_BYTES = 1024 * 1024; - function parseBody(body: string): Record | unknown[] { if (body.trim() === "") return {}; const parsed: unknown = JSON.parse(body); @@ -75,11 +68,6 @@ export async function POST( return Response.json({ error: "Rate limit exceeded" }, { status: 429 }); } - const contentLength = Number(request.headers.get("content-length")); - if (contentLength > MAX_BODY_BYTES) { - return Response.json({ error: "Body too large" }, { status: 413 }); - } - const triggers = await db .select({ secretHash: automationTriggers.secretHash, @@ -110,10 +98,8 @@ export async function POST( ); } - const body = await request.text(); - if (Buffer.byteLength(body) > MAX_BODY_BYTES) { - return Response.json({ error: "Body too large" }, { status: 413 }); - } + const body = await cappedBody(request); + if (body instanceof Response) return body; let payload: Record | unknown[]; try { payload = parseBody(body); @@ -124,53 +110,13 @@ export async function POST( ); } - const inserted = await recordAutomationEvent(db, { - organizationId, - integrationConnectionId: null, - provider: "webhook", - eventType: EVENT_TYPE, - externalEventId: randomUUID(), - title: "Webhook", - payload, - }); - - if (!inserted) { - return Response.json({ error: "Failed to record event" }, { status: 500 }); - } - - const event: WebhookMatchableEvent = { - provider: "webhook", - eventType: EVENT_TYPE, - actorId: null, - actorLogin: null, - body: null, - }; - let result: { matched: number; considered: number }; - try { - result = await dispatchMatchingTriggers({ - organizationId, - eventId: inserted.id, - event, - automationId, - }); - } catch (error) { - console.error( - `[automations/webhook] dispatch failed for event ${inserted.id}:`, - error, - ); - await db - .delete(automationEvents) - .where(eq(automationEvents.id, inserted.id)); - return Response.json({ error: "Dispatch failed" }, { status: 500 }); - } - - console.log( - `[automations/webhook] ${result.matched}/${result.considered} triggers matched:`, - inserted.id, + const outcome = await ingestAutomationEvent( + db, + normalizeWebhookDelivery({ organizationId, automationId, payload }), ); return Response.json({ ok: true, - eventId: inserted.id, - runs: result.matched, + eventId: "eventId" in outcome ? outcome.eventId : null, + runs: outcome.status === "dispatched" ? outcome.matched : 0, }); } diff --git a/apps/api/src/app/api/cloud-workspaces/provision/route.ts b/apps/api/src/app/api/cloud-workspaces/provision/route.ts index 5e8d6e40a28..c6963736147 100644 --- a/apps/api/src/app/api/cloud-workspaces/provision/route.ts +++ b/apps/api/src/app/api/cloud-workspaces/provision/route.ts @@ -1,19 +1,12 @@ import { provisionCloudWorkspace } from "@superset/trpc/cloud-workspace-provision"; -import { Receiver } from "@upstash/qstash"; import { z } from "zod"; - -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; // Provisioning is a second or two warm, but the first sandboxes after an image // rebuild pull the image and take tens of seconds. export const maxDuration = 60; export const dynamic = "force-dynamic"; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - const payloadSchema = z .object({ cloudWorkspaceId: z.string().uuid(), @@ -29,28 +22,12 @@ const payloadSchema = z */ export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - // `verify` throws rather than returning false on a signature it can't even - // parse, so a malformed header answered 500 — reported as a server fault, - // noisy in Sentry, and a different answer than a well-formed wrong - // signature gets. Both are the same refusal. - let valid = false; - try { - valid = await receiver.verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/cloud-workspaces/provision`, - }); - } catch { - valid = false; - } - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + const rejected = await verifyQstashRequest( + request, + body, + "/api/cloud-workspaces/provision", + ); + if (rejected) return rejected; const parsed = payloadSchema.safeParse(JSON.parse(body)); if (!parsed.success) { diff --git a/apps/api/src/app/api/github/jobs/initial-sync/route.ts b/apps/api/src/app/api/github/jobs/initial-sync/route.ts index 8c16bb386fc..7565f02a388 100644 --- a/apps/api/src/app/api/github/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/github/jobs/initial-sync/route.ts @@ -4,18 +4,12 @@ import { githubPullRequests, githubRepositories, } from "@superset/db/schema"; -import { Receiver } from "@upstash/qstash"; import { subDays } from "date-fns"; import { eq } from "drizzle-orm"; import { z } from "zod"; -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { githubApp } from "../../octokit"; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - const payloadSchema = z.object({ installationDbId: z.string().uuid(), organizationId: z.string().uuid(), @@ -23,33 +17,12 @@ const payloadSchema = z.object({ export async function POST(request: Request) { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - - const isDev = env.NODE_ENV === "development"; - - if (!isDev) { - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const isValid = await receiver - .verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, - }) - .catch((error) => { - console.error( - "[github/initial-sync] Signature verification failed:", - error, - ); - return false; - }); - - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } - } + const rejected = await verifyQstashRequest( + request, + body, + "/api/github/jobs/initial-sync", + ); + if (rejected) return rejected; let bodyData: unknown; try { diff --git a/apps/api/src/app/api/hosts/jobs/sync-presence/route.ts b/apps/api/src/app/api/hosts/jobs/sync-presence/route.ts index d2cabe04df3..3c977fd50b9 100644 --- a/apps/api/src/app/api/hosts/jobs/sync-presence/route.ts +++ b/apps/api/src/app/api/hosts/jobs/sync-presence/route.ts @@ -1,17 +1,12 @@ import { db } from "@superset/db/client"; -import { Receiver } from "@upstash/qstash"; import { Redis } from "@upstash/redis"; import { sql } from "drizzle-orm"; import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; export const dynamic = "force-dynamic"; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - const redis = new Redis({ url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN, @@ -22,27 +17,12 @@ const RELAY_TTL_KEY = "relay:tunnel-ttl"; export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - const isDev = env.NODE_ENV === "development"; - - if (!isDev) { - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - const valid = await receiver - .verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/hosts/jobs/sync-presence`, - }) - .catch((error) => { - console.error("[sync-presence] signature verify failed:", error); - return false; - }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } - } + const rejected = await verifyQstashRequest( + request, + body, + "/api/hosts/jobs/sync-presence", + ); + if (rejected) return rejected; let connected: string[]; try { diff --git a/apps/api/src/app/api/ingest/prune-payloads/route.ts b/apps/api/src/app/api/ingest/prune-payloads/route.ts index fbd2c5b3e72..6ed4284a011 100644 --- a/apps/api/src/app/api/ingest/prune-payloads/route.ts +++ b/apps/api/src/app/api/ingest/prune-payloads/route.ts @@ -1,16 +1,9 @@ import { dbWs } from "@superset/db/client"; -import { Receiver } from "@upstash/qstash"; import { sql } from "drizzle-orm"; - -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; export const dynamic = "force-dynamic"; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - /** * How long a raw payload is worth keeping. Nothing reads it back — its only use * is inspecting a delivery by hand, which is a days-old question, not a @@ -35,19 +28,12 @@ const TIME_BUDGET_MS = 20_000; export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const valid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/ingest/prune-payloads`, - }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + "/api/ingest/prune-payloads", + ); + if (rejected) return rejected; const startedAt = Date.now(); let pruned = 0; diff --git a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/normalizeCirclebackDelivery.ts b/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/normalizeCirclebackDelivery.ts new file mode 100644 index 00000000000..acd082cbfd5 --- /dev/null +++ b/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/normalizeCirclebackDelivery.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; + +export const EVENT_TYPE = "meeting.completed"; + +/** + * The fields matching and the row need. Everything else — notes, action items, + * transcript, insights — rides along in `payload` for the prompt. + */ +export const meetingSchema = z + .object({ + id: z.union([z.string().min(1), z.number()]).transform(String), + name: z.string().default(""), + tags: z.array(z.string()).default([]), + attendees: z + .array(z.object({ email: z.string().nullable().optional() })) + .default([]), + }) + .passthrough(); + +export type CirclebackMeeting = z.infer; + +/** + * A Circleback delivery is addressed: the user configured this trigger's URL + * in Circleback, so only this trigger is a candidate — every other Circleback + * trigger in the organization has its own URL and gets its own delivery of + * the same meeting. That is why the dedupe key carries the trigger id and the + * dispatch is narrowed to the one trigger. + */ +export function normalizeCirclebackDelivery(params: { + organizationId: string; + automationId: string; + triggerId: string; + meeting: CirclebackMeeting; + payload: unknown; +}): NormalizedDelivery { + const { meeting, triggerId } = params; + return { + event: { + organizationId: params.organizationId, + integrationConnectionId: null, + provider: "circleback", + eventType: EVENT_TYPE, + externalEventId: `${triggerId}:${meeting.id}`, + resourceKey: `circleback:${meeting.id}`, + title: meeting.name || meeting.id, + url: `https://circleback.ai/meetings/${meeting.id}`, + payload: params.payload, + }, + dispatch: { + automationId: params.automationId, + triggerId, + event: { + provider: "circleback", + eventType: EVENT_TYPE, + actorId: null, + actorLogin: null, + body: null, + name: meeting.name || null, + tags: meeting.tags, + attendeeEmails: meeting.attendees.flatMap((a) => + a.email ? [a.email] : [], + ), + }, + }, + }; +} diff --git a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts b/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts index 1e557dd020b..4805c83f829 100644 --- a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts +++ b/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts @@ -1,18 +1,18 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; import { dbWs } from "@superset/db/client"; -import { - automationEvents, - automations, - automationTriggers, -} from "@superset/db/schema"; +import { automations, automationTriggers } from "@superset/db/schema"; import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { env } from "@/env"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; +import { cappedBody, parseJson } from "@/lib/webhooks/body"; +import { hmacHex, timingSafeHex } from "@/lib/webhooks/verify"; +import { + meetingSchema, + normalizeCirclebackDelivery, +} from "./normalizeCirclebackDelivery"; export const dynamic = "force-dynamic"; @@ -25,49 +25,12 @@ const rateLimit = new Ratelimit({ prefix: "ratelimit:integrations:circleback:webhook", }); -const EVENT_TYPE = "meeting.completed"; -const MAX_BODY_BYTES = 1024 * 1024; - -/** - * The fields matching and the row need. Everything else — notes, action items, - * transcript, insights — rides along in `payload` for the prompt. - */ -const meetingSchema = z - .object({ - id: z.union([z.string().min(1), z.number()]).transform(String), - name: z.string().default(""), - tags: z.array(z.string()).default([]), - attendees: z - .array(z.object({ email: z.string().nullable().optional() })) - .default([]), - }) - .passthrough(); - -/** - * Circleback signs the raw body with the secret it issued for the automation - * and puts the hex digest in `x-signature`. Compared in constant time on the - * bytes Circleback sent, before anything is parsed. - */ -function signatureValid( - body: string, - signature: string | null, - secret: string, -): boolean { - if (!signature) return false; - const expected = createHmac("sha256", secret).update(body).digest("hex"); - const a = Buffer.from(expected, "utf8"); - const b = Buffer.from(signature.trim().toLowerCase(), "utf8"); - return a.length === b.length && timingSafeEqual(a, b); -} - /** * One meeting, delivered by Circleback to the trigger named in the URL. * * Unlike GitHub, where one delivery is matched against every trigger in the * organization, a Circleback delivery is addressed: the user configured this - * URL in Circleback, so only this trigger is evaluated. Two triggers wired to - * the same Circleback workspace each get their own delivery of the same - * meeting, which is why the dedupe key carries the trigger id. + * URL in Circleback, so only this trigger is evaluated. */ export async function POST( request: Request, @@ -83,11 +46,6 @@ export async function POST( return Response.json({ error: "Rate limit exceeded" }, { status: 429 }); } - const contentLength = Number(request.headers.get("content-length")); - if (contentLength > MAX_BODY_BYTES) { - return Response.json({ error: "Body too large" }, { status: 413 }); - } - const [trigger] = await dbWs .select({ organizationId: automationTriggers.organizationId, @@ -112,10 +70,8 @@ export async function POST( return Response.json({ error: "Unknown trigger" }, { status: 404 }); } - const body = await request.text(); - if (Buffer.byteLength(body) > MAX_BODY_BYTES) { - return Response.json({ error: "Body too large" }, { status: 413 }); - } + const body = await cappedBody(request); + if (body instanceof Response) return body; // A trigger with no secret yet cannot tell Circleback from anyone who has // seen the URL, so it accepts nothing until one is pasted in. @@ -130,7 +86,10 @@ export async function POST( { status: 401 }, ); } - if (!signatureValid(body, request.headers.get("x-signature"), secret)) { + // Circleback signs the raw body with the secret it issued for the automation + // and puts the hex digest in `x-signature`. + const signature = request.headers.get("x-signature"); + if (!signature || !timingSafeHex(signature, hmacHex(body, secret))) { console.warn( "[circleback/webhook] Invalid signature for trigger:", triggerId, @@ -147,12 +106,8 @@ export async function POST( return Response.json({ error: "Trigger is disabled" }, { status: 409 }); } - let json: unknown; - try { - json = JSON.parse(body); - } catch { - return Response.json({ error: "Invalid JSON payload" }, { status: 400 }); - } + const json = parseJson(body); + if (json instanceof Response) return json; const parsed = meetingSchema.safeParse(json); if (!parsed.success) { console.error( @@ -161,66 +116,16 @@ export async function POST( ); return Response.json({ error: "Invalid payload" }, { status: 400 }); } - const meeting = parsed.data; - - const inserted = await recordAutomationEvent(dbWs, { - organizationId: trigger.organizationId, - integrationConnectionId: null, - provider: "circleback", - eventType: EVENT_TYPE, - // Per trigger: the same meeting legitimately reaches every trigger - // whose URL is configured in Circleback, and a redelivery to one of - // them is still a duplicate for that one. - externalEventId: `${triggerId}:${meeting.id}`, - resourceKey: `circleback:${meeting.id}`, - title: meeting.name || meeting.id, - url: `https://circleback.ai/meetings/${meeting.id}`, - payload: json, - }); - - if (!inserted) { - return Response.json({ ok: true, duplicate: true }); - } - // A dispatch failure removes the row so Circleback's retry is not deduped - // against a delivery that never got its run enqueued. - let result: { matched: number; considered: number }; - try { - result = await dispatchMatchingTriggers({ + const outcome = await ingestAutomationEvent( + dbWs, + normalizeCirclebackDelivery({ organizationId: trigger.organizationId, - eventId: inserted.id, - // Addressed: Circleback was configured with this trigger's URL, so - // only this trigger is a candidate — every other Circleback trigger - // in the organization has its own URL and gets its own delivery. automationId: trigger.automationId, triggerId, - event: { - provider: "circleback", - eventType: EVENT_TYPE, - actorId: null, - actorLogin: null, - body: null, - name: meeting.name || null, - tags: meeting.tags, - attendeeEmails: meeting.attendees.flatMap((a) => - a.email ? [a.email] : [], - ), - }, - }); - } catch (error) { - console.error( - `[circleback/webhook] dispatch failed for event ${inserted.id}:`, - error, - ); - await dbWs - .delete(automationEvents) - .where(eq(automationEvents.id, inserted.id)); - return Response.json({ error: "Dispatch failed" }, { status: 500 }); - } - - console.log( - `[circleback/webhook] ${result.matched}/${result.considered} triggers matched:`, - inserted.id, + meeting: parsed.data, + payload: json, + }), ); - return Response.json({ ok: true, matched: result.matched > 0 }); + return Response.json({ ok: true, outcome }); } diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts index 4707b626152..87bdf10cd7e 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts @@ -2,21 +2,15 @@ import type { LinearClient } from "@linear/sdk"; import { buildConflictUpdateColumns, db } from "@superset/db"; import { members, taskStatuses, tasks, users } from "@superset/db/schema"; import { getLinearClient } from "@superset/trpc/integrations/linear"; -import { Receiver } from "@upstash/qstash"; import { and, eq, inArray, isNull } from "drizzle-orm"; import chunk from "lodash.chunk"; import { z } from "zod"; -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { syncWorkflowStates } from "./syncWorkflowStates"; import { fetchAllIssues, mapIssueToTask } from "./utils"; const BATCH_SIZE = 100; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - const payloadSchema = z.object({ organizationId: z.string().min(1), creatorUserId: z.string().min(1), @@ -24,26 +18,12 @@ const payloadSchema = z.object({ export async function POST(request: Request) { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - - // Skip signature verification in development (QStash can't reach localhost) - const isDev = env.NODE_ENV === "development"; - - if (!isDev) { - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const isValid = await receiver.verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`, - }); - - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } - } + const rejected = await verifyQstashRequest( + request, + body, + "/api/integrations/linear/jobs/initial-sync", + ); + if (rejected) return rejected; const parsed = payloadSchema.safeParse(JSON.parse(body)); if (!parsed.success) { diff --git a/apps/api/src/app/api/integrations/linear/jobs/refresh-tokens/route.ts b/apps/api/src/app/api/integrations/linear/jobs/refresh-tokens/route.ts index 377b80f4979..d6af6f4ad58 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/refresh-tokens/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/refresh-tokens/route.ts @@ -1,47 +1,17 @@ import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { refreshLinearToken } from "@superset/trpc/integrations/linear"; -import { Receiver } from "@upstash/qstash"; import { and, eq, isNotNull, isNull, lt, sql } from "drizzle-orm"; -import { env } from "@/env"; - -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); +import { verifyQstashRequest } from "@/lib/verifyQstash"; export async function POST(request: Request) { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - - const isDev = env.NODE_ENV === "development"; - - if (!isDev) { - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - try { - const isValid = await receiver.verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/refresh-tokens`, - }); - - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } - } catch (verifyError) { - console.error( - "[linear-refresh-cron] Signature verification failed:", - verifyError, - ); - return Response.json( - { error: "Signature verification failed" }, - { status: 401 }, - ); - } - } + const rejected = await verifyQstashRequest( + request, + body, + "/api/integrations/linear/jobs/refresh-tokens", + ); + if (rejected) return rejected; const stale = await db.query.integrationConnections.findMany({ where: and( diff --git a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts index 00e4f24c343..270fb33390d 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts @@ -12,15 +12,9 @@ import { getLinearClient, mapPriorityToLinear, } from "@superset/trpc/integrations/linear"; -import { Receiver } from "@upstash/qstash"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { env } from "@/env"; - -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); +import { verifyQstashRequest } from "@/lib/verifyQstash"; const payloadSchema = z.object({ taskId: z.string().min(1), @@ -249,29 +243,12 @@ async function syncTaskToLinear( export async function POST(request: Request) { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - try { - const isValid = await receiver.verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/sync-task`, - }); - - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } - } catch (verifyError) { - console.error("[sync-task] Signature verification failed:", verifyError); - return Response.json( - { error: "Signature verification failed" }, - { status: 401 }, - ); - } + const rejected = await verifyQstashRequest( + request, + body, + "/api/integrations/linear/jobs/sync-task", + ); + if (rejected) return rejected; const parsed = payloadSchema.safeParse(JSON.parse(body)); if (!parsed.success) { diff --git a/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts index a6f5fa54612..9927ece9bf8 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts @@ -1,19 +1,12 @@ import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { ensureTeamsSubscriptions } from "@superset/trpc/integrations/microsoft-teams"; -import { Receiver } from "@upstash/qstash"; import { and, eq, isNull } from "drizzle-orm"; - -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; export const dynamic = "force-dynamic"; export const maxDuration = 60; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - /** * Keeps every Teams connection's Graph subscriptions alive. * @@ -25,18 +18,12 @@ const receiver = new Receiver({ */ export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - const valid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/microsoft-teams/jobs/renew-subscriptions`, - }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + "/api/integrations/microsoft-teams/jobs/renew-subscriptions", + ); + if (rejected) return rejected; const connections = await db .select({ id: integrationConnections.id }) diff --git a/apps/api/src/app/api/integrations/microsoft-teams/process/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/process/route.ts index b00f82b315b..bc09049dbe2 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/process/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/process/route.ts @@ -2,21 +2,14 @@ import { ensureTeamsSubscriptions, type SubscriptionKey, } from "@superset/trpc/integrations/microsoft-teams"; -import { Receiver } from "@upstash/qstash"; - -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { handleNotification } from "../notify/handleNotification"; import { loadConnection } from "../notify/notifications"; -import { PROCESS_URL, teamsWorkSchema } from "../notify/queue"; +import { teamsWorkSchema } from "../notify/queue"; export const dynamic = "force-dynamic"; export const maxDuration = 60; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - /** * The slow half of a Teams notification, run by QStash after the notify or * lifecycle route has verified it came from Graph and answered 202. @@ -28,14 +21,12 @@ const receiver = new Receiver({ */ export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - const valid = await receiver.verify({ body, signature, url: PROCESS_URL }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + const rejected = await verifyQstashRequest( + request, + body, + "/api/integrations/microsoft-teams/process", + ); + if (rejected) return rejected; const parsed = teamsWorkSchema.safeParse(JSON.parse(body)); if (!parsed.success) { diff --git a/apps/api/src/app/api/integrations/slack/jobs/process-assistant-message/route.ts b/apps/api/src/app/api/integrations/slack/jobs/process-assistant-message/route.ts index b6ace1df798..c1cf09b515d 100644 --- a/apps/api/src/app/api/integrations/slack/jobs/process-assistant-message/route.ts +++ b/apps/api/src/app/api/integrations/slack/jobs/process-assistant-message/route.ts @@ -1,15 +1,8 @@ -import { Receiver } from "@upstash/qstash"; import { z } from "zod"; - -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { processAssistantMessage } from "../../events/process-assistant-message"; import { isUnpostableChannelError } from "../../events/utils/slack-client"; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - const slackFileSchema = z.object({ id: z.string(), name: z.string().optional(), @@ -37,21 +30,12 @@ const payloadSchema = z.object({ export async function POST(request: Request) { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const isValid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`, - }); - - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + "/api/integrations/slack/jobs/process-assistant-message", + ); + if (rejected) return rejected; const parsed = payloadSchema.safeParse(JSON.parse(body)); if (!parsed.success) { diff --git a/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts index 2f1b75ef5ee..254e4b1b4bf 100644 --- a/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts +++ b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts @@ -1,14 +1,7 @@ -import { Receiver } from "@upstash/qstash"; import { z } from "zod"; - -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { processSlackMention } from "../../events/process-mention"; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - const slackFileSchema = z.object({ id: z.string(), name: z.string().optional(), @@ -35,21 +28,12 @@ const payloadSchema = z.object({ export async function POST(request: Request) { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const isValid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, - }); - - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + "/api/integrations/slack/jobs/process-mention", + ); + if (rejected) return rejected; const parsed = payloadSchema.safeParse(JSON.parse(body)); if (!parsed.success) { diff --git a/apps/api/src/app/api/integrations/stripe/jobs/notify-slack/route.ts b/apps/api/src/app/api/integrations/stripe/jobs/notify-slack/route.ts index dc557ad94c7..350285e3cfe 100644 --- a/apps/api/src/app/api/integrations/stripe/jobs/notify-slack/route.ts +++ b/apps/api/src/app/api/integrations/stripe/jobs/notify-slack/route.ts @@ -1,12 +1,12 @@ import { db } from "@superset/db/client"; import { subscriptions } from "@superset/db/schema"; import * as authSchema from "@superset/db/schema/auth"; -import { Receiver } from "@upstash/qstash"; import { eq } from "drizzle-orm"; import Stripe from "stripe"; import { z } from "zod"; import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { type EnrichedSubscription, @@ -22,11 +22,6 @@ import { // --- QStash verification --- -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - // --- Stripe client --- const stripeClient = new Stripe(env.STRIPE_SECRET_KEY); @@ -137,21 +132,12 @@ async function enrichFromSubscription( export async function POST(request: Request) { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const isValid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/stripe/jobs/notify-slack`, - }); - - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + "/api/integrations/stripe/jobs/notify-slack", + ); + if (rejected) return rejected; let rawPayload: unknown; try { From fdb33ad484b2f4f4e694eca4a47c1cb225baca49 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 13:00:27 -0700 Subject: [PATCH 04/15] refactor(api,trpc): move GitHub and Linear onto ingestAutomationEvent and trigger option sources GitHub: recordGithubEvent + the local dispatch normalizer become one pure normalizeGithubDelivery; the route looks up the installation and calls ingestAutomationEvent once. Linear: recordAndDispatch becomes normalizeLinearDelivery + ingest; per-connection webhook_events row, task sync and failure isolation are untouched. Options: github.{repositories,people} and linear.{teams,projects,labels,statuses,people} are trigger option sources behind integration.triggerOptions; github.listPeople, linear.getTriggerOptions and linear.listPeople are gone. listRepositories and getTeams stay for their other callers. --- .../webhook/dispatchMatchingTriggers.ts | 81 --------- ...hubEvent.ts => normalizeGithubDelivery.ts} | 158 ++++++++++-------- apps/api/src/app/api/github/webhook/route.ts | 74 ++++---- ...earEvent.ts => normalizeLinearDelivery.ts} | 42 ++--- .../api/integrations/linear/webhook/route.ts | 45 ++--- .../src/router/integration/github/github.ts | 24 +-- .../github/{people.ts => trigger-options.ts} | 35 +++- .../src/router/integration/linear/linear.ts | 133 --------------- .../integration/linear/trigger-options.ts | 124 ++++++++++++++ .../src/router/integration/trigger-options.ts | 4 + 10 files changed, 313 insertions(+), 407 deletions(-) delete mode 100644 apps/api/src/app/api/github/webhook/dispatchMatchingTriggers.ts rename apps/api/src/app/api/github/webhook/{recordGithubEvent.ts => normalizeGithubDelivery.ts} (55%) rename apps/api/src/app/api/integrations/linear/webhook/{recordLinearEvent.ts => normalizeLinearDelivery.ts} (77%) rename packages/trpc/src/router/integration/github/{people.ts => trigger-options.ts} (68%) create mode 100644 packages/trpc/src/router/integration/linear/trigger-options.ts diff --git a/apps/api/src/app/api/github/webhook/dispatchMatchingTriggers.ts b/apps/api/src/app/api/github/webhook/dispatchMatchingTriggers.ts deleted file mode 100644 index 3838d88a915..00000000000 --- a/apps/api/src/app/api/github/webhook/dispatchMatchingTriggers.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - type GithubMatchableEvent, - githubEventNames, -} from "@superset/shared/automation-matching"; -import { dispatchMatchingTriggers as dispatch } from "@/lib/automations/dispatchMatchingTriggers"; -import type { GithubPayload } from "./recordGithubEvent"; - -/** - * Normalizes a GitHub delivery into what GitHub triggers filter on. Every - * field here exists only inside the payload, and only for some events — the - * columns on `automation_events` are what every provider shares; this is what - * GitHub adds. - */ -function matchableFrom( - payload: GithubPayload, - eventType: string, - repositoryId: string | null, - ref: string | null, -): GithubMatchableEvent { - return { - provider: "github", - eventType, - names: githubEventNames({ - eventType, - isDraft: payload.pull_request?.draft === true, - isMerged: payload.pull_request?.merged === true, - isPullRequestComment: payload.issue?.pull_request !== undefined, - reviewState: payload.review?.state ?? null, - runConclusion: payload.workflow_run?.conclusion ?? null, - }), - repositoryId, - ref, - actorId: - payload.sender?.id !== undefined ? String(payload.sender.id) : null, - actorLogin: payload.sender?.login ?? null, - actorIsExternal: null, - labels: (payload.pull_request?.labels ?? payload.issue?.labels ?? []) - .map((l) => l?.name) - .filter((n): n is string => typeof n === "string"), - body: payload.comment?.body ?? payload.review?.body ?? null, - // Only PR-shaped payloads carry the head repo; an issue_comment on a fork - // PR cannot be told apart and is treated as not a fork. - isFork: payload.pull_request?.head?.repo?.fork === true, - // Who opened the thing being commented on, which is a different person - // from whoever wrote the comment. - subjectAuthorId: (() => { - const id = - payload.pull_request?.user?.id ?? payload.issue?.user?.id ?? undefined; - return id !== undefined ? String(id) : null; - })(), - }; -} - -/** - * GitHub's entry into the shared dispatcher: normalize, then hand off. The - * candidate query, identity lookup and QStash publish live in - * `@/lib/automations/dispatchMatchingTriggers`, once, for every provider. - */ -export async function dispatchMatchingTriggers(params: { - organizationId: string; - eventId: string; - eventType: string; - repositoryId: string | null; - ref: string | null; - payload: GithubPayload; -}): Promise<{ matched: number; considered: number }> { - const event = matchableFrom( - params.payload, - params.eventType, - params.repositoryId, - params.ref, - ); - // Nothing in the product names this delivery, so there is nothing to match. - if (event.names.length === 0) return { matched: 0, considered: 0 }; - - return dispatch({ - organizationId: params.organizationId, - eventId: params.eventId, - event, - }); -} diff --git a/apps/api/src/app/api/github/webhook/recordGithubEvent.ts b/apps/api/src/app/api/github/webhook/normalizeGithubDelivery.ts similarity index 55% rename from apps/api/src/app/api/github/webhook/recordGithubEvent.ts rename to apps/api/src/app/api/github/webhook/normalizeGithubDelivery.ts index 20ec0b4fee8..2a7f9969b60 100644 --- a/apps/api/src/app/api/github/webhook/recordGithubEvent.ts +++ b/apps/api/src/app/api/github/webhook/normalizeGithubDelivery.ts @@ -1,20 +1,16 @@ -import { db } from "@superset/db/client"; -import { githubInstallations } from "@superset/db/schema"; -import { eq } from "drizzle-orm"; +import { + type GithubMatchableEvent, + githubEventNames, +} from "@superset/shared/automation-matching"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; /** - * Records an incoming GitHub delivery as an `automation_events` row. - * - * This is the normalized event stream triggers are matched against. It keeps - * its own copy of the payload because `ingest.webhook_events` is pruned, and - * flattens the handful of fields matching and prompting actually need out of - * payloads whose shape differs per event. - * - * Recording is deliberately separate from matching: nothing reads these rows - * yet, so a mistake here shows up as a wrong row rather than an agent running - * on the wrong pull request. + * Normalizes a GitHub delivery into the `automation_events` row triggers are + * matched against. It keeps its own copy of the payload because + * `ingest.webhook_events` is pruned, and flattens the handful of fields + * matching and prompting actually need out of payloads whose shape differs + * per event. */ export type GithubPayload = { @@ -134,72 +130,88 @@ export function urlFor(payload: GithubPayload): string | null { ); } -export async function recordGithubEvent(params: { +/** + * What GitHub triggers filter on. Every field here exists only inside the + * payload, and only for some events — the columns on `automation_events` are + * what every provider shares; this is what GitHub adds. + */ +export function matchableFrom( + payload: GithubPayload, + eventType: string, + repositoryId: string | null, + ref: string | null, +): GithubMatchableEvent { + return { + provider: "github", + eventType, + names: githubEventNames({ + eventType, + isDraft: payload.pull_request?.draft === true, + isMerged: payload.pull_request?.merged === true, + isPullRequestComment: payload.issue?.pull_request !== undefined, + reviewState: payload.review?.state ?? null, + runConclusion: payload.workflow_run?.conclusion ?? null, + }), + repositoryId, + ref, + actorId: + payload.sender?.id !== undefined ? String(payload.sender.id) : null, + actorLogin: payload.sender?.login ?? null, + actorIsExternal: null, + labels: (payload.pull_request?.labels ?? payload.issue?.labels ?? []) + .map((l) => l?.name) + .filter((n): n is string => typeof n === "string"), + body: payload.comment?.body ?? payload.review?.body ?? null, + // Only PR-shaped payloads carry the head repo; an issue_comment on a fork + // PR cannot be told apart and is treated as not a fork. + isFork: payload.pull_request?.head?.repo?.fork === true, + // Who opened the thing being commented on, which is a different person + // from whoever wrote the comment. + subjectAuthorId: (() => { + const id = + payload.pull_request?.user?.id ?? payload.issue?.user?.id ?? undefined; + return id !== undefined ? String(id) : null; + })(), + }; +} + +export function normalizeGithubDelivery(params: { + organizationId: string; eventType: string; deliveryId: string; - payload: unknown; + payload: GithubPayload; webhookEventId: string; -}): Promise< - | { - recorded: true; - eventId: string; - organizationId: string; - repositoryId: string | null; - ref: string | null; - } - | { recorded: false; reason: string } -> { - const payload = params.payload as GithubPayload; - - const installationId = payload.installation?.id; - if (installationId === undefined) { - // Pings and a few org-level events carry no installation, so there is no - // organization to attribute them to. - return { recorded: false, reason: "no installation" }; - } - - const [installation] = await db - .select({ organizationId: githubInstallations.organizationId }) - .from(githubInstallations) - .where(eq(githubInstallations.installationId, String(installationId))) - .limit(1); - - if (!installation) { - return { recorded: false, reason: "unknown installation" }; - } - +}): NormalizedDelivery { + const { payload } = params; + const eventType = qualifiedEventType(params.eventType, payload); + // The numeric id, not the full name: a repository can be renamed and + // triggers must keep matching it afterwards. const repositoryId = payload.repository?.id !== undefined ? String(payload.repository.id) : null; const ref = payload.pull_request?.head?.ref ?? payload.ref ?? null; - - // A redelivery of the same GitHub delivery id is the same event. - const inserted = await recordAutomationEvent(db, { - organizationId: installation.organizationId, - // GitHub installs are their own connection record, not an - // integration_connections row, so provenance is the delivery below. - integrationConnectionId: null, - provider: "github", - eventType: qualifiedEventType(params.eventType, payload), - externalEventId: params.deliveryId, - resourceKey: resourceKeyFor(payload, params.eventType), - title: titleFor(payload, params.eventType), - url: urlFor(payload), - // The numeric id, not the full name: a repository can be renamed and - // triggers must keep matching it afterwards. - repositoryId, - ref, - actorLogin: payload.sender?.login ?? null, - actorIsExternal: actorIsExternalFor(payload), - payload, - webhookEventId: params.webhookEventId, - }); - if (!inserted) return { recorded: false, reason: "duplicate delivery" }; + const event = matchableFrom(payload, eventType, repositoryId, ref); return { - recorded: true, - eventId: inserted.id, - organizationId: installation.organizationId, - repositoryId, - ref, + event: { + organizationId: params.organizationId, + // GitHub installs are their own connection record, not an + // integration_connections row, so provenance is the delivery below. + integrationConnectionId: null, + provider: "github", + eventType, + // A redelivery of the same GitHub delivery id is the same event. + externalEventId: params.deliveryId, + resourceKey: resourceKeyFor(payload, params.eventType), + title: titleFor(payload, params.eventType), + url: urlFor(payload), + repositoryId, + ref, + actorLogin: payload.sender?.login ?? null, + actorIsExternal: actorIsExternalFor(payload), + payload, + webhookEventId: params.webhookEventId, + }, + // An action nothing in the product names is recorded, never matched. + dispatch: event.names.length > 0 ? { event } : null, }; } diff --git a/apps/api/src/app/api/github/webhook/route.ts b/apps/api/src/app/api/github/webhook/route.ts index 26000dd8fe0..b701f042c16 100644 --- a/apps/api/src/app/api/github/webhook/route.ts +++ b/apps/api/src/app/api/github/webhook/route.ts @@ -1,13 +1,12 @@ import { db } from "@superset/db/client"; -import { webhookEvents } from "@superset/db/schema"; +import { githubInstallations, webhookEvents } from "@superset/db/schema"; import { eq, sql } from "drizzle-orm"; +import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; import { stripNullChars } from "@/lib/strip-null-chars"; -import { dispatchMatchingTriggers } from "./dispatchMatchingTriggers"; import { type GithubPayload, - qualifiedEventType, - recordGithubEvent, -} from "./recordGithubEvent"; + normalizeGithubDelivery, +} from "./normalizeGithubDelivery"; import { webhooks } from "./webhooks"; export const maxDuration = 60; @@ -88,50 +87,39 @@ export async function POST(request: Request) { // biome-ignore lint/suspicious/noExplicitAny: GitHub webhook event types are complex unions } as any); - // Recorded after processing and deliberately not inside the try above: - // nothing reads these rows yet, so a failure here must not fail a - // delivery GitHub would then retry. - try { - const recorded = await recordGithubEvent({ - eventType: eventType ?? "unknown", - deliveryId: eventId, - payload, - webhookEventId: webhookEvent.id, - }); - if (recorded.recorded) { - const result = await dispatchMatchingTriggers({ - organizationId: recorded.organizationId, - eventId: recorded.eventId, - eventType: qualifiedEventType( - eventType ?? "unknown", - payload as GithubPayload, - ), - repositoryId: recorded.repositoryId, - ref: recorded.ref, - payload: payload as GithubPayload, - }); - if (result.matched > 0) { - console.log( - `[github/webhook] ${result.matched}/${result.considered} triggers matched:`, - eventId, - ); - } - } else { - console.log( - `[github/webhook] Not recorded as automation event (${recorded.reason}):`, - eventId, - ); - } - } catch (error) { - console.error("[github/webhook] recordGithubEvent failed:", error); - } + // Pings and a few org-level events carry no installation, and an + // installation this deployment never saw has no organization: neither + // is recorded as an automation event. + const installationId = (payload as GithubPayload).installation?.id; + const installation = + installationId === undefined + ? undefined + : await db.query.githubInstallations.findFirst({ + where: eq( + githubInstallations.installationId, + String(installationId), + ), + columns: { organizationId: true }, + }); + const outcome = installation + ? await ingestAutomationEvent( + db, + normalizeGithubDelivery({ + organizationId: installation.organizationId, + eventType: eventType ?? "unknown", + deliveryId: eventId, + payload: payload as GithubPayload, + webhookEventId: webhookEvent.id, + }), + ) + : null; await db .update(webhookEvents) .set({ status: "processed", processedAt: new Date() }) .where(eq(webhookEvents.id, webhookEvent.id)); - return Response.json({ success: true }); + return Response.json({ success: true, outcome }); } catch (error) { console.error("[github/webhook] Webhook processing error:", error); diff --git a/apps/api/src/app/api/integrations/linear/webhook/recordLinearEvent.ts b/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts similarity index 77% rename from apps/api/src/app/api/integrations/linear/webhook/recordLinearEvent.ts rename to apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts index 663ddc7f725..b445e28bbd7 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/recordLinearEvent.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts @@ -1,12 +1,12 @@ -import { db } from "@superset/db/client"; import { type LinearMatchableEvent, linearEventNames, } from "@superset/shared/automation-matching"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; /** - * Records a Linear delivery as an `automation_events` row, per connection. + * Normalizes a Linear delivery into the `automation_events` row for one + * connection. * * Only deliveries a trigger could name are recorded. Linear sends every * attachment and comment edit to the same webhook, and most `Issue.update` @@ -83,25 +83,29 @@ export function titleFor(delivery: LinearDelivery): string { return `${delivery.type}.${delivery.action}`; } -export async function recordLinearEvent(params: { +export function normalizeLinearDelivery(params: { delivery: LinearDelivery; + event: LinearMatchableEvent; /** Linear's delivery id when the header carried one; a redelivery reuses it. */ deliveryId: string; connection: { id: string; organizationId: string }; webhookEventId: string; -}): Promise<{ id: string } | null> { - const { delivery, connection } = params; - return recordAutomationEvent(db, { - organizationId: connection.organizationId, - integrationConnectionId: connection.id, - provider: "linear", - eventType: `${delivery.type}.${delivery.action}`, - externalEventId: params.deliveryId, - resourceKey: resourceKeyFor(delivery), - title: titleFor(delivery), - url: delivery.url ?? delivery.data.url ?? null, - actorLogin: delivery.actor?.name ?? null, - payload: delivery, - webhookEventId: params.webhookEventId, - }); +}): NormalizedDelivery { + const { delivery, event, connection } = params; + return { + event: { + organizationId: connection.organizationId, + integrationConnectionId: connection.id, + provider: "linear", + eventType: event.eventType, + externalEventId: params.deliveryId, + resourceKey: resourceKeyFor(delivery), + title: titleFor(delivery), + url: delivery.url ?? delivery.data.url ?? null, + actorLogin: delivery.actor?.name ?? null, + payload: delivery, + webhookEventId: params.webhookEventId, + }, + dispatch: { event }, + }; } diff --git a/apps/api/src/app/api/integrations/linear/webhook/route.ts b/apps/api/src/app/api/integrations/linear/webhook/route.ts index 605ccffff8f..65d2686e33e 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/route.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/route.ts @@ -23,13 +23,13 @@ import { } from "@superset/trpc/integrations/linear"; import { and, asc, eq, isNull, sql } from "drizzle-orm"; import { env } from "@/env"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; +import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; import { stripNullChars } from "@/lib/strip-null-chars"; import { type LinearDelivery, matchableFrom, - recordLinearEvent, -} from "./recordLinearEvent"; + normalizeLinearDelivery, +} from "./normalizeLinearDelivery"; const webhookClient = new LinearWebhookClient(env.LINEAR_WEBHOOK_SECRET); @@ -187,9 +187,9 @@ async function processForConnection( if (isEntityDelivery(payload)) { try { - await recordAndDispatch(payload, deliveryId, connection, webhookEvent.id); + await ingest(payload, deliveryId, connection, webhookEvent.id); } catch (error) { - console.error("[linear/webhook] recordAndDispatch failed:", error); + console.error("[linear/webhook] automation event failed:", error); failures.push(errorMessage(error)); } } @@ -225,7 +225,7 @@ function isEntityDelivery(payload: unknown): payload is LinearDelivery { return typeof data?.id === "string"; } -async function recordAndDispatch( +async function ingest( delivery: LinearDelivery, deliveryHeader: string | null, connection: SelectIntegrationConnection, @@ -242,31 +242,16 @@ async function recordAndDispatch( deliveryHeader ?? `${delivery.type}:${delivery.data.id}:${delivery.webhookTimestamp}`; - const recorded = await recordLinearEvent({ - delivery, - deliveryId, - connection, - webhookEventId, - }); - if (!recorded) { - console.log( - "[linear/webhook] Not recorded as automation event (duplicate delivery):", + await ingestAutomationEvent( + db, + normalizeLinearDelivery({ + delivery, + event, deliveryId, - ); - return; - } - - const result = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: recorded.id, - event, - }); - if (result.matched > 0) { - console.log( - `[linear/webhook] ${result.matched}/${result.considered} triggers matched:`, - deliveryId, - ); - } + connection, + webhookEventId, + }), + ); } // `branchName` is derived by Linear from the identifier + title and is not diff --git a/packages/trpc/src/router/integration/github/github.ts b/packages/trpc/src/router/integration/github/github.ts index 2085d6a7ce5..6f8dd6feef1 100644 --- a/packages/trpc/src/router/integration/github/github.ts +++ b/packages/trpc/src/router/integration/github/github.ts @@ -12,7 +12,7 @@ import { z } from "zod"; import { env } from "../../../env"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; -import { listGithubPeople } from "./people"; +import { listGithubRepositories } from "./trigger-options"; const qstash = new Client({ token: env.QSTASH_TOKEN }); @@ -97,31 +97,11 @@ export const githubRouter = { return { success: true }; }), - listPeople: protectedProcedure - .input(z.object({ organizationId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - return listGithubPeople(input.organizationId); - }), - listRepositories: protectedProcedure .input(z.object({ organizationId: z.string().uuid() })) .query(async ({ ctx, input }) => { await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const installation = await db.query.githubInstallations.findFirst({ - where: eq(githubInstallations.organizationId, input.organizationId), - columns: { id: true }, - }); - - if (!installation) { - return []; - } - - return db.query.githubRepositories.findMany({ - where: eq(githubRepositories.installationId, installation.id), - orderBy: [desc(githubRepositories.updatedAt)], - }); + return listGithubRepositories(input.organizationId); }), listPullRequests: protectedProcedure diff --git a/packages/trpc/src/router/integration/github/people.ts b/packages/trpc/src/router/integration/github/trigger-options.ts similarity index 68% rename from packages/trpc/src/router/integration/github/people.ts rename to packages/trpc/src/router/integration/github/trigger-options.ts index a0a9c7deacc..18be22aaee5 100644 --- a/packages/trpc/src/router/integration/github/people.ts +++ b/packages/trpc/src/router/integration/github/trigger-options.ts @@ -1,7 +1,30 @@ import { db } from "@superset/db/client"; import { githubInstallations, githubRepositories } from "@superset/db/schema"; -import { eq } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { installationOctokit } from "../../../lib/blaxel/clone-token"; +import type { TriggerOptionSource } from "../trigger-options"; + +/** The synced repositories of the organization's installation, newest first. */ +export async function listGithubRepositories(organizationId: string) { + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, organizationId), + columns: { id: true }, + }); + if (!installation) return []; + return db.query.githubRepositories.findMany({ + where: eq(githubRepositories.installationId, installation.id), + orderBy: [desc(githubRepositories.updatedAt)], + }); +} + +/** + * repoId is GitHub's numeric id, which is what the matcher compares against — + * a full name would stop matching the moment someone renames the repo. + */ +const repositories: TriggerOptionSource = async ({ organizationId }) => { + const list = await listGithubRepositories(organizationId); + return list.map((repo) => ({ id: repo.repoId, label: repo.fullName })); +}; const PER_PAGE = 100; const MAX_PEOPLE = 1000; @@ -29,9 +52,7 @@ function isPermissionError(error: unknown): boolean { * members; user installations list the collaborators of the synced * repositories, deduplicated. */ -export async function listGithubPeople( - organizationId: string, -): Promise> { +const people: TriggerOptionSource = async ({ organizationId }) => { const installation = await db.query.githubInstallations.findFirst({ where: eq(githubInstallations.organizationId, organizationId), columns: { @@ -75,7 +96,7 @@ export async function listGithubPeople( // The App was installed without the members permission (or the // repository is gone): an empty picker, not a red editor. if (isPermissionError(error)) { - console.warn("[integration.github] listPeople refused:", error); + console.warn("[integration.github] people refused:", error); return []; } throw error; @@ -84,4 +105,6 @@ export async function listGithubPeople( return [...seen] .map(([id, login]) => ({ id, label: login })) .sort((a, b) => a.label.localeCompare(b.label)); -} +}; + +export const githubTriggerOptions = { repositories, people }; diff --git a/packages/trpc/src/router/integration/linear/linear.ts b/packages/trpc/src/router/integration/linear/linear.ts index 3b4b54e2a4d..2e37d53b56c 100644 --- a/packages/trpc/src/router/integration/linear/linear.ts +++ b/packages/trpc/src/router/integration/linear/linear.ts @@ -1,4 +1,3 @@ -import type { LinearClient } from "@linear/sdk"; import { db, dbWs } from "@superset/db/client"; import { integrationConnections, @@ -14,83 +13,6 @@ import { protectedProcedure } from "../../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; import { callLinear } from "./refresh"; -type Named = { id: string; name: string }; -type TeamScoped = Named & { team: { key: string } | null }; -type Page = { - nodes: T[]; - pageInfo: { hasNextPage: boolean; endCursor: string | null }; -}; - -const PAGE_INFO = "pageInfo { hasNextPage endCursor }"; -const TRIGGER_OPTION_CONNECTIONS = { - teams: `teams(first: 250, after: $after) { nodes { id name } ${PAGE_INFO} }`, - projects: `projects(first: 250, after: $after) { nodes { id name } ${PAGE_INFO} }`, - issueLabels: `issueLabels(first: 250, after: $after) { nodes { id name team { key } } ${PAGE_INFO} }`, - workflowStates: `workflowStates(first: 250, after: $after) { nodes { id name team { key } } ${PAGE_INFO} }`, -} as const; -type ConnectionName = keyof typeof TRIGGER_OPTION_CONNECTIONS; -type TriggerOptionsQuery = { - teams: Page; - projects: Page; - issueLabels: Page; - workflowStates: Page; -}; - -// A workspace can outgrow one page (250, Linear's cap) of labels or states. -// The common case is still one round trip: only a connection that reports -// more pages is followed, on its own. Bounded so a misbehaving API cannot -// keep the request open forever. -const MAX_PAGES = 20; - -async function fetchAllTriggerOptions( - client: LinearClient, -): Promise { - const query = (names: ConnectionName[]) => - `query TriggerOptions($after: String) { ${names - .map((n) => TRIGGER_OPTION_CONNECTIONS[n]) - .join(" ")} }`; - const request = (names: ConnectionName[], after: string | null) => - client.client.request< - Partial, - { after: string | null } - >(query(names), { after }); - - const names = Object.keys(TRIGGER_OPTION_CONNECTIONS) as ConnectionName[]; - const result = (await request(names, null)) as TriggerOptionsQuery; - for (const name of names) { - const all: Array = result[name].nodes; - let { pageInfo } = result[name]; - for (let i = 1; i < MAX_PAGES && pageInfo.hasNextPage; i++) { - const next = (await request([name], pageInfo.endCursor))[name]; - if (!next) break; - all.push(...next.nodes); - pageInfo = next.pageInfo; - } - } - return result; -} - -const MAX_PEOPLE = 1000; - -async function fetchAllPeople( - client: LinearClient, -): Promise> { - const people: Array<{ id: string; label: string }> = []; - let page = await client.users({ - first: 250, - filter: { active: { eq: true } }, - }); - while (true) { - for (const user of page.nodes) { - if (user.active === false) continue; - people.push({ id: user.id, label: user.displayName || user.name }); - } - if (!page.pageInfo.hasNextPage || people.length >= MAX_PEOPLE) break; - page = await page.fetchNext(); - } - return people; -} - export const linearRouter = { getConnection: protectedProcedure .input(z.object({ organizationId: z.uuid() })) @@ -213,61 +135,6 @@ export const linearRouter = { return teams.nodes.map((t) => ({ id: t.id, name: t.name, key: t.key })); }), - /** - * Everything a Linear trigger sentence can pick from. Ids throughout: a - * team key or state name can be renamed, the id cannot. - */ - getTriggerOptions: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const result = await callLinear( - input.organizationId, - fetchAllTriggerOptions, - ); - if (!result) return { teams: [], projects: [], labels: [], statuses: [] }; - - // Workflow states and labels repeat their names across teams, so the - // team key is part of the label rather than a separate column. - const withTeam = (name: string, team: { key: string } | null) => - team ? `${name} · ${team.key}` : name; - const byLabel = (options: Array<{ id: string; label: string }>) => - options.sort((a, b) => a.label.localeCompare(b.label)); - return { - teams: byLabel( - result.teams.nodes.map((t) => ({ id: t.id, label: t.name })), - ), - projects: byLabel( - result.projects.nodes.map((p) => ({ id: p.id, label: p.name })), - ), - labels: byLabel( - result.issueLabels.nodes.map((l) => ({ - id: l.id, - label: withTeam(l.name, l.team), - })), - ), - statuses: byLabel( - result.workflowStates.nodes.map((s) => ({ - id: s.id, - label: withTeam(s.name, s.team), - })), - ), - }; - }), - - /** - * The workspace's active members, keyed by Linear user id — what a - * webhook's `assigneeId` and actor id carry. - */ - listPeople: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const people = await callLinear(input.organizationId, fetchAllPeople); - if (!people) return []; - return people.sort((a, b) => a.label.localeCompare(b.label)); - }), - updateConfig: protectedProcedure .input( z.object({ diff --git a/packages/trpc/src/router/integration/linear/trigger-options.ts b/packages/trpc/src/router/integration/linear/trigger-options.ts new file mode 100644 index 00000000000..7b86e043771 --- /dev/null +++ b/packages/trpc/src/router/integration/linear/trigger-options.ts @@ -0,0 +1,124 @@ +import type { LinearClient } from "@linear/sdk"; +import type { TriggerOption, TriggerOptionSource } from "../trigger-options"; +import { callLinear } from "./refresh"; + +type Named = { id: string; name: string }; +type TeamScoped = Named & { team: { key: string } | null }; +type Member = { id: string; name: string; displayName: string | null }; +type Page = { + nodes: T[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}; + +const PAGE_INFO = "pageInfo { hasNextPage endCursor }"; +const CONNECTIONS = { + teams: `teams(first: 250, after: $after) { nodes { id name } ${PAGE_INFO} }`, + projects: `projects(first: 250, after: $after) { nodes { id name } ${PAGE_INFO} }`, + issueLabels: `issueLabels(first: 250, after: $after) { nodes { id name team { key } } ${PAGE_INFO} }`, + workflowStates: `workflowStates(first: 250, after: $after) { nodes { id name team { key } } ${PAGE_INFO} }`, + users: `users(first: 250, after: $after, filter: { active: { eq: true } }) { nodes { id name displayName } ${PAGE_INFO} }`, +} as const; +type ConnectionName = keyof typeof CONNECTIONS; +type TriggerOptionsQuery = { + teams: Page; + projects: Page; + issueLabels: Page; + workflowStates: Page; + users: Page; +}; + +// A workspace can outgrow one page (250, Linear's cap) of labels, states or +// members. The common case is still one round trip: only a connection that +// reports more pages is followed, on its own. Bounded so a misbehaving API +// cannot keep the request open forever. +const MAX_PAGES = 20; + +async function fetchAllTriggerOptions( + client: LinearClient, +): Promise { + const query = (names: ConnectionName[]) => + `query TriggerOptions($after: String) { ${names + .map((n) => CONNECTIONS[n]) + .join(" ")} }`; + const request = (names: ConnectionName[], after: string | null) => + client.client.request< + Partial, + { after: string | null } + >(query(names), { after }); + + const names = Object.keys(CONNECTIONS) as ConnectionName[]; + const result = (await request(names, null)) as TriggerOptionsQuery; + for (const name of names) { + const all: Array = result[name].nodes; + let { pageInfo } = result[name]; + for (let i = 1; i < MAX_PAGES && pageInfo.hasNextPage; i++) { + const next = (await request([name], pageInfo.endCursor))[name]; + if (!next) break; + all.push(...next.nodes); + pageInfo = next.pageInfo; + } + } + return result; +} + +/** + * The editor asks for every Linear list at once, and each source below picks + * one of them. One Linear round trip serves them all: concurrent sources for + * the same organization share the in-flight request rather than each + * resolving the client (and racing a token refresh) on their own. + */ +const inFlight = new Map>(); + +function triggerOptionsFor(organizationId: string) { + let pending = inFlight.get(organizationId); + if (!pending) { + pending = callLinear(organizationId, fetchAllTriggerOptions).finally(() => + inFlight.delete(organizationId), + ); + inFlight.set(organizationId, pending); + } + return pending; +} + +// Workflow states and labels repeat their names across teams, so the team +// key is part of the label rather than a separate column. +const withTeam = (name: string, team: { key: string } | null) => + team ? `${name} · ${team.key}` : name; +const byLabel = (options: TriggerOption[]) => + options.sort((a, b) => a.label.localeCompare(b.label)); + +/** + * Everything a Linear trigger sentence can pick from. Ids throughout: a team + * key or state name can be renamed, the id cannot. People are keyed by + * Linear user id — what a webhook's `assigneeId` and actor id carry. + */ +function source( + pick: (result: TriggerOptionsQuery) => TriggerOption[], +): TriggerOptionSource { + return async ({ organizationId }) => { + const result = await triggerOptionsFor(organizationId); + return result ? byLabel(pick(result)) : []; + }; +} + +export const linearTriggerOptions = { + teams: source((r) => r.teams.nodes.map((t) => ({ id: t.id, label: t.name }))), + projects: source((r) => + r.projects.nodes.map((p) => ({ id: p.id, label: p.name })), + ), + labels: source((r) => + r.issueLabels.nodes.map((l) => ({ + id: l.id, + label: withTeam(l.name, l.team), + })), + ), + statuses: source((r) => + r.workflowStates.nodes.map((s) => ({ + id: s.id, + label: withTeam(s.name, s.team), + })), + ), + people: source((r) => + r.users.nodes.map((u) => ({ id: u.id, label: u.displayName || u.name })), + ), +}; diff --git a/packages/trpc/src/router/integration/trigger-options.ts b/packages/trpc/src/router/integration/trigger-options.ts index 2d2b5379197..7c0d4e9b607 100644 --- a/packages/trpc/src/router/integration/trigger-options.ts +++ b/packages/trpc/src/router/integration/trigger-options.ts @@ -1,6 +1,8 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { z } from "zod"; import { protectedProcedure } from "../../trpc"; +import { githubTriggerOptions } from "./github/trigger-options"; +import { linearTriggerOptions } from "./linear/trigger-options"; import { sentryTriggerOptions } from "./sentry/trigger-options"; import { verifyOrgMembership } from "./utils"; @@ -32,6 +34,8 @@ export const triggerOptionSources: Record< string, Record > = { + github: githubTriggerOptions, + linear: linearTriggerOptions, sentry: sentryTriggerOptions, }; From e08047c250a7cbef46f590adad86a01e559a545a Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 13:00:44 -0700 Subject: [PATCH 05/15] refactor(api,trpc,desktop): migrate Teams and Google to ingestAutomationEvent and trigger options - Teams notify/process: replace record+dispatch pairs in handleNotification with pure normalizeChannelMessage/normalizeChannelCreated + ingestAutomationEvent; process and renew-subscriptions routes verify QStash via the shared helper. - Google: syncMailbox, syncCalendar and calendar/scheduled build a NormalizedDelivery and call ingestAutomationEvent once; a failed QStash handoff is left for the sweep instead of failing the sync. - Trigger options: microsoftTeams (teams, channels, people) and google (calendars, labels, people) sources registered in triggerOptionSources; editor-only list procedures removed from both routers. - Desktop gmail sentence reads options.google.labels. --- .../google/calendar/scheduled/route.ts | 68 ++++--- .../integrations/google/lib/syncCalendar.ts | 96 ++++++---- .../integrations/google/lib/syncMailbox.ts | 84 ++++----- .../jobs/renew-subscriptions/route.ts | 24 +-- .../notify/handleNotification.ts | 167 +++--------------- .../notify/normalizeTeamsDelivery.ts | 107 +++++++++++ .../microsoft-teams/notify/queue.ts | 3 +- .../microsoft-teams/process/route.ts | 22 +-- .../components/providers/google/gmail.tsx | 2 +- .../src/router/integration/google/google.ts | 80 +-------- .../integration/google/trigger-options.ts | 63 +++++++ .../microsoft-teams/microsoft-teams.ts | 99 ----------- .../microsoft-teams/trigger-options.ts | 85 +++++++++ .../src/router/integration/trigger-options.ts | 4 + 14 files changed, 447 insertions(+), 457 deletions(-) create mode 100644 apps/api/src/app/api/integrations/microsoft-teams/notify/normalizeTeamsDelivery.ts create mode 100644 packages/trpc/src/router/integration/google/trigger-options.ts create mode 100644 packages/trpc/src/router/integration/microsoft-teams/trigger-options.ts diff --git a/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts b/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts index 857afb35b76..ebfdcf83851 100644 --- a/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts +++ b/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts @@ -1,13 +1,17 @@ import { db } from "@superset/db/client"; +import type { SelectIntegrationConnection } from "@superset/db/schema"; import { eventEnd, eventStart, findGoogleConnectionById, + type GoogleCalendarEvent, getEvent, } from "@superset/trpc/integrations/google"; import { z } from "zod"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { + ingestAutomationEvent, + type NormalizedDelivery, +} from "@/lib/automations/ingestAutomationEvent"; import { verifyQstashRequest } from "@/lib/verifyQstash"; import { accountDomain, @@ -68,6 +72,21 @@ export async function POST(request: Request) { return Response.json({ ok: true, skipped: "moved" }); } + const outcome = await ingestAutomationEvent( + db, + normalizeFire(connection, fire, event), + ); + if (outcome.status === "duplicate") { + return Response.json({ ok: true, skipped: "duplicate" }); + } + return Response.json({ ok: true, ...outcome }); +} + +function normalizeFire( + connection: SelectIntegrationConnection, + fire: z.infer, + event: GoogleCalendarEvent, +): NormalizedDelivery { const eventType = fire.fire === "starting_soon" ? "event.starting_soon" : "event.ended"; const matchable = matchableCalendarEvent({ @@ -78,29 +97,24 @@ export async function POST(request: Request) { domain: accountDomain(connection), minutesBefore: fire.minutesBefore ?? undefined, }); - const inserted = await recordAutomationEvent(db, { - organizationId: connection.organizationId, - integrationConnectionId: connection.id, - provider: "google_calendar", - eventType, - externalEventId: `${fire.calendarId}:${fire.eventId}:${fire.expectedAt}:${fire.fire}:${fire.minutesBefore ?? ""}`, - resourceKey: resourceKeyFor(connection.id, fire.calendarId, fire.eventId), - title: event.summary ?? fire.eventId, - url: event.htmlLink ?? null, - actorLogin: event.organizer?.email?.toLowerCase() ?? null, - actorIsExternal: matchable.hasExternalAttendee, - payload: calendarPayload(fire.calendarId, event, matchable, { - fire: fire.fire, - minutesBefore: fire.minutesBefore, - expectedAt: fire.expectedAt, - }), - }); - if (!inserted) return Response.json({ ok: true, skipped: "duplicate" }); - - const result = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: inserted.id, - event: matchable, - }); - return Response.json({ ok: true, eventId: inserted.id, ...result }); + return { + event: { + organizationId: connection.organizationId, + integrationConnectionId: connection.id, + provider: "google_calendar", + eventType, + externalEventId: `${fire.calendarId}:${fire.eventId}:${fire.expectedAt}:${fire.fire}:${fire.minutesBefore ?? ""}`, + resourceKey: resourceKeyFor(connection.id, fire.calendarId, fire.eventId), + title: event.summary ?? fire.eventId, + url: event.htmlLink ?? null, + actorLogin: event.organizer?.email?.toLowerCase() ?? null, + actorIsExternal: matchable.hasExternalAttendee, + payload: calendarPayload(fire.calendarId, event, matchable, { + fire: fire.fire, + minutesBefore: fire.minutesBefore, + expectedAt: fire.expectedAt, + }), + }, + dispatch: { event: matchable }, + }; } diff --git a/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts b/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts index eb06c9093e7..6547f06aad9 100644 --- a/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts +++ b/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts @@ -13,8 +13,11 @@ import { patchCalendarState, } from "@superset/trpc/integrations/google"; import { and, desc, eq } from "drizzle-orm"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { + type IngestOutcome, + ingestAutomationEvent, + type NormalizedDelivery, +} from "@/lib/automations/ingestAutomationEvent"; import { accountDomain, accountEmail, @@ -137,9 +140,9 @@ export async function applyCalendarChanges( watchedSince: options.watchedSince, domain, }); - if (!outcome) continue; + if (outcome.status === "duplicate") continue; recorded += 1; - matched += outcome.matched; + if (outcome.status === "dispatched") matched += outcome.matched; // A change inside the horizon may move a fire; the sweep would catch it // eventually, but a meeting created for twenty minutes from now needs @@ -178,10 +181,8 @@ export async function applyCalendarChanges( /** * Created, updated or cancelled, decided from what we already recorded. * - * "Created" needs two things: no prior row for this event on this calendar, - * and Google's `created` timestamp after we started watching. Without the - * second, editing an event that predates the connection would read as its - * creation; without the first, every later edit of a new event would. + * Reads the previous row before writing, so changes to one calendar must be + * applied in order: a later change's kind depends on this one being recorded. */ async function recordChange(params: { connection: SelectIntegrationConnection; @@ -189,7 +190,7 @@ async function recordChange(params: { item: GoogleCalendarEvent; watchedSince: Date; domain: string | null; -}): Promise<{ matched: number } | null> { +}): Promise { const { connection, calendarId, item } = params; const resourceKey = resourceKeyFor(connection.id, calendarId, item.id); @@ -206,15 +207,45 @@ async function recordChange(params: { .orderBy(desc(automationEvents.receivedAt)) .limit(1); + return ingestAutomationEvent( + db, + normalizeChange({ + ...params, + resourceKey, + previous: previous + ? (previous.payload as { event?: GoogleCalendarEvent }) + : undefined, + }), + ); +} + +/** + * "Created" needs two things: no prior row for this event on this calendar, + * and Google's `created` timestamp after we started watching. Without the + * second, editing an event that predates the connection would read as its + * creation; without the first, every later edit of a new event would. + */ +function normalizeChange(params: { + connection: SelectIntegrationConnection; + calendarId: string; + item: GoogleCalendarEvent; + watchedSince: Date; + domain: string | null; + resourceKey: string; + /** The prior row's payload, when this event was recorded before. */ + previous: { event?: GoogleCalendarEvent } | undefined; +}): NormalizedDelivery { + const { connection, calendarId, item, previous } = params; + let eventType: GoogleCalendarTriggerEvent; // A cancellation in an incremental sync carries little more than the id and // status; the title and attendees come from what was recorded before it. let event = item; if (item.status === "cancelled") { eventType = "event.cancelled"; - const before = (previous?.payload as { event?: GoogleCalendarEvent } | null) - ?.event; - if (before) event = { ...before, ...item, status: "cancelled" }; + if (previous?.event) { + event = { ...previous.event, ...item, status: "cancelled" }; + } } else if ( !previous && item.created && @@ -232,27 +263,22 @@ async function recordChange(params: { event, domain: params.domain, }); - const inserted = await recordAutomationEvent(db, { - organizationId: connection.organizationId, - integrationConnectionId: connection.id, - provider: "google_calendar", - eventType, - // A stable key: an item carrying neither `updated` nor `etag` collapses - // onto one row per status rather than one per delivery. - externalEventId: `${calendarId}:${item.id}:${item.updated ?? item.etag ?? item.status ?? "unknown"}`, - resourceKey, - title: event.summary ?? item.id, - url: event.htmlLink ?? null, - actorLogin: event.organizer?.email?.toLowerCase() ?? null, - actorIsExternal: matchable.hasExternalAttendee, - payload: calendarPayload(calendarId, event, matchable), - }); - if (!inserted) return null; - - const { matched } = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: inserted.id, - event: matchable, - }); - return { matched }; + return { + event: { + organizationId: connection.organizationId, + integrationConnectionId: connection.id, + provider: "google_calendar", + eventType, + // A stable key: an item carrying neither `updated` nor `etag` collapses + // onto one row per status rather than one per delivery. + externalEventId: `${calendarId}:${item.id}:${item.updated ?? item.etag ?? item.status ?? "unknown"}`, + resourceKey: params.resourceKey, + title: event.summary ?? item.id, + url: event.htmlLink ?? null, + actorLogin: event.organizer?.email?.toLowerCase() ?? null, + actorIsExternal: matchable.hasExternalAttendee, + payload: calendarPayload(calendarId, event, matchable), + }, + dispatch: { event: matchable }, + }; } diff --git a/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts b/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts index 42ea271bb83..49320c44db1 100644 --- a/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts +++ b/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts @@ -12,8 +12,10 @@ import { parseAddresses, patchGmailState, } from "@superset/trpc/integrations/google"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { + ingestAutomationEvent, + type NormalizedDelivery, +} from "@/lib/automations/ingestAutomationEvent"; /** Labels that mean the message left this mailbox rather than arrived in it. */ const OUTGOING_LABELS = new Set(["SENT", "DRAFT"]); @@ -60,10 +62,13 @@ export async function syncMailbox( if ((message.labelIds ?? []).some((label) => OUTGOING_LABELS.has(label))) { continue; } - const outcome = await recordMessage(connection, message); - if (!outcome) continue; + const outcome = await ingestAutomationEvent( + db, + normalizeMessage(connection, message), + ); + if (outcome.status === "duplicate") continue; recorded += 1; - matched += outcome.matched; + if (outcome.status === "dispatched") matched += outcome.matched; } await patchGmailState(connection.id, { historyId: result.historyId }); @@ -79,10 +84,10 @@ export async function syncMailbox( * Headers and label ids are all a trigger filters on, and all that is stored: * the body stays in the mailbox. */ -export async function recordMessage( +export function normalizeMessage( connection: SelectIntegrationConnection, message: GmailMessage, -): Promise<{ matched: number } | null> { +): NormalizedDelivery { const from = headerValue(message, "From"); const to = headerValue(message, "To"); const cc = headerValue(message, "Cc"); @@ -104,40 +109,35 @@ export async function recordMessage( hasAttachment: messageHasAttachment(message), }; - const inserted = await recordAutomationEvent(db, { - organizationId: connection.organizationId, - integrationConnectionId: connection.id, - provider: "gmail", - eventType: "message.received", - externalEventId: message.id, - resourceKey: `gmail:${connection.id}:${message.threadId}`, - title: subject ?? "(no subject)", - url: `https://mail.google.com/mail/#all/${message.threadId}`, - actorLogin: matchable.fromAddress, - actorIsExternal: null, - payload: { - id: message.id, - threadId: message.threadId, - historyId: message.historyId ?? null, - internalDate: message.internalDate ?? null, - labelIds: matchable.labelIds, - from, - fromAddress: matchable.fromAddress, - to, - cc, - toAddresses: matchable.toAddresses, - subject, - date: headerValue(message, "Date"), - messageId: headerValue(message, "Message-ID"), - hasAttachment: matchable.hasAttachment, + return { + event: { + organizationId: connection.organizationId, + integrationConnectionId: connection.id, + provider: "gmail", + eventType: "message.received", + externalEventId: message.id, + resourceKey: `gmail:${connection.id}:${message.threadId}`, + title: subject ?? "(no subject)", + url: `https://mail.google.com/mail/#all/${message.threadId}`, + actorLogin: matchable.fromAddress, + actorIsExternal: null, + payload: { + id: message.id, + threadId: message.threadId, + historyId: message.historyId ?? null, + internalDate: message.internalDate ?? null, + labelIds: matchable.labelIds, + from, + fromAddress: matchable.fromAddress, + to, + cc, + toAddresses: matchable.toAddresses, + subject, + date: headerValue(message, "Date"), + messageId: headerValue(message, "Message-ID"), + hasAttachment: matchable.hasAttachment, + }, }, - }); - if (!inserted) return null; - - const { matched } = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: inserted.id, - event: matchable, - }); - return { matched }; + dispatch: { event: matchable }, + }; } diff --git a/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts index a6f5fa54612..dcfd21c791b 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts @@ -1,19 +1,13 @@ import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { ensureTeamsSubscriptions } from "@superset/trpc/integrations/microsoft-teams"; -import { Receiver } from "@upstash/qstash"; import { and, eq, isNull } from "drizzle-orm"; -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; export const dynamic = "force-dynamic"; export const maxDuration = 60; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - /** * Keeps every Teams connection's Graph subscriptions alive. * @@ -25,18 +19,12 @@ const receiver = new Receiver({ */ export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - const valid = await receiver.verify({ + const rejected = await verifyQstashRequest( + request, body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/microsoft-teams/jobs/renew-subscriptions`, - }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + "/api/integrations/microsoft-teams/jobs/renew-subscriptions", + ); + if (rejected) return rejected; const connections = await db .select({ id: integrationConnections.id }) diff --git a/apps/api/src/app/api/integrations/microsoft-teams/notify/handleNotification.ts b/apps/api/src/app/api/integrations/microsoft-teams/notify/handleNotification.ts index eb5fe6e6865..baf8473989b 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/notify/handleNotification.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/notify/handleNotification.ts @@ -1,58 +1,49 @@ import { db } from "@superset/db/client"; import { - type GraphChannel, - type GraphChatMessage, getChannel, getChannelMessage, getGraphAccessToken, - plainTextOf, } from "@superset/trpc/integrations/microsoft-teams"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { + type IngestOutcome, + ingestAutomationEvent, +} from "@/lib/automations/ingestAutomationEvent"; +import { + normalizeChannelCreated, + normalizeChannelMessage, +} from "./normalizeTeamsDelivery"; import { type AuthenticatedConnection, type ChangeNotification, parseTeamsResource, } from "./notifications"; -const TITLE_LENGTH = 120; - -export type NotificationOutcome = - | { - outcome: "recorded"; - eventId: string; - matched: number; - considered: number; - } - | { outcome: "skipped"; reason: string }; - /** * Turns one authenticated change notification into an automation event. * - * The notification says only what changed; the resource is fetched, recorded - * with the fetched payload, then matched. Ordering matters: the row is - * written before dispatch so a run always has an event to point at, and the - * dedupe on that row is what makes a Graph redelivery a no-op. + * The notification says only what changed; the resource is fetched, then + * recorded with the fetched payload and matched. The dedupe on the recorded + * row is what makes a Graph redelivery a no-op. */ export async function handleNotification( connection: AuthenticatedConnection, notification: ChangeNotification, -): Promise { +): Promise { if (notification.changeType !== "created") { return { - outcome: "skipped", + status: "skipped", reason: `changeType ${notification.changeType}`, }; } const resource = parseTeamsResource(notification.resource); if (!resource) { - return { outcome: "skipped", reason: "unrecognised resource" }; + return { status: "skipped", reason: "unrecognised resource" }; } const type = (notification.resourceData?.["@odata.type"] ?? "").toLowerCase(); const accessToken = await getGraphAccessToken(connection.id); - if (!accessToken) return { outcome: "skipped", reason: "no access token" }; + if (!accessToken) return { status: "skipped", reason: "no access token" }; if (type.endsWith(".chatmessage") && resource.messageId) { const message = await getChannelMessage( @@ -62,10 +53,13 @@ export async function handleNotification( resource.messageId, resource.replyId, ); - return ingestChannelMessage( - connection, - { ...resource, messageId: resource.messageId }, - message, + return ingestAutomationEvent( + db, + normalizeChannelMessage( + connection, + { ...resource, messageId: resource.messageId }, + message, + ), ); } @@ -75,118 +69,11 @@ export async function handleNotification( resource.teamId, resource.channelId, ); - return ingestChannel(connection, resource, channel); - } - - return { outcome: "skipped", reason: `unhandled resource type ${type}` }; -} - -/** Records a fetched channel message and dispatches what it matches. */ -export async function ingestChannelMessage( - connection: AuthenticatedConnection, - resource: { - teamId: string; - channelId: string; - messageId: string; - replyId?: string; - }, - message: GraphChatMessage, -): Promise { - // Membership changes and the like arrive as messages too; nobody means - // those when they say "a message in the channel". - if (message.messageType && message.messageType !== "message") { - return { - outcome: "skipped", - reason: `messageType ${message.messageType}`, - }; - } - const messageId = resource.replyId ?? resource.messageId; - const text = plainTextOf(message.body); - const actorLogin = - message.from?.user?.displayName ?? - message.from?.application?.displayName ?? - null; - - const recorded = await recordAutomationEvent(db, { - organizationId: connection.organizationId, - integrationConnectionId: connection.id, - provider: "microsoft_teams", - eventType: "message_in_channel", - externalEventId: `${resource.channelId}:${messageId}`, - // The thread, so replies debounce with the message they answer. - resourceKey: `microsoft_teams:${resource.teamId}:${resource.channelId}:${message.replyToId ?? resource.messageId}`, - title: titleFor(message.subject, text), - url: message.webUrl ?? null, - actorLogin, - payload: { - teamId: resource.teamId, - channelId: resource.channelId, - message, - }, - }); - if (!recorded) { - return { outcome: "skipped", reason: "duplicate delivery" }; - } - const result = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: recorded.id, - event: { - provider: "microsoft_teams", - eventType: "message_in_channel", - teamId: resource.teamId, - channelId: resource.channelId, - actorId: message.from?.user?.id ?? null, - actorLogin, - body: text, - }, - }); - return { outcome: "recorded", eventId: recorded.id, ...result }; -} - -/** Records a fetched channel and dispatches what it matches. */ -export async function ingestChannel( - connection: AuthenticatedConnection, - resource: { teamId: string; channelId: string }, - channel: GraphChannel, -): Promise { - const name = channel.displayName ?? resource.channelId; - const recorded = await recordAutomationEvent(db, { - organizationId: connection.organizationId, - integrationConnectionId: connection.id, - provider: "microsoft_teams", - eventType: "channel_created", - externalEventId: resource.channelId, - resourceKey: `microsoft_teams:${resource.teamId}:${resource.channelId}`, - title: name, - url: channel.webUrl ?? null, - actorLogin: null, - payload: { teamId: resource.teamId, channel }, - }); - if (!recorded) { - return { outcome: "skipped", reason: "duplicate delivery" }; + return ingestAutomationEvent( + db, + normalizeChannelCreated(connection, resource, channel), + ); } - const result = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: recorded.id, - event: { - provider: "microsoft_teams", - eventType: "channel_created", - teamId: resource.teamId, - channelId: resource.channelId, - actorId: null, - actorLogin: null, - body: name, - }, - }); - return { outcome: "recorded", eventId: recorded.id, ...result }; -} -function titleFor(subject: string | null | undefined, text: string | null) { - if (subject) return subject; - const line = text - ?.split("\n") - .find((l) => l.trim()) - ?.trim(); - if (!line) return "Teams message"; - return line.length > TITLE_LENGTH ? `${line.slice(0, TITLE_LENGTH)}…` : line; + return { status: "skipped", reason: `unhandled resource type ${type}` }; } diff --git a/apps/api/src/app/api/integrations/microsoft-teams/notify/normalizeTeamsDelivery.ts b/apps/api/src/app/api/integrations/microsoft-teams/notify/normalizeTeamsDelivery.ts new file mode 100644 index 00000000000..163df747d5d --- /dev/null +++ b/apps/api/src/app/api/integrations/microsoft-teams/notify/normalizeTeamsDelivery.ts @@ -0,0 +1,107 @@ +import type { MicrosoftTeamsMatchableEvent } from "@superset/shared/automation-matching"; +import { + type GraphChannel, + type GraphChatMessage, + plainTextOf, +} from "@superset/trpc/integrations/microsoft-teams"; + +import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; +import type { AuthenticatedConnection } from "./notifications"; + +const TITLE_LENGTH = 120; + +/** A fetched channel message as the row to record and the event to match. */ +export function normalizeChannelMessage( + connection: AuthenticatedConnection, + resource: { + teamId: string; + channelId: string; + messageId: string; + replyId?: string; + }, + message: GraphChatMessage, +): NormalizedDelivery { + // Membership changes and the like arrive as messages too; nobody means + // those when they say "a message in the channel". + if (message.messageType && message.messageType !== "message") { + return { skip: `messageType ${message.messageType}` }; + } + const messageId = resource.replyId ?? resource.messageId; + const text = plainTextOf(message.body); + const actorLogin = + message.from?.user?.displayName ?? + message.from?.application?.displayName ?? + null; + const event: MicrosoftTeamsMatchableEvent = { + provider: "microsoft_teams", + eventType: "message_in_channel", + teamId: resource.teamId, + channelId: resource.channelId, + actorId: message.from?.user?.id ?? null, + actorLogin, + body: text, + }; + return { + event: { + organizationId: connection.organizationId, + integrationConnectionId: connection.id, + provider: "microsoft_teams", + eventType: "message_in_channel", + externalEventId: `${resource.channelId}:${messageId}`, + // The thread, so replies debounce with the message they answer. + resourceKey: `microsoft_teams:${resource.teamId}:${resource.channelId}:${message.replyToId ?? resource.messageId}`, + title: titleFor(message.subject, text), + url: message.webUrl ?? null, + actorLogin, + payload: { + teamId: resource.teamId, + channelId: resource.channelId, + message, + }, + }, + dispatch: { event }, + }; +} + +/** A fetched channel as the row to record and the event to match. */ +export function normalizeChannelCreated( + connection: AuthenticatedConnection, + resource: { teamId: string; channelId: string }, + channel: GraphChannel, +): NormalizedDelivery { + const name = channel.displayName ?? resource.channelId; + const event: MicrosoftTeamsMatchableEvent = { + provider: "microsoft_teams", + eventType: "channel_created", + teamId: resource.teamId, + channelId: resource.channelId, + actorId: null, + actorLogin: null, + body: name, + }; + return { + event: { + organizationId: connection.organizationId, + integrationConnectionId: connection.id, + provider: "microsoft_teams", + eventType: "channel_created", + externalEventId: resource.channelId, + resourceKey: `microsoft_teams:${resource.teamId}:${resource.channelId}`, + title: name, + url: channel.webUrl ?? null, + actorLogin: null, + payload: { teamId: resource.teamId, channel }, + }, + dispatch: { event }, + }; +} + +function titleFor(subject: string | null | undefined, text: string | null) { + if (subject) return subject; + const line = text + ?.split("\n") + .find((l) => l.trim()) + ?.trim(); + if (!line) return "Teams message"; + return line.length > TITLE_LENGTH ? `${line.slice(0, TITLE_LENGTH)}…` : line; +} diff --git a/apps/api/src/app/api/integrations/microsoft-teams/notify/queue.ts b/apps/api/src/app/api/integrations/microsoft-teams/notify/queue.ts index 6fcfb31eeaa..c62993029ba 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/notify/queue.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/notify/queue.ts @@ -22,7 +22,8 @@ const qstash = new Client({ baseUrl: env.QSTASH_URL, }); -export const PROCESS_URL = `${env.NEXT_PUBLIC_API_URL}/api/integrations/microsoft-teams/process`; +export const PROCESS_PATH = "/api/integrations/microsoft-teams/process"; +const PROCESS_URL = `${env.NEXT_PUBLIC_API_URL}${PROCESS_PATH}`; export const teamsWorkSchema = z.discriminatedUnion("kind", [ z.object({ diff --git a/apps/api/src/app/api/integrations/microsoft-teams/process/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/process/route.ts index b00f82b315b..08748733c44 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/process/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/process/route.ts @@ -2,21 +2,15 @@ import { ensureTeamsSubscriptions, type SubscriptionKey, } from "@superset/trpc/integrations/microsoft-teams"; -import { Receiver } from "@upstash/qstash"; -import { env } from "@/env"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; import { handleNotification } from "../notify/handleNotification"; import { loadConnection } from "../notify/notifications"; -import { PROCESS_URL, teamsWorkSchema } from "../notify/queue"; +import { PROCESS_PATH, teamsWorkSchema } from "../notify/queue"; export const dynamic = "force-dynamic"; export const maxDuration = 60; -const receiver = new Receiver({ - currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, - nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, -}); - /** * The slow half of a Teams notification, run by QStash after the notify or * lifecycle route has verified it came from Graph and answered 202. @@ -28,14 +22,8 @@ const receiver = new Receiver({ */ export async function POST(request: Request): Promise { const body = await request.text(); - const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - const valid = await receiver.verify({ body, signature, url: PROCESS_URL }); - if (!valid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + const rejected = await verifyQstashRequest(request, body, PROCESS_PATH); + if (rejected) return rejected; const parsed = teamsWorkSchema.safeParse(JSON.parse(body)); if (!parsed.success) { @@ -52,7 +40,7 @@ export async function POST(request: Request): Promise { if (work.kind === "change") { const outcome = await handleNotification(connection, work.notification); - if (outcome.outcome === "recorded" && outcome.matched > 0) { + if (outcome.status === "dispatched" && outcome.matched > 0) { console.log( `[microsoft-teams/process] ${outcome.matched}/${outcome.considered} triggers matched:`, outcome.eventId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx index eb5c68b0c38..8d72299c433 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx @@ -71,7 +71,7 @@ function renderPart( key={index} scope={config.labels} onChange={(v) => set({ labels: v ?? { mode: "any" } })} - options={options.google?.gmailLabels ?? []} + options={options.google?.labels ?? []} emptyLabel="Any label" anyLabel="Any label" disabled={disabled} diff --git a/packages/trpc/src/router/integration/google/google.ts b/packages/trpc/src/router/integration/google/google.ts index 2be148617c0..e3f43b857db 100644 --- a/packages/trpc/src/router/integration/google/google.ts +++ b/packages/trpc/src/router/integration/google/google.ts @@ -1,16 +1,12 @@ import { db } from "@superset/db/client"; -import { - integrationConnections, - userIdentities, - users, -} from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgMembership } from "../utils"; -import { listCalendars, stopChannel } from "./calendar"; -import { listLabels, stopMailboxWatch } from "./gmail"; +import { stopChannel } from "./calendar"; +import { stopMailboxWatch } from "./gmail"; import { findGoogleConnection, googleConfigOf } from "./state"; export const googleRouter = { @@ -100,76 +96,6 @@ export const googleRouter = { } return { success: true }; }), - - /** The connected account's calendars, as scope options. */ - listCalendars: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const connection = await findGoogleConnection( - input.organizationId, - ctx.session.user.id, - ); - if (!connection) return []; - const calendars = await listCalendars(connection.id); - return calendars - .filter((calendar) => !calendar.deleted) - .map((calendar) => ({ - id: calendar.id, - label: calendar.primary - ? `${calendar.summary ?? calendar.id} (primary)` - : (calendar.summary ?? calendar.id), - })); - }), - - /** The connected mailbox's labels. Ids, since names can be renamed. */ - listLabels: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const connection = await findGoogleConnection( - input.organizationId, - ctx.session.user.id, - ); - if (!connection) return []; - const labels = await listLabels(connection.id); - return ( - labels - // Gmail's internal category and chat labels are noise in a picker. - .filter((label) => !label.id.startsWith("CATEGORY_")) - .map((label) => ({ id: label.id, label: label.name })) - ); - }), - - /** - * People an attendee filter can name, as Google addresses. Org members who - * have connected or linked a Google account; the id is the address because - * that is what a calendar event names people by. - */ - listLinkedPeople: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const rows = await db - .select({ - email: userIdentities.externalId, - name: users.name, - }) - .from(userIdentities) - .innerJoin(users, eq(users.id, userIdentities.userId)) - .where( - and( - eq(userIdentities.organizationId, input.organizationId), - eq(userIdentities.provider, "google"), - ), - ); - - return rows.map((row) => ({ - id: row.email.toLowerCase(), - label: row.name ? `${row.name} (${row.email})` : row.email, - })); - }), } satisfies TRPCRouterRecord; async function revokeGrant(token: string): Promise { diff --git a/packages/trpc/src/router/integration/google/trigger-options.ts b/packages/trpc/src/router/integration/google/trigger-options.ts new file mode 100644 index 00000000000..d2b05a6910d --- /dev/null +++ b/packages/trpc/src/router/integration/google/trigger-options.ts @@ -0,0 +1,63 @@ +import { db } from "@superset/db/client"; +import { userIdentities, users } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; +import type { TriggerOptionSource } from "../trigger-options"; +import { listCalendars as listGoogleCalendars } from "./calendar"; +import { listLabels as listGmailLabels } from "./gmail"; +import { findGoogleConnection } from "./state"; + +/** + * Google is connected per member, so every list here is the asking user's + * own account: another member's calendars are not theirs to pick from. + */ + +const calendars: TriggerOptionSource = async ({ organizationId, userId }) => { + const connection = await findGoogleConnection(organizationId, userId); + if (!connection) return []; + const list = await listGoogleCalendars(connection.id); + return list + .filter((calendar) => !calendar.deleted) + .map((calendar) => ({ + id: calendar.id, + label: calendar.primary + ? `${calendar.summary ?? calendar.id} (primary)` + : (calendar.summary ?? calendar.id), + })); +}; + +/** The mailbox's labels. Ids, since names can be renamed. */ +const labels: TriggerOptionSource = async ({ organizationId, userId }) => { + const connection = await findGoogleConnection(organizationId, userId); + if (!connection) return []; + const list = await listGmailLabels(connection.id); + return ( + list + // Gmail's internal category and chat labels are noise in a picker. + .filter((label) => !label.id.startsWith("CATEGORY_")) + .map((label) => ({ id: label.id, label: label.name })) + ); +}; + +/** + * People an attendee filter can name, as Google addresses: org members who + * have connected or linked a Google account. The id is the address because + * that is what a calendar event names people by. + */ +const people: TriggerOptionSource = async ({ organizationId }) => { + const rows = await db + .select({ email: userIdentities.externalId, name: users.name }) + .from(userIdentities) + .innerJoin(users, eq(users.id, userIdentities.userId)) + .where( + and( + eq(userIdentities.organizationId, organizationId), + eq(userIdentities.provider, "google"), + ), + ); + return rows.map((row) => ({ + id: row.email.toLowerCase(), + label: row.name ? `${row.name} (${row.email})` : row.email, + })); +}; + +export const googleTriggerOptions = { calendars, labels, people }; diff --git a/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts b/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts index 42f1ca1ac80..e125e8764aa 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts @@ -5,30 +5,8 @@ import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; -import { - findTeamsConnection, - getGraphAccessToken, - isGraphAuthError, -} from "./graph"; -import { listChannels, listTeams, listUsers } from "./resources"; import { deleteTeamsSubscriptions } from "./subscriptions"; -/** How many teams' channel lists are fetched at once when building the - * channel picker. Graph throttles per app per tenant; a tenant with hundreds - * of teams is walked in batches rather than all at once. */ -const CHANNEL_FETCH_CONCURRENCY = 5; - -async function requireAccessToken(organizationId: string): Promise<{ - connectionId: string; - accessToken: string; -} | null> { - const connection = await findTeamsConnection(organizationId); - if (!connection) return null; - const accessToken = await getGraphAccessToken(connection.id); - if (!accessToken) return null; - return { connectionId: connection.id, accessToken }; -} - export const microsoftTeamsRouter = { getConnection: protectedProcedure .input(z.object({ organizationId: z.uuid() })) @@ -95,81 +73,4 @@ export const microsoftTeamsRouter = { return { success: true }; }), - - listTeams: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const auth = await requireAccessToken(input.organizationId); - if (!auth) return []; - - const teams = await listTeams(auth.accessToken); - return teams - .map((team) => ({ id: team.id, label: team.displayName ?? team.id })) - .sort((a, b) => a.label.localeCompare(b.label)); - }), - - listPeople: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const auth = await requireAccessToken(input.organizationId); - if (!auth) return []; - - try { - const users = await listUsers(auth.accessToken); - return users - .map((user) => ({ - id: user.id, - label: - user.displayName ?? - user.mail ?? - user.userPrincipalName ?? - user.id, - })) - .sort((a, b) => a.label.localeCompare(b.label)); - } catch (error) { - // The tenant consented before User.ReadBasic.All was asked for: an - // empty picker, not a red editor. - if (!isGraphAuthError(error)) throw error; - console.warn("[microsoft-teams] listPeople refused:", error); - return []; - } - }), - - listChannels: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const auth = await requireAccessToken(input.organizationId); - if (!auth) return []; - - const teams = await listTeams(auth.accessToken); - const channels: Array<{ id: string; teamId: string; label: string }> = []; - for (let i = 0; i < teams.length; i += CHANNEL_FETCH_CONCURRENCY) { - const batch = teams.slice(i, i + CHANNEL_FETCH_CONCURRENCY); - const results = await Promise.allSettled( - batch.map(async (team) => { - const list = await listChannels(auth.accessToken, team.id); - return list.map((channel) => ({ - id: channel.id, - teamId: team.id, - // Channel names repeat across teams ("General" is in every - // one), so the picker shows which team a channel belongs to. - label: `${team.displayName ?? team.id} › ${channel.displayName ?? channel.id}`, - })); - }), - ); - for (const result of results) { - if (result.status === "fulfilled") channels.push(...result.value); - else { - console.error( - "[microsoft-teams] listChannels failed for a team", - result.reason, - ); - } - } - } - return channels.sort((a, b) => a.label.localeCompare(b.label)); - }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/microsoft-teams/trigger-options.ts b/packages/trpc/src/router/integration/microsoft-teams/trigger-options.ts new file mode 100644 index 00000000000..6009b022679 --- /dev/null +++ b/packages/trpc/src/router/integration/microsoft-teams/trigger-options.ts @@ -0,0 +1,85 @@ +import type { TriggerOptionSource } from "../trigger-options"; +import { + findTeamsConnection, + getGraphAccessToken, + isGraphAuthError, +} from "./graph"; +import { listChannels, listTeams, listUsers } from "./resources"; + +/** How many teams' channel lists are fetched at once when building the + * channel picker. Graph throttles per app per tenant; a tenant with hundreds + * of teams is walked in batches rather than all at once. */ +const CHANNEL_FETCH_CONCURRENCY = 5; + +async function accessTokenFor(organizationId: string): Promise { + const connection = await findTeamsConnection(organizationId); + if (!connection) return null; + return getGraphAccessToken(connection.id); +} + +function byLabel(a: T, b: T) { + return a.label.localeCompare(b.label); +} + +const teams: TriggerOptionSource = async ({ organizationId }) => { + const accessToken = await accessTokenFor(organizationId); + if (!accessToken) return []; + const list = await listTeams(accessToken); + return list + .map((team) => ({ id: team.id, label: team.displayName ?? team.id })) + .sort(byLabel); +}; + +const channels: TriggerOptionSource = async ({ organizationId }) => { + const accessToken = await accessTokenFor(organizationId); + if (!accessToken) return []; + const list = await listTeams(accessToken); + const options: Array<{ id: string; label: string }> = []; + for (let i = 0; i < list.length; i += CHANNEL_FETCH_CONCURRENCY) { + const batch = list.slice(i, i + CHANNEL_FETCH_CONCURRENCY); + const results = await Promise.allSettled( + batch.map(async (team) => { + const teamChannels = await listChannels(accessToken, team.id); + return teamChannels.map((channel) => ({ + id: channel.id, + // Channel names repeat across teams ("General" is in every + // one), so the picker shows which team a channel belongs to. + label: `${team.displayName ?? team.id} › ${channel.displayName ?? channel.id}`, + })); + }), + ); + for (const result of results) { + if (result.status === "fulfilled") options.push(...result.value); + else { + console.error( + "[microsoft-teams] listing channels failed for a team", + result.reason, + ); + } + } + } + return options.sort(byLabel); +}; + +const people: TriggerOptionSource = async ({ organizationId }) => { + const accessToken = await accessTokenFor(organizationId); + if (!accessToken) return []; + try { + const users = await listUsers(accessToken); + return users + .map((user) => ({ + id: user.id, + label: + user.displayName ?? user.mail ?? user.userPrincipalName ?? user.id, + })) + .sort(byLabel); + } catch (error) { + // The tenant consented before User.ReadBasic.All was asked for: an + // empty picker, not a red editor. + if (!isGraphAuthError(error)) throw error; + console.warn("[microsoft-teams] listing people refused:", error); + return []; + } +}; + +export const microsoftTeamsTriggerOptions = { teams, channels, people }; diff --git a/packages/trpc/src/router/integration/trigger-options.ts b/packages/trpc/src/router/integration/trigger-options.ts index 2d2b5379197..dda83905696 100644 --- a/packages/trpc/src/router/integration/trigger-options.ts +++ b/packages/trpc/src/router/integration/trigger-options.ts @@ -1,6 +1,8 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { z } from "zod"; import { protectedProcedure } from "../../trpc"; +import { googleTriggerOptions } from "./google/trigger-options"; +import { microsoftTeamsTriggerOptions } from "./microsoft-teams/trigger-options"; import { sentryTriggerOptions } from "./sentry/trigger-options"; import { verifyOrgMembership } from "./utils"; @@ -33,6 +35,8 @@ export const triggerOptionSources: Record< Record > = { sentry: sentryTriggerOptions, + microsoftTeams: microsoftTeamsTriggerOptions, + google: googleTriggerOptions, }; export const triggerOptionsRouter = { From c8b97a72b13ca8a44efc63972a56d6facdbc67c5 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 13:02:19 -0700 Subject: [PATCH 06/15] refactor(api,trpc): migrate Slack and Notion triggers onto ingestAutomationEvent and trigger-option sources Slack: processAutomationEvent is find-connection -> normalizeSlackDelivery -> ingestAutomationEvent; channels/people move to slack/trigger-options.ts on @slack/web-api WebClient.paginate, hand-rolled list-*.ts and the editor-only listChannels/listPeople procedures are gone. Notion: the webhook keeps its per-connection webhook_events row, challenge handling and skipped-on-permanent-error semantics, expressed as normalizeNotionDelivery -> { skip } and one ingestAutomationEvent call; the hand-rolled client is replaced by @notionhq/client, dataSources/people move to notion/trigger-options.ts. --- apps/api/package.json | 1 + .../notion/webhook/fetchNotionEvent.ts | 114 --------- .../notion/webhook/normalizeNotionDelivery.ts | 191 +++++++++++++++ .../api/integrations/notion/webhook/route.ts | 128 ++++------ ...normalize.ts => normalizeSlackDelivery.ts} | 31 ++- .../process-automation-event.ts | 51 ++-- .../integrations/slack/events/route.test.ts | 5 +- .../api/integrations/slack/events/route.ts | 6 +- bun.lock | 5 + packages/trpc/package.json | 2 + .../trpc/src/lib/integrations/notion/index.ts | 13 +- .../src/router/integration/notion/client.ts | 226 +++--------------- .../src/router/integration/notion/notion.ts | 76 ------ .../integration/notion/trigger-options.ts | 79 ++++++ .../router/integration/slack/list-channels.ts | 70 ------ .../router/integration/slack/list-people.ts | 64 ----- .../src/router/integration/slack/slack.ts | 45 +--- .../integration/slack/trigger-options.ts | 90 +++++++ .../src/router/integration/trigger-options.ts | 4 + 19 files changed, 507 insertions(+), 694 deletions(-) delete mode 100644 apps/api/src/app/api/integrations/notion/webhook/fetchNotionEvent.ts create mode 100644 apps/api/src/app/api/integrations/notion/webhook/normalizeNotionDelivery.ts rename apps/api/src/app/api/integrations/slack/events/process-automation-event/{normalize.ts => normalizeSlackDelivery.ts} (88%) create mode 100644 packages/trpc/src/router/integration/notion/trigger-options.ts delete mode 100644 packages/trpc/src/router/integration/slack/list-channels.ts delete mode 100644 packages/trpc/src/router/integration/slack/list-people.ts create mode 100644 packages/trpc/src/router/integration/slack/trigger-options.ts diff --git a/apps/api/package.json b/apps/api/package.json index 251ebb71728..1d88cd56105 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "@better-auth/oauth-provider": "1.6.22", "@linear/sdk": "68.1.1", "@modelcontextprotocol/sdk": "1.30.0", + "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", "@octokit/rest": "22.0.1", "@octokit/webhooks": "14.2.0", diff --git a/apps/api/src/app/api/integrations/notion/webhook/fetchNotionEvent.ts b/apps/api/src/app/api/integrations/notion/webhook/fetchNotionEvent.ts deleted file mode 100644 index 3b44c4fa579..00000000000 --- a/apps/api/src/app/api/integrations/notion/webhook/fetchNotionEvent.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { - mentionedUserIds, - type NotionComment, - type NotionDataSource, - type NotionPage, - pageTitle, - plainText, - retrieveComment, - retrieveDataSource, - retrievePage, -} from "@superset/trpc/integrations/notion"; -import { z } from "zod"; - -/** - * A Notion delivery names an entity and says what happened to it; it carries - * none of the content. Everything a trigger filters on — which data source, - * which page, who wrote it, who it mentions — is fetched here. - */ - -export const notionWebhookEventSchema = z.object({ - id: z.string().min(1), - timestamp: z.string(), - workspace_id: z.string().min(1), - workspace_name: z.string().nullish(), - type: z.string().min(1), - authors: z.array(z.object({ id: z.string(), type: z.string() })).optional(), - attempt_number: z.number().optional(), - entity: z.object({ id: z.string().min(1), type: z.string() }), - data: z - .looseObject({ - page_id: z.string().optional(), - parent: z.object({ id: z.string(), type: z.string() }).optional(), - }) - .optional(), -}); -export type NotionWebhookEvent = z.infer; - -/** The delivery types this provider turns into automation events. */ -export const HANDLED_EVENT_TYPES = new Set([ - "comment.created", - "data_source.content_updated", -]); - -export type FetchedNotionEvent = { - dataSourceId: string | null; - pageId: string | null; - actorId: string | null; - mentionedUserIds: string[]; - body: string | null; - title: string; - url: string | null; - /** Runs debounce per subject; a page's comments are one subject. */ - resourceKey: string; - /** The delivery plus what was fetched, kept for the run's prompt. */ - payload: { - event: NotionWebhookEvent; - comment?: NotionComment; - page?: NotionPage; - dataSource?: NotionDataSource; - }; -}; - -function dataSourceOf(page: NotionPage): string | null { - const parent = page.parent; - if (parent.type === "data_source_id") return parent.data_source_id; - // Only older API versions answer with database_id; kept so a stale - // response still attributes the row to something rather than nothing. - if (parent.type === "database_id") return parent.database_id; - return null; -} - -export async function fetchNotionEvent( - accessToken: string, - event: NotionWebhookEvent, -): Promise { - switch (event.type) { - case "comment.created": { - const comment = await retrieveComment(accessToken, event.entity.id); - // The delivery names the page; a block comment's own parent is the - // block, so the page id has to come from the delivery when present. - const pageId = - event.data?.page_id ?? - (comment.parent.type === "page_id" ? comment.parent.page_id : null); - const page = pageId ? await retrievePage(accessToken, pageId) : null; - return { - dataSourceId: page ? dataSourceOf(page) : null, - pageId, - actorId: comment.created_by.id, - mentionedUserIds: mentionedUserIds(comment.rich_text), - body: plainText(comment.rich_text) || null, - title: (page && pageTitle(page)) || "Untitled", - url: page?.url ?? null, - resourceKey: `notion:${pageId ?? comment.id}`, - payload: { event, comment, ...(page ? { page } : {}) }, - }; - } - case "data_source.content_updated": { - const dataSource = await retrieveDataSource(accessToken, event.entity.id); - return { - dataSourceId: event.entity.id, - pageId: null, - actorId: event.authors?.[0]?.id ?? null, - mentionedUserIds: [], - body: null, - title: plainText(dataSource.title) || "Untitled", - url: dataSource.url ?? null, - resourceKey: `notion:${event.entity.id}`, - payload: { event, dataSource }, - }; - } - default: - throw new Error(`Unhandled Notion event type ${event.type}`); - } -} diff --git a/apps/api/src/app/api/integrations/notion/webhook/normalizeNotionDelivery.ts b/apps/api/src/app/api/integrations/notion/webhook/normalizeNotionDelivery.ts new file mode 100644 index 00000000000..ece97cc0009 --- /dev/null +++ b/apps/api/src/app/api/integrations/notion/webhook/normalizeNotionDelivery.ts @@ -0,0 +1,191 @@ +import { + type CommentObjectResponse, + type DataSourceObjectResponse, + isFullComment, + isFullDataSource, + isFullPage, + type PageObjectResponse, + type PartialCommentObjectResponse, + type PartialDataSourceObjectResponse, + type PartialPageObjectResponse, +} from "@notionhq/client"; +import type { NotionMatchableEvent } from "@superset/shared/automation-matching"; +import { + isPermanentNotionError, + mentionedUserIds, + notionClient, + pageTitle, + plainText, +} from "@superset/trpc/integrations/notion"; +import { z } from "zod"; + +import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; + +/** + * A Notion delivery names an entity and says what happened to it; it carries + * none of the content. Everything a trigger filters on — which data source, + * which page, who wrote it, who it mentions — is fetched here. + */ + +export const notionWebhookEventSchema = z.object({ + id: z.string().min(1), + timestamp: z.string(), + workspace_id: z.string().min(1), + workspace_name: z.string().nullish(), + type: z.string().min(1), + authors: z.array(z.object({ id: z.string(), type: z.string() })).optional(), + attempt_number: z.number().optional(), + entity: z.object({ id: z.string().min(1), type: z.string() }), + data: z + .looseObject({ + page_id: z.string().optional(), + parent: z.object({ id: z.string(), type: z.string() }).optional(), + }) + .optional(), +}); +export type NotionWebhookEvent = z.infer; + +/** The delivery types this provider turns into automation events. */ +export const HANDLED_EVENT_TYPES = new Set([ + "comment.created", + "data_source.content_updated", +]); + +type FetchedNotionEvent = { + dataSourceId: string | null; + pageId: string | null; + actorId: string | null; + mentionedUserIds: string[]; + body: string | null; + title: string; + url: string | null; + /** Runs debounce per subject; a page's comments are one subject. */ + resourceKey: string; + /** The delivery plus what was fetched, kept for the run's prompt. */ + payload: { + event: NotionWebhookEvent; + comment?: CommentObjectResponse | PartialCommentObjectResponse; + page?: PageObjectResponse | PartialPageObjectResponse; + dataSource?: DataSourceObjectResponse | PartialDataSourceObjectResponse; + }; +}; + +function dataSourceOf(page: PageObjectResponse): string | null { + const parent = page.parent; + if (parent.type === "data_source_id") return parent.data_source_id; + // Only older API versions answer with database_id; kept so a stale + // response still attributes the row to something rather than nothing. + if (parent.type === "database_id") return parent.database_id; + return null; +} + +/** + * Fetches what the delivery points at. A partial object (the integration + * lacks the content capability) still names the entity, so it is used for + * what it carries rather than refused. + */ +async function fetchNotionEvent( + accessToken: string, + event: NotionWebhookEvent, +): Promise { + const client = notionClient(accessToken); + switch (event.type) { + case "comment.created": { + const comment = await client.comments.retrieve({ + comment_id: event.entity.id, + }); + const full = isFullComment(comment) ? comment : null; + // The delivery names the page; a block comment's own parent is the + // block, so the page id has to come from the delivery when present. + const pageId = + event.data?.page_id ?? + (full?.parent.type === "page_id" ? full.parent.page_id : null); + const page = pageId + ? await client.pages.retrieve({ page_id: pageId }) + : null; + const fullPage = page && isFullPage(page) ? page : null; + return { + dataSourceId: fullPage ? dataSourceOf(fullPage) : null, + pageId, + actorId: full?.created_by.id ?? null, + mentionedUserIds: mentionedUserIds(full?.rich_text), + body: plainText(full?.rich_text) || null, + title: (fullPage && pageTitle(fullPage)) || "Untitled", + url: fullPage?.url ?? null, + resourceKey: `notion:${pageId ?? comment.id}`, + payload: { event, comment, ...(page ? { page } : {}) }, + }; + } + case "data_source.content_updated": { + const dataSource = await client.dataSources.retrieve({ + data_source_id: event.entity.id, + }); + const full = isFullDataSource(dataSource) ? dataSource : null; + return { + dataSourceId: event.entity.id, + pageId: null, + actorId: event.authors?.[0]?.id ?? null, + mentionedUserIds: [], + body: null, + title: plainText(full?.title) || "Untitled", + url: full?.url ?? null, + resourceKey: `notion:${event.entity.id}`, + payload: { event, dataSource }, + }; + } + default: + throw new Error(`Unhandled Notion event type ${event.type}`); + } +} + +/** + * Fetches and normalizes one delivery for one connection. A permanent Notion + * refusal — the entity is not shared with this connection, or the token no + * longer works — is a skip: retrying will not change it. Anything else throws + * so the delivery is retried. + */ +export async function normalizeNotionDelivery(params: { + organizationId: string; + connectionId: string; + accessToken: string; + event: NotionWebhookEvent; + webhookEventId: string; +}): Promise { + const { event } = params; + let fetched: FetchedNotionEvent; + try { + fetched = await fetchNotionEvent(params.accessToken, event); + } catch (error) { + if (isPermanentNotionError(error)) { + return { skip: `${error.status} ${error.code}: ${error.message}` }; + } + throw error; + } + + const matchable: NotionMatchableEvent = { + provider: "notion", + eventType: event.type, + actorId: fetched.actorId, + actorLogin: null, + body: fetched.body, + dataSourceId: fetched.dataSourceId, + pageId: fetched.pageId, + mentionedUserIds: fetched.mentionedUserIds, + }; + return { + event: { + organizationId: params.organizationId, + integrationConnectionId: params.connectionId, + provider: "notion", + eventType: event.type, + // A redelivery of the same Notion event id is the same event. + externalEventId: event.id, + resourceKey: fetched.resourceKey, + title: fetched.title, + url: fetched.url, + payload: fetched.payload, + webhookEventId: params.webhookEventId, + }, + dispatch: { event: matchable }, + }; +} diff --git a/apps/api/src/app/api/integrations/notion/webhook/route.ts b/apps/api/src/app/api/integrations/notion/webhook/route.ts index 06eabba3ad7..e0fc6ab5c4b 100644 --- a/apps/api/src/app/api/integrations/notion/webhook/route.ts +++ b/apps/api/src/app/api/integrations/notion/webhook/route.ts @@ -1,22 +1,20 @@ -import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import { createHash } from "node:crypto"; import { db } from "@superset/db/client"; import type { SelectIntegrationConnection } from "@superset/db/schema"; import { integrationConnections, webhookEvents } from "@superset/db/schema"; -import type { NotionMatchableEvent } from "@superset/shared/automation-matching"; -import { NotionApiError } from "@superset/trpc/integrations/notion"; import { and, asc, eq, isNull, sql } from "drizzle-orm"; import { env } from "@/env"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; import { stripNullChars } from "@/lib/strip-null-chars"; +import { cappedBody, parseJson } from "@/lib/webhooks/body"; +import { hmacHex, timingSafeHex, unauthorized } from "@/lib/webhooks/verify"; import { - type FetchedNotionEvent, - fetchNotionEvent, HANDLED_EVENT_TYPES, type NotionWebhookEvent, + normalizeNotionDelivery, notionWebhookEventSchema, -} from "./fetchNotionEvent"; +} from "./normalizeNotionDelivery"; export const maxDuration = 60; @@ -24,22 +22,14 @@ export const maxDuration = 60; * Notion signs every delivery with the verification token it sent when the * subscription was created: `sha256=` + HMAC-SHA256(token, raw body). */ -function signatureValid(body: string, signature: string, token: string) { - const expected = `sha256=${createHmac("sha256", token).update(body).digest("hex")}`; - const a = Buffer.from(expected); - const b = Buffer.from(signature); - return a.length === b.length && timingSafeEqual(a, b); -} +const SIGNATURE_PREFIX = "sha256="; export async function POST(request: Request) { - const body = await request.text(); + const body = await cappedBody(request); + if (body instanceof Response) return body; - let json: unknown; - try { - json = JSON.parse(body); - } catch { - return Response.json({ error: "Invalid JSON payload" }, { status: 400 }); - } + const json = parseJson(body); + if (json instanceof Response) return json; const token = env.NOTION_WEBHOOK_VERIFICATION_TOKEN; @@ -81,8 +71,14 @@ export async function POST(request: Request) { ); } const signature = request.headers.get("x-notion-signature"); - if (!signature || !signatureValid(body, signature, token)) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); + if ( + !signature?.startsWith(SIGNATURE_PREFIX) || + !timingSafeHex( + signature.slice(SIGNATURE_PREFIX.length), + hmacHex(body, token), + ) + ) { + return unauthorized("Invalid signature"); } const parsed = notionWebhookEventSchema.safeParse(json); @@ -189,69 +185,35 @@ async function processForConnection( } try { - let fetched: FetchedNotionEvent; - try { - fetched = await fetchNotionEvent(connection.accessToken, event); - } catch (error) { - // The entity is not shared with this connection, or the token no - // longer works. Retrying will not change that, so acknowledge it. - if (error instanceof NotionApiError && error.permanent) { - await db - .update(webhookEvents) - .set({ - status: "skipped", - error: `${error.status} ${error.code}: ${error.message}`, - processedAt: new Date(), - }) - .where(eq(webhookEvents.id, webhookEvent.id)); - return { connectionId: connection.id, outcome: "skipped" }; - } - throw error; - } - - // A redelivery of the same Notion event id is the same event. - const inserted = await recordAutomationEvent(db, { - organizationId: connection.organizationId, - integrationConnectionId: connection.id, - provider: "notion", - eventType: event.type, - externalEventId: event.id, - resourceKey: fetched.resourceKey, - title: fetched.title, - url: fetched.url, - payload: fetched.payload, - webhookEventId: webhookEvent.id, - }); + const outcome = await ingestAutomationEvent( + db, + await normalizeNotionDelivery({ + organizationId: connection.organizationId, + connectionId: connection.id, + accessToken: connection.accessToken, + event, + webhookEventId: webhookEvent.id, + }), + ); - // Not inside the failure path: the event row above already dedupes a - // redelivery, so failing the delivery here would make Notion retry - // something that can no longer dispatch. Same tradeoff as GitHub. - if (inserted) { - try { - const matchable: NotionMatchableEvent = { - provider: "notion", - eventType: event.type, - actorId: fetched.actorId, - actorLogin: null, - body: fetched.body, - dataSourceId: fetched.dataSourceId, - pageId: fetched.pageId, - mentionedUserIds: fetched.mentionedUserIds, - }; - const result = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: inserted.id, - event: matchable, - }); - console.log( - `[notion/webhook] ${result.matched}/${result.considered} triggers matched:`, - event.id, - ); - } catch (error) { - console.error("[notion/webhook] dispatch failed:", event.id, error); - } + if (outcome.status === "skipped") { + await db + .update(webhookEvents) + .set({ + status: "skipped", + error: outcome.reason, + processedAt: new Date(), + }) + .where(eq(webhookEvents.id, webhookEvent.id)); + return { connectionId: connection.id, outcome: "skipped" }; } + if (outcome.status === "dispatched") { + console.log( + `[notion/webhook] ${outcome.matched}/${outcome.considered} triggers matched:`, + event.id, + ); + } await db .update(webhookEvents) .set({ status: "processed", processedAt: new Date() }) diff --git a/apps/api/src/app/api/integrations/slack/events/process-automation-event/normalize.ts b/apps/api/src/app/api/integrations/slack/events/process-automation-event/normalizeSlackDelivery.ts similarity index 88% rename from apps/api/src/app/api/integrations/slack/events/process-automation-event/normalize.ts rename to apps/api/src/app/api/integrations/slack/events/process-automation-event/normalizeSlackDelivery.ts index cc4edb44b10..136003ee263 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-automation-event/normalize.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-automation-event/normalizeSlackDelivery.ts @@ -13,6 +13,8 @@ import { slackEventNames, } from "@superset/shared/automation-matching"; +import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; + /** * The message subtypes that are still "a message in the channel". Edits, * deletes, joins and topic changes are also delivered as `message` events and @@ -47,7 +49,7 @@ export type SlackAutomationEnvelope = { * matchable event triggers filter on, plus what the `automation_events` row * needs beyond that. */ -export type NormalizedSlackEvent = { +type NormalizedSlackEvent = { event: SlackMatchableEvent; resourceKey: string; title: string; @@ -165,7 +167,7 @@ function matchable( }; } -export function normalizeSlackEvent( +function normalizeSlackEvent( event: SlackAutomationEvent, ): NormalizedSlackEvent { switch (event.type) { @@ -221,3 +223,28 @@ export function normalizeSlackEvent( }; } } + +export function normalizeSlackDelivery(params: { + organizationId: string; + connectionId: string; + envelope: SlackAutomationEnvelope; +}): NormalizedDelivery { + const { envelope } = params; + const normalized = normalizeSlackEvent(envelope.event); + return { + event: { + organizationId: params.organizationId, + integrationConnectionId: params.connectionId, + provider: "slack", + eventType: normalized.event.eventType, + // Idempotent on Slack's event_id: a retried delivery is the same event. + externalEventId: envelope.event_id, + resourceKey: normalized.resourceKey, + title: normalized.title, + url: normalized.url, + actorLogin: normalized.event.actorLogin, + payload: envelope, + }, + dispatch: { event: normalized.event }, + }; +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-automation-event/process-automation-event.ts b/apps/api/src/app/api/integrations/slack/events/process-automation-event/process-automation-event.ts index 8755aee45db..6fa13e2d0bd 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-automation-event/process-automation-event.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-automation-event/process-automation-event.ts @@ -3,16 +3,18 @@ import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq, isNull } from "drizzle-orm"; -import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers"; -import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent"; +import { + type IngestOutcome, + ingestAutomationEvent, +} from "@/lib/automations/ingestAutomationEvent"; import { isChannelMessage, isMessageReaction, - normalizeSlackEvent, + normalizeSlackDelivery, type SlackAutomationEnvelope, -} from "./normalize"; +} from "./normalizeSlackDelivery"; -export type { SlackAutomationEnvelope } from "./normalize"; +export type { SlackAutomationEnvelope } from "./normalizeSlackDelivery"; /** * Whether this delivery is one triggers can name. Narrows the envelope so the @@ -47,10 +49,7 @@ export function isAutomationEvent(envelope: { */ export async function processAutomationEvent( envelope: SlackAutomationEnvelope, -): Promise< - | { recorded: false; reason: string } - | { recorded: true; eventId: string; matched: number; considered: number } -> { +): Promise { const connection = await db.query.integrationConnections.findFirst({ where: and( eq(integrationConnections.provider, "slack"), @@ -59,30 +58,14 @@ export async function processAutomationEvent( ), columns: { id: true, organizationId: true }, }); - if (!connection) return { recorded: false, reason: "unknown workspace" }; + if (!connection) return { status: "skipped", reason: "unknown workspace" }; - const normalized = normalizeSlackEvent(envelope.event); - // Idempotent on Slack's event_id: a retried delivery is the same event. - const recorded = await recordAutomationEvent(db, { - organizationId: connection.organizationId, - integrationConnectionId: connection.id, - provider: "slack", - eventType: normalized.event.eventType, - externalEventId: envelope.event_id, - resourceKey: normalized.resourceKey, - title: normalized.title, - url: normalized.url, - actorLogin: normalized.event.actorLogin, - payload: envelope, - }); - if (!recorded) { - return { recorded: false, reason: "duplicate delivery" }; - } - - const result = await dispatchMatchingTriggers({ - organizationId: connection.organizationId, - eventId: recorded.id, - event: normalized.event, - }); - return { recorded: true, eventId: recorded.id, ...result }; + return ingestAutomationEvent( + db, + normalizeSlackDelivery({ + organizationId: connection.organizationId, + connectionId: connection.id, + envelope, + }), + ); } diff --git a/apps/api/src/app/api/integrations/slack/events/route.test.ts b/apps/api/src/app/api/integrations/slack/events/route.test.ts index 59b20aa5713..97957944167 100644 --- a/apps/api/src/app/api/integrations/slack/events/route.test.ts +++ b/apps/api/src/app/api/integrations/slack/events/route.test.ts @@ -24,7 +24,10 @@ mock.module("./process-app-home-opened", () => ({ mock.module("./process-automation-event", () => ({ isAutomationEvent: () => false, - processAutomationEvent: mock(async () => ({ recorded: false })), + processAutomationEvent: mock(async () => ({ + status: "skipped", + reason: "unknown workspace", + })), })); mock.module("./process-entity-details", () => ({ diff --git a/apps/api/src/app/api/integrations/slack/events/route.ts b/apps/api/src/app/api/integrations/slack/events/route.ts index c58c4281b19..c3f1e8ad140 100644 --- a/apps/api/src/app/api/integrations/slack/events/route.ts +++ b/apps/api/src/app/api/integrations/slack/events/route.ts @@ -97,10 +97,10 @@ export async function POST(request: Request) { }; if (isAutomationEvent(envelope)) { try { - const result = await processAutomationEvent(envelope); - if (result.recorded && result.matched > 0) { + const outcome = await processAutomationEvent(envelope); + if (outcome.status === "dispatched" && outcome.matched > 0) { console.log( - `[slack/events] ${result.matched}/${result.considered} triggers matched:`, + `[slack/events] ${outcome.matched}/${outcome.considered} triggers matched:`, event_id, ); } diff --git a/bun.lock b/bun.lock index 6644cf1bd47..2974cdd7bf9 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "@better-auth/oauth-provider": "1.6.22", "@linear/sdk": "68.1.1", "@modelcontextprotocol/sdk": "1.30.0", + "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", "@octokit/rest": "22.0.1", "@octokit/webhooks": "14.2.0", @@ -1124,7 +1125,9 @@ "@anthropic-ai/sdk": "0.78.0", "@blaxel/core": "0.3.11", "@linear/sdk": "68.1.1", + "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", + "@slack/web-api": "7.15.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/email": "workspace:*", @@ -2259,6 +2262,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@notionhq/client": ["@notionhq/client@5.25.2", "", {}, "sha512-kbaCZxiTxuWS7nTP29s3q9qbfMGd4cyfjMMdpVr7vK7SuB+0C6zBAP1sw8Gx/HKa+giWGnReuFovamN696Qy8w=="], + "@nozbe/microfuzz": ["@nozbe/microfuzz@1.0.0", "", {}, "sha512-XKIg/guk+s1tkPTkHch9hfGOWgsKojT7BqSQddXTppOfVr3SWQhhTCqbgQaPTbppf9gc2kFeG0gpBZZ612UXHA=="], "@octokit/app": ["@octokit/app@16.1.2", "", { "dependencies": { "@octokit/auth-app": "^8.1.2", "@octokit/auth-unauthenticated": "^7.0.3", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ=="], diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 08e1db6a51c..30183aedc44 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -37,7 +37,9 @@ "@anthropic-ai/sdk": "0.78.0", "@blaxel/core": "0.3.11", "@linear/sdk": "68.1.1", + "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", + "@slack/web-api": "7.15.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/email": "workspace:*", diff --git a/packages/trpc/src/lib/integrations/notion/index.ts b/packages/trpc/src/lib/integrations/notion/index.ts index 727b25a39bb..f39e8b38d0f 100644 --- a/packages/trpc/src/lib/integrations/notion/index.ts +++ b/packages/trpc/src/lib/integrations/notion/index.ts @@ -1,17 +1,8 @@ export { - listDataSources, - listUsers, + isPermanentNotionError, mentionedUserIds, NOTION_VERSION, - NotionApiError, - type NotionComment, - type NotionDataSource, - type NotionPage, - type NotionUser, - notionRequest, + notionClient, pageTitle, plainText, - retrieveComment, - retrieveDataSource, - retrievePage, } from "../../../router/integration/notion/client"; diff --git a/packages/trpc/src/router/integration/notion/client.ts b/packages/trpc/src/router/integration/notion/client.ts index 025e9362b05..ad5d9e0be7b 100644 --- a/packages/trpc/src/router/integration/notion/client.ts +++ b/packages/trpc/src/router/integration/notion/client.ts @@ -1,215 +1,57 @@ +import { + type APIResponseError, + Client, + isHTTPResponseError, + type PageObjectResponse, + type RichTextItemResponse, + type UnknownHTTPResponseError, +} from "@notionhq/client"; + +/** The API version the SDK speaks; the OAuth exchange must send the same one. */ +export const NOTION_VERSION = Client.defaultNotionVersion; + /** - * The slice of Notion's REST API the integration uses. Plain fetch rather than - * the SDK: four endpoints, one header, and no dependency to keep in step with - * the API version pinned here. + * A client on one connection's token. No SDK retries: a stalled Notion + * response must not hold a webhook delivery open, and the sender retries a + * transient failure itself. */ - -const NOTION_API = "https://api.notion.com"; -/** Data sources became first-class objects in this version. */ -export const NOTION_VERSION = "2025-09-03"; -/** A stalled Notion response must not hold a webhook delivery open indefinitely. */ -const REQUEST_TIMEOUT_MS = 15_000; - -export class NotionApiError extends Error { - constructor( - readonly status: number, - readonly code: string, - message: string, - ) { - super(message); - this.name = "NotionApiError"; - } - - /** True when retrying will not help — the token, capability or entity is gone. */ - get permanent(): boolean { - return [400, 401, 403, 404].includes(this.status); - } +export function notionClient(accessToken: string): Client { + return new Client({ auth: accessToken, timeoutMs: 15_000, retry: false }); } -export async function notionRequest( - accessToken: string, - path: string, - init: { method?: "GET" | "POST"; body?: unknown } = {}, -): Promise { - const response = await fetch(`${NOTION_API}${path}`, { - method: init.method ?? "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "Notion-Version": NOTION_VERSION, - ...(init.body !== undefined - ? { "Content-Type": "application/json" } - : {}), - }, - body: init.body !== undefined ? JSON.stringify(init.body) : undefined, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - const error = (await response.json().catch(() => ({}))) as { - code?: string; - message?: string; - }; - throw new NotionApiError( - response.status, - error.code ?? "unknown", - error.message ?? `Notion API ${response.status}`, - ); - } - return (await response.json()) as T; +/** True when retrying will not help — the token, capability or entity is gone. */ +export function isPermanentNotionError( + error: unknown, +): error is APIResponseError | UnknownHTTPResponseError { + return ( + isHTTPResponseError(error) && [400, 401, 403, 404].includes(error.status) + ); } -export type NotionRichText = { - type: string; - plain_text?: string; - mention?: { type: string; user?: { id: string } }; -}; - -export type NotionUser = { - object: "user"; - id: string; - type?: "person" | "bot"; - name?: string | null; - avatar_url?: string | null; - person?: { email?: string }; -}; - -export type NotionComment = { - object: "comment"; - id: string; - parent: - | { type: "page_id"; page_id: string } - | { type: "block_id"; block_id: string }; - discussion_id: string; - created_time: string; - created_by: { object: "user"; id: string }; - rich_text: NotionRichText[]; - display_name?: { type: string; resolved_name: string | null }; -}; - -export type NotionParent = - | { type: "data_source_id"; data_source_id: string; database_id?: string } - | { type: "database_id"; database_id: string } - | { type: "page_id"; page_id: string } - | { type: "block_id"; block_id: string } - | { type: "workspace"; workspace: true }; - -export type NotionPage = { - object: "page"; - id: string; - parent: NotionParent; - url?: string; - properties?: Record< - string, - { type: string; title?: NotionRichText[] } | undefined - >; -}; - -export type NotionDataSource = { - object: "data_source"; - id: string; - title?: NotionRichText[]; - url?: string; - parent?: NotionParent; -}; - -type Paginated = { - results: T[]; - has_more: boolean; - next_cursor: string | null; -}; - -export function plainText(richText: NotionRichText[] | undefined): string { - return (richText ?? []).map((t) => t.plain_text ?? "").join(""); +export function plainText( + richText: RichTextItemResponse[] | undefined, +): string { + return (richText ?? []).map((t) => t.plain_text).join(""); } /** The user ids a comment @-mentions. Page and date mentions do not count. */ export function mentionedUserIds( - richText: NotionRichText[] | undefined, + richText: RichTextItemResponse[] | undefined, ): string[] { const ids = new Set(); for (const item of richText ?? []) { - if (item.type !== "mention") continue; - if (item.mention?.type !== "user") continue; - if (item.mention.user?.id) ids.add(item.mention.user.id); + if (item.type !== "mention" || item.mention.type !== "user") continue; + ids.add(item.mention.user.id); } return [...ids]; } -export function pageTitle(page: NotionPage): string | null { - for (const property of Object.values(page.properties ?? {})) { - if (property?.type === "title") { +export function pageTitle(page: PageObjectResponse): string | null { + for (const property of Object.values(page.properties)) { + if (property.type === "title") { const title = plainText(property.title); if (title) return title; } } return null; } - -export function retrieveComment(accessToken: string, commentId: string) { - return notionRequest( - accessToken, - `/v1/comments/${encodeURIComponent(commentId)}`, - ); -} - -export function retrievePage(accessToken: string, pageId: string) { - return notionRequest( - accessToken, - `/v1/pages/${encodeURIComponent(pageId)}`, - ); -} - -export function retrieveDataSource(accessToken: string, dataSourceId: string) { - return notionRequest( - accessToken, - `/v1/data_sources/${encodeURIComponent(dataSourceId)}`, - ); -} - -/** Bounds a picker list at this many pages of 100; more is not pickable one by one anyway. */ -const MAX_PAGES = 5; - -/** Every data source the connection can see, by title. */ -export async function listDataSources( - accessToken: string, -): Promise { - const results: NotionDataSource[] = []; - let cursor: string | undefined; - let pages = 0; - do { - const page = await notionRequest>( - accessToken, - "/v1/search", - { - method: "POST", - body: { - filter: { property: "object", value: "data_source" }, - page_size: 100, - ...(cursor ? { start_cursor: cursor } : {}), - }, - }, - ); - results.push(...page.results); - pages += 1; - cursor = page.has_more && page.next_cursor ? page.next_cursor : undefined; - } while (cursor && pages < MAX_PAGES); - return results; -} - -/** Every person in the workspace. Bots are left out: nobody filters on them. */ -export async function listUsers(accessToken: string): Promise { - const results: NotionUser[] = []; - let cursor: string | undefined; - let pages = 0; - do { - const query = new URLSearchParams({ page_size: "100" }); - if (cursor) query.set("start_cursor", cursor); - const page = await notionRequest>( - accessToken, - `/v1/users?${query}`, - ); - results.push(...page.results.filter((u) => u.type === "person")); - pages += 1; - cursor = page.has_more && page.next_cursor ? page.next_cursor : undefined; - } while (cursor && pages < MAX_PAGES); - return results; -} diff --git a/packages/trpc/src/router/integration/notion/notion.ts b/packages/trpc/src/router/integration/notion/notion.ts index a0c74938046..be61023253f 100644 --- a/packages/trpc/src/router/integration/notion/notion.ts +++ b/packages/trpc/src/router/integration/notion/notion.ts @@ -5,40 +5,6 @@ import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; -import { - listDataSources, - listUsers, - NotionApiError, - plainText, -} from "./client"; - -/** - * A picker with nothing in it beats a picker that errors: a token without the - * matching capability (or a personal token, which cannot list users) leaves - * the chip on its "anyone / me" entries rather than the whole editor red. - */ -async function listOrEmpty(list: () => Promise): Promise { - try { - return await list(); - } catch (error) { - if (error instanceof NotionApiError && error.permanent) { - console.warn("[integration.notion] list refused:", error.message); - return []; - } - throw error; - } -} - -async function activeConnection(organizationId: string) { - return db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "notion"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { id: true, accessToken: true, externalOrgName: true }, - }); -} export const notionRouter = { getConnection: protectedProcedure @@ -85,46 +51,4 @@ export const notionRouter = { return { success: true }; }), - - /** - * The data sources the workspace has shared with the integration, for the - * trigger editor's scope chip. Ids, not titles: a title can be renamed. - */ - listDataSources: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await activeConnection(input.organizationId); - if (!connection) return []; - - const dataSources = await listOrEmpty(() => - listDataSources(connection.accessToken), - ); - return dataSources.map((source) => ({ - id: source.id, - label: plainText(source.title) || "Untitled", - })); - }), - - /** - * The people in the connected workspace, for the actor and mention chips. - * Notion user ids, since that is what a comment's author and mentions carry. - */ - listPeople: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await activeConnection(input.organizationId); - if (!connection) return []; - - const users = await listOrEmpty(() => listUsers(connection.accessToken)); - return users - .map((user) => ({ - id: user.id, - label: user.name || user.person?.email || user.id, - })) - .sort((a, b) => a.label.localeCompare(b.label)); - }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/notion/trigger-options.ts b/packages/trpc/src/router/integration/notion/trigger-options.ts new file mode 100644 index 00000000000..1e5d481345e --- /dev/null +++ b/packages/trpc/src/router/integration/notion/trigger-options.ts @@ -0,0 +1,79 @@ +import { + type Client, + isFullDataSource, + isFullUser, + iteratePaginatedAPI, +} from "@notionhq/client"; +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import type { TriggerOption, TriggerOptionSource } from "../trigger-options"; +import { notionClient, plainText } from "./client"; + +/** Bounds a picker list; more is not pickable one by one anyway. */ +const MAX_OPTIONS = 500; + +/** + * A client on the organization's connection, or null when there is none. A + * token without the matching capability (or a personal token, which cannot + * list users) throws to the shared procedure, which shows an empty list — + * the chip keeps its "anyone / me" entries rather than the editor going red. + */ +async function connectedClient(organizationId: string): Promise { + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, organizationId), + eq(integrationConnections.provider, "notion"), + isNull(integrationConnections.disconnectedAt), + ), + columns: { accessToken: true }, + }); + return connection ? notionClient(connection.accessToken) : null; +} + +/** + * The data sources the workspace has shared with the integration. Ids, not + * titles: a title can be renamed. + */ +const dataSources: TriggerOptionSource = async ({ organizationId }) => { + const client = await connectedClient(organizationId); + if (!client) return []; + + const options: TriggerOption[] = []; + for await (const result of iteratePaginatedAPI(client.search, { + filter: { property: "object", value: "data_source" }, + page_size: 100, + })) { + if (!isFullDataSource(result)) continue; + options.push({ + id: result.id, + label: plainText(result.title) || "Untitled", + }); + if (options.length >= MAX_OPTIONS) break; + } + return options; +}; + +/** + * The people in the workspace, by Notion user id — what a comment's author + * and mentions carry. Bots are left out: nobody filters on them. + */ +const people: TriggerOptionSource = async ({ organizationId }) => { + const client = await connectedClient(organizationId); + if (!client) return []; + + const options: TriggerOption[] = []; + for await (const user of iteratePaginatedAPI(client.users.list, { + page_size: 100, + })) { + if (!isFullUser(user) || user.type !== "person") continue; + options.push({ + id: user.id, + label: user.name || user.person.email || user.id, + }); + if (options.length >= MAX_OPTIONS) break; + } + return options.sort((a, b) => a.label.localeCompare(b.label)); +}; + +export const notionTriggerOptions = { dataSources, people }; diff --git a/packages/trpc/src/router/integration/slack/list-channels.ts b/packages/trpc/src/router/integration/slack/list-channels.ts deleted file mode 100644 index 11a235cadb1..00000000000 --- a/packages/trpc/src/router/integration/slack/list-channels.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * The channels a Slack bot token can see, for the trigger editor's channel - * picker. - * - * A direct call rather than `@slack/web-api`: this package does not depend on - * it, and one paginated GET does not justify adding it. Public and private - * channels both — a private channel the bot has been invited to is where "a - * message in #incidents" most often means something. - */ - -type ConversationsListResponse = { - ok: boolean; - error?: string; - channels?: Array<{ id?: string; name?: string; is_archived?: boolean }>; - response_metadata?: { next_cursor?: string }; -}; - -/** Enough for any workspace this is likely to be pointed at; a hard stop, not a page size. */ -const MAX_PAGES = 10; - -export async function listSlackChannels( - accessToken: string, -): Promise> { - const channels: Array<{ id: string; name: string }> = []; - let cursor: string | undefined; - - for (let page = 0; page < MAX_PAGES; page++) { - const params = new URLSearchParams({ - types: "public_channel,private_channel", - exclude_archived: "true", - limit: "200", - }); - if (cursor) params.set("cursor", cursor); - - let data: ConversationsListResponse; - try { - const response = await fetch( - `https://slack.com/api/conversations.list?${params.toString()}`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - signal: AbortSignal.timeout(5_000), - }, - ); - data = (await response.json()) as ConversationsListResponse; - } catch (error) { - // Slack unreachable or answering with something other than JSON: the - // picker shows what was fetched so far rather than the query failing. - console.error("[slack/listChannels] conversations.list failed:", error); - return channels; - } - if (!data.ok) { - // `missing_scope` until the app is reinstalled with channels:read; an - // empty picker is the honest state, not an error the editor can act on. - console.error( - "[slack/listChannels] conversations.list failed:", - data.error, - ); - return channels; - } - for (const channel of data.channels ?? []) { - if (channel.id && channel.name) { - channels.push({ id: channel.id, name: channel.name }); - } - } - cursor = data.response_metadata?.next_cursor || undefined; - if (!cursor) break; - } - - return channels.sort((a, b) => a.name.localeCompare(b.name)); -} diff --git a/packages/trpc/src/router/integration/slack/list-people.ts b/packages/trpc/src/router/integration/slack/list-people.ts deleted file mode 100644 index 1ee0d77fea7..00000000000 --- a/packages/trpc/src/router/integration/slack/list-people.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * The workspace's human members, for the trigger editor's people picker. Keyed - * by Slack user id — what an event's `user` carries and the matcher compares - * against. Same direct-fetch shape as `listSlackChannels`. - */ - -type UsersListResponse = { - ok: boolean; - error?: string; - members?: Array<{ - id?: string; - name?: string; - real_name?: string; - deleted?: boolean; - is_bot?: boolean; - }>; - response_metadata?: { next_cursor?: string }; -}; - -const PAGE_SIZE = 200; -const MAX_PEOPLE = 1000; - -export async function listSlackPeople( - accessToken: string, -): Promise> { - const people: Array<{ id: string; label: string }> = []; - let cursor: string | undefined; - - while (people.length < MAX_PEOPLE) { - const params = new URLSearchParams({ limit: String(PAGE_SIZE) }); - if (cursor) params.set("cursor", cursor); - - let data: UsersListResponse; - try { - const response = await fetch( - `https://slack.com/api/users.list?${params.toString()}`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - signal: AbortSignal.timeout(5_000), - }, - ); - data = (await response.json()) as UsersListResponse; - } catch (error) { - console.error("[slack/listPeople] users.list failed:", error); - break; - } - if (!data.ok) { - // `missing_scope` until the app is reinstalled with users:read; an empty - // picker is the honest state, not an error the editor can act on. - console.error("[slack/listPeople] users.list failed:", data.error); - break; - } - for (const member of data.members ?? []) { - if (!member.id || member.deleted || member.is_bot) continue; - if (member.id === "USLACKBOT") continue; - const label = member.real_name || member.name; - if (label) people.push({ id: member.id, label }); - } - cursor = data.response_metadata?.next_cursor || undefined; - if (!cursor) break; - } - - return people.sort((a, b) => a.label.localeCompare(b.label)); -} diff --git a/packages/trpc/src/router/integration/slack/slack.ts b/packages/trpc/src/router/integration/slack/slack.ts index 38a36da112f..156f4ec8e30 100644 --- a/packages/trpc/src/router/integration/slack/slack.ts +++ b/packages/trpc/src/router/integration/slack/slack.ts @@ -1,12 +1,10 @@ import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; -import { listSlackChannels } from "./list-channels"; -import { listSlackPeople } from "./list-people"; export const slackRouter = { getConnection: protectedProcedure @@ -35,47 +33,6 @@ export const slackRouter = { }; }), - /** Channels the trigger editor can scope a Slack trigger to. */ - listChannels: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "slack"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { accessToken: true }, - }); - if (!connection) return []; - - return listSlackChannels(connection.accessToken); - }), - - /** - * The connected workspace's members, keyed by Slack user id — what the - * matcher compares an event's `user` against. - */ - listPeople: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "slack"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { accessToken: true }, - }); - if (!connection) return []; - - return listSlackPeople(connection.accessToken); - }), - disconnect: protectedProcedure .input(z.object({ organizationId: z.uuid() })) .mutation(async ({ ctx, input }) => { diff --git a/packages/trpc/src/router/integration/slack/trigger-options.ts b/packages/trpc/src/router/integration/slack/trigger-options.ts new file mode 100644 index 00000000000..84b58811ea2 --- /dev/null +++ b/packages/trpc/src/router/integration/slack/trigger-options.ts @@ -0,0 +1,90 @@ +import { + type ConversationsListResponse, + type UsersListResponse, + WebClient, +} from "@slack/web-api"; +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import type { TriggerOption, TriggerOptionSource } from "../trigger-options"; + +/** A hard stop, not a page size: enough for any workspace this is pointed at. */ +const MAX_CHANNELS = 2000; +const MAX_PEOPLE = 1000; + +/** + * The bot's own client, or null when the workspace is not connected. No + * retries and a short timeout: this answers a picker, and a `missing_scope` + * (until the app is reinstalled with the scope) throws to the shared + * procedure, which shows an empty list. + */ +async function slackClient(organizationId: string): Promise { + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, organizationId), + eq(integrationConnections.provider, "slack"), + isNull(integrationConnections.disconnectedAt), + ), + columns: { accessToken: true }, + }); + if (!connection) return null; + return new WebClient(connection.accessToken, { + timeout: 5_000, + retryConfig: { retries: 0 }, + }); +} + +function byLabel(options: TriggerOption[]): TriggerOption[] { + return options.sort((a, b) => a.label.localeCompare(b.label)); +} + +/** + * Public and private channels both — a private channel the bot has been + * invited to is where "a message in #incidents" most often means something. + */ +const channels: TriggerOptionSource = async ({ organizationId }) => { + const client = await slackClient(organizationId); + if (!client) return []; + + const options: TriggerOption[] = []; + const pages = client.paginate("conversations.list", { + types: "public_channel,private_channel", + exclude_archived: true, + limit: 200, + }) as AsyncIterable; + for await (const page of pages) { + for (const channel of page.channels ?? []) { + if (channel.id && channel.name) { + options.push({ id: channel.id, label: channel.name }); + } + } + if (options.length >= MAX_CHANNELS) break; + } + return byLabel(options); +}; + +/** + * The workspace's human members, keyed by Slack user id — what an event's + * `user` carries and the matcher compares against. + */ +const people: TriggerOptionSource = async ({ organizationId }) => { + const client = await slackClient(organizationId); + if (!client) return []; + + const options: TriggerOption[] = []; + const pages = client.paginate("users.list", { + limit: 200, + }) as AsyncIterable; + for await (const page of pages) { + for (const member of page.members ?? []) { + if (!member.id || member.deleted || member.is_bot) continue; + if (member.id === "USLACKBOT") continue; + const label = member.real_name || member.name; + if (label) options.push({ id: member.id, label }); + } + if (options.length >= MAX_PEOPLE) break; + } + return byLabel(options); +}; + +export const slackTriggerOptions = { channels, people }; diff --git a/packages/trpc/src/router/integration/trigger-options.ts b/packages/trpc/src/router/integration/trigger-options.ts index 2d2b5379197..8f4ec50f5f8 100644 --- a/packages/trpc/src/router/integration/trigger-options.ts +++ b/packages/trpc/src/router/integration/trigger-options.ts @@ -1,7 +1,9 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { z } from "zod"; import { protectedProcedure } from "../../trpc"; +import { notionTriggerOptions } from "./notion/trigger-options"; import { sentryTriggerOptions } from "./sentry/trigger-options"; +import { slackTriggerOptions } from "./slack/trigger-options"; import { verifyOrgMembership } from "./utils"; /** One selectable value in a scope or actor chip. */ @@ -32,7 +34,9 @@ export const triggerOptionSources: Record< string, Record > = { + notion: notionTriggerOptions, sentry: sentryTriggerOptions, + slack: slackTriggerOptions, }; export const triggerOptionsRouter = { From 22fa22c3b7aa0b75b4489d01f4723c547cfe5fc3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 13:04:09 -0700 Subject: [PATCH 07/15] refactor(api,trpc): every provider on the shared tail; dispatchInput required --- apps/api/src/lib/automations/recordAutomationEvent.ts | 9 ++++----- packages/trpc/src/router/integration/trigger-options.ts | 1 - 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/api/src/lib/automations/recordAutomationEvent.ts b/apps/api/src/lib/automations/recordAutomationEvent.ts index 15576d181a2..15f6e2dd6ca 100644 --- a/apps/api/src/lib/automations/recordAutomationEvent.ts +++ b/apps/api/src/lib/automations/recordAutomationEvent.ts @@ -24,10 +24,9 @@ export type AutomationEventInput = { /** * What matching needs, stored so a failed QStash handoff can be retried by * the sweep. Null when the delivery names no product event: it is kept for - * the record and never dispatched. Callers that still dispatch on their own - * leave it unset; the sweep never touches those rows. + * the record and never dispatched. */ - dispatchInput?: AutomationEventDispatchInput | null; + dispatchInput: AutomationEventDispatchInput | null; }; /** @@ -61,9 +60,9 @@ export async function recordAutomationEvent( // throw and lose the event. payload: stripNullChars(input.payload) as Record, webhookEventId: input.webhookEventId ?? null, - dispatchInput: input.dispatchInput ?? null, + dispatchInput: input.dispatchInput, // Nothing to dispatch is dispatched. - dispatchedAt: input.dispatchInput === null ? new Date() : null, + dispatchedAt: input.dispatchInput ? null : new Date(), }) .onConflictDoNothing({ target: [ diff --git a/packages/trpc/src/router/integration/trigger-options.ts b/packages/trpc/src/router/integration/trigger-options.ts index 83a4b7259d6..e07fd9c545c 100644 --- a/packages/trpc/src/router/integration/trigger-options.ts +++ b/packages/trpc/src/router/integration/trigger-options.ts @@ -44,7 +44,6 @@ export const triggerOptionSources: Record< microsoftTeams: microsoftTeamsTriggerOptions, google: googleTriggerOptions, notion: notionTriggerOptions, - sentry: sentryTriggerOptions, slack: slackTriggerOptions, }; From ca56ad069bd0e7a9c8d00539658ac392ad5a444e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 15:51:34 -0700 Subject: [PATCH 08/15] refactor(automations): drop per-trigger enabled; tie the kind enum to shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing ever disabled a trigger except the evaluator retiring an exhausted recurrence, so the second switch was dead surface: the draft schema, six query predicates, a 409, and a 401-on-disabled quirk all guarded a state the product cannot produce. Retirement is now nextRunAt: null (the dispatcher already skips nulls) and the exhausted row's "Ran … ago" label is computed from the rrule (new lastOccurrenceBefore). The column stays default-true until a follow-up drop so a rolling deploy never selects a missing column. automationTriggerKindValues now satisfies the shared config kinds and the reverse is a type error, which is what let the dispatcher's "event.provider as never" cast go. --- .../src/app/api/automations/evaluate/route.ts | 5 +- .../webhook/[automationId]/route.ts | 1 - .../circleback/webhook/[triggerId]/route.ts | 4 -- .../google/jobs/sweep-schedules/route.ts | 1 - .../google/lib/scheduleCalendarFires.ts | 1 - .../automations/dispatchMatchingTriggers.ts | 3 +- .../components/TriggersCard/TriggersCard.tsx | 50 +++++++++++-------- .../TriggersEditor/TriggersEditor.tsx | 3 +- packages/db/src/schema/enums.ts | 18 ++++++- packages/db/src/schema/schema.ts | 3 +- packages/shared/src/automation-triggers.ts | 1 - packages/shared/src/rrule.ts | 18 +++++++ .../trpc/src/router/automation/automation.ts | 1 - .../trpc/src/router/automation/triggerSet.ts | 3 +- 14 files changed, 71 insertions(+), 41 deletions(-) diff --git a/apps/api/src/app/api/automations/evaluate/route.ts b/apps/api/src/app/api/automations/evaluate/route.ts index 062be421e25..69a4eb35205 100644 --- a/apps/api/src/app/api/automations/evaluate/route.ts +++ b/apps/api/src/app/api/automations/evaluate/route.ts @@ -62,7 +62,6 @@ export async function POST(request: Request): Promise { .where( and( eq(automationTriggers.kind, "schedule"), - eq(automationTriggers.enabled, true), eq(automations.enabled, true), lte(automationTriggers.nextRunAt, now), ), @@ -139,7 +138,9 @@ export async function POST(request: Request): Promise { planned.map(async ({ triggerId, next }) => { await dbWs .update(automationTriggers) - .set(next ? { nextRunAt: next } : { enabled: false }) + // A null next retires the trigger: the select above only reads + // rows whose next_run_at is set. + .set({ nextRunAt: next }) .where(eq(automationTriggers.id, triggerId)); }), ); diff --git a/apps/api/src/app/api/automations/webhook/[automationId]/route.ts b/apps/api/src/app/api/automations/webhook/[automationId]/route.ts index 7d4ab044853..dfa45b81444 100644 --- a/apps/api/src/app/api/automations/webhook/[automationId]/route.ts +++ b/apps/api/src/app/api/automations/webhook/[automationId]/route.ts @@ -80,7 +80,6 @@ export async function POST( and( eq(automationTriggers.automationId, automationId), eq(automationTriggers.kind, "webhook"), - eq(automationTriggers.enabled, true), ), ); diff --git a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts b/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts index 4805c83f829..aff969194a9 100644 --- a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts +++ b/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts @@ -53,7 +53,6 @@ export async function POST( // For an HMAC provider the column holds the signing key itself — a // hash could not verify a signature. secret: automationTriggers.secretHash, - triggerEnabled: automationTriggers.enabled, automationEnabled: automations.enabled, }) .from(automationTriggers) @@ -102,9 +101,6 @@ export async function POST( if (!trigger.automationEnabled) { return Response.json({ error: "Automation is disabled" }, { status: 400 }); } - if (!trigger.triggerEnabled) { - return Response.json({ error: "Trigger is disabled" }, { status: 409 }); - } const json = parseJson(body); if (json instanceof Response) return json; diff --git a/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts b/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts index b36f77bf5ec..32160e45b19 100644 --- a/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts +++ b/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts @@ -42,7 +42,6 @@ export async function POST(request: Request) { .where( and( eq(automationTriggers.kind, "google_calendar"), - eq(automationTriggers.enabled, true), eq(automations.enabled, true), inArray(sql`${automationTriggers.config}->>'event'`, [ "event.starting_soon", diff --git a/apps/api/src/app/api/integrations/google/lib/scheduleCalendarFires.ts b/apps/api/src/app/api/integrations/google/lib/scheduleCalendarFires.ts index cb4492bcbf1..b332edb8fbd 100644 --- a/apps/api/src/app/api/integrations/google/lib/scheduleCalendarFires.ts +++ b/apps/api/src/app/api/integrations/google/lib/scheduleCalendarFires.ts @@ -52,7 +52,6 @@ export async function loadFirePlan( eq(automationTriggers.organizationId, organizationId), eq(automations.ownerUserId, ownerUserId), eq(automationTriggers.kind, "google_calendar"), - eq(automationTriggers.enabled, true), eq(automations.enabled, true), inArray(sql`${automationTriggers.config}->>'event'`, [ "event.starting_soon", diff --git a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts index cf0abedbdb7..1d03d418417 100644 --- a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts +++ b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts @@ -65,8 +65,7 @@ export async function dispatchMatchingTriggers(params: { // The kind enum and the provider discriminant share values by // construction; a provider whose kind name differed would need a // map here, and none does. - eq(automationTriggers.kind, event.provider as never), - eq(automationTriggers.enabled, true), + eq(automationTriggers.kind, event.provider), eq(automations.enabled, true), params.automationId ? eq(automations.id, params.automationId) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx index 8478e1c3051..c6a8c31e858 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx @@ -1,6 +1,7 @@ import type { DraftTrigger } from "@superset/shared/automation-triggers"; import { formatDateTimeInTimezone, + lastOccurrenceBefore, nextOccurrenceAfter, } from "@superset/shared/rrule"; import type { RouterOutputs } from "@superset/trpc"; @@ -62,32 +63,38 @@ export function TriggersCard({ const config = trigger.config as DraftTrigger["config"]; if (config.kind !== "schedule") continue; - // The evaluator disables a schedule once its last occurrence fires - // (COUNT/UNTIL) and leaves nextRunAt at that final time — so a - // disabled trigger's nextRunAt is when it last ran, not when it will. - if (automation.enabled && trigger.nextRunAt) { - entries.set(trigger.id, { - at: new Date(trigger.nextRunAt), - timezone: config.timezone, - exhausted: !trigger.enabled, - }); - continue; - } - // A paused automation keeps a stale nextRunAt, so compute what this - // schedule would fire next — the row is previewable before resuming. try { - const next = nextOccurrenceAfter({ + const schedule = { rrule: config.rrule, dtstart: new Date(config.dtstart), timezone: config.timezone, - after: new Date(), - }); - if (next) - entries.set(trigger.id, { - at: next, - timezone: config.timezone, - exhausted: false, + }; + const next = nextOccurrenceAfter({ ...schedule, after: new Date() }); + // An exhausted recurrence (a run-once that ran) has no future + // occurrence; its row shows when it last fired instead. + if (!next) { + const last = lastOccurrenceBefore({ + ...schedule, + before: new Date(), }); + if (last) + entries.set(trigger.id, { + at: last, + timezone: config.timezone, + exhausted: true, + }); + continue; + } + // The saved nextRunAt is the dispatcher's truth; the computed one + // covers a paused automation, whose stored value is stale. + entries.set(trigger.id, { + at: + automation.enabled && trigger.nextRunAt + ? new Date(trigger.nextRunAt) + : next, + timezone: config.timezone, + exhausted: false, + }); } catch (error) { console.warn( `[TriggersCard] failed to compute next occurrence for trigger ${trigger.id}`, @@ -134,7 +141,6 @@ export function TriggersCard({ ({ id: t.id, - enabled: t.enabled, config: t.config as DraftTrigger["config"], }))} onChange={onSaveTriggers} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx index e8ac7704e18..9a2405c09d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx @@ -130,8 +130,7 @@ export function TriggersEditor({ setSubmitted(false); }; - const add = (config: DraftTrigger["config"]) => - edit([...drafts, { enabled: true, config }]); + const add = (config: DraftTrigger["config"]) => edit([...drafts, { config }]); return (
diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index 771baa03ad9..62387a29712 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -1,3 +1,4 @@ +import type { TriggerConfigInput } from "@superset/shared/automation-triggers"; import { z } from "zod"; export const taskStatusEnumValues = [ @@ -99,6 +100,11 @@ export const automationPromptSourceValues = [ export const automationPromptSourceEnum = z.enum(automationPromptSourceValues); export type AutomationPromptSource = z.infer; +/** + * Must list exactly the config kinds in `@superset/shared/automation-triggers` + * — the `satisfies` below and the `_EveryKindHasEnumValue` check make either + * direction of drift a compile error instead of a runtime cast. + */ export const automationTriggerKindValues = [ "schedule", "webhook", @@ -113,7 +119,17 @@ export const automationTriggerKindValues = [ "gmail", "notion", "circleback", -] as const; +] as const satisfies readonly TriggerConfigInput["kind"][]; + +export type _EveryKindHasEnumValue = [ + Exclude< + TriggerConfigInput["kind"], + (typeof automationTriggerKindValues)[number] + >, +] extends [never] + ? true + : never; + export const automationTriggerKindEnum = z.enum(automationTriggerKindValues); export type AutomationTriggerKind = z.infer; diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index 7a657365a69..45356469c3f 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -939,7 +939,8 @@ export const automationEvents = pgTable( // matchable event plus any automation/trigger narrowing — so a delivery // whose QStash publish failed can be re-dispatched without the provider's // normalizer. Null only for rows written before this existed. - dispatchInput: jsonb("dispatch_input").$type(), + dispatchInput: + jsonb("dispatch_input").$type(), // Set once every matched run has been handed to QStash (or nothing // matched). Null past a grace period means the handoff failed and the // sweep should retry it. diff --git a/packages/shared/src/automation-triggers.ts b/packages/shared/src/automation-triggers.ts index bfce161e78b..16496aba620 100644 --- a/packages/shared/src/automation-triggers.ts +++ b/packages/shared/src/automation-triggers.ts @@ -403,7 +403,6 @@ export const draftTriggerSchema = z.object({ // a save updates in place rather than deleting and recreating, which would // otherwise roll a webhook trigger's key and lose a schedule's next run. id: z.string().uuid().optional(), - enabled: z.boolean().default(true), config: z.union([ scheduleTriggerConfigSchema, webhookTriggerConfigSchema, diff --git a/packages/shared/src/rrule.ts b/packages/shared/src/rrule.ts index 06e0543fd57..dfbf34db23f 100644 --- a/packages/shared/src/rrule.ts +++ b/packages/shared/src/rrule.ts @@ -369,6 +369,24 @@ export function nextOccurrenceAfter(args: { return next ? rruleDateToUtc(next, args.timezone) : null; } +/** + * The most recent occurrence at or before `before`, or null when the + * recurrence has not started yet — how an exhausted schedule's last fire is + * recovered from the rule alone. + */ +export function lastOccurrenceBefore(args: { + rrule: string; + dtstart: Date; + timezone: string; + before: Date; +}): Date | null { + const rule = RRule.fromString( + buildRuleString(args.rrule, args.dtstart, args.timezone), + ); + const last = rule.before(utcToRruleDate(args.before, args.timezone), true); + return last ? rruleDateToUtc(last, args.timezone) : null; +} + /** Parses + validates an RRule body, returning the next occurrence. */ export function parseRrule(args: { rrule: string; diff --git a/packages/trpc/src/router/automation/automation.ts b/packages/trpc/src/router/automation/automation.ts index ab3617356eb..e9d5d13cb04 100644 --- a/packages/trpc/src/router/automation/automation.ts +++ b/packages/trpc/src/router/automation/automation.ts @@ -261,7 +261,6 @@ export const automationRouter = { id: automationTriggers.id, kind: automationTriggers.kind, config: automationTriggers.config, - enabled: automationTriggers.enabled, nextRunAt: automationTriggers.nextRunAt, secretPrefix: automationTriggers.secretPrefix, secretRotatedAt: automationTriggers.secretRotatedAt, diff --git a/packages/trpc/src/router/automation/triggerSet.ts b/packages/trpc/src/router/automation/triggerSet.ts index a7e835098d8..5e977f584e6 100644 --- a/packages/trpc/src/router/automation/triggerSet.ts +++ b/packages/trpc/src/router/automation/triggerSet.ts @@ -103,7 +103,7 @@ export async function saveTriggerSet( const [row] = await tx .update(automationTriggers) - .set({ config, enabled: trigger.enabled, nextRunAt }) + .set({ config, nextRunAt }) .where(eq(automationTriggers.id, previous.id)) .returning({ id: automationTriggers.id }); if (row) saved.push(row.id); @@ -117,7 +117,6 @@ export async function saveTriggerSet( organizationId: params.organizationId, kind: config.kind, config, - enabled: trigger.enabled, nextRunAt: nextRunAtFor(trigger.config), }) .returning({ id: automationTriggers.id }); From ef0df77af33ad37aee20c69fedff02ef7e85b408 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 16:10:18 -0700 Subject: [PATCH 09/15] =?UTF-8?q?refactor(automations):=20schedules=20are?= =?UTF-8?q?=20repeating=20only=20=E2=80=94=20no=20finite=20rules,=20no=20r?= =?UTF-8?q?etirement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standing decision: one-time triggers are not a feature. A schedule rrule carrying COUNT or UNTIL is refused at save (shared validation, so editor and API agree), which is what lets the retirement machinery go entirely: the evaluator's advance just writes the next occurrence, the exhausted display machinery is gone, and automation_triggers.enabled is dropped — migration 0086 — since nothing can produce a disabled trigger. --- .../src/app/api/automations/evaluate/route.ts | 5 +- .../components/TriggersCard/TriggersCard.tsx | 52 +- .../db/drizzle/0086_drop_trigger_enabled.sql | 5 + packages/db/drizzle/meta/0086_snapshot.json | 7011 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/schema.ts | 7 +- packages/shared/src/automation-triggers.ts | 9 + packages/shared/src/rrule.ts | 19 +- .../trpc/src/router/automation/automation.ts | 2 - .../trpc/src/router/automation/helpers.ts | 4 +- 10 files changed, 7058 insertions(+), 63 deletions(-) create mode 100644 packages/db/drizzle/0086_drop_trigger_enabled.sql create mode 100644 packages/db/drizzle/meta/0086_snapshot.json diff --git a/apps/api/src/app/api/automations/evaluate/route.ts b/apps/api/src/app/api/automations/evaluate/route.ts index 69a4eb35205..bad8fc5474b 100644 --- a/apps/api/src/app/api/automations/evaluate/route.ts +++ b/apps/api/src/app/api/automations/evaluate/route.ts @@ -138,8 +138,9 @@ export async function POST(request: Request): Promise { planned.map(async ({ triggerId, next }) => { await dbWs .update(automationTriggers) - // A null next retires the trigger: the select above only reads - // rows whose next_run_at is set. + // Always a date for rules the product accepts (finite recurrences + // are refused at save); legacy finite data writes null and simply + // leaves the select, which reads only set next_run_at. .set({ nextRunAt: next }) .where(eq(automationTriggers.id, triggerId)); }), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx index c6a8c31e858..a56c934281c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx @@ -1,7 +1,6 @@ import type { DraftTrigger } from "@superset/shared/automation-triggers"; import { formatDateTimeInTimezone, - lastOccurrenceBefore, nextOccurrenceAfter, } from "@superset/shared/rrule"; import type { RouterOutputs } from "@superset/trpc"; @@ -54,47 +53,30 @@ export function TriggersCard({ // The zone travels with the date: schedules on one automation can sit in // different timezones, so formatting them all in the automation-level one // would label the tooltip wrongly for every schedule but the soonest. - const entries = new Map< - string, - { at: Date; timezone: string; exhausted: boolean } - >(); + const entries = new Map(); for (const trigger of automation.triggers) { const config = trigger.config as DraftTrigger["config"]; if (config.kind !== "schedule") continue; + // The saved nextRunAt is the dispatcher's truth; the computed one + // covers a paused automation, whose stored value is stale. + if (automation.enabled && trigger.nextRunAt) { + entries.set(trigger.id, { + at: new Date(trigger.nextRunAt), + timezone: config.timezone, + }); + continue; + } try { - const schedule = { + const next = nextOccurrenceAfter({ rrule: config.rrule, dtstart: new Date(config.dtstart), timezone: config.timezone, - }; - const next = nextOccurrenceAfter({ ...schedule, after: new Date() }); - // An exhausted recurrence (a run-once that ran) has no future - // occurrence; its row shows when it last fired instead. - if (!next) { - const last = lastOccurrenceBefore({ - ...schedule, - before: new Date(), - }); - if (last) - entries.set(trigger.id, { - at: last, - timezone: config.timezone, - exhausted: true, - }); - continue; - } - // The saved nextRunAt is the dispatcher's truth; the computed one - // covers a paused automation, whose stored value is stale. - entries.set(trigger.id, { - at: - automation.enabled && trigger.nextRunAt - ? new Date(trigger.nextRunAt) - : next, - timezone: config.timezone, - exhausted: false, + after: new Date(), }); + if (next) + entries.set(trigger.id, { at: next, timezone: config.timezone }); } catch (error) { console.warn( `[TriggersCard] failed to compute next occurrence for trigger ${trigger.id}`, @@ -121,11 +103,7 @@ export function TriggersCard({ - {run.exhausted - ? "Ran " - : automation.enabled - ? "Next run " - : "Would run "} + {automation.enabled ? "Next run " : "Would run "} {formatDistanceStrict(run.at, new Date(), { addSuffix: true })} diff --git a/packages/db/drizzle/0086_drop_trigger_enabled.sql b/packages/db/drizzle/0086_drop_trigger_enabled.sql new file mode 100644 index 00000000000..48b18bd3e0d --- /dev/null +++ b/packages/db/drizzle/0086_drop_trigger_enabled.sql @@ -0,0 +1,5 @@ +DROP INDEX "automation_triggers_dispatcher_idx";--> statement-breakpoint +DROP INDEX "automation_triggers_matcher_idx";--> statement-breakpoint +CREATE INDEX "automation_triggers_dispatcher_idx" ON "automation_triggers" USING btree ("next_run_at") WHERE kind = 'schedule';--> statement-breakpoint +CREATE INDEX "automation_triggers_matcher_idx" ON "automation_triggers" USING btree ("organization_id","kind");--> statement-breakpoint +ALTER TABLE "automation_triggers" DROP COLUMN "enabled"; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0086_snapshot.json b/packages/db/drizzle/meta/0086_snapshot.json new file mode 100644 index 00000000000..90ead4d7505 --- /dev/null +++ b/packages/db/drizzle/meta/0086_snapshot.json @@ -0,0 +1,7011 @@ +{ + "id": "1c2a32c1-067a-4ed4-86ef-d293adda0693", + "prevId": "cf1c8fe2-7680-4b18-87a0-4b9e24039f50", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n\t\t\t\tWHEN metadata IS NULL OR metadata = '' THEN NULL\n\t\t\t\tWHEN NOT (metadata IS JSON OBJECT) THEN NULL\n\t\t\t\tWHEN (metadata::jsonb->>'organizationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'\n\t\t\t\t\tTHEN (metadata::jsonb->>'organizationId')::uuid\n\t\t\t\tELSE NULL\n\t\t\tEND", + "type": "stored" + } + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_organization_id_idx": { + "name": "apikeys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_metadata_trgm_idx": { + "name": "apikeys_metadata_trgm_idx", + "columns": [ + { + "expression": "\"metadata\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.team_members": { + "name": "team_members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_user_id_idx": { + "name": "team_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_organization_id_idx": { + "name": "team_members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_team_user_unique": { + "name": "team_members_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_organization_id_organizations_id_fk": { + "name": "team_members_organization_id_organizations_id_fk", + "tableFrom": "team_members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.teams": { + "name": "teams", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_org_slug_unique": { + "name": "teams_org_slug_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletion_requested_at": { + "name": "deletion_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_organization_ids_idx": { + "name": "users_organization_ids_idx", + "columns": [ + { + "expression": "organization_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_events": { + "name": "automation_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "integration_connection_id": { + "name": "integration_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_login": { + "name": "actor_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_is_external": { + "name": "actor_is_external", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "webhook_event_id": { + "name": "webhook_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatch_input": { + "name": "dispatch_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_events_undispatched_idx": { + "name": "automation_events_undispatched_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"automation_events\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_org_received_idx": { + "name": "automation_events_org_received_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_resource_idx": { + "name": "automation_events_resource_idx", + "columns": [ + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_events_organization_id_organizations_id_fk": { + "name": "automation_events_organization_id_organizations_id_fk", + "tableFrom": "automation_events", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_events_integration_connection_id_integration_connections_id_fk": { + "name": "automation_events_integration_connection_id_integration_connections_id_fk", + "tableFrom": "automation_events", + "tableTo": "integration_connections", + "columnsFrom": [ + "integration_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automation_events_dedup_unique": { + "name": "automation_events_dedup_unique", + "nullsNotDistinct": true, + "columns": [ + "integration_connection_id", + "provider", + "external_event_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_prompt_versions": { + "name": "automation_prompt_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_bucket": { + "name": "window_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "automation_prompt_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "restored_from_version_id": { + "name": "restored_from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_prompt_versions_bucket_uniq": { + "name": "automation_prompt_versions_bucket_uniq", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"automation_prompt_versions\".\"source\" <> 'restore'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_prompt_versions_automation_idx": { + "name": "automation_prompt_versions_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_prompt_versions_automation_id_automations_id_fk": { + "name": "automation_prompt_versions_automation_id_automations_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_author_user_id_users_id_fk": { + "name": "automation_prompt_versions_author_user_id_users_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_restored_from_version_id_fk": { + "name": "automation_prompt_versions_restored_from_version_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automation_prompt_versions", + "columnsFrom": [ + "restored_from_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_kind": { + "name": "session_kind", + "type": "automation_session_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terminal_session_id": { + "name": "terminal_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_runs_schedule_dedup_idx": { + "name": "automation_runs_schedule_dedup_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "scheduled_for IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_event_dedup_idx": { + "name": "automation_runs_event_dedup_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "event_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_inflight_resource_idx": { + "name": "automation_runs_inflight_resource_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status IN ('dispatching', 'dispatched')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_history_idx": { + "name": "automation_runs_history_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_status_idx": { + "name": "automation_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_workspace_idx": { + "name": "automation_runs_workspace_idx", + "columns": [ + { + "expression": "v2_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_organization_id_organizations_id_fk": { + "name": "automation_runs_organization_id_organizations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_trigger_id_automation_triggers_id_fk": { + "name": "automation_runs_trigger_id_automation_triggers_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automation_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_event_id_automation_events_id_fk": { + "name": "automation_runs_event_id_automation_events_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automation_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_chat_session_id_chat_sessions_id_fk": { + "name": "automation_runs_chat_session_id_chat_sessions_id_fk", + "tableFrom": "automation_runs", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_automation_org_fk": { + "name": "automation_runs_automation_org_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_triggers": { + "name": "automation_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "automation_trigger_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_prefix": { + "name": "secret_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_triggers_dispatcher_idx": { + "name": "automation_triggers_dispatcher_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "kind = 'schedule'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_matcher_idx": { + "name": "automation_triggers_matcher_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_automation_idx": { + "name": "automation_triggers_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_schedule_idx": { + "name": "automation_triggers_schedule_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "kind = 'schedule'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_triggers_organization_id_organizations_id_fk": { + "name": "automation_triggers_organization_id_organizations_id_fk", + "tableFrom": "automation_triggers", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_triggers_automation_org_fk": { + "name": "automation_triggers_automation_org_fk", + "tableFrom": "automation_triggers", + "tableTo": "automations", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "automation_triggers_kind_matches_config": { + "name": "automation_triggers_kind_matches_config", + "value": "config->>'kind' = kind::text" + } + }, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_host_id": { + "name": "target_host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_project_id": { + "name": "v2_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automations_owner_idx": { + "name": "automations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_organization_idx": { + "name": "automations_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_organization_id_organizations_id_fk": { + "name": "automations_organization_id_organizations_id_fk", + "tableFrom": "automations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_owner_user_id_users_id_fk": { + "name": "automations_owner_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automations_id_org_unique": { + "name": "automations_id_org_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blob_pathname": { + "name": "blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_attachments_session_idx": { + "name": "chat_attachments_session_idx", + "columns": [ + { + "expression": "chat_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_attachments_created_by_idx": { + "name": "chat_attachments_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_attachments_chat_session_id_chat_sessions_id_fk": { + "name": "chat_attachments_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_created_by_users_id_fk": { + "name": "chat_attachments_created_by_users_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspaces": { + "name": "cloud_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blaxel'" + }, + "provider_sandbox_id": { + "name": "provider_sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_url": { + "name": "sandbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "cloud_workspace_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloud_workspaces_organization_id_idx": { + "name": "cloud_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloud_workspaces_project_id_idx": { + "name": "cloud_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_workspaces_organization_id_organizations_id_fk": { + "name": "cloud_workspaces_organization_id_organizations_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspaces_project_id_v2_projects_id_fk": { + "name": "cloud_workspaces_project_id_v2_projects_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "v2_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspaces_created_by_user_id_users_id_fk": { + "name": "cloud_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cloud_workspaces_provider_sandbox_id_unique": { + "name": "cloud_workspaces_provider_sandbox_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_sandbox_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.desktop_notices": { + "name": "desktop_notices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "severity": { + "name": "severity", + "type": "desktop_notice_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "desktop_notice_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'immediate'" + }, + "min_version": { + "name": "min_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_version": { + "name": "max_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platforms": { + "name": "platforms", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "channels": { + "name": "channels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta_label": { + "name": "cta_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cta_action": { + "name": "cta_action", + "type": "desktop_notice_cta_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cta_url": { + "name": "cta_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissible": { + "name": "dismissible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "desktop_notices_active_idx": { + "name": "desktop_notices_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_provider_unique": { + "name": "integration_connections_org_provider_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" <> 'google'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_google_user_unique": { + "name": "integration_connections_google_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'google'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_slack_external_org_active_unique": { + "name": "integration_connections_slack_external_org_active_unique", + "columns": [ + { + "expression": "external_org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'slack' AND \"integration_connections\".\"disconnected_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submitted_prompts": { + "name": "submitted_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt_text": { + "name": "prompt_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submitter_name": { + "name": "submitter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submitted_prompts_user_id_idx": { + "name": "submitted_prompts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_organization_id_idx": { + "name": "submitted_prompts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_created_at_idx": { + "name": "submitted_prompts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submitted_prompts_user_id_users_id_fk": { + "name": "submitted_prompts_user_id_users_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submitted_prompts_organization_id_organizations_id_fk": { + "name": "submitted_prompts_organization_id_organizations_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_project_id": { + "name": "external_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_project_name": { + "name": "external_project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_id": { + "name": "external_cycle_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_name": { + "name": "external_cycle_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_project_id_idx": { + "name": "tasks_external_project_id_idx", + "columns": [ + { + "expression": "external_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_project_name_idx": { + "name": "tasks_external_project_name_idx", + "columns": [ + { + "expression": "external_project_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_cycle_id_idx": { + "name": "tasks_external_cycle_id_idx", + "columns": [ + { + "expression": "external_cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_scope_id": { + "name": "external_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_identities_user_idx": { + "name": "user_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_identities_org_provider_idx": { + "name": "user_identities_org_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_identities_organization_id_organizations_id_fk": { + "name": "user_identities_organization_id_organizations_id_fk", + "tableFrom": "user_identities", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_identities_account_unique": { + "name": "user_identities_account_unique", + "nullsNotDistinct": true, + "columns": [ + "organization_id", + "provider", + "external_scope_id", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_clients_organization_id_user_id_machine_id_pk": { + "name": "v2_clients_organization_id_user_id_machine_id_pk", + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_online": { + "name": "is_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "wake_command": { + "name": "wake_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_hosts_organization_id_machine_id_pk": { + "name": "v2_hosts_organization_id_machine_id_pk", + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_clone_url": { + "name": "repo_clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_host_fk": { + "name": "v2_users_hosts_host_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_users_hosts_organization_id_user_id_host_id_pk": { + "name": "v2_users_hosts_organization_id_user_id_host_id_pk", + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'worktree'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_task_id_idx": { + "name": "v2_workspaces_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_one_main_per_host": { + "name": "v2_workspaces_one_main_per_host", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"v2_workspaces\".\"type\" = 'main'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_task_id_tasks_id_fk": { + "name": "v2_workspaces_task_id_tasks_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_host_fk": { + "name": "v2_workspaces_host_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.automation_prompt_source": { + "name": "automation_prompt_source", + "schema": "public", + "values": [ + "human", + "agent", + "restore" + ] + }, + "public.automation_run_status": { + "name": "automation_run_status", + "schema": "public", + "values": [ + "dispatching", + "dispatched", + "skipped_offline", + "dispatch_failed", + "debounced", + "rejected" + ] + }, + "public.automation_session_kind": { + "name": "automation_session_kind", + "schema": "public", + "values": [ + "chat", + "terminal" + ] + }, + "public.automation_trigger_kind": { + "name": "automation_trigger_kind", + "schema": "public", + "values": [ + "schedule", + "webhook", + "github", + "slack", + "linear", + "sentry", + "microsoft_teams", + "google_calendar", + "gmail", + "notion", + "circleback" + ] + }, + "public.cloud_workspace_status": { + "name": "cloud_workspace_status", + "schema": "public", + "values": [ + "provisioning", + "ready", + "failed", + "deleted" + ] + }, + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.desktop_notice_cta_action": { + "name": "desktop_notice_cta_action", + "schema": "public", + "values": [ + "install-update", + "open-url" + ] + }, + "public.desktop_notice_severity": { + "name": "desktop_notice_severity", + "schema": "public", + "values": [ + "info", + "warning", + "blocking" + ] + }, + "public.desktop_notice_trigger": { + "name": "desktop_notice_trigger", + "schema": "public", + "values": [ + "immediate", + "pre-update", + "post-update" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack", + "sentry", + "microsoft_teams", + "google", + "notion" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.v2_workspace_type": { + "name": "v2_workspace_type", + "schema": "public", + "values": [ + "main", + "worktree" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ab6a61f762c..01a5238340c 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -603,6 +603,13 @@ "when": 1787079112721, "tag": "0085_automation_events_dispatch_state", "breakpoints": true + }, + { + "idx": 86, + "version": "7", + "when": 1787094486824, + "tag": "0086_drop_trigger_enabled", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index 45356469c3f..5d64b56ad7a 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -842,7 +842,6 @@ export const automationTriggers = pgTable( kind: automationTriggerKind().notNull(), config: jsonb().$type().notNull(), - enabled: boolean().notNull().default(true), // Schedule kind only. A column rather than config because the dispatcher // indexes and sorts on it. @@ -874,11 +873,9 @@ export const automationTriggers = pgTable( sql`config->>'kind' = kind::text`, ), index("automation_triggers_dispatcher_idx") - .on(t.enabled, t.nextRunAt) + .on(t.nextRunAt) .where(sql`kind = 'schedule'`), - index("automation_triggers_matcher_idx") - .on(t.organizationId, t.kind) - .where(sql`enabled`), + index("automation_triggers_matcher_idx").on(t.organizationId, t.kind), index("automation_triggers_automation_idx").on(t.automationId), // Deliberately not unique on (automation_id) where kind = 'schedule'. An // automation may carry several schedules — "every weekday at 9" and "on diff --git a/packages/shared/src/automation-triggers.ts b/packages/shared/src/automation-triggers.ts index 16496aba620..038006b6eb3 100644 --- a/packages/shared/src/automation-triggers.ts +++ b/packages/shared/src/automation-triggers.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { hasFiniteRecurrence } from "./rrule"; /** * Trigger validation, shared by the editor and the API so a form can block Save @@ -562,6 +563,14 @@ export function describeTriggerProblems( case "circleback": break; case "schedule": + if (hasFiniteRecurrence(config.rrule)) { + problems.push({ + index, + field: "rrule", + message: "Schedules repeat — remove COUNT or UNTIL.", + }); + } + break; case "webhook": break; } diff --git a/packages/shared/src/rrule.ts b/packages/shared/src/rrule.ts index dfbf34db23f..39d5c6b2590 100644 --- a/packages/shared/src/rrule.ts +++ b/packages/shared/src/rrule.ts @@ -370,21 +370,12 @@ export function nextOccurrenceAfter(args: { } /** - * The most recent occurrence at or before `before`, or null when the - * recurrence has not started yet — how an exhausted schedule's last fire is - * recovered from the rule alone. + * True when the rule ends (COUNT or UNTIL). Schedules are repeating only — + * a rule that runs out is refused at save so no trigger ever needs retiring. */ -export function lastOccurrenceBefore(args: { - rrule: string; - dtstart: Date; - timezone: string; - before: Date; -}): Date | null { - const rule = RRule.fromString( - buildRuleString(args.rrule, args.dtstart, args.timezone), - ); - const last = rule.before(utcToRruleDate(args.before, args.timezone), true); - return last ? rruleDateToUtc(last, args.timezone) : null; +export function hasFiniteRecurrence(rrule: string): boolean { + const parts = parseRruleParts(rrule); + return parts !== null && ("COUNT" in parts || "UNTIL" in parts); } /** Parses + validates an RRule body, returning the next occurrence. */ diff --git a/packages/trpc/src/router/automation/automation.ts b/packages/trpc/src/router/automation/automation.ts index e9d5d13cb04..d83b57b3a3e 100644 --- a/packages/trpc/src/router/automation/automation.ts +++ b/packages/trpc/src/router/automation/automation.ts @@ -385,7 +385,6 @@ export const automationRouter = { automationId: row.id, organizationId, ...legacySchedule, - enabled: row.enabled, }); } @@ -552,7 +551,6 @@ export const automationRouter = { dtstart: nextDtstart, timezone: nextTimezone, nextRunAt: recomputedNextRunAt, - enabled: row.enabled, }); } diff --git a/packages/trpc/src/router/automation/helpers.ts b/packages/trpc/src/router/automation/helpers.ts index 851b536af30..0be01b28fb1 100644 --- a/packages/trpc/src/router/automation/helpers.ts +++ b/packages/trpc/src/router/automation/helpers.ts @@ -46,7 +46,6 @@ export async function syncScheduleTrigger( dtstart: Date; timezone: string; nextRunAt: Date | null; - enabled: boolean; }, ) { const config: ScheduleTriggerConfig = { @@ -80,7 +79,6 @@ export async function syncScheduleTrigger( organizationId: params.organizationId, kind: "schedule", config, - enabled: params.enabled, nextRunAt: params.nextRunAt, }); return; @@ -88,7 +86,7 @@ export async function syncScheduleTrigger( await tx .update(automationTriggers) - .set({ config, enabled: params.enabled, nextRunAt: params.nextRunAt }) + .set({ config, nextRunAt: params.nextRunAt }) .where(eq(automationTriggers.id, keep.id)); if (extra.length > 0) { From 3045731c587fe44881a8678e090ae362a41aa282 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 17:04:36 -0700 Subject: [PATCH 10/15] refactor(microsoft-teams): replace hand-rolled Graph client with official SDKs MSAL ConfidentialClientApplication.acquireTokenByClientCredential replaces the hand-rolled client-credentials POST, keeping the advisory-lock token cache on the connection and the unauthorized_client/invalid_client/ invalid_grant -> disconnectedAt classification (now on MSAL AuthError codes). Graph calls go through @microsoft/microsoft-graph-client with a token auth provider; the SDK's default retry middleware handles 429 Retry-After, which the hand-rolled client never did. List pagination uses PageIterator with the ~1000-item caps kept, and the channel picker keeps its 5-teams-at-a-time batching. Hand-typed GraphTeam/GraphChannel/ GraphChatMessage/GraphUser give way to Team/Channel/ChatMessage/User from @microsoft/microsoft-graph-types; the change-notification envelope types stay local. Subscription create/renew/delete run through the client with identical TTLs, renew window and restore-on-404 behaviour. Claude-Session: https://claude.ai/code/session_01MUPLfPFVm34EJmVrVr2pxu --- apps/api/package.json | 1 + .../notify/handleNotification.ts | 6 +- .../notify/normalizeTeamsDelivery.ts | 11 +- bun.lock | 30 ++++ packages/trpc/package.json | 3 + .../lib/integrations/microsoft-teams/index.ts | 5 +- .../integration/microsoft-teams/graph.ts | 169 +++++------------- .../integration/microsoft-teams/resources.ts | 108 +++++------ .../microsoft-teams/subscriptions.ts | 88 +++++---- .../microsoft-teams/trigger-options.ts | 72 +++++--- 10 files changed, 223 insertions(+), 270 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 1d88cd56105..85af2943980 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -49,6 +49,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@microsoft/microsoft-graph-types": "2.43.1", "@superset/typescript": "workspace:*", "@types/lodash.chunk": "4.2.9", "@types/node": "24.12.0", diff --git a/apps/api/src/app/api/integrations/microsoft-teams/notify/handleNotification.ts b/apps/api/src/app/api/integrations/microsoft-teams/notify/handleNotification.ts index baf8473989b..9f7e80c9d2f 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/notify/handleNotification.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/notify/handleNotification.ts @@ -3,6 +3,7 @@ import { getChannel, getChannelMessage, getGraphAccessToken, + graphClient, } from "@superset/trpc/integrations/microsoft-teams"; import { @@ -44,10 +45,11 @@ export async function handleNotification( const accessToken = await getGraphAccessToken(connection.id); if (!accessToken) return { status: "skipped", reason: "no access token" }; + const graph = graphClient(accessToken); if (type.endsWith(".chatmessage") && resource.messageId) { const message = await getChannelMessage( - accessToken, + graph, resource.teamId, resource.channelId, resource.messageId, @@ -65,7 +67,7 @@ export async function handleNotification( if (type.endsWith(".channel") && !resource.messageId) { const channel = await getChannel( - accessToken, + graph, resource.teamId, resource.channelId, ); diff --git a/apps/api/src/app/api/integrations/microsoft-teams/notify/normalizeTeamsDelivery.ts b/apps/api/src/app/api/integrations/microsoft-teams/notify/normalizeTeamsDelivery.ts index 163df747d5d..a5c16879a4c 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/notify/normalizeTeamsDelivery.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/notify/normalizeTeamsDelivery.ts @@ -1,9 +1,6 @@ +import type { Channel, ChatMessage } from "@microsoft/microsoft-graph-types"; import type { MicrosoftTeamsMatchableEvent } from "@superset/shared/automation-matching"; -import { - type GraphChannel, - type GraphChatMessage, - plainTextOf, -} from "@superset/trpc/integrations/microsoft-teams"; +import { plainTextOf } from "@superset/trpc/integrations/microsoft-teams"; import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; import type { AuthenticatedConnection } from "./notifications"; @@ -19,7 +16,7 @@ export function normalizeChannelMessage( messageId: string; replyId?: string; }, - message: GraphChatMessage, + message: ChatMessage, ): NormalizedDelivery { // Membership changes and the like arrive as messages too; nobody means // those when they say "a message in the channel". @@ -67,7 +64,7 @@ export function normalizeChannelMessage( export function normalizeChannelCreated( connection: AuthenticatedConnection, resource: { teamId: string; channelId: string }, - channel: GraphChannel, + channel: Channel, ): NormalizedDelivery { const name = channel.displayName ?? resource.channelId; const event: MicrosoftTeamsMatchableEvent = { diff --git a/bun.lock b/bun.lock index 067a1799a26..f761e888a32 100644 --- a/bun.lock +++ b/bun.lock @@ -102,6 +102,7 @@ "zod": "4.4.3", }, "devDependencies": { + "@microsoft/microsoft-graph-types": "2.43.1", "@superset/typescript": "workspace:*", "@types/lodash.chunk": "4.2.9", "@types/node": "24.12.0", @@ -1123,8 +1124,10 @@ "version": "0.1.0", "dependencies": { "@anthropic-ai/sdk": "0.78.0", + "@azure/msal-node": "5.5.0", "@blaxel/core": "0.3.11", "@linear/sdk": "68.1.1", + "@microsoft/microsoft-graph-client": "3.0.7", "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", "@slack/web-api": "7.15.0", @@ -1146,6 +1149,7 @@ "zod": "4.4.3", }, "devDependencies": { + "@microsoft/microsoft-graph-types": "2.43.1", "@superset/typescript": "workspace:*", "@types/node": "24.12.0", "typescript": "6.0.3", @@ -1426,6 +1430,10 @@ "@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xFp68OCUEmWYcqoreZFaf2xwMhm/22Qf6bR2Qyn8WNVY9RF4m4+k5K+7Wn+n9xy0vHUPhtFd1So/SvuaqLHEoA=="], + "@azure/msal-common": ["@azure/msal-common@16.12.0", "", {}, "sha512-hgLgfRdbG2AmhXPygebf1KYJEvse86+ZZLWufdiTKaGRYEUqOzHdlf6AS1IiuUCHWbynkgbHc451jSNkbfhWlg=="], + + "@azure/msal-node": ["@azure/msal-node@5.5.0", "", { "dependencies": { "@azure/msal-common": "16.12.0", "jsonwebtoken": "^9.0.0" } }, "sha512-A/2WIsuH0vsC6JVkkafjS4kHpi2LDR4AzDT0kJ+oIRtXYeYtvGQ2pwN2X88thQPhSek+82ela3MprsKXWQRrhQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -2220,6 +2228,10 @@ "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], + "@microsoft/microsoft-graph-client": ["@microsoft/microsoft-graph-client@3.0.7", "", { "dependencies": { "@babel/runtime": "^7.12.5", "tslib": "^2.2.0" }, "peerDependencies": { "@azure/identity": "*", "@azure/msal-browser": "*", "buffer": "*", "stream-browserify": "*" }, "optionalPeers": ["@azure/identity", "@azure/msal-browser", "buffer", "stream-browserify"] }, "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw=="], + + "@microsoft/microsoft-graph-types": ["@microsoft/microsoft-graph-types@2.43.1", "", {}, "sha512-7r3FiJYW2qTWnl+Li8GV5MzJqPiJp27hvY98kH5V/ZMzGuIOkcJqOfIpusoIQrskLDfYk5kFT8AjpeW713qcIg=="], + "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], @@ -5064,6 +5076,8 @@ "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -5162,10 +5176,24 @@ "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "lodash.samplesize": ["lodash.samplesize@4.2.0", "", {}, "sha512-1ZhKV7/nuISuaQdxfCqrs4HHxXIYN+0Z4f7NMQn2PHkxFZJGavJQ1j/paxyJnLJmN2ZamNN6SMepneV+dCgQTA=="], "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], @@ -7580,6 +7608,8 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "jsonwebtoken/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 30183aedc44..b289ecb5696 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -35,8 +35,10 @@ }, "dependencies": { "@anthropic-ai/sdk": "0.78.0", + "@azure/msal-node": "5.5.0", "@blaxel/core": "0.3.11", "@linear/sdk": "68.1.1", + "@microsoft/microsoft-graph-client": "3.0.7", "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", "@slack/web-api": "7.15.0", @@ -58,6 +60,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@microsoft/microsoft-graph-types": "2.43.1", "@superset/typescript": "workspace:*", "@types/node": "24.12.0", "typescript": "6.0.3" diff --git a/packages/trpc/src/lib/integrations/microsoft-teams/index.ts b/packages/trpc/src/lib/integrations/microsoft-teams/index.ts index 04a272459cd..cb935bc089d 100644 --- a/packages/trpc/src/lib/integrations/microsoft-teams/index.ts +++ b/packages/trpc/src/lib/integrations/microsoft-teams/index.ts @@ -3,15 +3,12 @@ export { findTeamsConnection, GraphError, getGraphAccessToken, - graphList, + graphClient, graphRequest, isGraphAuthError, microsoftCredentials, } from "../../../router/integration/microsoft-teams/graph"; export { - type GraphChannel, - type GraphChatMessage, - type GraphTeam, getChannel, getChannelMessage, plainTextOf, diff --git a/packages/trpc/src/router/integration/microsoft-teams/graph.ts b/packages/trpc/src/router/integration/microsoft-teams/graph.ts index c8c77f5bf16..04736d12f20 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/graph.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/graph.ts @@ -1,8 +1,9 @@ +import { AuthError, ConfidentialClientApplication } from "@azure/msal-node"; +import { Client, GraphError } from "@microsoft/microsoft-graph-client"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { withConnectionLock } from "@superset/db/utils"; import { and, eq } from "drizzle-orm"; -import { z } from "zod"; import { env } from "../../../env"; /** @@ -15,27 +16,16 @@ import { env } from "../../../env"; * in on the Microsoft side. */ -export const GRAPH_URL = "https://graph.microsoft.com/v1.0"; -const REQUEST_TIMEOUT_MS = 15_000; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; -const MAX_PAGES = 20; +const GRAPH_SCOPES = ["https://graph.microsoft.com/.default"]; -export class GraphError extends Error { - constructor( - public readonly status: number, - public readonly code: string | null, - message: string, - ) { - super(message); - this.name = "GraphError"; - } -} +export { GraphError }; /** The failures that mean the app can no longer act in this tenant at all. */ export function isGraphAuthError(error: unknown): boolean { return ( error instanceof GraphError && - (error.status === 401 || error.status === 403) + (error.statusCode === 401 || error.statusCode === 403) ); } @@ -52,69 +42,46 @@ export function microsoftCredentials(): { }; } -const tokenResponseSchema = z.object({ - access_token: z.string().min(1), - expires_in: z.number(), -}); - -const tokenErrorSchema = z.object({ - error: z.string(), - error_description: z.string().optional(), -}); - -async function fetchWithTimeout( - input: string, - init: RequestInit, -): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - return await fetch(input, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeout); - } -} - /** * Asks Entra for an app-only token in `tenantId`. Fails when the tenant has * not consented (the app has no service principal there) or the secret is - * wrong; both surface as a GraphError so callers can tell them from outages. + * wrong; both surface as an MSAL AuthError so callers can tell them from + * outages. */ export async function acquireAppToken( tenantId: string, ): Promise<{ accessToken: string; expiresAt: Date }> { const { clientId, clientSecret } = microsoftCredentials(); - const response = await fetchWithTimeout( - `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`, - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - scope: "https://graph.microsoft.com/.default", - grant_type: "client_credentials", - }), + const application = new ConfidentialClientApplication({ + auth: { + clientId, + clientSecret, + authority: `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}`, }, - ); - const json: unknown = await response.json().catch(() => null); - if (!response.ok) { - const parsed = tokenErrorSchema.safeParse(json); - throw new GraphError( - response.status, - parsed.success ? parsed.data.error : null, - parsed.success - ? (parsed.data.error_description ?? parsed.data.error) - : `Token request failed with ${response.status}`, - ); + }); + const result = await application.acquireTokenByClientCredential({ + scopes: GRAPH_SCOPES, + }); + if (!result?.accessToken) { + throw new Error("Entra returned no token for the tenant"); } - const token = tokenResponseSchema.parse(json); return { - accessToken: token.access_token, - expiresAt: new Date(Date.now() + token.expires_in * 1000), + accessToken: result.accessToken, + expiresAt: result.expiresOn ?? new Date(Date.now() + 60 * 60 * 1000), }; } +/** The tenant admin removed the app, or the secret rotated: no amount of + * retrying gets a token. */ +function isTenantRevokedError(error: unknown): error is AuthError { + return ( + error instanceof AuthError && + (error.errorCode === "unauthorized_client" || + error.errorCode === "invalid_client" || + error.errorCode === "invalid_grant") + ); +} + /** * The cached app token for a connection, refreshed under the connection lock * when it is within the buffer of expiring. Null when the connection is gone @@ -156,19 +123,12 @@ export async function getGraphAccessToken( .where(eq(integrationConnections.id, connectionId)); return token.accessToken; } catch (error) { - // The tenant admin removed the app, or the secret rotated: no amount - // of retrying gets a token, so stop pretending the connection works. - if ( - error instanceof GraphError && - (error.code === "unauthorized_client" || - error.code === "invalid_client" || - error.code === "invalid_grant") - ) { + if (isTenantRevokedError(error)) { await tx .update(integrationConnections) .set({ disconnectedAt: new Date(), - disconnectReason: error.code, + disconnectReason: error.errorCode, updatedAt: new Date(), }) .where(eq(integrationConnections.id, connectionId)); @@ -191,59 +151,18 @@ export async function findTeamsConnection(organizationId: string) { return connection; } -const graphErrorSchema = z.object({ - error: z.object({ - code: z.string().optional(), - message: z.string().optional(), - }), -}); - -/** One Graph call. Throws GraphError on a non-2xx; undefined on 204. */ -export async function graphRequest( - accessToken: string, - path: string, - init: { method?: string; body?: unknown } = {}, -): Promise { - const url = path.startsWith("https://") ? path : `${GRAPH_URL}${path}`; - const response = await fetchWithTimeout(url, { - method: init.method ?? "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - ...(init.body !== undefined - ? { "Content-Type": "application/json" } - : {}), - }, - body: init.body !== undefined ? JSON.stringify(init.body) : undefined, +/** + * A Graph client over a token from `getGraphAccessToken`. The SDK's default + * middleware retries 429/503/504 honouring Retry-After. + */ +export function graphClient(accessToken: string): Client { + return Client.initWithMiddleware({ + authProvider: { getAccessToken: async () => accessToken }, }); - if (response.status === 204) return undefined as T; - const json: unknown = await response.json().catch(() => null); - if (!response.ok) { - const parsed = graphErrorSchema.safeParse(json); - throw new GraphError( - response.status, - parsed.success ? (parsed.data.error.code ?? null) : null, - parsed.success - ? (parsed.data.error.message ?? `Graph ${response.status}`) - : `Graph ${response.status} on ${path}`, - ); - } - return json as T; } -/** A collection, following `@odata.nextLink` up to a page cap or `limit` items. */ -export async function graphList( - accessToken: string, - path: string, - limit = Number.POSITIVE_INFINITY, -): Promise { - const items: T[] = []; - let next: string | undefined = path; - for (let page = 0; next && page < MAX_PAGES && items.length < limit; page++) { - const body: { value?: T[]; "@odata.nextLink"?: string } = - await graphRequest(accessToken, next); - items.push(...(body.value ?? [])); - next = body["@odata.nextLink"]; - } - return items; +/** One Graph GET, for callers that hold a raw token. Throws the SDK's + * GraphError on a non-2xx. */ +export function graphRequest(accessToken: string, path: string): Promise { + return graphClient(accessToken).api(path).get(); } diff --git a/packages/trpc/src/router/integration/microsoft-teams/resources.ts b/packages/trpc/src/router/integration/microsoft-teams/resources.ts index 4dbd904f54e..d4099c41c8e 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/resources.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/resources.ts @@ -1,97 +1,83 @@ -import { graphList, graphRequest } from "./graph"; +import { + type Client, + type PageCollection, + PageIterator, +} from "@microsoft/microsoft-graph-client"; +import type { + Channel, + ChatMessage, + Team, + User, +} from "@microsoft/microsoft-graph-types"; /** - * The Graph resources the Teams provider reads, typed down to the fields it - * uses. Graph returns far more; the rest is carried in the recorded payload - * as-is and never named here. + * The Graph resources the Teams provider reads. Graph returns far more fields + * than the matcher uses; the rest is carried in the recorded payload as-is. */ -export type GraphTeam = { id: string; displayName: string | null }; -export type GraphChannel = { - id: string; - displayName: string | null; - webUrl?: string | null; - membershipType?: string | null; - createdDateTime?: string | null; -}; +/** Graph throttles per app per tenant; no picker needs an unbounded walk. */ +const LIST_CAP = 1000; -export type GraphChatMessage = { - id: string; - replyToId?: string | null; - messageType?: string | null; - createdDateTime?: string | null; - subject?: string | null; - webUrl?: string | null; - from?: { - user?: { id?: string | null; displayName?: string | null } | null; - application?: { id?: string | null; displayName?: string | null } | null; - } | null; - body?: { contentType?: string | null; content?: string | null } | null; - channelIdentity?: { - teamId?: string | null; - channelId?: string | null; - } | null; -}; - -export function listTeams(accessToken: string): Promise { - return graphList(accessToken, "/teams?$select=id,displayName"); +async function listCollection(client: Client, path: string): Promise { + const items: T[] = []; + const firstPage: PageCollection = await client.api(path).get(); + const iterator = new PageIterator(client, firstPage, (item) => { + items.push(item as T); + return items.length < LIST_CAP; + }); + await iterator.iterate(); + return items; } -const MAX_PEOPLE = 1000; - -export type GraphUser = { - id: string; - displayName: string | null; - mail?: string | null; - userPrincipalName?: string | null; -}; +export function listTeams(client: Client): Promise { + return listCollection(client, "/teams?$select=id,displayName"); +} /** * The tenant's people, as Entra object ids — what a channel message's * `from.user.id` carries. Needs the User.ReadBasic.All application permission. */ -export function listUsers(accessToken: string): Promise { - return graphList( - accessToken, +export function listUsers(client: Client): Promise { + return listCollection( + client, "/users?$select=id,displayName,mail,userPrincipalName&$top=999", - MAX_PEOPLE, ); } export function listChannels( - accessToken: string, + client: Client, teamId: string, -): Promise { - return graphList( - accessToken, +): Promise { + return listCollection( + client, `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName,membershipType`, ); } export function getChannel( - accessToken: string, + client: Client, teamId: string, channelId: string, -): Promise { - return graphRequest( - accessToken, - `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}`, - ); +): Promise { + return client + .api( + `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}`, + ) + .get(); } /** A channel message, or a reply to one when `replyId` is set. */ export function getChannelMessage( - accessToken: string, + client: Client, teamId: string, channelId: string, messageId: string, replyId?: string, -): Promise { +): Promise { const base = `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`; - return graphRequest( - accessToken, - replyId ? `${base}/replies/${encodeURIComponent(replyId)}` : base, - ); + return client + .api(replyId ? `${base}/replies/${encodeURIComponent(replyId)}` : base) + .get(); } /** @@ -99,7 +85,7 @@ export function getChannelMessage( * tags are dropped and the handful of entities Teams emits are decoded so * "contains" means what the person typing the pattern sees. */ -export function plainTextOf(body: GraphChatMessage["body"]): string | null { +export function plainTextOf(body: ChatMessage["body"]): string | null { const content = body?.content; if (!content) return null; if (body?.contentType !== "html") return content; diff --git a/packages/trpc/src/router/integration/microsoft-teams/subscriptions.ts b/packages/trpc/src/router/integration/microsoft-teams/subscriptions.ts index ac22d342e33..da790a589a1 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/subscriptions.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/subscriptions.ts @@ -1,3 +1,5 @@ +import type { Client } from "@microsoft/microsoft-graph-client"; +import type { Subscription } from "@microsoft/microsoft-graph-types"; import { db } from "@superset/db/client"; import { integrationConnections, @@ -7,7 +9,7 @@ import { import { withConnectionLock } from "@superset/db/utils"; import { eq } from "drizzle-orm"; import { env } from "../../../env"; -import { GraphError, getGraphAccessToken, graphRequest } from "./graph"; +import { GraphError, getGraphAccessToken, graphClient } from "./graph"; /** * Graph change-notification subscriptions for a Teams connection. @@ -50,51 +52,46 @@ export function teamsNotificationUrls() { }; } -type GraphSubscription = { id: string; expirationDateTime: string }; +function storedSubscription( + subscription: Subscription, +): MicrosoftTeamsSubscription { + if (!subscription.id || !subscription.expirationDateTime) { + throw new Error("Graph subscription response carries no id or expiry"); + } + return { id: subscription.id, expiresAt: subscription.expirationDateTime }; +} async function createSubscription( - accessToken: string, + graph: Client, key: SubscriptionKey, clientState: string, ): Promise { const { resource, changeType } = TEAMS_SUBSCRIPTION_RESOURCES[key]; - const created = await graphRequest( - accessToken, - "/subscriptions", - { - method: "POST", - body: { - changeType, - resource, - ...teamsNotificationUrls(), - expirationDateTime: new Date( - Date.now() + SUBSCRIPTION_LIFETIME_MS, - ).toISOString(), - clientState, - includeResourceData: false, - }, - }, - ); - return { id: created.id, expiresAt: created.expirationDateTime }; + const created: Subscription = await graph.api("/subscriptions").post({ + changeType, + resource, + ...teamsNotificationUrls(), + expirationDateTime: new Date( + Date.now() + SUBSCRIPTION_LIFETIME_MS, + ).toISOString(), + clientState, + includeResourceData: false, + }); + return storedSubscription(created); } async function renewSubscription( - accessToken: string, + graph: Client, subscriptionId: string, ): Promise { - const renewed = await graphRequest( - accessToken, - `/subscriptions/${encodeURIComponent(subscriptionId)}`, - { - method: "PATCH", - body: { - expirationDateTime: new Date( - Date.now() + SUBSCRIPTION_LIFETIME_MS, - ).toISOString(), - }, - }, - ); - return { id: renewed.id, expiresAt: renewed.expirationDateTime }; + const renewed: Subscription = await graph + .api(`/subscriptions/${encodeURIComponent(subscriptionId)}`) + .patch({ + expirationDateTime: new Date( + Date.now() + SUBSCRIPTION_LIFETIME_MS, + ).toISOString(), + }); + return storedSubscription(renewed); } export type EnsureResult = { @@ -119,6 +116,7 @@ export async function ensureTeamsSubscriptions( ): Promise { const accessToken = await getGraphAccessToken(connectionId); if (!accessToken) return null; + const graph = graphClient(accessToken); return withConnectionLock(connectionId, async (tx) => { const [connection] = await tx @@ -145,21 +143,18 @@ export async function ensureTeamsSubscriptions( try { if (existing) { try { - subscriptions[key] = await renewSubscription( - accessToken, - existing.id, - ); + subscriptions[key] = await renewSubscription(graph, existing.id); continue; } catch (error) { // Graph forgot it — it expired, or the tenant removed it. A new // one is the only way forward. - if (!(error instanceof GraphError && error.status === 404)) { + if (!(error instanceof GraphError && error.statusCode === 404)) { throw error; } } } subscriptions[key] = await createSubscription( - accessToken, + graph, key, config.clientState, ); @@ -200,17 +195,16 @@ export async function deleteTeamsSubscriptions( return; } if (!accessToken) return; + const graph = graphClient(accessToken); await Promise.all( Object.values(config.subscriptions).map(async (subscription) => { try { - await graphRequest( - accessToken, - `/subscriptions/${encodeURIComponent(subscription.id)}`, - { method: "DELETE" }, - ); + await graph + .api(`/subscriptions/${encodeURIComponent(subscription.id)}`) + .delete(); } catch (error) { - if (error instanceof GraphError && error.status === 404) return; + if (error instanceof GraphError && error.statusCode === 404) return; console.error( "[microsoft-teams] failed to delete subscription", subscription.id, diff --git a/packages/trpc/src/router/integration/microsoft-teams/trigger-options.ts b/packages/trpc/src/router/integration/microsoft-teams/trigger-options.ts index 6009b022679..d4bb183c36b 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/trigger-options.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/trigger-options.ts @@ -1,7 +1,10 @@ +import type { Client } from "@microsoft/microsoft-graph-client"; +import type { Team } from "@microsoft/microsoft-graph-types"; import type { TriggerOptionSource } from "../trigger-options"; import { findTeamsConnection, getGraphAccessToken, + graphClient, isGraphAuthError, } from "./graph"; import { listChannels, listTeams, listUsers } from "./resources"; @@ -11,10 +14,12 @@ import { listChannels, listTeams, listUsers } from "./resources"; * of teams is walked in batches rather than all at once. */ const CHANNEL_FETCH_CONCURRENCY = 5; -async function accessTokenFor(organizationId: string): Promise { +async function graphFor(organizationId: string): Promise { const connection = await findTeamsConnection(organizationId); if (!connection) return null; - return getGraphAccessToken(connection.id); + const accessToken = await getGraphAccessToken(connection.id); + if (!accessToken) return null; + return graphClient(accessToken); } function byLabel(a: T, b: T) { @@ -22,30 +27,40 @@ function byLabel(a: T, b: T) { } const teams: TriggerOptionSource = async ({ organizationId }) => { - const accessToken = await accessTokenFor(organizationId); - if (!accessToken) return []; - const list = await listTeams(accessToken); + const graph = await graphFor(organizationId); + if (!graph) return []; + const list = await listTeams(graph); return list - .map((team) => ({ id: team.id, label: team.displayName ?? team.id })) + .flatMap((team) => + team.id ? [{ id: team.id, label: team.displayName ?? team.id }] : [], + ) .sort(byLabel); }; const channels: TriggerOptionSource = async ({ organizationId }) => { - const accessToken = await accessTokenFor(organizationId); - if (!accessToken) return []; - const list = await listTeams(accessToken); + const graph = await graphFor(organizationId); + if (!graph) return []; + const list = (await listTeams(graph)).filter( + (team): team is Team & { id: string } => Boolean(team.id), + ); const options: Array<{ id: string; label: string }> = []; for (let i = 0; i < list.length; i += CHANNEL_FETCH_CONCURRENCY) { const batch = list.slice(i, i + CHANNEL_FETCH_CONCURRENCY); const results = await Promise.allSettled( batch.map(async (team) => { - const teamChannels = await listChannels(accessToken, team.id); - return teamChannels.map((channel) => ({ - id: channel.id, - // Channel names repeat across teams ("General" is in every - // one), so the picker shows which team a channel belongs to. - label: `${team.displayName ?? team.id} › ${channel.displayName ?? channel.id}`, - })); + const teamChannels = await listChannels(graph, team.id); + return teamChannels.flatMap((channel) => + channel.id + ? [ + { + id: channel.id, + // Channel names repeat across teams ("General" is in every + // one), so the picker shows which team a channel belongs to. + label: `${team.displayName ?? team.id} › ${channel.displayName ?? channel.id}`, + }, + ] + : [], + ); }), ); for (const result of results) { @@ -62,16 +77,25 @@ const channels: TriggerOptionSource = async ({ organizationId }) => { }; const people: TriggerOptionSource = async ({ organizationId }) => { - const accessToken = await accessTokenFor(organizationId); - if (!accessToken) return []; + const graph = await graphFor(organizationId); + if (!graph) return []; try { - const users = await listUsers(accessToken); + const users = await listUsers(graph); return users - .map((user) => ({ - id: user.id, - label: - user.displayName ?? user.mail ?? user.userPrincipalName ?? user.id, - })) + .flatMap((user) => + user.id + ? [ + { + id: user.id, + label: + user.displayName ?? + user.mail ?? + user.userPrincipalName ?? + user.id, + }, + ] + : [], + ) .sort(byLabel); } catch (error) { // The tenant consented before User.ReadBasic.All was asked for: an From f65e1c0afdd5d29b2b202fc722d986f62e9faa49 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 17:06:50 -0700 Subject: [PATCH 11/15] refactor(google): replace hand-rolled HTTP client with official Google SDKs @googleapis/calendar and @googleapis/gmail typed clients replace the hand-rolled fetch wrapper and hand-typed resource types; resource types narrow the SDK schemas only where Google always sends a field (ids). google-auth-library's OAuth2Client owns token refresh: a per-connection subclass wraps the SDK refresh in the connection advisory lock, keeps the stored-token short-circuit for concurrent refreshers, persists rotated tokens inside the lock, and still marks invalid_grant / no_refresh_token connections disconnected. 401 retry-once maps to forceRefreshOnFailure; 410/404 protocol signals read GaxiosError.status. Claude-Session: https://claude.ai/code/session_01MUPLfPFVm34EJmVrVr2pxu --- .../google/lib/reconcileWatches.ts | 2 +- bun.lock | 128 ++++++----- packages/trpc/package.json | 4 + .../trpc/src/lib/integrations/google/index.ts | 8 +- .../src/router/integration/google/auth.ts | 167 ++++++++++++++ .../src/router/integration/google/calendar.ts | 198 +++++++--------- .../src/router/integration/google/gmail.ts | 124 +++++----- .../src/router/integration/google/refresh.ts | 214 ------------------ 8 files changed, 382 insertions(+), 463 deletions(-) create mode 100644 packages/trpc/src/router/integration/google/auth.ts delete mode 100644 packages/trpc/src/router/integration/google/refresh.ts diff --git a/apps/api/src/app/api/integrations/google/lib/reconcileWatches.ts b/apps/api/src/app/api/integrations/google/lib/reconcileWatches.ts index 491299bcd8a..4fca5b25569 100644 --- a/apps/api/src/app/api/integrations/google/lib/reconcileWatches.ts +++ b/apps/api/src/app/api/integrations/google/lib/reconcileWatches.ts @@ -71,7 +71,7 @@ export async function reconcileWatches( result.baselined += 1; } await patchCalendarState(connectionId, calendar.id, { - summary: calendar.summary, + summary: calendar.summary ?? undefined, }); const expiresSoon = diff --git a/bun.lock b/bun.lock index 067a1799a26..a3c68bddaa5 100644 --- a/bun.lock +++ b/bun.lock @@ -1124,6 +1124,8 @@ "dependencies": { "@anthropic-ai/sdk": "0.78.0", "@blaxel/core": "0.3.11", + "@googleapis/calendar": "16.0.0", + "@googleapis/gmail": "18.0.0", "@linear/sdk": "68.1.1", "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", @@ -1140,6 +1142,8 @@ "@vercel/blob": "2.3.2", "@vercel/kv": "3.0.0", "drizzle-orm": "0.45.2", + "gaxios": "7.3.1", + "google-auth-library": "10.5.0", "posthog-node": "5.28.8", "resend": "6.18.0", "superjson": "2.2.6", @@ -1932,6 +1936,10 @@ "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + "@googleapis/calendar": ["@googleapis/calendar@16.0.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-Z/8Jdf4hMvmMlBMhP0whZNPSixEpYv4mC5RJfrlvrnqpf/rV68MJ+8M9MmyqUt7/SQp2zRfVmEqkk+csB2Cgmg=="], + + "@googleapis/gmail": ["@googleapis/gmail@18.0.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-qC8CaF3mgi8ZMm7IYK1Pk6AF3z2v1IE++8rtn+Mmpx+zIhbS9MMazY7+WicwgLKKskn5zQLXCoNv0Jn7OdFfPg=="], + "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="], "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="], @@ -3684,7 +3692,7 @@ "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], - "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-browser": ["agent-browser@0.19.0", "", { "dependencies": { "node-simctl": "^7.4.0", "playwright-core": "^1.57.0", "webdriverio": "^9.15.0", "ws": "^8.19.0", "zod": "^3.22.4" }, "bin": { "agent-browser": "bin/agent-browser.js" } }, "sha512-Ull7jF/OO4P+Zm/SHKCOyJPRcPIGe0nvHPM+/E611qzYIyKGoSMfv2UozIs878uB4bI++cHeAUzitOXyjiawBQ=="], @@ -4762,10 +4770,12 @@ "glsl-noise": ["glsl-noise@0.0.0", "", {}, "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w=="], - "google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "googleapis-common": ["googleapis-common@8.0.3", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "7.1.3", "google-auth-library": "10.5.0", "google-logging-utils": "1.1.3", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], @@ -4778,6 +4788,8 @@ "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], "happy-dom": ["happy-dom@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw=="], @@ -4866,7 +4878,7 @@ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -5482,7 +5494,7 @@ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], @@ -6590,6 +6602,8 @@ "uri-template-matcher": ["uri-template-matcher@1.1.2", "", {}, "sha512-uZc1h12jdO3m/R77SfTEOuo6VbMhgWznaawKpBjRGSJb7i91x5PgI37NQJtG+Cerxkk0yr1pylBY2qG1kQ+aEQ=="], + "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -6824,6 +6838,8 @@ "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-izgUo50kamJMwoYCXoFwVccuJeyYp0y1+twf0FfpEz7kdQBloZLZbgLYUOUpannLWrV7xYSJR48BQJIQFbFiAQ=="], + "@ai-sdk/google-vertex/google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], + "@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-izgUo50kamJMwoYCXoFwVccuJeyYp0y1+twf0FfpEz7kdQBloZLZbgLYUOUpannLWrV7xYSJR48BQJIQFbFiAQ=="], @@ -6886,6 +6902,8 @@ "@browserbasehq/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@browserbasehq/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@browserbasehq/stagehand/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.95", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V0nIwhyv8f9rD+p6glm0uq30seid8y2CrvNvHCAbnuPm5bPpqVChEB63kixrIDNnogkgI+ZjSTtIbIL2q3QPvQ=="], "@browserbasehq/stagehand/@ai-sdk/openai": ["@ai-sdk/openai@2.0.118", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EkQYOcG74tbQDSq4PSqRHvPaMyQtfg7KDHQQXDqClqOvwRE/i6F4iSV0a/s8tImh96X4W+AaiASdLPIzYwt3vQ=="], @@ -7030,6 +7048,8 @@ "@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@google/genai/google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], + "@gorhom/portal/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "@happy-dom/global-registrator/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], @@ -7050,8 +7070,6 @@ "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@libsql/hrana-client/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@mariozechner/pi-tui/@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], @@ -7274,8 +7292,6 @@ "@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@tavily/core/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], @@ -7328,6 +7344,8 @@ "ava/pretty-ms": ["pretty-ms@8.0.0", "", { "dependencies": { "parse-ms": "^3.0.0" } }, "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q=="], + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -7338,8 +7356,6 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "c12/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -7428,8 +7444,6 @@ "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "edgedriver/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "edgedriver/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -7530,12 +7544,6 @@ "fumadocs-ui/shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], - "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - - "geckodriver/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], @@ -7548,6 +7556,8 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "googleapis-common/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + "gray-matter/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -7560,8 +7570,6 @@ "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], @@ -7626,8 +7634,6 @@ "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], - "metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "metro-source-map/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "metro-symbolicate/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], @@ -7686,6 +7692,8 @@ "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "openai/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], @@ -7694,10 +7702,6 @@ "p-event/p-timeout": ["p-timeout@5.1.0", "", {}, "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew=="], - "pac-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "parent-module/callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -7746,10 +7750,6 @@ "prosemirror-markdown/@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], - "proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "proxy-agent/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -7836,8 +7836,6 @@ "socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "stacktrace-parser/type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], @@ -7898,8 +7896,6 @@ "webdriver/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], - "webdriver/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "webdriverio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "webdriverio/serialize-error": ["serialize-error@12.0.0", "", { "dependencies": { "type-fest": "^4.31.0" } }, "sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw=="], @@ -7972,6 +7968,8 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@browserbasehq/stagehand/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.134", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-n+dVco86tzsoQ5Lq8YI62ALsNQw37P5jJa/1NuzCtezfZCzH1UfgDWE3EZ0U1U3rqHlHFWV1N8D3QD5KBY9myg=="], "@browserbasehq/stagehand/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-izgUo50kamJMwoYCXoFwVccuJeyYp0y1+twf0FfpEz7kdQBloZLZbgLYUOUpannLWrV7xYSJR48BQJIQFbFiAQ=="], @@ -8340,6 +8338,10 @@ "@sentry/bundler-plugin-core/@sentry/cli/@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.6", "", { "os": "win32", "cpu": "x64" }, "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA=="], + "@sentry/bundler-plugin-core/@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "@sentry/bundler-plugin-core/@sentry/cli/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@sentry/bundler-plugin-core/@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "@sentry/bundler-plugins/@sentry/cli/@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.6", "", { "os": "darwin" }, "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA=="], @@ -8358,6 +8360,10 @@ "@sentry/bundler-plugins/@sentry/cli/@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.6", "", { "os": "win32", "cpu": "x64" }, "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA=="], + "@sentry/bundler-plugins/@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "@sentry/bundler-plugins/@sentry/cli/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@sentry/bundler-plugins/@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "@sentry/electron/@sentry/node/@sentry/node-core": ["@sentry/node-core@10.67.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.67.0", "@sentry/opentelemetry": "10.67.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-dBHHRwZyan1pOnFJ+sNBvR8TkXbZAfZU/jpxmALS3JZ2/8AGR7cQKL+b7SleKuJ7iUDZyklN3Nqi0i5JkcA+HA=="], @@ -8416,8 +8422,6 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "@tavily/core/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "@wdio/config/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "@wdio/config/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -8454,9 +8458,9 @@ "ava/pretty-ms/parse-ms": ["parse-ms@3.0.0", "", {}, "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw=="], - "builder-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "builder-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -8538,8 +8542,6 @@ "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "edgedriver/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "edgedriver/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "electron-builder/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -8818,12 +8820,10 @@ "fumadocs-ui/shiki/@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], - "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "geckodriver/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "global-agent/serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "googleapis-common/gaxios/rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -8876,8 +8876,6 @@ "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], - "metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], "miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], @@ -9160,8 +9158,6 @@ "webdriver/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "webdriver/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "webdriverio/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "webdriverio/serialize-error/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -9322,8 +9318,6 @@ "@react-native/metro-config/metro-config/metro/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], - "@react-native/metro-config/metro-config/metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "@react-native/metro-config/metro-config/metro-core/metro-resolver": ["metro-resolver@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg=="], "@rn-primitives/accordion/@radix-ui/react-accordion/@radix-ui/react-collapsible/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], @@ -9400,6 +9394,10 @@ "@rn-primitives/tabs/@radix-ui/react-tabs/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + "@sentry/bundler-plugin-core/@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "@sentry/bundler-plugins/@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "@sentry/electron/@sentry/node/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], "@sentry/react-native/@sentry/bundler-plugins/@sentry/cli/@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.6", "", { "os": "darwin" }, "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA=="], @@ -9418,6 +9416,10 @@ "@sentry/react-native/@sentry/bundler-plugins/@sentry/cli/@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.6", "", { "os": "win32", "cpu": "x64" }, "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA=="], + "@sentry/react-native/@sentry/bundler-plugins/@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "@sentry/react-native/@sentry/bundler-plugins/@sentry/cli/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@sentry/react-native/@sentry/bundler-plugins/@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "@wdio/config/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -9570,6 +9572,8 @@ "fumadocs-ui/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="], + "googleapis-common/gaxios/rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "iconv-corefoundation/cli-truncate/slice-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "iconv-corefoundation/cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -9668,8 +9672,6 @@ "@react-native/dev-middleware/serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "@react-native/metro-config/metro-config/metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "@react-native/metro-config/metro-config/metro/hermes-parser/hermes-estree": ["hermes-estree@0.36.1", "", {}, "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw=="], "@react-native/metro-config/metro-config/metro/metro-source-map/ob1": ["ob1@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-8Q8sKCiUwsxgSmjDtVWyRgmxsgeJXXam3oQH6Id8ADfNaJMV6GZKyeAl8+pGVVdgwAZabfk5+aExle7AP/nZiA=="], @@ -9682,6 +9684,8 @@ "@rn-primitives/tabs/@radix-ui/react-tabs/@radix-ui/react-roving-focus/@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + "@sentry/react-native/@sentry/bundler-plugins/@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "@wdio/config/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@wdio/config/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], @@ -9706,6 +9710,12 @@ "fumadocs-ui/@radix-ui/react-popover/@radix-ui/react-dismissable-layer/@radix-ui/react-use-effect-event/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + "googleapis-common/gaxios/rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "googleapis-common/gaxios/rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "googleapis-common/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "iconv-corefoundation/cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "node-simctl/rimraf/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -9732,6 +9742,12 @@ "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "googleapis-common/gaxios/rimraf/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "googleapis-common/gaxios/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + + "googleapis-common/gaxios/rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "node-simctl/rimraf/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "node-simctl/rimraf/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], @@ -9742,6 +9758,14 @@ "temp/rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "googleapis-common/gaxios/rimraf/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "googleapis-common/gaxios/rimraf/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "googleapis-common/gaxios/rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "node-simctl/rimraf/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "googleapis-common/gaxios/rimraf/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], } } diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 30183aedc44..68b0248d001 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -36,6 +36,8 @@ "dependencies": { "@anthropic-ai/sdk": "0.78.0", "@blaxel/core": "0.3.11", + "@googleapis/calendar": "16.0.0", + "@googleapis/gmail": "18.0.0", "@linear/sdk": "68.1.1", "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", @@ -52,6 +54,8 @@ "@vercel/blob": "2.3.2", "@vercel/kv": "3.0.0", "drizzle-orm": "0.45.2", + "gaxios": "7.3.1", + "google-auth-library": "10.5.0", "posthog-node": "5.28.8", "resend": "6.18.0", "superjson": "2.2.6", diff --git a/packages/trpc/src/lib/integrations/google/index.ts b/packages/trpc/src/lib/integrations/google/index.ts index 2801bb537a6..0572cb01d5a 100644 --- a/packages/trpc/src/lib/integrations/google/index.ts +++ b/packages/trpc/src/lib/integrations/google/index.ts @@ -1,3 +1,4 @@ +export { googleTokenResponseSchema } from "../../../router/integration/google/auth"; export { eventAttendeeEmails, eventEnd, @@ -30,13 +31,6 @@ export { stopMailboxWatch, watchMailbox, } from "../../../router/integration/google/gmail"; -export { - GoogleApiError, - getGoogleAccessToken, - googleFetch, - googleTokenResponseSchema, - refreshGoogleToken, -} from "../../../router/integration/google/refresh"; export { findGoogleConnection, findGoogleConnectionById, diff --git a/packages/trpc/src/router/integration/google/auth.ts b/packages/trpc/src/router/integration/google/auth.ts new file mode 100644 index 00000000000..ebced2b37d6 --- /dev/null +++ b/packages/trpc/src/router/integration/google/auth.ts @@ -0,0 +1,167 @@ +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { withConnectionLock } from "@superset/db/utils"; +import { eq } from "drizzle-orm"; +import { GaxiosError, type GaxiosResponse } from "gaxios"; +import { type Credentials, OAuth2Client } from "google-auth-library"; +import { z } from "zod"; +import { env } from "../../../env"; +import { REFRESH_BUFFER_MS, REFRESH_TOKEN_TIMEOUT_MS } from "./constants"; + +export const googleTokenResponseSchema = z.object({ + access_token: z.string(), + expires_in: z.number(), + // Only on the initial exchange, and on a refresh when Google chooses to + // rotate. Absent means keep the one already stored. + refresh_token: z.string().optional(), + scope: z.string().optional(), + token_type: z.string().optional(), + id_token: z.string().optional(), +}); +export type GoogleTokenResponse = z.infer; + +/** The HTTP status of a failed Google API call, for 404/410 protocol signals. */ +export function googleErrorStatus(error: unknown): number | undefined { + return error instanceof GaxiosError ? error.status : undefined; +} + +type GetTokenResponse = { tokens: Credentials; res: GaxiosResponse | null }; + +/** + * One connection's OAuth2 client. The SDK refreshes the access token when it + * is within the buffer of expiring (and once more after a 401); the refresh + * itself runs under the connection's advisory lock so two concurrent callers + * do not both spend the refresh token, and every refreshed token is persisted + * inside that lock. + */ +class ConnectionOAuth2Client extends OAuth2Client { + constructor(private readonly connectionId: string) { + super({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + eagerRefreshThresholdMillis: REFRESH_BUFFER_MS, + forceRefreshOnFailure: true, + // Applies to the token endpoint; API calls carry their own timeout. + transporterOptions: { timeout: REFRESH_TOKEN_TIMEOUT_MS }, + }); + } + + protected override async refreshTokenNoCache(): Promise { + return withConnectionLock(this.connectionId, async (tx) => { + const [connection] = await tx + .select({ + accessToken: integrationConnections.accessToken, + refreshToken: integrationConnections.refreshToken, + tokenExpiresAt: integrationConnections.tokenExpiresAt, + disconnectedAt: integrationConnections.disconnectedAt, + }) + .from(integrationConnections) + .where(eq(integrationConnections.id, this.connectionId)) + .limit(1); + if (!connection?.refreshToken || connection.disconnectedAt) { + throw new Error(`Google connection ${this.connectionId} disconnected`); + } + + // Another caller refreshed while this one waited on the lock: its + // token is stored and fresh, so use it instead of refreshing again. + if ( + connection.accessToken !== this.credentials.access_token && + connection.tokenExpiresAt && + connection.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS + ) { + return { + tokens: { + access_token: connection.accessToken, + refresh_token: connection.refreshToken, + expiry_date: connection.tokenExpiresAt.getTime(), + }, + res: null, + }; + } + + let response: GetTokenResponse; + try { + response = await super.refreshTokenNoCache(connection.refreshToken); + } catch (error) { + // The grant was revoked (or the app's access removed in the account + // settings). Nothing here can recover it; the person has to reconnect. + if (isInvalidGrant(error)) { + await tx + .update(integrationConnections) + .set({ + disconnectedAt: new Date(), + disconnectReason: "invalid_grant", + }) + .where(eq(integrationConnections.id, this.connectionId)); + } + throw error; + } + + const { tokens } = response; + if (tokens.access_token) { + await tx + .update(integrationConnections) + .set({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? connection.refreshToken, + tokenExpiresAt: tokens.expiry_date + ? new Date(tokens.expiry_date) + : null, + disconnectedAt: null, + disconnectReason: null, + }) + .where(eq(integrationConnections.id, this.connectionId)); + } + return response; + }); + } +} + +function isInvalidGrant(error: unknown): boolean { + if (!(error instanceof GaxiosError)) return false; + const data = error.response?.data as { error?: string } | undefined; + return data?.error === "invalid_grant" || error.message === "invalid_grant"; +} + +/** + * An authenticated client for one connection, its tokens loaded from + * `integration_connections`. Throws when the connection is disconnected or, + * with no refresh token, its access token is about to expire. + */ +export async function googleAuthFor( + connectionId: string, +): Promise { + const connection = await db.query.integrationConnections.findFirst({ + where: eq(integrationConnections.id, connectionId), + columns: { + accessToken: true, + refreshToken: true, + tokenExpiresAt: true, + disconnectedAt: true, + }, + }); + if (!connection || connection.disconnectedAt) { + throw new Error(`Google connection ${connectionId} disconnected`); + } + + const expiresSoon = + !connection.tokenExpiresAt || + connection.tokenExpiresAt.getTime() - Date.now() < REFRESH_BUFFER_MS; + if (!connection.refreshToken && expiresSoon) { + await db + .update(integrationConnections) + .set({ disconnectedAt: new Date(), disconnectReason: "no_refresh_token" }) + .where(eq(integrationConnections.id, connectionId)); + throw new Error(`Google connection ${connectionId} disconnected`); + } + + const client = new ConnectionOAuth2Client(connectionId); + client.setCredentials({ + access_token: connection.accessToken, + refresh_token: connection.refreshToken, + // A row without an expiry cannot be trusted; 1 rather than 0, which the + // SDK reads as "never expires", forces a refresh before first use. + expiry_date: connection.tokenExpiresAt?.getTime() ?? 1, + }); + return client; +} diff --git a/packages/trpc/src/router/integration/google/calendar.ts b/packages/trpc/src/router/integration/google/calendar.ts index 99e20455eae..8c00051812b 100644 --- a/packages/trpc/src/router/integration/google/calendar.ts +++ b/packages/trpc/src/router/integration/google/calendar.ts @@ -1,69 +1,43 @@ -import { CALENDAR_CHANNEL_TTL_MS } from "./constants"; -import { GoogleApiError, googleFetch } from "./refresh"; +import { calendar, type calendar_v3 } from "@googleapis/calendar"; +import { googleAuthFor, googleErrorStatus } from "./auth"; +import { CALENDAR_CHANNEL_TTL_MS, GOOGLE_API_TIMEOUT_MS } from "./constants"; -const CALENDAR_API = "https://www.googleapis.com/calendar/v3"; - -export type GoogleCalendarListEntry = { +/** The SDK's resources, narrowed to the id Google always sends. */ +export type GoogleCalendarEvent = calendar_v3.Schema$Event & { id: string }; +export type GoogleCalendarListEntry = calendar_v3.Schema$CalendarListEntry & { id: string; - summary?: string; - primary?: boolean; - accessRole?: string; - deleted?: boolean; - timeZone?: string; -}; - -export type GoogleCalendarPerson = { - email?: string; - displayName?: string; - self?: boolean; - organizer?: boolean; - responseStatus?: string; }; -export type GoogleCalendarEvent = { - id: string; - status?: "confirmed" | "tentative" | "cancelled" | string; - etag?: string; - htmlLink?: string; - summary?: string; - description?: string; - location?: string; - created?: string; - updated?: string; - start?: { date?: string; dateTime?: string; timeZone?: string }; - end?: { date?: string; dateTime?: string; timeZone?: string }; - recurrence?: string[]; - recurringEventId?: string; - organizer?: GoogleCalendarPerson; - creator?: GoogleCalendarPerson; - attendees?: GoogleCalendarPerson[]; - hangoutLink?: string; - eventType?: string; -}; +function hasId( + item: T, +): item is T & { id: string } { + return typeof item.id === "string" && item.id.length > 0; +} -type EventsPage = { - items?: GoogleCalendarEvent[]; - nextPageToken?: string; - nextSyncToken?: string; -}; +async function calendarFor( + connectionId: string, +): Promise { + return calendar({ + version: "v3", + auth: await googleAuthFor(connectionId), + timeout: GOOGLE_API_TIMEOUT_MS, + }); +} export async function listCalendars( connectionId: string, ): Promise { + const client = await calendarFor(connectionId); const items: GoogleCalendarListEntry[] = []; let pageToken: string | undefined; do { - const params = new URLSearchParams({ - maxResults: "250", - showDeleted: "false", + const { data } = await client.calendarList.list({ + maxResults: 250, + showDeleted: false, + pageToken, }); - if (pageToken) params.set("pageToken", pageToken); - const page = await googleFetch<{ - items?: GoogleCalendarListEntry[]; - nextPageToken?: string; - }>(connectionId, `${CALENDAR_API}/users/me/calendarList?${params}`); - items.push(...(page.items ?? [])); - pageToken = page.nextPageToken; + items.push(...(data.items ?? []).filter(hasId)); + pageToken = data.nextPageToken ?? undefined; } while (pageToken); return items; } @@ -85,33 +59,29 @@ export async function listEventChanges( | { expired: true } | { expired: false; items: GoogleCalendarEvent[]; nextSyncToken: string } > { + const client = await calendarFor(connectionId); const items: GoogleCalendarEvent[] = []; let pageToken: string | undefined; let nextSyncToken: string | undefined; do { - const params = new URLSearchParams({ - showDeleted: "true", - maxResults: syncToken ? "250" : "2500", - }); - if (syncToken) params.set("syncToken", syncToken); - if (pageToken) params.set("pageToken", pageToken); - let page: EventsPage; + let page: calendar_v3.Schema$Events; try { - page = await googleFetch( - connectionId, - `${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events?${params}`, - ); + ({ data: page } = await client.events.list({ + calendarId, + showDeleted: true, + maxResults: syncToken ? 250 : 2500, + syncToken, + pageToken, + })); } catch (error) { // 410 Gone: the token is too old to diff from. Google's protocol is to // drop it and start over with a full sync. - if (error instanceof GoogleApiError && error.status === 410) { - return { expired: true }; - } + if (googleErrorStatus(error) === 410) return { expired: true }; throw error; } - items.push(...(page.items ?? [])); - pageToken = page.nextPageToken; - nextSyncToken = page.nextSyncToken; + items.push(...(page.items ?? []).filter(hasId)); + pageToken = page.nextPageToken ?? undefined; + nextSyncToken = page.nextSyncToken ?? undefined; } while (pageToken); if (!nextSyncToken) { throw new Error(`Calendar sync of ${calendarId} returned no sync token`); @@ -128,23 +98,21 @@ export async function listUpcomingInstances( calendarId: string, window: { from: Date; to: Date }, ): Promise { + const client = await calendarFor(connectionId); const items: GoogleCalendarEvent[] = []; let pageToken: string | undefined; do { - const params = new URLSearchParams({ - singleEvents: "true", + const { data } = await client.events.list({ + calendarId, + singleEvents: true, orderBy: "startTime", timeMin: window.from.toISOString(), timeMax: window.to.toISOString(), - maxResults: "250", + maxResults: 250, + pageToken, }); - if (pageToken) params.set("pageToken", pageToken); - const page = await googleFetch( - connectionId, - `${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events?${params}`, - ); - items.push(...(page.items ?? [])); - pageToken = page.nextPageToken; + items.push(...(data.items ?? []).filter(hasId)); + pageToken = data.nextPageToken ?? undefined; } while (pageToken); return items; } @@ -156,21 +124,20 @@ export async function listEventInstances( eventId: string, window: { from: Date; to: Date }, ): Promise { + const client = await calendarFor(connectionId); const items: GoogleCalendarEvent[] = []; let pageToken: string | undefined; do { - const params = new URLSearchParams({ + const { data } = await client.events.instances({ + calendarId, + eventId, timeMin: window.from.toISOString(), timeMax: window.to.toISOString(), - maxResults: "250", + maxResults: 250, + pageToken, }); - if (pageToken) params.set("pageToken", pageToken); - const page = await googleFetch( - connectionId, - `${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}/instances?${params}`, - ); - items.push(...(page.items ?? [])); - pageToken = page.nextPageToken; + items.push(...(data.items ?? []).filter(hasId)); + pageToken = data.nextPageToken ?? undefined; } while (pageToken); return items; } @@ -181,13 +148,12 @@ export async function getEvent( calendarId: string, eventId: string, ): Promise { + const client = await calendarFor(connectionId); try { - return await googleFetch( - connectionId, - `${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, - ); + const { data } = await client.events.get({ calendarId, eventId }); + return hasId(data) ? data : null; } catch (error) { - if (error instanceof GoogleApiError && error.status === 404) return null; + if (googleErrorStatus(error) === 404) return null; throw error; } } @@ -202,25 +168,23 @@ export async function watchCalendar( calendarId: string, channel: { id: string; token: string; address: string }, ): Promise<{ resourceId: string; expiration: number }> { - const result = await googleFetch<{ resourceId: string; expiration?: string }>( - connectionId, - `${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/watch`, - { - method: "POST", - body: JSON.stringify({ - id: channel.id, - type: "web_hook", - address: channel.address, - token: channel.token, - expiration: String(Date.now() + CALENDAR_CHANNEL_TTL_MS), - }), + const client = await calendarFor(connectionId); + const { data } = await client.events.watch({ + calendarId, + requestBody: { + id: channel.id, + type: "web_hook", + address: channel.address, + token: channel.token, + expiration: String(Date.now() + CALENDAR_CHANNEL_TTL_MS), }, - ); + }); + if (!data.resourceId) { + throw new Error(`Calendar watch on ${calendarId} returned no resource id`); + } return { - resourceId: result.resourceId, - expiration: Number( - result.expiration ?? Date.now() + CALENDAR_CHANNEL_TTL_MS, - ), + resourceId: data.resourceId, + expiration: Number(data.expiration ?? Date.now() + CALENDAR_CHANNEL_TTL_MS), }; } @@ -229,17 +193,11 @@ export async function stopChannel( connectionId: string, channel: { id: string; resourceId: string }, ): Promise { + const client = await calendarFor(connectionId); try { - await googleFetch( - connectionId, - `${CALENDAR_API}/channels/stop`, - { - method: "POST", - body: JSON.stringify(channel), - }, - ); + await client.channels.stop({ requestBody: channel }); } catch (error) { - if (error instanceof GoogleApiError && error.status === 404) return; + if (googleErrorStatus(error) === 404) return; throw error; } } diff --git a/packages/trpc/src/router/integration/google/gmail.ts b/packages/trpc/src/router/integration/google/gmail.ts index 0a5acd59a7c..ae114a2677e 100644 --- a/packages/trpc/src/router/integration/google/gmail.ts +++ b/packages/trpc/src/router/integration/google/gmail.ts @@ -1,48 +1,39 @@ -import { GMAIL_WATCH_TTL_MS } from "./constants"; -import { GoogleApiError, googleFetch } from "./refresh"; +import { gmail, type gmail_v1 } from "@googleapis/gmail"; +import { googleAuthFor, googleErrorStatus } from "./auth"; +import { GMAIL_WATCH_TTL_MS, GOOGLE_API_TIMEOUT_MS } from "./constants"; -const GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me"; - -export type GmailLabel = { - id: string; - name: string; - type?: "system" | "user" | string; -}; - -export type GmailHeader = { name: string; value: string }; - -export type GmailMessagePart = { - partId?: string; - mimeType?: string; - filename?: string; - headers?: GmailHeader[]; - body?: { attachmentId?: string; size?: number; data?: string }; - parts?: GmailMessagePart[]; -}; - -export type GmailMessage = { +/** The SDK's resources, narrowed to what Gmail always sends. */ +export type GmailLabel = gmail_v1.Schema$Label & { id: string; name: string }; +export type GmailMessage = gmail_v1.Schema$Message & { id: string; threadId: string; - labelIds?: string[]; - snippet?: string; - historyId?: string; - internalDate?: string; - sizeEstimate?: number; - payload?: GmailMessagePart; }; +async function gmailFor(connectionId: string): Promise { + return gmail({ + version: "v1", + auth: await googleAuthFor(connectionId), + timeout: GOOGLE_API_TIMEOUT_MS, + }); +} + export async function listLabels(connectionId: string): Promise { - const result = await googleFetch<{ labels?: GmailLabel[] }>( - connectionId, - `${GMAIL_API}/labels`, + const client = await gmailFor(connectionId); + const { data } = await client.users.labels.list({ userId: "me" }); + return (data.labels ?? []).filter( + (label): label is GmailLabel => !!label.id && !!label.name, ); - return result.labels ?? []; } export async function getProfile( connectionId: string, ): Promise<{ emailAddress: string; historyId: string }> { - return googleFetch(connectionId, `${GMAIL_API}/profile`); + const client = await gmailFor(connectionId); + const { data } = await client.users.getProfile({ userId: "me" }); + if (!data.emailAddress || !data.historyId) { + throw new Error("Gmail profile returned no email or history id"); + } + return { emailAddress: data.emailAddress, historyId: data.historyId }; } /** @@ -61,6 +52,7 @@ export async function listAddedMessages( messages: Array<{ id: string; threadId: string; labelIds: string[] }>; } > { + const client = await gmailFor(connectionId); const byId = new Map< string, { id: string; threadId: string; labelIds: string[] } @@ -68,25 +60,17 @@ export async function listAddedMessages( let pageToken: string | undefined; let historyId: string | undefined; do { - const params = new URLSearchParams({ - startHistoryId, - historyTypes: "messageAdded", - maxResults: "500", - }); - if (pageToken) params.set("pageToken", pageToken); - let page: { - history?: Array<{ - messagesAdded?: Array<{ - message?: { id?: string; threadId?: string; labelIds?: string[] }; - }>; - }>; - historyId?: string; - nextPageToken?: string; - }; + let page: gmail_v1.Schema$ListHistoryResponse; try { - page = await googleFetch(connectionId, `${GMAIL_API}/history?${params}`); + ({ data: page } = await client.users.history.list({ + userId: "me", + startHistoryId, + historyTypes: ["messageAdded"], + maxResults: 500, + pageToken, + })); } catch (error) { - if (error instanceof GoogleApiError && error.status === 404) { + if (googleErrorStatus(error) === 404) { return { expired: true }; } throw error; @@ -105,7 +89,7 @@ export async function listAddedMessages( } } historyId = page.historyId ?? historyId; - pageToken = page.nextPageToken; + pageToken = page.nextPageToken ?? undefined; } while (pageToken); if (!historyId) { throw new Error("Gmail history.list returned no history id"); @@ -133,17 +117,17 @@ export async function getMessage( connectionId: string, messageId: string, ): Promise { + const client = await gmailFor(connectionId); try { - const params = new URLSearchParams({ + const { data } = await client.users.messages.get({ + userId: "me", + id: messageId, format: "full", fields: MESSAGE_FIELDS, }); - return await googleFetch( - connectionId, - `${GMAIL_API}/messages/${encodeURIComponent(messageId)}?${params}`, - ); + return data.id && data.threadId ? (data as GmailMessage) : null; } catch (error) { - if (error instanceof GoogleApiError && error.status === 404) return null; + if (googleErrorStatus(error) === 404) return null; throw error; } } @@ -156,21 +140,23 @@ export async function watchMailbox( connectionId: string, topicName: string, ): Promise<{ historyId: string; expiration: number }> { - const result = await googleFetch<{ historyId: string; expiration?: string }>( - connectionId, - `${GMAIL_API}/watch`, - { method: "POST", body: JSON.stringify({ topicName }) }, - ); + const client = await gmailFor(connectionId); + const { data } = await client.users.watch({ + userId: "me", + requestBody: { topicName }, + }); + if (!data.historyId) { + throw new Error("Gmail watch returned no history id"); + } return { - historyId: result.historyId, - expiration: Number(result.expiration ?? Date.now() + GMAIL_WATCH_TTL_MS), + historyId: data.historyId, + expiration: Number(data.expiration ?? Date.now() + GMAIL_WATCH_TTL_MS), }; } export async function stopMailboxWatch(connectionId: string): Promise { - await googleFetch(connectionId, `${GMAIL_API}/stop`, { - method: "POST", - }); + const client = await gmailFor(connectionId); + await client.users.stop({ userId: "me" }); } export function headerValue( @@ -179,7 +165,7 @@ export function headerValue( ): string | null { const wanted = name.toLowerCase(); const header = message.payload?.headers?.find( - (h) => h.name.toLowerCase() === wanted, + (h) => h.name?.toLowerCase() === wanted, ); return header?.value ?? null; } @@ -196,7 +182,7 @@ export function parseAddresses(header: string | null): string[] { /** A part with a filename or an attachment id is an attachment. */ export function messageHasAttachment(message: GmailMessage): boolean { - const walk = (part: GmailMessagePart | undefined): boolean => { + const walk = (part: gmail_v1.Schema$MessagePart | undefined): boolean => { if (!part) return false; if (part.filename || part.body?.attachmentId) return true; return (part.parts ?? []).some(walk); diff --git a/packages/trpc/src/router/integration/google/refresh.ts b/packages/trpc/src/router/integration/google/refresh.ts deleted file mode 100644 index 8e740769faf..00000000000 --- a/packages/trpc/src/router/integration/google/refresh.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { withConnectionLock } from "@superset/db/utils"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import { env } from "../../../env"; -import { - GOOGLE_API_TIMEOUT_MS, - REFRESH_BUFFER_MS, - REFRESH_TOKEN_TIMEOUT_MS, -} from "./constants"; - -export const googleTokenResponseSchema = z.object({ - access_token: z.string(), - expires_in: z.number(), - // Only on the initial exchange, and on a refresh when Google chooses to - // rotate. Absent means keep the one already stored. - refresh_token: z.string().optional(), - scope: z.string().optional(), - token_type: z.string().optional(), - id_token: z.string().optional(), -}); -export type GoogleTokenResponse = z.infer; - -export class GoogleApiError extends Error { - constructor( - readonly status: number, - readonly url: string, - readonly body: string, - ) { - super(`Google API ${status} for ${url}: ${body.slice(0, 300)}`); - this.name = "GoogleApiError"; - } -} - -type RefreshResult = - | { disconnected: true } - | { disconnected: false; accessToken: string }; - -/** - * Refreshes under the connection's advisory lock so two concurrent callers do - * not both spend the refresh token. Google's refresh tokens do not usually - * rotate, but when the response carries one it replaces the stored one. - */ -export async function refreshGoogleToken( - connectionId: string, - // After a 401 the stored expiry is not to be trusted — the token was - // revoked or the clock is off — so the caller forces a real refresh. - options: { force?: boolean } = {}, -): Promise { - return withConnectionLock(connectionId, async (tx) => { - const [connection] = await tx - .select({ - accessToken: integrationConnections.accessToken, - refreshToken: integrationConnections.refreshToken, - tokenExpiresAt: integrationConnections.tokenExpiresAt, - disconnectedAt: integrationConnections.disconnectedAt, - }) - .from(integrationConnections) - .where(eq(integrationConnections.id, connectionId)) - .limit(1); - - if (!connection?.refreshToken || connection.disconnectedAt) { - return { disconnected: true }; - } - if ( - !options.force && - connection.tokenExpiresAt && - connection.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS - ) { - return { disconnected: false, accessToken: connection.accessToken }; - } - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - REFRESH_TOKEN_TIMEOUT_MS, - ); - let response: Response; - try { - response = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - signal: controller.signal, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: connection.refreshToken, - client_id: env.GOOGLE_CLIENT_ID, - client_secret: env.GOOGLE_CLIENT_SECRET, - }), - }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { - error?: string; - }; - // The grant was revoked (or the app's access removed in the account - // settings). Nothing here can recover it; the person has to reconnect. - if (body?.error === "invalid_grant") { - await tx - .update(integrationConnections) - .set({ - disconnectedAt: new Date(), - disconnectReason: "invalid_grant", - }) - .where(eq(integrationConnections.id, connectionId)); - return { disconnected: true }; - } - throw new Error( - `Google token refresh failed: ${response.status} ${response.statusText}`, - ); - } - - const data = googleTokenResponseSchema.parse(await response.json()); - const tokenExpiresAt = new Date(Date.now() + data.expires_in * 1000); - - await tx - .update(integrationConnections) - .set({ - accessToken: data.access_token, - refreshToken: data.refresh_token ?? connection.refreshToken, - tokenExpiresAt, - disconnectedAt: null, - disconnectReason: null, - }) - .where(eq(integrationConnections.id, connectionId)); - - return { disconnected: false, accessToken: data.access_token }; - }); -} - -/** A token good for at least the refresh buffer, or null if disconnected. */ -export async function getGoogleAccessToken( - connectionId: string, -): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: eq(integrationConnections.id, connectionId), - columns: { - accessToken: true, - refreshToken: true, - tokenExpiresAt: true, - disconnectedAt: true, - }, - }); - if (!connection || connection.disconnectedAt) return null; - - const expiresSoon = - !connection.tokenExpiresAt || - connection.tokenExpiresAt.getTime() - Date.now() < REFRESH_BUFFER_MS; - if (!expiresSoon) return connection.accessToken; - - if (!connection.refreshToken) { - await db - .update(integrationConnections) - .set({ disconnectedAt: new Date(), disconnectReason: "no_refresh_token" }) - .where(eq(integrationConnections.id, connectionId)); - return null; - } - const result = await refreshGoogleToken(connectionId); - return result.disconnected ? null : result.accessToken; -} - -/** - * One authenticated call against a Google API. A 401 is retried once after a - * forced refresh; anything else non-2xx throws a `GoogleApiError` carrying the - * status so callers can treat 404/410 as the protocol signals they are. - */ -export async function googleFetch( - connectionId: string, - url: string, - init: RequestInit = {}, -): Promise { - const token = await getGoogleAccessToken(connectionId); - if (!token) throw new GoogleApiError(401, url, "connection disconnected"); - - let response = await send(url, init, token); - if (response.status === 401) { - const refreshed = await refreshGoogleToken(connectionId, { force: true }); - if (refreshed.disconnected) { - throw new GoogleApiError(401, url, "connection disconnected"); - } - response = await send(url, init, refreshed.accessToken); - } - if (!response.ok) { - throw new GoogleApiError(response.status, url, await response.text()); - } - if (response.status === 204) return undefined as T; - return (await response.json()) as T; -} - -async function send( - url: string, - init: RequestInit, - token: string, -): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), GOOGLE_API_TIMEOUT_MS); - try { - return await fetch(url, { - ...init, - signal: controller.signal, - headers: { - ...(init.headers as Record | undefined), - Authorization: `Bearer ${token}`, - ...(init.body ? { "Content-Type": "application/json" } : {}), - }, - }); - } finally { - clearTimeout(timeout); - } -} From 94731f6e6122a8dd276564467e734054290ab66e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 17:11:48 -0700 Subject: [PATCH 12/15] refactor(integrations): one roster, full desktop pane, shared Sentence and error handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/shared/src/integrations.ts: INTEGRATIONS roster (provider, label, description, category, webPath) — single source for the web index page, desktop Settings > Integrations, and settings-search's id/variant/item lists. - Desktop Settings > Integrations now renders every roster provider: adds Sentry, Notion, and Microsoft Teams rows (status via their getConnection procedures) with matching settings-search entries. - providers/components/Sentence collapses the per-provider SentencePart/renderPart/unknown-event-fallback copies; each provider keeps only its slot-to-chip switch. - Web IntegrationErrorHandler replaces the seven per-provider ErrorHandler copies, with exhaustive per-provider callback key unions; adds the missing github/linear `unauthorized` messages and linear's `sync_queued_failed` warning, and unifies on the toast-before-replaceState order so deferred toasts are not cleaned up before they show. Claude-Session: https://claude.ai/code/session_01MUPLfPFVm34EJmVrVr2pxu --- .../providers/circleback/circleback.tsx | 26 ++- .../components/Sentence/Sentence.tsx | 41 ++++ .../providers/components/Sentence/index.ts | 1 + .../components/providers/github/github.tsx | 38 ++-- .../components/providers/google/gmail.tsx | 23 +-- .../providers/google/googleCalendar.tsx | 33 ++-- .../components/providers/linear/linear.tsx | 37 ++-- .../microsoftTeams/microsoftTeams.tsx | 34 ++-- .../components/providers/notion/notion.tsx | 34 ++-- .../components/providers/sentry/sentry.tsx | 36 ++-- .../components/providers/slack/slack.tsx | 37 ++-- .../IntegrationsSettings.tsx | 170 ++++++++++------- .../utils/settings-search/settings-search.ts | 176 +++++++++--------- .../IntegrationErrorHandler.tsx | 162 ++++++++++++++++ .../IntegrationErrorHandler/index.ts | 4 + .../components/ErrorHandler/ErrorHandler.tsx | 49 ----- .../github/components/ErrorHandler/index.ts | 1 - .../integrations/github/page.tsx | 31 ++- .../components/ErrorHandler/ErrorHandler.tsx | 34 ---- .../google/components/ErrorHandler/index.ts | 1 - .../integrations/google/page.tsx | 17 +- .../components/ErrorHandler/ErrorHandler.tsx | 29 --- .../linear/components/ErrorHandler/index.ts | 1 - .../integrations/linear/page.tsx | 21 ++- .../components/ErrorHandler/ErrorHandler.tsx | 49 ----- .../components/ErrorHandler/index.ts | 1 - .../integrations/microsoft-teams/page.tsx | 41 +++- .../components/ErrorHandler/ErrorHandler.tsx | 31 --- .../notion/components/ErrorHandler/index.ts | 1 - .../integrations/notion/page.tsx | 13 +- .../(dashboard-legacy)/integrations/page.tsx | 82 +++----- .../components/ErrorHandler/ErrorHandler.tsx | 33 ---- .../sentry/components/ErrorHandler/index.ts | 1 - .../integrations/sentry/page.tsx | 17 +- .../components/ErrorHandler/ErrorHandler.tsx | 36 ---- .../slack/components/ErrorHandler/index.ts | 1 - .../integrations/slack/page.tsx | 20 +- packages/shared/package.json | 4 + packages/shared/src/integrations.ts | 67 +++++++ 39 files changed, 747 insertions(+), 686 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/Sentence/Sentence.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/Sentence/index.ts create mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/components/IntegrationErrorHandler/IntegrationErrorHandler.tsx create mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/components/IntegrationErrorHandler/index.ts delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/github/components/ErrorHandler/ErrorHandler.tsx delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/github/components/ErrorHandler/index.ts delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/google/components/ErrorHandler/ErrorHandler.tsx delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/google/components/ErrorHandler/index.ts delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/ErrorHandler.tsx delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/index.ts delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/components/ErrorHandler/ErrorHandler.tsx delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/components/ErrorHandler/index.ts delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/notion/components/ErrorHandler/ErrorHandler.tsx delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/notion/components/ErrorHandler/index.ts delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/sentry/components/ErrorHandler/ErrorHandler.tsx delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/sentry/components/ErrorHandler/index.ts delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/slack/components/ErrorHandler/ErrorHandler.tsx delete mode 100644 apps/web/src/app/(dashboard-legacy)/integrations/slack/components/ErrorHandler/index.ts create mode 100644 packages/shared/src/integrations.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/circleback.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/circleback.tsx index 921c10dcd92..5acea536321 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/circleback.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/circleback.tsx @@ -3,33 +3,27 @@ import { env } from "renderer/env.renderer"; import { EndpointChip } from "../../TriggerSentence/components/EndpointChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { SigningSecretChip } from "./components/SigningSecretChip"; import { CIRCLEBACK_MENU, CIRCLEBACK_SENTENCE, type CirclebackConfig, - type SentencePart, + type Slot, } from "./grammar"; export function circlebackWebhookUrl(triggerId: string): string { return `${env.NEXT_PUBLIC_API_URL}/api/integrations/circleback/webhook/${triggerId}`; } -function renderPart( +function renderSlot( config: CirclebackConfig, - part: SentencePart, + slot: Slot, index: number, { set, disabled, triggerId }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } - switch (part.slot) { + switch (slot) { case "tags": return ( = { label: "Circleback", icon: LuMic, menu: CIRCLEBACK_MENU, - renderSentence: (config, ctx) => - CIRCLEBACK_SENTENCE.map((part, index) => - renderPart(config, part, index, ctx), - ), + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/Sentence/Sentence.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/Sentence/Sentence.tsx new file mode 100644 index 00000000000..95626f54f46 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/Sentence/Sentence.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +interface SentenceProps { + /** The grammar's parts for the config's event; undefined for an unknown event. */ + parts: readonly ({ text: string } | { slot: Slot })[] | undefined; + /** + * Rendered raw when `parts` is missing. The event comes from a persisted + * config; if its grammar entry is ever removed or renamed, the row must + * still render — a thrown error here takes the whole editor down — so an + * unknown event reads as its raw name rather than as nothing. + */ + fallback?: string; + /** Renders one slot chip. Chips are list items: set `key={index}`. */ + renderSlot: (slot: Slot, index: number) => ReactNode; +} + +export function Sentence({ + parts, + fallback, + renderSlot, +}: SentenceProps) { + if (!parts) { + return ( + {fallback} + ); + } + return ( + <> + {parts.map((part, index) => + "text" in part ? ( + // biome-ignore lint/suspicious/noArrayIndexKey: grammar parts are a static sentence that never reorders + + {part.text} + + ) : ( + renderSlot(part.slot, index) + ), + )} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/Sentence/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/Sentence/index.ts new file mode 100644 index 00000000000..9e5a4d73c41 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/Sentence/index.ts @@ -0,0 +1 @@ +export { Sentence } from "./Sentence"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx index ab7834c9a89..a9688f88605 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx @@ -2,35 +2,29 @@ import { FaGithub } from "react-icons/fa"; import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { GITHUB_MENU, GITHUB_SENTENCES, type GithubConfig, - type SentencePart, + type Slot, } from "./grammar"; /** * Renders one slot of a GitHub sentence. Each slot names the config field it * edits, so `set` patches by that name and `mark` finds it in the problems. */ -function renderPart( +function renderSlot( config: GithubConfig, - part: SentencePart, + slot: Slot, index: number, { set, mark, options, disabled }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } // The slot list is derived from this event, so the fields it names are // present on this config member even where the union type cannot say so. const c = config as unknown as Record; - switch (part.slot) { + switch (slot) { case "repositories": return ( = { label: "GitHub", icon: FaGithub, menu: GITHUB_MENU, - renderSentence: (config, ctx) => { - // The event comes from a persisted config. If its grammar entry is ever - // removed or renamed, the row must still render — a thrown error here - // takes the whole editor down with it — so an unknown event reads as - // its raw name rather than as nothing. - const parts = GITHUB_SENTENCES[config.event]; - if (!parts) { - return ( - - {config.event} - - ); - } - return parts.map((part, index) => renderPart(config, part, index, ctx)); - }, + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx index 8d72299c433..0f6f658cad1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx @@ -2,6 +2,7 @@ import { SiGmail } from "react-icons/si"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { SelectChip } from "../../TriggerSentence/components/SelectChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { ATTACHMENT_OPTIONS, @@ -9,23 +10,15 @@ import { GMAIL_SENTENCE, type GmailConfig, type GmailSlot, - type SentencePart, } from "./grammar"; -function renderPart( +function renderSlot( config: GmailConfig, - part: SentencePart, + slot: GmailSlot, index: number, { set, mark, options, disabled }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } - switch (part.slot) { + switch (slot) { case "from": return ( = { label: "Gmail", icon: SiGmail, menu: GMAIL_MENU, - renderSentence: (config, ctx) => - GMAIL_SENTENCE.map((part, index) => renderPart(config, part, index, ctx)), + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx index 0b4fda0e3c7..dcf259017f6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx @@ -3,6 +3,7 @@ import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { SelectChip } from "../../TriggerSentence/components/SelectChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { CALENDAR_MENU, @@ -11,26 +12,18 @@ import { EXTERNAL_ATTENDEE_OPTIONS, type GoogleCalendarConfig, MINUTES_BEFORE_OPTIONS, - type SentencePart, } from "./grammar"; -function renderPart( +function renderSlot( config: GoogleCalendarConfig, - part: SentencePart, + slot: CalendarSlot, index: number, { set, mark, options, disabled }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } // The slot list is derived from this event, so the fields it names are // present on this config member even where the union type cannot say so. const c = config as unknown as Record; - switch (part.slot) { + switch (slot) { case "calendars": return ( = { label: "Google Calendar", icon: SiGooglecalendar, menu: CALENDAR_MENU, - renderSentence: (config, ctx) => { - const parts = CALENDAR_SENTENCES[config.event]; - if (!parts) { - return ( - - {config.event} - - ); - } - return parts.map((part, index) => renderPart(config, part, index, ctx)); - }, + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx index cfcbf683e25..5d98bc435a5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx @@ -1,29 +1,23 @@ import { SiLinear } from "react-icons/si"; import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { LINEAR_MENU, LINEAR_SENTENCES, type LinearConfig, - type SentencePart, + type Slot, } from "./grammar"; /** Renders one slot of a Linear sentence; each slot edits the field it names. */ -function renderPart( +function renderSlot( config: LinearConfig, - part: SentencePart, + slot: Slot, index: number, { set, mark, options, disabled }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } - switch (part.slot) { + switch (slot) { case "teams": return ( = { label: "Linear", icon: SiLinear, menu: LINEAR_MENU, - renderSentence: (config, ctx) => { - // A persisted config whose event has since been renamed must still - // render, so an unknown event reads as its raw name rather than as - // nothing. - const parts = LINEAR_SENTENCES[config.event]; - if (!parts) { - return ( - - {config.event} - - ); - } - return parts.map((part, index) => renderPart(config, part, index, ctx)); - }, + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx index 3e5f7770e51..e122b615bff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx @@ -2,28 +2,22 @@ import { BsMicrosoftTeams } from "react-icons/bs"; import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { type MicrosoftTeamsConfig, - type SentencePart, + type Slot, TEAMS_MENU, TEAMS_SENTENCES, } from "./grammar"; -function renderPart( +function renderSlot( config: MicrosoftTeamsConfig, - part: SentencePart, + slot: Slot, index: number, { set, mark, options, disabled }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } - switch (part.slot) { + switch (slot) { case "teams": return ( = { label: "Microsoft Teams", icon: BsMicrosoftTeams, menu: TEAMS_MENU, - renderSentence: (config, ctx) => { - const parts = TEAMS_SENTENCES[config.event]; - if (!parts) { - return ( - - {config.event} - - ); - } - return parts.map((part, index) => renderPart(config, part, index, ctx)); - }, + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx index e225b4b669c..05c28db8a5e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx @@ -1,31 +1,25 @@ import { SiNotion } from "react-icons/si"; import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { NOTION_MENU, NOTION_SENTENCES, type NotionConfig, - type SentencePart, + type Slot, } from "./grammar"; -function renderPart( +function renderSlot( config: NotionConfig, - part: SentencePart, + slot: Slot, index: number, { set, mark, options, disabled }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } // The slot list is derived from this event, so the fields it names are // present on this config member even where the union type cannot say so. const c = config as unknown as Record; - switch (part.slot) { + switch (slot) { case "dataSources": return ( = { label: "Notion", icon: SiNotion, menu: NOTION_MENU, - renderSentence: (config, ctx) => { - const parts = NOTION_SENTENCES[config.event]; - if (!parts) { - return ( - - {config.event} - - ); - } - return parts.map((part, index) => renderPart(config, part, index, ctx)); - }, + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx index 289ebd630ce..5f1cb430eff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx @@ -1,27 +1,21 @@ import { SiSentry } from "react-icons/si"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { SENTRY_MENU, SENTRY_SENTENCES, - type SentencePart, type SentryConfig, + type Slot, } from "./grammar"; -function renderPart( +function renderSlot( config: SentryConfig, - part: SentencePart, + slot: Slot, index: number, { set, mark, options, disabled }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } - switch (part.slot) { + switch (slot) { case "projects": return ( = { label: "Sentry", icon: SiSentry, menu: SENTRY_MENU, - renderSentence: (config, ctx) => { - // A persisted config whose event has since been renamed must still - // render, so an unknown event reads as its raw name rather than as nothing. - const parts = SENTRY_SENTENCES[config.event]; - if (!parts) { - return ( - - {config.event} - - ); - } - return parts.map((part, index) => renderPart(config, part, index, ctx)); - }, + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx index d130eeb85b5..3604669f474 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx @@ -3,13 +3,14 @@ import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { SelectChip } from "../../TriggerSentence/components/SelectChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; +import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; import { EmojiNameChip } from "./components/EmojiNameChip"; import { - type SentencePart, SLACK_MENU, SLACK_SENTENCES, type SlackConfig, + type Slot, } from "./grammar"; const THREAD_OPTIONS = [ @@ -21,20 +22,13 @@ const THREAD_OPTIONS = [ * Renders one slot of a Slack sentence. Each slot names the config field it * edits, so `set` patches by that name and `mark` finds it in the problems. */ -function renderPart( +function renderSlot( config: SlackConfig, - part: SentencePart, + slot: Slot, index: number, { set, mark, options, disabled }: SentenceContext, ) { - if ("text" in part) { - return ( - - {part.text} - - ); - } - switch (part.slot) { + switch (slot) { case "channels": return ( = { label: "Slack", icon: FaSlack, menu: SLACK_MENU, - renderSentence: (config, ctx) => { - // The event comes from a persisted config. If its grammar entry is ever - // removed or renamed, the row must still render rather than take the - // editor down, so an unknown event reads as its raw name. - const parts = SLACK_SENTENCES[config.event]; - if (!parts) { - return ( - - {config.event} - - ); - } - return parts.map((part, index) => renderPart(config, part, index, ctx)); - }, + renderSentence: (config, ctx) => ( + renderSlot(config, slot, index, ctx)} + /> + ), }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx index 60d540d7e04..57defc4da29 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx @@ -1,9 +1,14 @@ +import { + INTEGRATIONS, + type IntegrationProvider, +} from "@superset/shared/integrations"; import { Button } from "@superset/ui/button"; import { Skeleton } from "@superset/ui/skeleton"; import { useCallback, useEffect, useState } from "react"; +import { BsMicrosoftTeams } from "react-icons/bs"; import { FaGithub, FaGoogle, FaSlack } from "react-icons/fa"; import { HiOutlineArrowTopRightOnSquare } from "react-icons/hi2"; -import { SiLinear } from "react-icons/si"; +import { SiLinear, SiNotion, SiSentry } from "react-icons/si"; import { env } from "renderer/env.renderer"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { authClient } from "renderer/lib/auth-client"; @@ -11,8 +16,8 @@ import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { HighlightText } from "renderer/routes/_authenticated/settings/components/HighlightText"; import { useSettingsSearchQuery } from "renderer/stores/settings-state"; import { + integrationSettingItemId, isItemVisible, - SETTING_ITEM_ID, type SettingItemId, } from "../../../utils/settings-search"; @@ -29,6 +34,22 @@ interface GithubInstallation { createdAt: Date; } +const INTEGRATION_ICONS: Record = { + linear: , + github: , + slack: , + notion: , + microsoft_teams: , + sentry: , + google: , +}; + +interface ProviderState { + isConnected: boolean; + connectedOrgName?: string | null; + isLoading: boolean; +} + export function IntegrationsSettings({ visibleItems, }: IntegrationsSettingsProps) { @@ -50,19 +71,28 @@ export function IntegrationsSettings({ { enabled: !!activeOrganizationId }, ); + // These three keep a disconnected row around, which integration.list does + // not filter out — their per-provider getConnection does. + const { data: sentryConnection, isPending: isSentryPending } = + cloudTrpc.integration.sentry.getConnection.useQuery( + { organizationId: activeOrganizationId ?? "" }, + { enabled: !!activeOrganizationId }, + ); + const { data: notionConnection, isPending: isNotionPending } = + cloudTrpc.integration.notion.getConnection.useQuery( + { organizationId: activeOrganizationId ?? "" }, + { enabled: !!activeOrganizationId }, + ); + const { data: teamsConnection, isPending: isTeamsPending } = + cloudTrpc.integration.microsoftTeams.getConnection.useQuery( + { organizationId: activeOrganizationId ?? "" }, + { enabled: !!activeOrganizationId }, + ); + const [githubInstallation, setGithubInstallation] = useState(null); const [isLoadingGithub, setIsLoadingGithub] = useState(true); - const showLinear = isItemVisible( - SETTING_ITEM_ID.INTEGRATIONS_LINEAR, - visibleItems, - ); - const showGithub = isItemVisible( - SETTING_ITEM_ID.INTEGRATIONS_GITHUB, - visibleItems, - ); - const fetchGithubInstallation = useCallback(async () => { if (!activeOrganizationId) { setIsLoadingGithub(false); @@ -88,20 +118,44 @@ export function IntegrationsSettings({ const linearConnection = integrations?.find((i) => i.provider === "linear"); const slackConnection = integrations?.find((i) => i.provider === "slack"); - const isLinearConnected = !!linearConnection; - const isSlackConnected = !!slackConnection; - const isGoogleConnected = - !!googleConnection && !googleConnection.needsReconnect; - const isGithubConnected = - !!githubInstallation && !githubInstallation.suspended; - const showSlack = isItemVisible( - SETTING_ITEM_ID.INTEGRATIONS_SLACK, - visibleItems, - ); - const showGoogle = isItemVisible( - SETTING_ITEM_ID.INTEGRATIONS_GOOGLE, - visibleItems, - ); + + const providerStates: Record = { + linear: { + isConnected: !!linearConnection, + connectedOrgName: linearConnection?.externalOrgName, + isLoading: isIntegrationsPending, + }, + github: { + isConnected: !!githubInstallation && !githubInstallation.suspended, + connectedOrgName: githubInstallation?.accountLogin, + isLoading: isLoadingGithub, + }, + slack: { + isConnected: !!slackConnection, + connectedOrgName: slackConnection?.externalOrgName, + isLoading: isIntegrationsPending, + }, + notion: { + isConnected: !!notionConnection, + connectedOrgName: notionConnection?.externalOrgName, + isLoading: isNotionPending, + }, + microsoft_teams: { + isConnected: !!teamsConnection, + connectedOrgName: teamsConnection?.externalOrgName, + isLoading: isTeamsPending, + }, + sentry: { + isConnected: !!sentryConnection, + connectedOrgName: sentryConnection?.organizationName, + isLoading: isSentryPending, + }, + google: { + isConnected: !!googleConnection && !googleConnection.needsReconnect, + connectedOrgName: googleConnection?.email, + isLoading: isGooglePending, + }, + }; const handleOpenWeb = (path: string) => { window.open(`${env.NEXT_PUBLIC_WEB_URL}${path}`, "_blank"); @@ -133,53 +187,25 @@ export function IntegrationsSettings({
- {showLinear && ( - } - description="Sync issues bidirectionally with Linear." - icon={} - isConnected={isLinearConnected} - connectedOrgName={linearConnection?.externalOrgName} - isLoading={isIntegrationsPending} - onManage={() => handleOpenWeb("/integrations/linear")} - /> - )} - - {showGithub && ( - } - description="Connect repos and sync pull requests." - icon={} - isConnected={isGithubConnected} - connectedOrgName={githubInstallation?.accountLogin} - isLoading={isLoadingGithub} - onManage={() => handleOpenWeb("/integrations/github")} - /> - )} - - {showSlack && ( - } - description="Manage tasks from Slack conversations." - icon={} - isConnected={isSlackConnected} - connectedOrgName={slackConnection?.externalOrgName} - isLoading={isIntegrationsPending} - onManage={() => handleOpenWeb("/integrations/slack")} - /> - )} - - {showGoogle && ( - } - description="Trigger automations from Google Calendar and Gmail." - icon={} - isConnected={isGoogleConnected} - connectedOrgName={googleConnection?.email} - isLoading={isGooglePending} - onManage={() => handleOpenWeb("/integrations/google")} - /> - )} + {INTEGRATIONS.map((integration) => { + const itemId = integrationSettingItemId(integration.provider); + if (!isItemVisible(itemId, visibleItems)) return null; + const state = providerStates[integration.provider]; + return ( + + } + description={integration.description} + icon={INTEGRATION_ICONS[integration.provider]} + isConnected={state.isConnected} + connectedOrgName={state.connectedOrgName} + isLoading={state.isLoading} + onManage={() => handleOpenWeb(integration.webPath)} + /> + ); + })}

diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 55187d50264..c76cd9faa0d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -1,3 +1,7 @@ +import { + INTEGRATIONS, + type IntegrationProvider, +} from "@superset/shared/integrations"; import type { SettingsSection } from "renderer/stores/settings-state"; export const SETTING_ITEM_ID = { @@ -62,11 +66,6 @@ export const SETTING_ITEM_ID = { EXPERIMENTAL_WAIT_FOR_SETUP_BEFORE_AGENT: "experimental-wait-for-setup-before-agent", - INTEGRATIONS_LINEAR: "integrations-linear", - INTEGRATIONS_GITHUB: "integrations-github", - INTEGRATIONS_SLACK: "integrations-slack", - INTEGRATIONS_GOOGLE: "integrations-google", - BILLING_OVERVIEW: "billing-overview", BILLING_PLANS: "billing-plans", BILLING_USAGE: "billing-usage", @@ -98,8 +97,18 @@ export const SETTING_ITEM_ID = { HOST_DELETE: "host-delete", } as const; +/** One settings-search row per roster entry, id derived from the provider. */ +export function integrationSettingItemId

( + provider: P, +): `integrations-${P}` { + return `integrations-${provider}`; +} + +type IntegrationSettingItemId = `integrations-${IntegrationProvider}`; + export type SettingItemId = - (typeof SETTING_ITEM_ID)[keyof typeof SETTING_ITEM_ID]; + | (typeof SETTING_ITEM_ID)[keyof typeof SETTING_ITEM_ID] + | IntegrationSettingItemId; export interface SettingsItem { id: SettingItemId; @@ -120,7 +129,16 @@ export interface SettingsItem { */ export type SettingVariant = "v1" | "v2" | "shared"; +const INTEGRATION_ITEM_VARIANTS = Object.fromEntries( + INTEGRATIONS.map((integration) => [ + integrationSettingItemId(integration.provider), + "shared", + ]), +) as Record; + export const SETTING_ITEM_VARIANT: Record = { + ...INTEGRATION_ITEM_VARIANTS, + [SETTING_ITEM_ID.ACCOUNT_PROFILE]: "shared", [SETTING_ITEM_ID.ACCOUNT_SIGNOUT]: "shared", [SETTING_ITEM_ID.ACCOUNT_DELETE]: "shared", @@ -183,11 +201,6 @@ export const SETTING_ITEM_VARIANT: Record = { // Gates both the v1 renderer launch and the v2 host-side launch. [SETTING_ITEM_ID.EXPERIMENTAL_WAIT_FOR_SETUP_BEFORE_AGENT]: "shared", - [SETTING_ITEM_ID.INTEGRATIONS_LINEAR]: "shared", - [SETTING_ITEM_ID.INTEGRATIONS_GITHUB]: "shared", - [SETTING_ITEM_ID.INTEGRATIONS_SLACK]: "shared", - [SETTING_ITEM_ID.INTEGRATIONS_GOOGLE]: "shared", - [SETTING_ITEM_ID.BILLING_OVERVIEW]: "shared", [SETTING_ITEM_ID.BILLING_PLANS]: "shared", [SETTING_ITEM_ID.BILLING_USAGE]: "shared", @@ -227,6 +240,74 @@ export function isItemAllowedForVariant( return isV2 ? variant === "v2" : variant === "v1"; } +/** + * Search keywords per integration; everything else (title, description, id) + * comes from the shared roster. Exhaustive so a new roster entry fails + * typecheck until it gets keywords. + */ +const INTEGRATION_KEYWORDS: Record = { + linear: ["issues", "tasks", "sync", "project management"], + github: [ + "repos", + "repositories", + "pull requests", + "pr", + "sync", + "version control", + "git", + ], + slack: [ + "messages", + "conversations", + "tasks", + "chat", + "sync", + "communication", + ], + notion: [ + "pages", + "databases", + "data sources", + "comments", + "docs", + "knowledge", + ], + microsoft_teams: [ + "teams", + "microsoft", + "channels", + "messages", + "chat", + "communication", + ], + sentry: ["errors", "issues", "monitoring", "alerts", "triage"], + google: [ + "calendar", + "gmail", + "email", + "mail", + "events", + "triggers", + "automations", + ], +}; + +const INTEGRATION_SEARCH_ITEMS: SettingsItem[] = INTEGRATIONS.map( + (integration) => ({ + id: integrationSettingItemId(integration.provider), + section: "integrations", + title: integration.label, + description: integration.description, + keywords: [ + "integrations", + integration.label.toLowerCase(), + ...INTEGRATION_KEYWORDS[integration.provider], + "connect", + "connected", + ], + }), +); + export const SETTINGS_ITEMS: SettingsItem[] = [ { id: SETTING_ITEM_ID.ACCOUNT_PROFILE, @@ -1134,78 +1215,7 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "install", ], }, - { - id: SETTING_ITEM_ID.INTEGRATIONS_LINEAR, - section: "integrations", - title: "Linear", - description: "Sync issues bidirectionally with Linear", - keywords: [ - "integrations", - "linear", - "issues", - "tasks", - "sync", - "connect", - "connected", - "project management", - ], - }, - { - id: SETTING_ITEM_ID.INTEGRATIONS_GITHUB, - section: "integrations", - title: "GitHub", - description: "Connect repos and sync pull requests", - keywords: [ - "integrations", - "github", - "repos", - "repositories", - "pull requests", - "pr", - "sync", - "connect", - "connected", - "version control", - "git", - ], - }, - { - id: SETTING_ITEM_ID.INTEGRATIONS_SLACK, - section: "integrations", - title: "Slack", - description: "Manage tasks from Slack conversations", - keywords: [ - "integrations", - "slack", - "messages", - "conversations", - "tasks", - "chat", - "sync", - "connect", - "connected", - "communication", - ], - }, - { - id: SETTING_ITEM_ID.INTEGRATIONS_GOOGLE, - section: "integrations", - title: "Google", - description: "Trigger automations from Google Calendar and Gmail", - keywords: [ - "integrations", - "google", - "calendar", - "gmail", - "email", - "mail", - "events", - "triggers", - "automations", - "connect", - "connected", - ], - }, + ...INTEGRATION_SEARCH_ITEMS, { id: SETTING_ITEM_ID.BILLING_OVERVIEW, section: "billing", diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/components/IntegrationErrorHandler/IntegrationErrorHandler.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/components/IntegrationErrorHandler/IntegrationErrorHandler.tsx new file mode 100644 index 00000000000..85af0c97644 --- /dev/null +++ b/apps/web/src/app/(dashboard-legacy)/integrations/components/IntegrationErrorHandler/IntegrationErrorHandler.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { toast } from "@superset/ui/sonner"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +/** + * Every query param each provider's API callback can redirect back with. Kept + * in sync with apps/api/src/app/api/github/callback and + * apps/api/src/app/api/integrations//{connect,callback}: a callback + * emitting a key that has no message is a compile error in the page that + * renders this handler. + */ +type CallbackKeys = { + github: { + error: + | "already_connected" + | "installation_cancelled" + | "installation_fetch_failed" + | "invalid_state" + | "missing_params" + | "save_failed" + | "unauthorized" + | "unexpected"; + warning: "sync_queue_failed"; + success: "github_installed"; + }; + google: { + error: + | "invalid_state" + | "missing_params" + | "missing_scopes" + | "no_refresh_token" + | "oauth_denied" + | "token_exchange_failed" + | "unauthorized" + | "userinfo_failed"; + }; + linear: { + error: + | "invalid_state" + | "missing_params" + | "oauth_denied" + | "token_exchange_failed" + | "unauthorized"; + warning: "sync_queued_failed"; + }; + "microsoft-teams": { + error: + | "identity_denied" + | "identity_failed" + | "invalid_state" + | "missing_params" + | "oauth_denied" + | "subscription_failed" + | "tenant_already_linked" + | "token_exchange_failed" + | "unauthorized"; + }; + notion: { + error: + | "invalid_state" + | "missing_params" + | "not_configured" + | "oauth_denied" + | "token_exchange_failed" + | "unauthorized"; + }; + sentry: { + error: + | "invalid_state" + | "missing_params" + | "not_configured" + | "oauth_denied" + | "organization_lookup_failed" + | "token_exchange_failed" + | "unauthorized"; + }; + slack: { + error: + | "invalid_state" + | "missing_params" + | "oauth_denied" + | "slack_api_error" + | "token_exchange_failed" + | "unauthorized" + | "workspace_already_linked"; + }; +}; + +type Provider = keyof CallbackKeys; + +/** + * A plain message, or one that varies on a companion query param the callback + * sends alongside the key (Slack's `owner`, Teams' `detail`). Kept + * serializable so server pages can pass it to this client component. + * `{param}` in `withParam` is replaced by the param's value. + */ +export type CallbackMessage = + | string + | { param: string; withParam: string; withoutParam: string }; + +type KeysOf

= + CallbackKeys[P] extends Record ? K : never; + +type IntegrationErrorHandlerProps

= { + provider: P; + messages: Record, CallbackMessage>; +} & (KeysOf extends never + ? { warnings?: undefined } + : { warnings: Record, string> }) & + (KeysOf extends never + ? { successes?: undefined } + : { successes: Record, string> }); + +function resolveMessage( + message: CallbackMessage | undefined, + params: URLSearchParams, +): string { + if (message === undefined) return "Something went wrong."; + if (typeof message === "string") return message; + const value = params.get(message.param); + return value + ? message.withParam.replace(`{${message.param}}`, value) + : message.withoutParam; +} + +export function IntegrationErrorHandler

({ + provider, + messages, + warnings, + successes, +}: IntegrationErrorHandlerProps

) { + const searchParams = useSearchParams(); + + useEffect(() => { + const error = searchParams.get("error"); + const warning = searchParams.get("warning"); + const success = searchParams.get("success"); + if (!error && !warning && !success) return; + + // Toast first: replacing the URL re-runs this effect through Next's + // patched history, and a deferred toast can be cleaned up before it shows. + if (error) { + const message = (messages as Record)[error]; + toast.error(resolveMessage(message, searchParams)); + } else if (warning) { + toast.warning( + (warnings as Record | undefined)?.[warning] ?? + "Warning occurred.", + ); + } else if (success) { + toast.success( + (successes as Record | undefined)?.[success] ?? + "Success!", + ); + } + window.history.replaceState({}, "", `/integrations/${provider}`); + }, [searchParams, provider, messages, warnings, successes]); + + return null; +} diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/components/IntegrationErrorHandler/index.ts b/apps/web/src/app/(dashboard-legacy)/integrations/components/IntegrationErrorHandler/index.ts new file mode 100644 index 00000000000..9856767d00b --- /dev/null +++ b/apps/web/src/app/(dashboard-legacy)/integrations/components/IntegrationErrorHandler/index.ts @@ -0,0 +1,4 @@ +export { + type CallbackMessage, + IntegrationErrorHandler, +} from "./IntegrationErrorHandler"; diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/github/components/ErrorHandler/ErrorHandler.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/github/components/ErrorHandler/ErrorHandler.tsx deleted file mode 100644 index 59036a4bd1e..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/github/components/ErrorHandler/ErrorHandler.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { toast } from "@superset/ui/sonner"; -import { useSearchParams } from "next/navigation"; -import { useEffect } from "react"; - -const ERROR_MESSAGES: Record = { - installation_cancelled: "GitHub App installation was cancelled.", - missing_params: "Invalid installation response. Please try again.", - invalid_state: "Invalid state parameter. Please try again.", - installation_fetch_failed: - "Failed to fetch installation details. Please try again.", - save_failed: "Failed to save installation. Please try again.", - already_connected: - "This GitHub installation is already connected to another Superset organization. Disconnect it there, or uninstall the Superset GitHub App, then try again.", - unexpected: "Something went wrong. Please try again.", -}; - -const WARNING_MESSAGES: Record = { - sync_queue_failed: - "GitHub connected, but initial sync failed to start. Please try reconnecting.", -}; - -const SUCCESS_MESSAGES: Record = { - github_installed: "GitHub App installed successfully!", -}; - -export function ErrorHandler() { - const searchParams = useSearchParams(); - - useEffect(() => { - const error = searchParams.get("error"); - const warning = searchParams.get("warning"); - const success = searchParams.get("success"); - - if (error) { - toast.error(ERROR_MESSAGES[error] ?? "Something went wrong."); - window.history.replaceState({}, "", "/integrations/github"); - } else if (warning) { - toast.warning(WARNING_MESSAGES[warning] ?? "Warning occurred."); - window.history.replaceState({}, "", "/integrations/github"); - } else if (success) { - toast.success(SUCCESS_MESSAGES[success] ?? "Success!"); - window.history.replaceState({}, "", "/integrations/github"); - } - }, [searchParams]); - - return null; -} diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/github/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard-legacy)/integrations/github/components/ErrorHandler/index.ts deleted file mode 100644 index c5e593d602a..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/github/components/ErrorHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/github/page.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/github/page.tsx index d6fab3f850c..35cd299977a 100644 --- a/apps/web/src/app/(dashboard-legacy)/integrations/github/page.tsx +++ b/apps/web/src/app/(dashboard-legacy)/integrations/github/page.tsx @@ -10,10 +10,32 @@ import { ArrowLeft, CheckCircle2 } from "lucide-react"; import Link from "next/link"; import { FaGithub } from "react-icons/fa"; import { api } from "@/trpc/server"; +import { IntegrationErrorHandler } from "../components/IntegrationErrorHandler"; import { ConnectionControls } from "./components/ConnectionControls"; -import { ErrorHandler } from "./components/ErrorHandler"; import { RepositoryList } from "./components/RepositoryList"; +const CALLBACK_MESSAGES = { + installation_cancelled: "GitHub App installation was cancelled.", + missing_params: "Invalid installation response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + installation_fetch_failed: + "Failed to fetch installation details. Please try again.", + save_failed: "Failed to save installation. Please try again.", + already_connected: + "This GitHub installation is already connected to another Superset organization. Disconnect it there, or uninstall the Superset GitHub App, then try again.", + unauthorized: "You are not authorized to perform this action.", + unexpected: "Something went wrong. Please try again.", +}; + +const CALLBACK_WARNINGS = { + sync_queue_failed: + "GitHub connected, but initial sync failed to start. Please try reconnecting.", +}; + +const CALLBACK_SUCCESSES = { + github_installed: "GitHub App installed successfully!", +}; + export default async function GitHubIntegrationPage() { const trpc = await api(); const organization = await trpc.user.myOrganization.query(); @@ -35,7 +57,12 @@ export default async function GitHubIntegrationPage() { return (

- + = { - oauth_denied: "Authorization was denied. Please try again.", - missing_params: "Invalid OAuth response. Please try again.", - invalid_state: "Invalid state parameter. Please try again.", - token_exchange_failed: "Failed to connect to Google. Please try again.", - missing_scopes: - "Both Calendar and Gmail access are required. Please allow both when asked.", - no_refresh_token: - "Google did not grant lasting access. Remove Superset from your Google account's third-party access and try again.", - userinfo_failed: "Could not read the Google account. Please try again.", - unauthorized: "You are not authorized to perform this action.", -}; - -export function ErrorHandler() { - const searchParams = useSearchParams(); - - useEffect(() => { - const error = searchParams.get("error"); - if (!error) return; - const message = ERROR_MESSAGES[error] ?? "Something went wrong."; - // Toast first: replacing the URL re-runs this effect through Next's - // patched history, and a deferred toast can be cleaned up before it shows. - toast.error(message); - window.history.replaceState({}, "", "/integrations/google"); - }, [searchParams]); - - return null; -} diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/google/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard-legacy)/integrations/google/components/ErrorHandler/index.ts deleted file mode 100644 index c5e593d602a..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/google/components/ErrorHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/google/page.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/google/page.tsx index 83d6056d7f0..c74381e428a 100644 --- a/apps/web/src/app/(dashboard-legacy)/integrations/google/page.tsx +++ b/apps/web/src/app/(dashboard-legacy)/integrations/google/page.tsx @@ -10,8 +10,21 @@ import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react"; import Link from "next/link"; import { FaGoogle } from "react-icons/fa"; import { api } from "@/trpc/server"; +import { IntegrationErrorHandler } from "../components/IntegrationErrorHandler"; import { ConnectionControls } from "./components/ConnectionControls"; -import { ErrorHandler } from "./components/ErrorHandler"; + +const CALLBACK_MESSAGES = { + oauth_denied: "Authorization was denied. Please try again.", + missing_params: "Invalid OAuth response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + token_exchange_failed: "Failed to connect to Google. Please try again.", + missing_scopes: + "Both Calendar and Gmail access are required. Please allow both when asked.", + no_refresh_token: + "Google did not grant lasting access. Remove Superset from your Google account's third-party access and try again.", + userinfo_failed: "Could not read the Google account. Please try again.", + unauthorized: "You are not authorized to perform this action.", +}; export default async function GoogleIntegrationPage() { const trpc = await api(); @@ -35,7 +48,7 @@ export default async function GoogleIntegrationPage() { return (
- + = { - oauth_denied: "Authorization was denied. Please try again.", - missing_params: "Invalid OAuth response. Please try again.", - invalid_state: "Invalid state parameter. Please try again.", - token_exchange_failed: "Failed to connect to Linear. Please try again.", -}; - -export function ErrorHandler() { - const searchParams = useSearchParams(); - - useEffect(() => { - const error = searchParams.get("error"); - if (!error) return; - - const message = ERROR_MESSAGES[error] ?? "Something went wrong."; - - window.history.replaceState({}, "", "/integrations/linear"); - const id = setTimeout(() => toast.error(message), 0); - return () => clearTimeout(id); - }, [searchParams]); - - return null; -} diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/index.ts deleted file mode 100644 index c5e593d602a..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/linear/page.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/linear/page.tsx index b197e3c62ea..0189b1a1d04 100644 --- a/apps/web/src/app/(dashboard-legacy)/integrations/linear/page.tsx +++ b/apps/web/src/app/(dashboard-legacy)/integrations/linear/page.tsx @@ -10,10 +10,23 @@ import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react"; import Link from "next/link"; import { SiLinear } from "react-icons/si"; import { api } from "@/trpc/server"; +import { IntegrationErrorHandler } from "../components/IntegrationErrorHandler"; import { ConnectionControls } from "./components/ConnectionControls"; -import { ErrorHandler } from "./components/ErrorHandler"; import { TeamSelector } from "./components/TeamSelector"; +const CALLBACK_MESSAGES = { + oauth_denied: "Authorization was denied. Please try again.", + missing_params: "Invalid OAuth response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + token_exchange_failed: "Failed to connect to Linear. Please try again.", + unauthorized: "You are not authorized to perform this action.", +}; + +const CALLBACK_WARNINGS = { + sync_queued_failed: + "Linear connected, but initial sync failed to start. Please try reconnecting.", +}; + export default async function LinearIntegrationPage() { const trpc = await api(); const organization = await trpc.user.myOrganization.query(); @@ -36,7 +49,11 @@ export default async function LinearIntegrationPage() { return (
- + = { - oauth_denied: - "Consent was not granted. A tenant administrator has to approve.", - missing_params: "Invalid consent response. Please try again.", - invalid_state: "Invalid state parameter. Please try again.", - token_exchange_failed: - "Consent finished but Microsoft did not issue a token for the tenant.", - subscription_failed: - "Connected, but Microsoft Graph refused the notification subscriptions.", - identity_denied: - 'Connected. Sign-in was cancelled, so triggers by "Me" will not match your Teams account until you reconnect.', - identity_failed: - 'Connected, but your Microsoft account could not be linked. Triggers by "Me" will not match until you reconnect.', - unauthorized: "You are not authorized to perform this action.", -}; - -export function ErrorHandler() { - const searchParams = useSearchParams(); - - useEffect(() => { - const error = searchParams.get("error"); - if (!error) return; - - const detail = searchParams.get("detail"); - let message: string; - if (error === "tenant_already_linked") { - message = detail - ? `This Microsoft tenant is already connected by ${detail}. Ask them to disconnect first.` - : "This Microsoft tenant is already connected by another Superset organization."; - } else { - message = ERROR_MESSAGES[error] ?? "Something went wrong."; - // Graph's own words are the useful part of a refused subscription — - // they name the missing permission or the protected-API approval. - if (detail) message = `${message} ${detail}`; - } - - window.history.replaceState({}, "", "/integrations/microsoft-teams"); - const id = setTimeout(() => toast.error(message), 0); - return () => clearTimeout(id); - }, [searchParams]); - - return null; -} diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/components/ErrorHandler/index.ts deleted file mode 100644 index c5e593d602a..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/components/ErrorHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/page.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/page.tsx index 8f8f82fe237..50ad0da39d8 100644 --- a/apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/page.tsx +++ b/apps/web/src/app/(dashboard-legacy)/integrations/microsoft-teams/page.tsx @@ -10,8 +10,42 @@ import { ArrowLeft, CheckCircle2 } from "lucide-react"; import Link from "next/link"; import { BsMicrosoftTeams } from "react-icons/bs"; import { api } from "@/trpc/server"; +import { + type CallbackMessage, + IntegrationErrorHandler, +} from "../components/IntegrationErrorHandler"; import { ConnectionControls } from "./components/ConnectionControls"; -import { ErrorHandler } from "./components/ErrorHandler"; + +// Graph's own words are the useful part of a refused subscription — they name +// the missing permission or the protected-API approval. +function withDetail(text: string): CallbackMessage { + return { param: "detail", withParam: `${text} {detail}`, withoutParam: text }; +} + +const CALLBACK_MESSAGES = { + oauth_denied: + "Consent was not granted. A tenant administrator has to approve.", + missing_params: "Invalid consent response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + token_exchange_failed: withDetail( + "Consent finished but Microsoft did not issue a token for the tenant.", + ), + subscription_failed: withDetail( + "Connected, but Microsoft Graph refused the notification subscriptions.", + ), + tenant_already_linked: { + param: "detail", + withParam: + "This Microsoft tenant is already connected by {detail}. Ask them to disconnect first.", + withoutParam: + "This Microsoft tenant is already connected by another Superset organization.", + }, + identity_denied: + 'Connected. Sign-in was cancelled, so triggers by "Me" will not match your Teams account until you reconnect.', + identity_failed: + 'Connected, but your Microsoft account could not be linked. Triggers by "Me" will not match until you reconnect.', + unauthorized: "You are not authorized to perform this action.", +}; export default async function MicrosoftTeamsIntegrationPage() { const trpc = await api(); @@ -37,7 +71,10 @@ export default async function MicrosoftTeamsIntegrationPage() { return (
- + = { - oauth_denied: "Authorization was denied. Please try again.", - missing_params: "Invalid OAuth response. Please try again.", - invalid_state: "Invalid state parameter. Please try again.", - token_exchange_failed: "Failed to connect to Notion. Please try again.", - not_configured: "Notion is not configured for this environment.", - unauthorized: "You are not authorized to perform this action.", -}; - -export function ErrorHandler() { - const searchParams = useSearchParams(); - - useEffect(() => { - const error = searchParams.get("error"); - if (!error) return; - - const message = ERROR_MESSAGES[error] ?? "Something went wrong."; - - window.history.replaceState({}, "", "/integrations/notion"); - const id = setTimeout(() => toast.error(message), 0); - return () => clearTimeout(id); - }, [searchParams]); - - return null; -} diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/notion/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard-legacy)/integrations/notion/components/ErrorHandler/index.ts deleted file mode 100644 index c5e593d602a..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/notion/components/ErrorHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/notion/page.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/notion/page.tsx index 6412c1f7e7a..16d50e24abb 100644 --- a/apps/web/src/app/(dashboard-legacy)/integrations/notion/page.tsx +++ b/apps/web/src/app/(dashboard-legacy)/integrations/notion/page.tsx @@ -10,8 +10,17 @@ import { ArrowLeft, CheckCircle2 } from "lucide-react"; import Link from "next/link"; import { SiNotion } from "react-icons/si"; import { api } from "@/trpc/server"; +import { IntegrationErrorHandler } from "../components/IntegrationErrorHandler"; import { ConnectionControls } from "./components/ConnectionControls"; -import { ErrorHandler } from "./components/ErrorHandler"; + +const CALLBACK_MESSAGES = { + oauth_denied: "Authorization was denied. Please try again.", + missing_params: "Invalid OAuth response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + token_exchange_failed: "Failed to connect to Notion. Please try again.", + not_configured: "Notion is not configured for this environment.", + unauthorized: "You are not authorized to perform this action.", +}; export default async function NotionIntegrationPage() { const trpc = await api(); @@ -34,7 +43,7 @@ export default async function NotionIntegrationPage() { return (
- + , - }, - { - id: "github", - name: "GitHub", - description: "Connect repos and sync pull requests.", - category: "Version Control", - accentColor: "#238636", - icon: , - }, - { - id: "slack", - name: "Slack", - description: "Connect Slack to manage tasks from conversations.", - category: "Communication", - accentColor: "#4A154B", - icon: , - }, - { - id: "notion", - name: "Notion", - description: "Run automations on data source and comment activity.", - category: "Knowledge", - accentColor: "#5F5E5B", - icon: , - }, - { - id: "microsoft-teams", - name: "Microsoft Teams", - description: "Trigger automations from Teams channel messages.", - category: "Communication", +const CARD_STYLES: Record< + IntegrationProvider, + { accentColor: string; icon: ReactNode } +> = { + linear: { accentColor: "#5E6AD2", icon: }, + github: { accentColor: "#238636", icon: }, + slack: { accentColor: "#4A154B", icon: }, + notion: { accentColor: "#5F5E5B", icon: }, + microsoft_teams: { accentColor: "#5B5FC7", icon: , }, - { - id: "sentry", - name: "Sentry", - description: "Run automations when Sentry issues change.", - category: "Monitoring", - accentColor: "#362D59", - icon: , - }, - { - id: "google", - name: "Google", - description: "Trigger automations from Google Calendar and Gmail.", - category: "Productivity", - accentColor: "#4285F4", - icon: , - }, -]; + sentry: { accentColor: "#362D59", icon: }, + google: { accentColor: "#4285F4", icon: }, +}; + +const integrations: IntegrationCardProps[] = INTEGRATIONS.map( + (integration) => ({ + id: integration.webPath.replace("/integrations/", ""), + name: integration.label, + description: integration.description, + category: integration.category, + ...CARD_STYLES[integration.provider], + }), +); export default function IntegrationsPage() { return ( diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/sentry/components/ErrorHandler/ErrorHandler.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/sentry/components/ErrorHandler/ErrorHandler.tsx deleted file mode 100644 index 8adee38d2c1..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/sentry/components/ErrorHandler/ErrorHandler.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { toast } from "@superset/ui/sonner"; -import { useSearchParams } from "next/navigation"; -import { useEffect } from "react"; - -const ERROR_MESSAGES: Record = { - not_configured: - "Sentry isn't available yet — the Superset app hasn't been registered with Sentry.", - oauth_denied: "The install was cancelled. Please try again.", - missing_params: "Invalid response from Sentry. Please try again.", - invalid_state: "Your session expired. Start the connection again.", - unauthorized: "You are not authorized to perform this action.", - token_exchange_failed: - "Failed to complete the Sentry install. Please try again.", - organization_lookup_failed: - "Connected, but couldn't read your Sentry organization. Please reconnect.", -}; - -export function ErrorHandler() { - const searchParams = useSearchParams(); - - useEffect(() => { - const error = searchParams.get("error"); - if (!error) return; - const message = ERROR_MESSAGES[error] ?? "Something went wrong."; - window.history.replaceState({}, "", "/integrations/sentry"); - const id = setTimeout(() => toast.error(message), 0); - return () => clearTimeout(id); - }, [searchParams]); - - return null; -} diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/sentry/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard-legacy)/integrations/sentry/components/ErrorHandler/index.ts deleted file mode 100644 index c5e593d602a..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/sentry/components/ErrorHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/sentry/page.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/sentry/page.tsx index e40a3dc23a8..8b4773a927f 100644 --- a/apps/web/src/app/(dashboard-legacy)/integrations/sentry/page.tsx +++ b/apps/web/src/app/(dashboard-legacy)/integrations/sentry/page.tsx @@ -10,8 +10,21 @@ import { ArrowLeft, CheckCircle2 } from "lucide-react"; import Link from "next/link"; import { SiSentry } from "react-icons/si"; import { api } from "@/trpc/server"; +import { IntegrationErrorHandler } from "../components/IntegrationErrorHandler"; import { ConnectionControls } from "./components/ConnectionControls"; -import { ErrorHandler } from "./components/ErrorHandler"; + +const CALLBACK_MESSAGES = { + not_configured: + "Sentry isn't available yet — the Superset app hasn't been registered with Sentry.", + oauth_denied: "The install was cancelled. Please try again.", + missing_params: "Invalid response from Sentry. Please try again.", + invalid_state: "Your session expired. Start the connection again.", + unauthorized: "You are not authorized to perform this action.", + token_exchange_failed: + "Failed to complete the Sentry install. Please try again.", + organization_lookup_failed: + "Connected, but couldn't read your Sentry organization. Please reconnect.", +}; export default async function SentryIntegrationPage() { const trpc = await api(); @@ -34,7 +47,7 @@ export default async function SentryIntegrationPage() { return (
- + = { - oauth_denied: "Authorization was denied. Please try again.", - missing_params: "Invalid OAuth response. Please try again.", - invalid_state: "Invalid state parameter. Please try again.", - token_exchange_failed: "Failed to connect to Slack. Please try again.", - slack_api_error: "Slack API error occurred. Please try again.", - unauthorized: "You are not authorized to perform this action.", -}; - -export function ErrorHandler() { - const searchParams = useSearchParams(); - - useEffect(() => { - const error = searchParams.get("error"); - if (!error) return; - - const message = - error === "workspace_already_linked" - ? searchParams.get("owner") - ? `This Slack workspace is already connected by ${searchParams.get("owner")}. Ask them to disconnect first.` - : "This Slack workspace is already connected by another Superset organization." - : (ERROR_MESSAGES[error] ?? "Something went wrong."); - - window.history.replaceState({}, "", "/integrations/slack"); - const id = setTimeout(() => toast.error(message), 0); - return () => clearTimeout(id); - }, [searchParams]); - - return null; -} diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/slack/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard-legacy)/integrations/slack/components/ErrorHandler/index.ts deleted file mode 100644 index c5e593d602a..00000000000 --- a/apps/web/src/app/(dashboard-legacy)/integrations/slack/components/ErrorHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard-legacy)/integrations/slack/page.tsx b/apps/web/src/app/(dashboard-legacy)/integrations/slack/page.tsx index 400213d8099..c0cf661b5f8 100644 --- a/apps/web/src/app/(dashboard-legacy)/integrations/slack/page.tsx +++ b/apps/web/src/app/(dashboard-legacy)/integrations/slack/page.tsx @@ -10,8 +10,24 @@ import { ArrowLeft, CheckCircle2 } from "lucide-react"; import Link from "next/link"; import { FaSlack } from "react-icons/fa"; import { api } from "@/trpc/server"; +import { IntegrationErrorHandler } from "../components/IntegrationErrorHandler"; import { ConnectionControls } from "./components/ConnectionControls"; -import { ErrorHandler } from "./components/ErrorHandler"; + +const CALLBACK_MESSAGES = { + oauth_denied: "Authorization was denied. Please try again.", + missing_params: "Invalid OAuth response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + token_exchange_failed: "Failed to connect to Slack. Please try again.", + slack_api_error: "Slack API error occurred. Please try again.", + unauthorized: "You are not authorized to perform this action.", + workspace_already_linked: { + param: "owner", + withParam: + "This Slack workspace is already connected by {owner}. Ask them to disconnect first.", + withoutParam: + "This Slack workspace is already connected by another Superset organization.", + }, +}; export default async function SlackIntegrationPage() { const trpc = await api(); @@ -34,7 +50,7 @@ export default async function SlackIntegrationPage() { return (
- + Date: Tue, 18 Aug 2026 17:13:12 -0700 Subject: [PATCH 13/15] refactor(integrations): dedupe OAuth connect/callback flows and connection procedures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/api gains lib/integrations: requireOrgMember (connect preamble), resolveCallback (callback preamble on findOrgMembership), upsertConnection (one upsert with the google/non-google conflict target and cross-org exclusivity by default), upsertIdentity (the five user_identities upserts). Every provider that skipped the exclusivity check (Linear, Notion, Sentry, Google) now refuses an external org already linked to another organization. packages/trpc gains integration/token-refresh (withRefreshedToken under the advisory lock, one REFRESH_BUFFER_MS, shared markDisconnected) applied to Linear and Sentry — a successful Sentry refresh now clears disconnectedAt — and integration/connections (activeConnection filtering disconnectedAt IS NULL, getConnectionProcedure, disconnectProcedure) applied across the provider routers, keeping Linear's needsReconnect shape and the Linear/Teams disconnect side effects. Claude-Session: https://claude.ai/code/session_01MUPLfPFVm34EJmVrVr2pxu --- apps/api/src/app/api/github/callback/route.ts | 77 +++------ apps/api/src/app/api/github/install/route.ts | 41 +---- .../api/integrations/google/callback/route.ts | 139 +++++----------- .../api/integrations/google/connect/route.ts | 34 +--- .../api/integrations/linear/callback/route.ts | 153 +++++------------- .../api/integrations/linear/connect/route.ts | 43 +---- .../microsoft-teams/callback/route.ts | 115 +++++-------- .../microsoft-teams/connect/route.ts | 35 +--- .../identity/callback/route.ts | 66 +++----- .../api/integrations/notion/callback/route.ts | 117 ++++---------- .../api/integrations/notion/connect/route.ts | 35 +--- .../api/integrations/sentry/callback/route.ts | 90 +++-------- .../api/integrations/sentry/connect/route.ts | 39 +---- .../api/integrations/slack/callback/route.ts | 142 +++------------- .../api/integrations/slack/connect/route.ts | 41 +---- .../app/api/integrations/slack/link/route.ts | 31 ++-- .../src/lib/integrations/requireOrgMember.ts | 52 ++++++ .../src/lib/integrations/resolveCallback.ts | 67 ++++++++ .../src/lib/integrations/upsertConnection.ts | 146 +++++++++++++++++ .../src/lib/integrations/upsertIdentity.ts | 49 ++++++ .../src/router/integration/connections.ts | 87 ++++++++++ .../router/integration/linear/constants.ts | 2 - .../src/router/integration/linear/linear.ts | 127 ++++++--------- .../src/router/integration/linear/refresh.ts | 133 ++++++--------- .../src/router/integration/linear/utils.ts | 14 +- .../microsoft-teams/microsoft-teams.ts | 72 +++------ .../src/router/integration/notion/notion.ts | 58 ++----- .../integration/notion/trigger-options.ts | 13 +- .../src/router/integration/sentry/sentry.ts | 65 ++------ .../integration/sentry/trigger-options.ts | 16 +- .../src/router/integration/sentry/utils.ts | 150 ++++++----------- .../src/router/integration/slack/index.ts | 1 - .../src/router/integration/slack/slack.ts | 61 ++----- .../integration/slack/trigger-options.ts | 13 +- .../src/router/integration/slack/utils.ts | 14 -- .../src/router/integration/token-refresh.ts | 128 +++++++++++++++ 36 files changed, 1021 insertions(+), 1445 deletions(-) create mode 100644 apps/api/src/lib/integrations/requireOrgMember.ts create mode 100644 apps/api/src/lib/integrations/resolveCallback.ts create mode 100644 apps/api/src/lib/integrations/upsertConnection.ts create mode 100644 apps/api/src/lib/integrations/upsertIdentity.ts create mode 100644 packages/trpc/src/router/integration/connections.ts delete mode 100644 packages/trpc/src/router/integration/slack/utils.ts create mode 100644 packages/trpc/src/router/integration/token-refresh.ts diff --git a/apps/api/src/app/api/github/callback/route.ts b/apps/api/src/app/api/github/callback/route.ts index e05d15f99a6..a79edf4f52f 100644 --- a/apps/api/src/app/api/github/callback/route.ts +++ b/apps/api/src/app/api/github/callback/route.ts @@ -1,63 +1,32 @@ import { db } from "@superset/db/client"; -import { githubInstallations, members } from "@superset/db/schema"; +import { githubInstallations } from "@superset/db/schema"; import { Client } from "@upstash/qstash"; import { and, eq, ne } from "drizzle-orm"; import { env } from "@/env"; -import { verifySignedState } from "@/lib/oauth-state"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; import { githubApp } from "../octokit"; const qstash = new Client({ token: env.QSTASH_TOKEN }); +const settingsUrl = `${env.NEXT_PUBLIC_WEB_URL}/integrations/github`; + /** * Callback handler for GitHub App installation. * GitHub redirects here after the user installs/configures the app. */ export async function GET(request: Request) { - const url = new URL(request.url); - const installationId = url.searchParams.get("installation_id"); - const setupAction = url.searchParams.get("setup_action"); - const state = url.searchParams.get("state"); - - if (setupAction === "cancel") { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=installation_cancelled`, - ); - } - - if (!installationId || !state) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=missing_params`, - ); + if (new URL(request.url).searchParams.get("setup_action") === "cancel") { + return Response.redirect(`${settingsUrl}?error=installation_cancelled`); } - // Verify signed state (prevents forgery) - const stateData = verifySignedState(state); - if (!stateData) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=invalid_state`, - ); - } - - const { organizationId, userId } = stateData; - - // Re-verify membership at callback time (defense-in-depth) - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), + const callback = await resolveCallback(request, { + params: ["installation_id"], + redirect: (error) => Response.redirect(`${settingsUrl}?error=${error}`), }); - - if (!membership) { - console.error("[github/callback] Membership verification failed:", { - organizationId, - userId, - }); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=unauthorized`, - ); - } + if (callback instanceof Response) return callback; + const { organizationId, userId, params } = callback; + const installationId = params.installation_id; try { const octokit = await githubApp.getInstallationOctokit( @@ -75,7 +44,7 @@ export async function GET(request: Request) { if (!installationResult) { return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=installation_fetch_failed`, + `${settingsUrl}?error=installation_fetch_failed`, ); } @@ -103,9 +72,7 @@ export async function GET(request: Request) { }); if (existingForInstallation) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=already_connected`, - ); + return Response.redirect(`${settingsUrl}?error=already_connected`); } // Save the installation to our database @@ -135,9 +102,7 @@ export async function GET(request: Request) { .returning(); if (!savedInstallation) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=save_failed`, - ); + return Response.redirect(`${settingsUrl}?error=save_failed`); } // Queue initial sync job @@ -155,18 +120,12 @@ export async function GET(request: Request) { "[github/callback] Failed to queue initial sync job:", error, ); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?warning=sync_queue_failed`, - ); + return Response.redirect(`${settingsUrl}?warning=sync_queue_failed`); } - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?success=github_installed`, - ); + return Response.redirect(`${settingsUrl}?success=github_installed`); } catch (error) { console.error("[github/callback] Unexpected error:", error); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=unexpected`, - ); + return Response.redirect(`${settingsUrl}?error=unexpected`); } } diff --git a/apps/api/src/app/api/github/install/route.ts b/apps/api/src/app/api/github/install/route.ts index 9ad31207c9f..6fef1bf08bd 100644 --- a/apps/api/src/app/api/github/install/route.ts +++ b/apps/api/src/app/api/github/install/route.ts @@ -1,37 +1,9 @@ -import { auth } from "@superset/auth/server"; -import { findOrgMembership } from "@superset/db/utils"; - import { env } from "@/env"; -import { createSignedState } from "@/lib/oauth-state"; +import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; export async function GET(request: Request) { - const session = await auth.api.getSession({ headers: request.headers }); - - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const url = new URL(request.url); - const organizationId = url.searchParams.get("organizationId"); - - if (!organizationId) { - return Response.json( - { error: "Missing organizationId parameter" }, - { status: 400 }, - ); - } - - const membership = await findOrgMembership({ - userId: session.user.id, - organizationId, - }); - - if (!membership) { - return Response.json( - { error: "User is not a member of this organization" }, - { status: 403 }, - ); - } + const member = await requireOrgMember(request); + if (member instanceof Response) return member; if (!env.GH_APP_ID) { return Response.json( @@ -40,18 +12,13 @@ export async function GET(request: Request) { ); } - const state = createSignedState({ - organizationId, - userId: session.user.id, - }); - // The slug must match GH_APP_ID: installing a different App produces an // installation this deployment cannot mint tokens for, which surfaces much // later as a 404 from create-an-installation-access-token. const installUrl = new URL( `https://github.com/apps/${env.GH_APP_SLUG}/installations/new`, ); - installUrl.searchParams.set("state", state); + installUrl.searchParams.set("state", member.state); installUrl.searchParams.set( "redirect_url", `${env.NEXT_PUBLIC_API_URL}/api/github/callback`, diff --git a/apps/api/src/app/api/integrations/google/callback/route.ts b/apps/api/src/app/api/integrations/google/callback/route.ts index 5c15f78f929..df4d93836e0 100644 --- a/apps/api/src/app/api/integrations/google/callback/route.ts +++ b/apps/api/src/app/api/integrations/google/callback/route.ts @@ -1,16 +1,13 @@ -import { db } from "@superset/db/client"; -import { - integrationConnections, - members, - userIdentities, -} from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import { googleTokenResponseSchema } from "@superset/trpc/integrations/google"; import { Client } from "@upstash/qstash"; -import { and, eq, sql } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import { z } from "zod"; import { env } from "@/env"; -import { verifySignedState } from "@/lib/oauth-state"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; +import { upsertConnection } from "@/lib/integrations/upsertConnection"; +import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; const qstash = new Client({ token: env.QSTASH_TOKEN, baseUrl: env.QSTASH_URL }); @@ -34,29 +31,12 @@ function fail(reason: string): Response { } export async function GET(request: Request) { - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (url.searchParams.get("error")) return fail("oauth_denied"); - if (!code || !state) return fail("missing_params"); - - const stateData = verifySignedState(state); - if (!stateData) return fail("invalid_state"); - const { organizationId, userId } = stateData; - - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), + const callback = await resolveCallback(request, { + params: ["code"], + redirect: fail, }); - if (!membership) { - console.error("[google/callback] membership verification failed", { - organizationId, - userId, - }); - return fail("unauthorized"); - } + if (callback instanceof Response) return callback; + const { organizationId, userId, params } = callback; const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { method: "POST", @@ -67,7 +47,7 @@ export async function GET(request: Request) { client_id: env.GOOGLE_CLIENT_ID, client_secret: env.GOOGLE_CLIENT_SECRET, redirect_uri: `${env.NEXT_PUBLIC_API_URL}/api/integrations/google/callback`, - code, + code: params.code, }), }); if (!tokenResponse.ok) { @@ -108,80 +88,41 @@ export async function GET(request: Request) { const info = parsedInfo.data; const email = info.email.toLowerCase(); - const tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000); - const [connection] = await db - .insert(integrationConnections) - .values({ - organizationId, - connectedByUserId: userId, - provider: "google", - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - tokenExpiresAt, - // The account's address, not an organization: Calendar and Gmail are - // one person's, and everything downstream treats them as theirs. - externalOrgId: email, - externalOrgName: email, - config: { provider: "google" }, - }) - .onConflictDoUpdate({ - // One Google connection per member: the partial index on - // (org, provider, connected_by_user_id) WHERE provider = 'google'. - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - integrationConnections.connectedByUserId, - ], - targetWhere: sql`${integrationConnections.provider} = 'google'`, - set: { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - tokenExpiresAt, - disconnectedAt: null, - disconnectReason: null, - externalOrgId: email, - externalOrgName: email, - // Reconnecting the same account keeps its sync tokens and channels; - // a different account starts over. The old account's channels are - // then unknown to the push route and expire within a week. - config: sql`CASE WHEN ${integrationConnections.externalOrgId} = ${email} THEN ${integrationConnections.config} ELSE '{"provider":"google"}'::jsonb END`, - updatedAt: new Date(), - }, - }) - .returning({ id: integrationConnections.id }); + const result = await upsertConnection({ + organizationId, + userId, + provider: "google", + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), + // The account's address, not an organization: Calendar and Gmail are + // one person's, and everything downstream treats them as theirs. + externalOrgId: email, + externalOrgName: email, + config: { provider: "google" }, + // Reconnecting the same account keeps its sync tokens and channels; + // a different account starts over. The old account's channels are + // then unknown to the push route and expire within a week. + configOnUpdate: sql`CASE WHEN ${integrationConnections.externalOrgId} = ${email} THEN ${integrationConnections.config} ELSE '{"provider":"google"}'::jsonb END`, + }); + if (result.conflict) return fail("account_already_linked"); // The linked identity is what lets an attendee filter of "me" resolve to // this person. Its external id is the address rather than Google's subject // id because the matcher compares owner ids against what events carry, and // calendar events name people by address. - await db - .insert(userIdentities) - .values({ - provider: "google", - externalId: email, - externalScopeId: null, - userId, - organizationId, - handle: email, - displayName: info.name ?? null, - metadata: { provider: "google", sub: info.sub }, - }) - .onConflictDoUpdate({ - target: [ - userIdentities.organizationId, - userIdentities.provider, - userIdentities.externalScopeId, - userIdentities.externalId, - ], - set: { - userId, - handle: email, - displayName: info.name ?? null, - metadata: { provider: "google", sub: info.sub }, - }, - }); + await upsertIdentity({ + userId, + organizationId, + provider: "google", + externalId: email, + externalScopeId: null, + handle: email, + displayName: info.name ?? null, + metadata: { provider: "google", sub: info.sub }, + }); - if (connection) await enqueueWatchSetup(connection.id); + await enqueueWatchSetup(result.connectionId); return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/google`); } diff --git a/apps/api/src/app/api/integrations/google/connect/route.ts b/apps/api/src/app/api/integrations/google/connect/route.ts index ea42bcff92c..d076a10aad2 100644 --- a/apps/api/src/app/api/integrations/google/connect/route.ts +++ b/apps/api/src/app/api/integrations/google/connect/route.ts @@ -1,37 +1,11 @@ -import { auth } from "@superset/auth/server"; -import { findOrgMembership } from "@superset/db/utils"; import { GOOGLE_SCOPES } from "@superset/trpc/integrations/google"; import { env } from "@/env"; -import { createSignedState } from "@/lib/oauth-state"; +import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; export async function GET(request: Request) { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const url = new URL(request.url); - const organizationId = url.searchParams.get("organizationId"); - if (!organizationId) { - return Response.json( - { error: "Missing organizationId parameter" }, - { status: 400 }, - ); - } - - const membership = await findOrgMembership({ - userId: session.user.id, - organizationId, - }); - if (!membership) { - return Response.json( - { error: "User is not a member of this organization" }, - { status: 403 }, - ); - } - - const state = createSignedState({ organizationId, userId: session.user.id }); + const member = await requireOrgMember(request); + if (member instanceof Response) return member; const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); authUrl.searchParams.set("client_id", env.GOOGLE_CLIENT_ID); @@ -46,7 +20,7 @@ export async function GET(request: Request) { authUrl.searchParams.set("access_type", "offline"); authUrl.searchParams.set("prompt", "consent"); authUrl.searchParams.set("include_granted_scopes", "true"); - authUrl.searchParams.set("state", state); + authUrl.searchParams.set("state", member.state); return Response.redirect(authUrl.toString()); } diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts index 968f5f2d8ac..20054363e48 100644 --- a/apps/api/src/app/api/integrations/linear/callback/route.ts +++ b/apps/api/src/app/api/integrations/linear/callback/route.ts @@ -1,64 +1,23 @@ import { LinearClient } from "@linear/sdk"; -import { db } from "@superset/db/client"; -import { - integrationConnections, - members, - userIdentities, -} from "@superset/db/schema"; import { linearTokenResponseSchema } from "@superset/trpc/integrations/linear"; import { Client } from "@upstash/qstash"; -import { and, eq, sql } from "drizzle-orm"; import { env } from "@/env"; -import { verifySignedState } from "@/lib/oauth-state"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; +import { upsertConnection } from "@/lib/integrations/upsertConnection"; +import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; const qstash = new Client({ token: env.QSTASH_TOKEN }); -export async function GET(request: Request) { - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - if (error) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=oauth_denied`, - ); - } - - if (!code || !state) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=missing_params`, - ); - } +const settingsUrl = `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear`; - // Verify signed state (prevents forgery) - const stateData = verifySignedState(state); - if (!stateData) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=invalid_state`, - ); - } - - const { organizationId, userId } = stateData; - - // Re-verify membership at callback time (defense-in-depth) - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), +export async function GET(request: Request) { + const callback = await resolveCallback(request, { + params: ["code"], + redirect: (error) => Response.redirect(`${settingsUrl}?error=${error}`), }); - - if (!membership) { - console.error("[linear/callback] Membership verification failed:", { - organizationId, - userId, - }); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=unauthorized`, - ); - } + if (callback instanceof Response) return callback; + const { organizationId, userId, params } = callback; const tokenResponse = await fetch("https://api.linear.app/oauth/token", { method: "POST", @@ -68,14 +27,12 @@ export async function GET(request: Request) { client_id: env.LINEAR_CLIENT_ID, client_secret: env.LINEAR_CLIENT_SECRET, redirect_uri: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/callback`, - code, + code: params.code, }), }); if (!tokenResponse.ok) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=token_exchange_failed`, - ); + return Response.redirect(`${settingsUrl}?error=token_exchange_failed`); } const tokenData = linearTokenResponseSchema.parse(await tokenResponse.json()); @@ -86,68 +43,32 @@ export async function GET(request: Request) { const viewer = await linearClient.viewer; const linearOrg = await viewer.organization; - const tokenExpiresAt = new Date(Date.now() + tokenData.expires_in * 1000); - - await db - .insert(integrationConnections) - .values({ - organizationId, - connectedByUserId: userId, - provider: "linear", - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - tokenExpiresAt, - externalOrgId: linearOrg.id, - externalOrgName: linearOrg.name, - }) - .onConflictDoUpdate({ - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - ], - // The org-scoped uniqueness is a partial index (Google connections - // are per user); Postgres only infers it when the predicate is named. - targetWhere: sql`${integrationConnections.provider}<> 'google'`, - set: { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - tokenExpiresAt, - disconnectedAt: null, - disconnectReason: null, - externalOrgId: linearOrg.id, - externalOrgName: linearOrg.name, - connectedByUserId: userId, - updatedAt: new Date(), - }, - }); + const result = await upsertConnection({ + organizationId, + userId, + provider: "linear", + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + tokenExpiresAt: new Date(Date.now() + tokenData.expires_in * 1000), + externalOrgId: linearOrg.id, + externalOrgName: linearOrg.name, + }); + if (result.conflict) { + return Response.redirect(`${settingsUrl}?error=workspace_already_linked`); + } // The person who connected is the one Linear account we know for certain // belongs to a Superset user, so link it: it is what automation triggers // resolve "me" through. Linear user ids are scoped to the Linear workspace. - await db - .insert(userIdentities) - .values({ - userId, - organizationId, - provider: "linear", - externalId: viewer.id, - externalScopeId: linearOrg.id, - handle: viewer.displayName, - displayName: viewer.name, - }) - .onConflictDoUpdate({ - target: [ - userIdentities.organizationId, - userIdentities.provider, - userIdentities.externalScopeId, - userIdentities.externalId, - ], - set: { - userId, - handle: viewer.displayName, - displayName: viewer.name, - }, - }); + await upsertIdentity({ + userId, + organizationId, + provider: "linear", + externalId: viewer.id, + externalScopeId: linearOrg.id, + handle: viewer.displayName, + displayName: viewer.name, + }); try { await qstash.publishJSON({ @@ -157,10 +78,8 @@ export async function GET(request: Request) { }); } catch (error) { console.error("Failed to queue initial sync job:", error); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?warning=sync_queued_failed`, - ); + return Response.redirect(`${settingsUrl}?warning=sync_queued_failed`); } - return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/linear`); + return Response.redirect(settingsUrl); } diff --git a/apps/api/src/app/api/integrations/linear/connect/route.ts b/apps/api/src/app/api/integrations/linear/connect/route.ts index 3e2e43db0e2..0787e86bf75 100644 --- a/apps/api/src/app/api/integrations/linear/connect/route.ts +++ b/apps/api/src/app/api/integrations/linear/connect/route.ts @@ -1,44 +1,9 @@ -import { auth } from "@superset/auth/server"; -import { findOrgMembership } from "@superset/db/utils"; - import { env } from "@/env"; -import { createSignedState } from "@/lib/oauth-state"; +import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; export async function GET(request: Request) { - const session = await auth.api.getSession({ - headers: request.headers, - }); - - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const url = new URL(request.url); - const organizationId = url.searchParams.get("organizationId"); - - if (!organizationId) { - return Response.json( - { error: "Missing organizationId parameter" }, - { status: 400 }, - ); - } - - const membership = await findOrgMembership({ - userId: session.user.id, - organizationId, - }); - - if (!membership) { - return Response.json( - { error: "User is not a member of this organization" }, - { status: 403 }, - ); - } - - const state = createSignedState({ - organizationId, - userId: session.user.id, - }); + const member = await requireOrgMember(request); + if (member instanceof Response) return member; const linearAuthUrl = new URL("https://linear.app/oauth/authorize"); linearAuthUrl.searchParams.set("client_id", env.LINEAR_CLIENT_ID); @@ -48,7 +13,7 @@ export async function GET(request: Request) { ); linearAuthUrl.searchParams.set("response_type", "code"); linearAuthUrl.searchParams.set("scope", "read,write,issues:create"); - linearAuthUrl.searchParams.set("state", state); + linearAuthUrl.searchParams.set("state", member.state); return Response.redirect(linearAuthUrl.toString()); } diff --git a/apps/api/src/app/api/integrations/microsoft-teams/callback/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/callback/route.ts index 68c4e6dd233..8a3f7dece52 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/callback/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/callback/route.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { db } from "@superset/db/client"; import type { MicrosoftTeamsConfig } from "@superset/db/schema"; -import { integrationConnections, members, users } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import { acquireAppToken, deleteTeamsSubscriptions, @@ -9,11 +9,16 @@ import { graphRequest, microsoftCredentials, } from "@superset/trpc/integrations/microsoft-teams"; -import { and, eq, isNull, ne, sql } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { env } from "@/env"; import { posthog } from "@/lib/analytics"; -import { createSignedState, verifySignedState } from "@/lib/oauth-state"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; +import { + connectionConflict, + upsertConnection, +} from "@/lib/integrations/upsertConnection"; +import { createSignedState } from "@/lib/oauth-state"; import { IDENTITY_REDIRECT_URI, IDENTITY_SCOPES, @@ -56,31 +61,21 @@ async function tenantDisplayName( */ export async function GET(request: Request) { const url = new URL(request.url); - const state = url.searchParams.get("state"); - const tenantId = url.searchParams.get("tenant"); - const error = url.searchParams.get("error"); - - if (error) { + if (url.searchParams.get("error")) { console.error("[microsoft-teams/callback] consent refused:", { - error, + error: url.searchParams.get("error"), description: url.searchParams.get("error_description"), }); return fail("oauth_denied"); } - if (!state || !tenantId) return fail("missing_params"); - - const stateData = verifySignedState(state); - if (!stateData) return fail("invalid_state"); - const { organizationId, userId } = stateData; - // Re-verify membership at callback time (state was signed earlier) - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), + const callback = await resolveCallback(request, { + params: ["tenant"], + redirect: fail, }); - if (!membership) return fail("unauthorized"); + if (callback instanceof Response) return callback; + const { organizationId, userId, params } = callback; + const tenantId = params.tenant; let token: Awaited>; try { @@ -96,22 +91,16 @@ export async function GET(request: Request) { ); } - // One tenant, one organization: a second organization claiming the same - // tenant would receive that tenant's messages too. - const [conflict] = await db - .select({ email: users.email }) - .from(integrationConnections) - .innerJoin(users, eq(users.id, integrationConnections.connectedByUserId)) - .where( - and( - eq(integrationConnections.provider, "microsoft_teams"), - eq(integrationConnections.externalOrgId, tenantId), - isNull(integrationConnections.disconnectedAt), - ne(integrationConnections.organizationId, organizationId), - ), - ) - .limit(1); - if (conflict) return fail("tenant_already_linked", conflict.email); + // One tenant, one organization — checked before touching the previous + // connection's subscriptions, so a refused reconnect leaves them running. + const conflict = await connectionConflict( + "microsoft_teams", + tenantId, + organizationId, + ); + if (conflict) { + return fail("tenant_already_linked", conflict.ownerEmail ?? undefined); + } // A reconnect replaces the clientState below, so whatever subscriptions the // previous connection held would only ever be refused. Remove them from @@ -135,40 +124,22 @@ export async function GET(request: Request) { }; const externalOrgName = await tenantDisplayName(token.accessToken, tenantId); - const [connection] = await db - .insert(integrationConnections) - .values({ - organizationId, - connectedByUserId: userId, - provider: "microsoft_teams", - accessToken: token.accessToken, - tokenExpiresAt: token.expiresAt, - externalOrgId: tenantId, - externalOrgName, - config, - }) - .onConflictDoUpdate({ - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - ], - // The org-scoped uniqueness is a partial index (Google connections - // are per user); Postgres only infers it when the predicate is named. - targetWhere: sql`${integrationConnections.provider} <> 'google'`, - set: { - accessToken: token.accessToken, - tokenExpiresAt: token.expiresAt, - externalOrgId: tenantId, - externalOrgName, - connectedByUserId: userId, - config, - disconnectedAt: null, - disconnectReason: null, - updatedAt: new Date(), - }, - }) - .returning({ id: integrationConnections.id }); - if (!connection) return fail("token_exchange_failed"); + const result = await upsertConnection({ + organizationId, + userId, + provider: "microsoft_teams", + accessToken: token.accessToken, + tokenExpiresAt: token.expiresAt, + externalOrgId: tenantId, + externalOrgName, + config, + }); + if (result.conflict) { + return fail( + "tenant_already_linked", + result.conflict.ownerEmail ?? undefined, + ); + } posthog.capture({ distinctId: userId, @@ -179,7 +150,7 @@ export async function GET(request: Request) { // The connection is saved either way; the renew job retries subscriptions // that could not be created here. But the person who just consented is the // one who can fix a permission Graph refused, so tell them now. - const ensured = await ensureTeamsSubscriptions(connection.id); + const ensured = await ensureTeamsSubscriptions(result.connectionId); const failure = ensured ? Object.values(ensured.failures).find(Boolean) : "no access token"; diff --git a/apps/api/src/app/api/integrations/microsoft-teams/connect/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/connect/route.ts index a1328bd42f5..239c0e10f54 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/connect/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/connect/route.ts @@ -1,8 +1,5 @@ -import { auth } from "@superset/auth/server"; -import { findOrgMembership } from "@superset/db/utils"; - import { env } from "@/env"; -import { createSignedState } from "@/lib/oauth-state"; +import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; /** * Starts the Teams connection: an Entra admin-consent flow, not a user @@ -12,15 +9,6 @@ import { createSignedState } from "@/lib/oauth-state"; * supported for tenant-wide message notifications. */ export async function GET(request: Request) { - const url = new URL(request.url); - const organizationId = url.searchParams.get("organizationId"); - if (!organizationId) { - return Response.json( - { error: "Missing organizationId parameter" }, - { status: 400 }, - ); - } - if (!env.MICROSOFT_CLIENT_ID) { return Response.json( { error: "Microsoft Teams integration is not configured" }, @@ -28,23 +16,8 @@ export async function GET(request: Request) { ); } - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const membership = await findOrgMembership({ - userId: session.user.id, - organizationId, - }); - if (!membership) { - return Response.json( - { error: "User is not a member of this organization" }, - { status: 403 }, - ); - } - - const state = createSignedState({ organizationId, userId: session.user.id }); + const member = await requireOrgMember(request); + if (member instanceof Response) return member; // `organizations`, not `common`: personal accounts cannot grant admin // consent, and Microsoft's own guidance says not to use common here. @@ -59,7 +32,7 @@ export async function GET(request: Request) { "redirect_uri", `${env.NEXT_PUBLIC_API_URL}/api/integrations/microsoft-teams/callback`, ); - consentUrl.searchParams.set("state", state); + consentUrl.searchParams.set("state", member.state); return Response.redirect(consentUrl.toString()); } diff --git a/apps/api/src/app/api/integrations/microsoft-teams/identity/callback/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/identity/callback/route.ts index f9f91d690ae..9a724498bb7 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/identity/callback/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/identity/callback/route.ts @@ -1,11 +1,9 @@ -import { db } from "@superset/db/client"; -import { members, userIdentities } from "@superset/db/schema"; import { microsoftCredentials } from "@superset/trpc/integrations/microsoft-teams"; -import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { env } from "@/env"; -import { verifySignedState } from "@/lib/oauth-state"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; +import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; import { IDENTITY_REDIRECT_URI, IDENTITY_SCOPES } from "../identityFlow"; @@ -35,23 +33,13 @@ const idTokenClaims = z.object({ * declining here costs only "Me", not the integration. */ export async function GET(request: Request) { - const url = new URL(request.url); - const state = url.searchParams.get("state"); - const code = url.searchParams.get("code"); - if (url.searchParams.get("error")) return back("identity_denied"); - if (!state || !code) return back("missing_params"); - - const stateData = verifySignedState(state); - if (!stateData) return back("invalid_state"); - const { organizationId, userId } = stateData; - - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), + const callback = await resolveCallback(request, { + params: ["code"], + redirect: back, + denied: "identity_denied", }); - if (!membership) return back("unauthorized"); + if (callback instanceof Response) return callback; + const { organizationId, userId, params } = callback; const { clientId, clientSecret } = microsoftCredentials(); const response = await fetch( @@ -63,7 +51,7 @@ export async function GET(request: Request) { client_id: clientId, client_secret: clientSecret, grant_type: "authorization_code", - code, + code: params.code, redirect_uri: IDENTITY_REDIRECT_URI, scope: IDENTITY_SCOPES, }), @@ -88,32 +76,16 @@ export async function GET(request: Request) { return back("identity_failed"); } - await db - .insert(userIdentities) - .values({ - provider: "microsoft_teams", - externalId: claims.data.oid, - // Entra object ids are only meaningful within their tenant. - externalScopeId: claims.data.tid, - userId, - organizationId, - handle: claims.data.preferred_username ?? null, - displayName: claims.data.name ?? null, - }) - // Re-linking claims the Entra account for whoever linked it last. - .onConflictDoUpdate({ - target: [ - userIdentities.organizationId, - userIdentities.provider, - userIdentities.externalScopeId, - userIdentities.externalId, - ], - set: { - userId, - handle: claims.data.preferred_username ?? null, - displayName: claims.data.name ?? null, - }, - }); + await upsertIdentity({ + userId, + organizationId, + provider: "microsoft_teams", + externalId: claims.data.oid, + // Entra object ids are only meaningful within their tenant. + externalScopeId: claims.data.tid, + handle: claims.data.preferred_username ?? null, + displayName: claims.data.name ?? null, + }); return back(); } diff --git a/apps/api/src/app/api/integrations/notion/callback/route.ts b/apps/api/src/app/api/integrations/notion/callback/route.ts index 527fd9a66db..d0dbdd14ded 100644 --- a/apps/api/src/app/api/integrations/notion/callback/route.ts +++ b/apps/api/src/app/api/integrations/notion/callback/route.ts @@ -1,15 +1,10 @@ -import { db } from "@superset/db/client"; -import { - integrationConnections, - members, - userIdentities, -} from "@superset/db/schema"; import { NOTION_VERSION } from "@superset/trpc/integrations/notion"; -import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; import { env } from "@/env"; -import { verifySignedState } from "@/lib/oauth-state"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; +import { upsertConnection } from "@/lib/integrations/upsertConnection"; +import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; /** * What Notion returns for an authorization code. `owner` is the person who @@ -35,41 +30,16 @@ const tokenResponseSchema = z.object({ const settingsUrl = `${env.NEXT_PUBLIC_WEB_URL}/integrations/notion`; export async function GET(request: Request) { - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - if (error) { - return Response.redirect(`${settingsUrl}?error=oauth_denied`); - } - if (!code || !state) { - return Response.redirect(`${settingsUrl}?error=missing_params`); - } if (!env.NOTION_CLIENT_ID || !env.NOTION_CLIENT_SECRET) { return Response.redirect(`${settingsUrl}?error=not_configured`); } - const stateData = verifySignedState(state); - if (!stateData) { - return Response.redirect(`${settingsUrl}?error=invalid_state`); - } - const { organizationId, userId } = stateData; - - // Re-verify membership at callback time (state was signed earlier). - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), + const callback = await resolveCallback(request, { + params: ["code"], + redirect: (error) => Response.redirect(`${settingsUrl}?error=${error}`), }); - if (!membership) { - console.error("[notion/callback] Membership verification failed:", { - organizationId, - userId, - }); - return Response.redirect(`${settingsUrl}?error=unauthorized`); - } + if (callback instanceof Response) return callback; + const { organizationId, userId, params } = callback; const basic = Buffer.from( `${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`, @@ -83,7 +53,7 @@ export async function GET(request: Request) { }, body: JSON.stringify({ grant_type: "authorization_code", - code, + code: params.code, redirect_uri: `${env.NEXT_PUBLIC_API_URL}/api/integrations/notion/callback`, }), signal: AbortSignal.timeout(15_000), @@ -109,59 +79,30 @@ export async function GET(request: Request) { } const token = parsed.data; - await db - .insert(integrationConnections) - .values({ - organizationId, - connectedByUserId: userId, - provider: "notion", - accessToken: token.access_token, - refreshToken: token.refresh_token ?? null, - externalOrgId: token.workspace_id, - externalOrgName: token.workspace_name ?? null, - }) - .onConflictDoUpdate({ - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - ], - // The org-scoped uniqueness is a partial index (Google connections - // are per user); Postgres only infers it when the predicate is named. - targetWhere: sql`${integrationConnections.provider}<> 'google'`, - set: { - accessToken: token.access_token, - refreshToken: token.refresh_token ?? null, - externalOrgId: token.workspace_id, - externalOrgName: token.workspace_name ?? null, - connectedByUserId: userId, - disconnectedAt: null, - disconnectReason: null, - updatedAt: new Date(), - }, - }); + const result = await upsertConnection({ + organizationId, + userId, + provider: "notion", + accessToken: token.access_token, + refreshToken: token.refresh_token ?? null, + externalOrgId: token.workspace_id, + externalOrgName: token.workspace_name ?? null, + }); + if (result.conflict) { + return Response.redirect(`${settingsUrl}?error=workspace_already_linked`); + } // The authorizing member's Notion user id, so `me` in a mention trigger // resolves for them. Notion user ids are per workspace, hence the scope. if (token.owner.user) { - await db - .insert(userIdentities) - .values({ - userId, - organizationId, - provider: "notion", - externalId: token.owner.user.id, - externalScopeId: token.workspace_id, - displayName: token.owner.user.name ?? null, - }) - .onConflictDoUpdate({ - target: [ - userIdentities.organizationId, - userIdentities.provider, - userIdentities.externalScopeId, - userIdentities.externalId, - ], - set: { userId, displayName: token.owner.user.name ?? null }, - }); + await upsertIdentity({ + userId, + organizationId, + provider: "notion", + externalId: token.owner.user.id, + externalScopeId: token.workspace_id, + displayName: token.owner.user.name ?? null, + }); } return Response.redirect(settingsUrl); diff --git a/apps/api/src/app/api/integrations/notion/connect/route.ts b/apps/api/src/app/api/integrations/notion/connect/route.ts index 897dc7a4f8f..12c0115435d 100644 --- a/apps/api/src/app/api/integrations/notion/connect/route.ts +++ b/apps/api/src/app/api/integrations/notion/connect/route.ts @@ -1,8 +1,5 @@ -import { auth } from "@superset/auth/server"; -import { findOrgMembership } from "@superset/db/utils"; - import { env } from "@/env"; -import { createSignedState } from "@/lib/oauth-state"; +import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; export async function GET(request: Request) { if (!env.NOTION_CLIENT_ID) { @@ -12,32 +9,8 @@ export async function GET(request: Request) { ); } - const url = new URL(request.url); - const organizationId = url.searchParams.get("organizationId"); - if (!organizationId) { - return Response.json( - { error: "Missing organizationId parameter" }, - { status: 400 }, - ); - } - - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const membership = await findOrgMembership({ - userId: session.user.id, - organizationId, - }); - if (!membership) { - return Response.json( - { error: "User is not a member of this organization" }, - { status: 403 }, - ); - } - - const state = createSignedState({ organizationId, userId: session.user.id }); + const member = await requireOrgMember(request); + if (member instanceof Response) return member; const notionAuthUrl = new URL("https://api.notion.com/v1/oauth/authorize"); notionAuthUrl.searchParams.set("client_id", env.NOTION_CLIENT_ID); @@ -49,7 +22,7 @@ export async function GET(request: Request) { // The only value Notion accepts; a workspace is authorized by one of its // members, whose identity comes back as `owner`. notionAuthUrl.searchParams.set("owner", "user"); - notionAuthUrl.searchParams.set("state", state); + notionAuthUrl.searchParams.set("state", member.state); return Response.redirect(notionAuthUrl.toString()); } diff --git a/apps/api/src/app/api/integrations/sentry/callback/route.ts b/apps/api/src/app/api/integrations/sentry/callback/route.ts index 0a846bf54e4..6ff92b08d47 100644 --- a/apps/api/src/app/api/integrations/sentry/callback/route.ts +++ b/apps/api/src/app/api/integrations/sentry/callback/route.ts @@ -1,18 +1,13 @@ -import { db } from "@superset/db/client"; -import { - integrationConnections, - members, - type SentryConfig, -} from "@superset/db/schema"; +import type { SentryConfig } from "@superset/db/schema"; import { exchangeSentryCode, fetchSentryOrganization, verifySentryInstall, } from "@superset/trpc/integrations/sentry"; -import { and, eq, sql } from "drizzle-orm"; import { env } from "@/env"; -import { verifySignedState } from "@/lib/oauth-state"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; +import { upsertConnection } from "@/lib/integrations/upsertConnection"; import { SENTRY_STATE_COOKIE } from "../connect/route"; /** @@ -38,36 +33,21 @@ const web = (params = "") => * ever update a row this route already wrote; the two never both create. */ export async function GET(request: Request) { - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const installationId = url.searchParams.get("installationId"); - const error = url.searchParams.get("error"); - - if (error) return web("?error=oauth_denied"); - if (!code || !installationId) return web("?error=missing_params"); - - const stateCookie = readCookie( - request.headers.get("cookie"), - SENTRY_STATE_COOKIE, - ); - const stateData = stateCookie ? verifySignedState(stateCookie) : null; - if (!stateData) return web("?error=invalid_state"); - const { organizationId, userId } = stateData; - - // Re-verify membership at callback time (the cookie was signed earlier). - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), + const callback = await resolveCallback(request, { + params: ["code", "installationId"], + redirect: (error) => web(`?error=${error}`), + stateFrom: (req) => + readCookie(req.headers.get("cookie"), SENTRY_STATE_COOKIE), }); - if (!membership) return web("?error=unauthorized"); + if (callback instanceof Response) return callback; + const { organizationId, userId, params } = callback; + const installationId = params.installationId; let token: Awaited>; try { token = await exchangeSentryCode({ installationUuid: installationId, - code, + code: params.code, }); } catch (e) { console.error("[sentry/callback] Token exchange failed:", e); @@ -83,40 +63,18 @@ export async function GET(request: Request) { regionUrl: organization.regionUrl, }; - await db - .insert(integrationConnections) - .values({ - organizationId, - connectedByUserId: userId, - provider: "sentry", - accessToken: token.token, - refreshToken: token.refreshToken, - tokenExpiresAt: new Date(token.expiresAt), - externalOrgId: organization.slug, - externalOrgName: organization.name, - config, - }) - .onConflictDoUpdate({ - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - ], - // The org-scoped uniqueness is a partial index (Google connections are - // per user); Postgres only infers it when the predicate is named. - targetWhere: sql`${integrationConnections.provider} <> 'google'`, - set: { - accessToken: token.token, - refreshToken: token.refreshToken, - tokenExpiresAt: new Date(token.expiresAt), - externalOrgId: organization.slug, - externalOrgName: organization.name, - connectedByUserId: userId, - config, - disconnectedAt: null, - disconnectReason: null, - updatedAt: new Date(), - }, - }); + const result = await upsertConnection({ + organizationId, + userId, + provider: "sentry", + accessToken: token.token, + refreshToken: token.refreshToken, + tokenExpiresAt: new Date(token.expiresAt), + externalOrgId: organization.slug, + externalOrgName: organization.name, + config, + }); + if (result.conflict) return web("?error=organization_already_linked"); // Verify Install, if the app has it on; best-effort, the token already works. await verifySentryInstall(installationId, token.token); diff --git a/apps/api/src/app/api/integrations/sentry/connect/route.ts b/apps/api/src/app/api/integrations/sentry/connect/route.ts index 0341136ac21..6b19749aa9e 100644 --- a/apps/api/src/app/api/integrations/sentry/connect/route.ts +++ b/apps/api/src/app/api/integrations/sentry/connect/route.ts @@ -1,8 +1,5 @@ -import { auth } from "@superset/auth/server"; -import { findOrgMembership } from "@superset/db/utils"; - import { env } from "@/env"; -import { createSignedState } from "@/lib/oauth-state"; +import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; /** How the callback recovers which Superset org started the install. */ export const SENTRY_STATE_COOKIE = "sentry_oauth_state"; @@ -18,31 +15,8 @@ export const SENTRY_STATE_COOKIE = "sentry_oauth_state"; * through Sentry. */ export async function GET(request: Request) { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const organizationId = new URL(request.url).searchParams.get( - "organizationId", - ); - if (!organizationId) { - return Response.json( - { error: "Missing organizationId parameter" }, - { status: 400 }, - ); - } - - const membership = await findOrgMembership({ - userId: session.user.id, - organizationId, - }); - if (!membership) { - return Response.json( - { error: "User is not a member of this organization" }, - { status: 403 }, - ); - } + const member = await requireOrgMember(request); + if (member instanceof Response) return member; if (!env.SENTRY_APP_SLUG || !env.SENTRY_CLIENT_ID) { return Response.redirect( @@ -50,11 +24,6 @@ export async function GET(request: Request) { ); } - const state = createSignedState({ - organizationId, - userId: session.user.id, - }); - const installUrl = `https://sentry.io/sentry-apps/${env.SENTRY_APP_SLUG}/external-install/`; const secure = env.NEXT_PUBLIC_API_URL.startsWith("https") ? " Secure;" : ""; @@ -64,7 +33,7 @@ export async function GET(request: Request) { Location: installUrl, // Scoped to the callback's path so it is sent on the top-level GET // redirect back and nowhere else; short-lived, like the state's TTL. - "Set-Cookie": `${SENTRY_STATE_COOKIE}=${state}; HttpOnly;${secure} SameSite=Lax; Path=/api/integrations/sentry; Max-Age=600`, + "Set-Cookie": `${SENTRY_STATE_COOKIE}=${member.state}; HttpOnly;${secure} SameSite=Lax; Path=/api/integrations/sentry; Max-Age=600`, }, }); } diff --git a/apps/api/src/app/api/integrations/slack/callback/route.ts b/apps/api/src/app/api/integrations/slack/callback/route.ts index a25d389e8dc..9d52b580520 100644 --- a/apps/api/src/app/api/integrations/slack/callback/route.ts +++ b/apps/api/src/app/api/integrations/slack/callback/route.ts @@ -1,63 +1,20 @@ import { WebClient } from "@slack/web-api"; -import { db } from "@superset/db/client"; import type { SlackConfig } from "@superset/db/schema"; -import { integrationConnections, members, users } from "@superset/db/schema"; -import { and, eq, isNull, ne, sql } from "drizzle-orm"; import { env } from "@/env"; import { posthog } from "@/lib/analytics"; -import { verifySignedState } from "@/lib/oauth-state"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; +import { upsertConnection } from "@/lib/integrations/upsertConnection"; -const UNIQUE_VIOLATION = "23505"; -const ACTIVE_LINKAGE_INDEXES = new Set([ - "integration_connections_provider_external_org_active_unique", - "integration_connections_slack_external_org_active_unique", -]); +const settingsUrl = `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack`; export async function GET(request: Request) { - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - if (error) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=oauth_denied`, - ); - } - - if (!code || !state) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=missing_params`, - ); - } - - const stateData = verifySignedState(state); - if (!stateData) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=invalid_state`, - ); - } - - const { organizationId, userId } = stateData; - - // Re-verify membership at callback time (state was signed earlier) - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), + const callback = await resolveCallback(request, { + params: ["code"], + redirect: (error) => Response.redirect(`${settingsUrl}?error=${error}`), }); - - if (!membership) { - console.error("[slack/callback] Membership verification failed:", { - organizationId, - userId, - }); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=unauthorized`, - ); - } + if (callback instanceof Response) return callback; + const { organizationId, userId, params } = callback; const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; const client = new WebClient(); @@ -67,71 +24,36 @@ export async function GET(request: Request) { client_id: env.SLACK_CLIENT_ID, client_secret: env.SLACK_CLIENT_SECRET, redirect_uri: redirectUri, - code, + code: params.code, }); if (!tokenData.ok || !tokenData.access_token || !tokenData.team?.id) { console.error("[slack/callback] Slack API error:", tokenData.error); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=slack_api_error`, - ); + return Response.redirect(`${settingsUrl}?error=slack_api_error`); } const config: SlackConfig = { provider: "slack", }; - const [conflict] = await db - .select({ email: users.email }) - .from(integrationConnections) - .innerJoin(users, eq(users.id, integrationConnections.connectedByUserId)) - .where( - and( - eq(integrationConnections.provider, "slack"), - eq(integrationConnections.externalOrgId, tokenData.team.id), - isNull(integrationConnections.disconnectedAt), - ne(integrationConnections.organizationId, organizationId), - ), - ) - .limit(1); - - if (conflict) { + const result = await upsertConnection({ + organizationId, + userId, + provider: "slack", + accessToken: tokenData.access_token, + externalOrgId: tokenData.team.id, + externalOrgName: tokenData.team.name, + config, + }); + if (result.conflict) { + const owner = result.conflict.ownerEmail + ? `&owner=${encodeURIComponent(result.conflict.ownerEmail)}` + : ""; return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=workspace_already_linked&owner=${encodeURIComponent(conflict.email)}`, + `${settingsUrl}?error=workspace_already_linked${owner}`, ); } - await db - .insert(integrationConnections) - .values({ - organizationId, - connectedByUserId: userId, - provider: "slack", - accessToken: tokenData.access_token, - externalOrgId: tokenData.team.id, - externalOrgName: tokenData.team.name, - config, - }) - .onConflictDoUpdate({ - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - ], - // The org-scoped uniqueness is a partial index (Google connections - // are per user); Postgres only infers it when the predicate is named. - targetWhere: sql`${integrationConnections.provider}<> 'google'`, - set: { - accessToken: tokenData.access_token, - externalOrgId: tokenData.team.id, - externalOrgName: tokenData.team.name, - connectedByUserId: userId, - config, - disconnectedAt: null, - disconnectReason: null, - updatedAt: new Date(), - }, - }); - console.log("[slack/callback] Connected workspace:", { organizationId, teamId: tokenData.team.id, @@ -144,21 +66,9 @@ export async function GET(request: Request) { properties: { team_id: tokenData.team.id }, }); - return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack`); + return Response.redirect(settingsUrl); } catch (error) { - const e = error as { code?: string; constraint?: string }; - if ( - e.code === UNIQUE_VIOLATION && - e.constraint && - ACTIVE_LINKAGE_INDEXES.has(e.constraint) - ) { - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=workspace_already_linked`, - ); - } console.error("[slack/callback] Token exchange failed:", error); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=token_exchange_failed`, - ); + return Response.redirect(`${settingsUrl}?error=token_exchange_failed`); } } diff --git a/apps/api/src/app/api/integrations/slack/connect/route.ts b/apps/api/src/app/api/integrations/slack/connect/route.ts index 170557147fa..ea6cf517669 100644 --- a/apps/api/src/app/api/integrations/slack/connect/route.ts +++ b/apps/api/src/app/api/integrations/slack/connect/route.ts @@ -1,8 +1,5 @@ -import { auth } from "@superset/auth/server"; -import { findOrgMembership } from "@superset/db/utils"; - import { env } from "@/env"; -import { createSignedState } from "@/lib/oauth-state"; +import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; const SLACK_SCOPES = [ "app_mentions:read", @@ -25,38 +22,8 @@ const SLACK_SCOPES = [ ].join(","); export async function GET(request: Request) { - const url = new URL(request.url); - const organizationId = url.searchParams.get("organizationId"); - if (!organizationId) { - return Response.json( - { error: "Missing organizationId parameter" }, - { status: 400 }, - ); - } - - const session = await auth.api.getSession({ - headers: request.headers, - }); - - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - const membership = await findOrgMembership({ userId, organizationId }); - - if (!membership) { - return Response.json( - { error: "User is not a member of this organization" }, - { status: 403 }, - ); - } - - const state = createSignedState({ - organizationId, - userId, - }); + const member = await requireOrgMember(request); + if (member instanceof Response) return member; const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; @@ -64,7 +31,7 @@ export async function GET(request: Request) { slackAuthUrl.searchParams.set("client_id", env.SLACK_CLIENT_ID); slackAuthUrl.searchParams.set("redirect_uri", redirectUri); slackAuthUrl.searchParams.set("scope", SLACK_SCOPES); - slackAuthUrl.searchParams.set("state", state); + slackAuthUrl.searchParams.set("state", member.state); return Response.redirect(slackAuthUrl.toString()); } diff --git a/apps/api/src/app/api/integrations/slack/link/route.ts b/apps/api/src/app/api/integrations/slack/link/route.ts index 229030cb0ed..bcdfab2ccd6 100644 --- a/apps/api/src/app/api/integrations/slack/link/route.ts +++ b/apps/api/src/app/api/integrations/slack/link/route.ts @@ -1,11 +1,12 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { auth } from "@superset/auth/server"; import { db } from "@superset/db/client"; -import { integrationConnections, userIdentities } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import { findOrgMembership } from "@superset/db/utils"; import { and, desc, eq, isNull } from "drizzle-orm"; import { headers } from "next/headers"; import { env } from "@/env"; +import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; export async function GET(request: Request) { const url = new URL(request.url); @@ -84,26 +85,14 @@ export async function GET(request: Request) { ); } - await db - .insert(userIdentities) - .values({ - provider: "slack", - externalId: payload.slackUserId, - // A Slack user id is only unique within a workspace. - externalScopeId: payload.teamId, - userId: session.user.id, - organizationId: connection.organizationId, - }) - // Re-linking claims the Slack account for whoever linked it last. - .onConflictDoUpdate({ - target: [ - userIdentities.organizationId, - userIdentities.provider, - userIdentities.externalScopeId, - userIdentities.externalId, - ], - set: { userId: session.user.id }, - }); + await upsertIdentity({ + userId: session.user.id, + organizationId: connection.organizationId, + provider: "slack", + externalId: payload.slackUserId, + // A Slack user id is only unique within a workspace. + externalScopeId: payload.teamId, + }); return Response.redirect( `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack/linked`, diff --git a/apps/api/src/lib/integrations/requireOrgMember.ts b/apps/api/src/lib/integrations/requireOrgMember.ts new file mode 100644 index 00000000000..628a6056936 --- /dev/null +++ b/apps/api/src/lib/integrations/requireOrgMember.ts @@ -0,0 +1,52 @@ +import { auth } from "@superset/auth/server"; +import { findOrgMembership } from "@superset/db/utils"; + +import { createSignedState } from "@/lib/oauth-state"; + +export type OrgMember = { + organizationId: string; + userId: string; + /** Signed OAuth state carrying the pair through the provider round-trip. */ + state: string; +}; + +/** + * The connect-route preamble: who is asking, for which organization, and + * whether they belong to it. Returns the error response to send when a check + * fails. + */ +export async function requireOrgMember( + request: Request, +): Promise { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const organizationId = new URL(request.url).searchParams.get( + "organizationId", + ); + if (!organizationId) { + return Response.json( + { error: "Missing organizationId parameter" }, + { status: 400 }, + ); + } + + const membership = await findOrgMembership({ + userId: session.user.id, + organizationId, + }); + if (!membership) { + return Response.json( + { error: "User is not a member of this organization" }, + { status: 403 }, + ); + } + + return { + organizationId, + userId: session.user.id, + state: createSignedState({ organizationId, userId: session.user.id }), + }; +} diff --git a/apps/api/src/lib/integrations/resolveCallback.ts b/apps/api/src/lib/integrations/resolveCallback.ts new file mode 100644 index 00000000000..54710f3f0ac --- /dev/null +++ b/apps/api/src/lib/integrations/resolveCallback.ts @@ -0,0 +1,67 @@ +import { findOrgMembership } from "@superset/db/utils"; + +import { verifySignedState } from "@/lib/oauth-state"; + +type ResolveCallbackOptions = { + /** Query params (besides `state`) the provider must send back. */ + params: readonly Name[]; + /** Builds the provider's error redirect for a failure reason. */ + redirect: (error: string) => Response; + /** Error value when the provider reports a denial (default "oauth_denied"). */ + denied?: string; + /** + * Where the state token travels when not in the `state` query param + * (Sentry carries it in a first-party cookie). Missing then means + * `invalid_state`, not `missing_params`. + */ + stateFrom?: (request: Request) => string | null; +}; + +export type CallbackContext = { + organizationId: string; + userId: string; + url: URL; + params: Record; +}; + +/** + * The callback-route preamble: provider denial, required params, signed-state + * verification, and membership re-verified at callback time (the state was + * signed earlier). Returns the provider's error redirect when a check fails. + */ +export async function resolveCallback( + request: Request, + options: ResolveCallbackOptions, +): Promise | Response> { + const url = new URL(request.url); + if (url.searchParams.get("error")) { + return options.redirect(options.denied ?? "oauth_denied"); + } + + const params = {} as Record; + for (const name of options.params) { + const value = url.searchParams.get(name); + if (!value) return options.redirect("missing_params"); + params[name] = value; + } + + const state = options.stateFrom + ? options.stateFrom(request) + : url.searchParams.get("state"); + if (!state && !options.stateFrom) return options.redirect("missing_params"); + + const stateData = state ? verifySignedState(state) : null; + if (!stateData) return options.redirect("invalid_state"); + const { organizationId, userId } = stateData; + + const membership = await findOrgMembership({ userId, organizationId }); + if (!membership) { + console.error("[integrations] callback membership verification failed:", { + organizationId, + userId, + }); + return options.redirect("unauthorized"); + } + + return { organizationId, userId, url, params }; +} diff --git a/apps/api/src/lib/integrations/upsertConnection.ts b/apps/api/src/lib/integrations/upsertConnection.ts new file mode 100644 index 00000000000..d079b7ff32f --- /dev/null +++ b/apps/api/src/lib/integrations/upsertConnection.ts @@ -0,0 +1,146 @@ +import { db } from "@superset/db/client"; +import { + type IntegrationConfig, + type IntegrationProvider, + integrationConnections, + users, +} from "@superset/db/schema"; +import { and, eq, isNull, ne, type SQL, sql } from "drizzle-orm"; + +const UNIQUE_VIOLATION = "23505"; +const ACTIVE_LINKAGE_INDEXES = new Set([ + "integration_connections_provider_external_org_active_unique", + "integration_connections_slack_external_org_active_unique", +]); + +export type ConnectionConflict = { ownerEmail: string | null }; + +/** + * The active connection in a DIFFERENT Superset organization already holding + * this external workspace/tenant/account, if any. One external org, one + * Superset organization: a second organization claiming the same one would + * receive its events too. + */ +export async function connectionConflict( + provider: IntegrationProvider, + externalOrgId: string, + organizationId: string, +): Promise { + const [conflict] = await db + .select({ email: users.email }) + .from(integrationConnections) + .innerJoin(users, eq(users.id, integrationConnections.connectedByUserId)) + .where( + and( + eq(integrationConnections.provider, provider), + eq(integrationConnections.externalOrgId, externalOrgId), + isNull(integrationConnections.disconnectedAt), + ne(integrationConnections.organizationId, organizationId), + ), + ) + .limit(1); + return conflict ? { ownerEmail: conflict.email } : null; +} + +type UpsertConnectionInput = { + organizationId: string; + userId: string; + provider: IntegrationProvider; + accessToken: string; + /** Omit to leave the stored value alone on reconnect. */ + refreshToken?: string | null; + tokenExpiresAt?: Date | null; + externalOrgId: string; + externalOrgName?: string | null; + /** Omit to leave the stored config alone on reconnect. */ + config?: IntegrationConfig; + /** Overrides `config` on the update path (Google merges sync state in SQL). */ + configOnUpdate?: SQL; +}; + +export type UpsertConnectionResult = + | { conflict: ConnectionConflict } + | { conflict?: undefined; connectionId: string }; + +/** + * Writes (or revives) the organization's connection for a provider. Refuses + * with `conflict` when the external org is already actively linked to a + * different Superset organization. + */ +export async function upsertConnection( + input: UpsertConnectionInput, +): Promise { + const conflict = await connectionConflict( + input.provider, + input.externalOrgId, + input.organizationId, + ); + if (conflict) return { conflict }; + + // One connection per organization for org-scoped providers; Google is per + // member. Both uniquenesses are partial indexes, and Postgres only infers a + // partial index when the predicate is named in the conflict target. + const conflictTarget = + input.provider === "google" + ? { + target: [ + integrationConnections.organizationId, + integrationConnections.provider, + integrationConnections.connectedByUserId, + ], + targetWhere: sql`${integrationConnections.provider} = 'google'`, + } + : { + target: [ + integrationConnections.organizationId, + integrationConnections.provider, + ], + targetWhere: sql`${integrationConnections.provider} <> 'google'`, + }; + + try { + const [connection] = await db + .insert(integrationConnections) + .values({ + organizationId: input.organizationId, + connectedByUserId: input.userId, + provider: input.provider, + accessToken: input.accessToken, + refreshToken: input.refreshToken, + tokenExpiresAt: input.tokenExpiresAt, + externalOrgId: input.externalOrgId, + externalOrgName: input.externalOrgName, + config: input.config, + }) + .onConflictDoUpdate({ + ...conflictTarget, + set: { + accessToken: input.accessToken, + refreshToken: input.refreshToken, + tokenExpiresAt: input.tokenExpiresAt, + externalOrgId: input.externalOrgId, + externalOrgName: input.externalOrgName, + connectedByUserId: input.userId, + config: input.configOnUpdate ?? input.config, + disconnectedAt: null, + disconnectReason: null, + updatedAt: new Date(), + }, + }) + .returning({ id: integrationConnections.id }); + if (!connection) throw new Error("Connection upsert returned no row"); + return { connectionId: connection.id }; + } catch (error) { + // Racing connects: the partial unique index on active external orgs wins + // where the pre-check above cannot. + const raced = error as { code?: string; constraint?: string }; + if ( + raced.code === UNIQUE_VIOLATION && + raced.constraint && + ACTIVE_LINKAGE_INDEXES.has(raced.constraint) + ) { + return { conflict: { ownerEmail: null } }; + } + throw error; + } +} diff --git a/apps/api/src/lib/integrations/upsertIdentity.ts b/apps/api/src/lib/integrations/upsertIdentity.ts new file mode 100644 index 00000000000..7a47eb3cfa8 --- /dev/null +++ b/apps/api/src/lib/integrations/upsertIdentity.ts @@ -0,0 +1,49 @@ +import { db } from "@superset/db/client"; +import { type UserIdentityMetadata, userIdentities } from "@superset/db/schema"; + +type UpsertIdentityInput = { + userId: string; + organizationId: string; + provider: string; + externalId: string; + /** The workspace/tenant the id is scoped to; null for globally-unique ids. */ + externalScopeId: string | null; + handle?: string | null; + displayName?: string | null; + metadata?: UserIdentityMetadata; +}; + +/** + * Links an external account to a Superset user — what lets "me" on a trigger + * resolve. Re-linking claims the account for whoever linked it last. + */ +export async function upsertIdentity( + input: UpsertIdentityInput, +): Promise { + await db + .insert(userIdentities) + .values({ + userId: input.userId, + organizationId: input.organizationId, + provider: input.provider, + externalId: input.externalId, + externalScopeId: input.externalScopeId, + handle: input.handle, + displayName: input.displayName, + metadata: input.metadata, + }) + .onConflictDoUpdate({ + target: [ + userIdentities.organizationId, + userIdentities.provider, + userIdentities.externalScopeId, + userIdentities.externalId, + ], + set: { + userId: input.userId, + handle: input.handle, + displayName: input.displayName, + metadata: input.metadata, + }, + }); +} diff --git a/packages/trpc/src/router/integration/connections.ts b/packages/trpc/src/router/integration/connections.ts new file mode 100644 index 00000000000..0d01743288c --- /dev/null +++ b/packages/trpc/src/router/integration/connections.ts @@ -0,0 +1,87 @@ +import { db } from "@superset/db/client"; +import { + type IntegrationProvider, + integrationConnections, + type SelectIntegrationConnection, +} from "@superset/db/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../trpc"; +import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; + +type ConnectionColumns = Partial< + Record +>; + +type Selected = Pick< + SelectIntegrationConnection, + Extract +>; + +/** + * The organization's live connection for a provider. "Is it connected" has one + * answer everywhere: a row marked disconnected is not. + */ +export async function activeConnection( + organizationId: string, + provider: IntegrationProvider, + columns: C, +): Promise | undefined> { + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, organizationId), + eq(integrationConnections.provider, provider), + isNull(integrationConnections.disconnectedAt), + ), + columns, + }); + return connection as Selected | undefined; +} + +export function getConnectionProcedure( + provider: IntegrationProvider, + columns: C, + present: (connection: Selected) => R, +) { + return protectedProcedure + .input(z.object({ organizationId: z.uuid() })) + .query(async ({ ctx, input }): Promise => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + const connection = await activeConnection( + input.organizationId, + provider, + columns, + ); + return connection ? present(connection) : null; + }); +} + +export function disconnectProcedure( + provider: IntegrationProvider, + before?: (connectionId: string, organizationId: string) => Promise, +) { + return protectedProcedure + .input(z.object({ organizationId: z.uuid() })) + .mutation(async ({ ctx, input }) => { + await verifyOrgAdmin(ctx.session.user.id, input.organizationId); + + // Not activeConnection: a needs-reconnect row must still be removable. + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, input.organizationId), + eq(integrationConnections.provider, provider), + ), + columns: { id: true }, + }); + if (!connection) { + return { success: false, error: "No connection found" }; + } + + if (before) await before(connection.id, input.organizationId); + await db + .delete(integrationConnections) + .where(eq(integrationConnections.id, connection.id)); + + return { success: true }; + }); +} diff --git a/packages/trpc/src/router/integration/linear/constants.ts b/packages/trpc/src/router/integration/linear/constants.ts index d20121e87e7..bbb61652516 100644 --- a/packages/trpc/src/router/integration/linear/constants.ts +++ b/packages/trpc/src/router/integration/linear/constants.ts @@ -1,3 +1 @@ -export const REFRESH_BUFFER_MS = 5 * 60 * 1000; - export const REFRESH_TOKEN_TIMEOUT_MS = 10 * 1000; diff --git a/packages/trpc/src/router/integration/linear/linear.ts b/packages/trpc/src/router/integration/linear/linear.ts index 2e37d53b56c..7e5ce6e836b 100644 --- a/packages/trpc/src/router/integration/linear/linear.ts +++ b/packages/trpc/src/router/integration/linear/linear.ts @@ -10,6 +10,7 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; +import { disconnectProcedure } from "../connections"; import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; import { callLinear } from "./refresh"; @@ -38,91 +39,67 @@ export const linearRouter = { }; }), - disconnect: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .mutation(async ({ ctx, input }) => { - await verifyOrgAdmin(ctx.session.user.id, input.organizationId); + disconnect: disconnectProcedure("linear", async (_, organizationId) => { + try { + await callLinear(organizationId, (client) => client.logout()); + } catch {} - try { - await callLinear(input.organizationId, (client) => client.logout()); - } catch {} - - const result = await dbWs.transaction(async (tx) => { - // 1. Delete Linear-synced tasks - await tx - .delete(tasks) - .where( - and( - eq(tasks.organizationId, input.organizationId), - eq(tasks.externalProvider, "linear"), - ), - ); - - // 2. Seed default statuses inside the transaction - const backlogStatusId = await seedDefaultStatuses( - input.organizationId, - tx, + await dbWs.transaction(async (tx) => { + // 1. Delete Linear-synced tasks + await tx + .delete(tasks) + .where( + and( + eq(tasks.organizationId, organizationId), + eq(tasks.externalProvider, "linear"), + ), ); - // 3. Remap remaining local tasks from Linear statuses to default statuses - const allStatuses = await tx.query.taskStatuses.findMany({ - where: eq(taskStatuses.organizationId, input.organizationId), - }); + // 2. Seed default statuses inside the transaction + const backlogStatusId = await seedDefaultStatuses(organizationId, tx); - const defaultStatusByType = new Map(); - for (const status of allStatuses) { - if (!status.externalProvider && status.type) { - if (!defaultStatusByType.has(status.type)) { - defaultStatusByType.set(status.type, status.id); - } - } - } + // 3. Remap remaining local tasks from Linear statuses to default statuses + const allStatuses = await tx.query.taskStatuses.findMany({ + where: eq(taskStatuses.organizationId, organizationId), + }); - for (const status of allStatuses) { - if (status.externalProvider === "linear") { - const defaultStatusId = - (status.type && defaultStatusByType.get(status.type)) || - backlogStatusId; - await tx - .update(tasks) - .set({ statusId: defaultStatusId }) - .where( - and( - eq(tasks.organizationId, input.organizationId), - eq(tasks.statusId, status.id), - ), - ); + const defaultStatusByType = new Map(); + for (const status of allStatuses) { + if (!status.externalProvider && status.type) { + if (!defaultStatusByType.has(status.type)) { + defaultStatusByType.set(status.type, status.id); } } + } - // 4. Delete Linear task statuses - await tx - .delete(taskStatuses) - .where( - and( - eq(taskStatuses.organizationId, input.organizationId), - eq(taskStatuses.externalProvider, "linear"), - ), - ); - - // 5. Delete the integration connection - return tx - .delete(integrationConnections) - .where( - and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "linear"), - ), - ) - .returning({ id: integrationConnections.id }); - }); - - if (result.length === 0) { - return { success: false, error: "No connection found" }; + for (const status of allStatuses) { + if (status.externalProvider === "linear") { + const defaultStatusId = + (status.type && defaultStatusByType.get(status.type)) || + backlogStatusId; + await tx + .update(tasks) + .set({ statusId: defaultStatusId }) + .where( + and( + eq(tasks.organizationId, organizationId), + eq(tasks.statusId, status.id), + ), + ); + } } - return { success: true }; - }), + // 4. Delete Linear task statuses + await tx + .delete(taskStatuses) + .where( + and( + eq(taskStatuses.organizationId, organizationId), + eq(taskStatuses.externalProvider, "linear"), + ), + ); + }); + }), getTeams: protectedProcedure .input(z.object({ organizationId: z.uuid() })) diff --git a/packages/trpc/src/router/integration/linear/refresh.ts b/packages/trpc/src/router/integration/linear/refresh.ts index dc8a017295c..863dfc7a29b 100644 --- a/packages/trpc/src/router/integration/linear/refresh.ts +++ b/packages/trpc/src/router/integration/linear/refresh.ts @@ -1,12 +1,17 @@ import { LinearClient } from "@linear/sdk"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; -import { withConnectionLock } from "@superset/db/utils"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { env } from "../../../env"; -import { REFRESH_BUFFER_MS, REFRESH_TOKEN_TIMEOUT_MS } from "./constants"; -import { getLinearClient, markConnectionDisconnected } from "./utils"; +import { + markDisconnected, + type RefreshedToken, + TokenRefreshError, + withRefreshedToken, +} from "../token-refresh"; +import { REFRESH_TOKEN_TIMEOUT_MS } from "./constants"; +import { getLinearClient } from "./utils"; export const linearTokenResponseSchema = z.object({ access_token: z.string(), @@ -18,91 +23,55 @@ export const linearTokenResponseSchema = z.object({ export type LinearTokenResponse = z.infer; -type RefreshResult = - | { disconnected: true } - | { disconnected: false; accessToken: string }; - export async function refreshLinearToken( connectionId: string, -): Promise { - return withConnectionLock(connectionId, async (tx) => { - const [connection] = await tx - .select({ - accessToken: integrationConnections.accessToken, - refreshToken: integrationConnections.refreshToken, - tokenExpiresAt: integrationConnections.tokenExpiresAt, - disconnectedAt: integrationConnections.disconnectedAt, - }) - .from(integrationConnections) - .where(eq(integrationConnections.id, connectionId)) - .limit(1); - - if (!connection?.refreshToken) return { disconnected: true }; - if (connection.disconnectedAt) return { disconnected: true }; - - if ( - connection.tokenExpiresAt && - connection.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS - ) { - return { disconnected: false, accessToken: connection.accessToken }; - } - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - REFRESH_TOKEN_TIMEOUT_MS, - ); - let response: Response; - try { - response = await fetch("https://api.linear.app/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - signal: controller.signal, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: connection.refreshToken, - client_id: env.LINEAR_CLIENT_ID, - client_secret: env.LINEAR_CLIENT_SECRET, - }), - }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { - error?: string; - }; - if (body?.error === "invalid_grant") { - await tx - .update(integrationConnections) - .set({ - disconnectedAt: new Date(), - disconnectReason: "invalid_grant", - }) - .where(eq(integrationConnections.id, connectionId)); - return { disconnected: true }; - } - throw new Error( - `Linear token refresh failed: ${response.status} ${response.statusText}`, +): Promise { + return withRefreshedToken(connectionId, { + exchange: async (connection) => { + if (!connection.refreshToken) return { revoked: "no_refresh_token" }; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + REFRESH_TOKEN_TIMEOUT_MS, ); - } + let response: Response; + try { + response = await fetch("https://api.linear.app/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + signal: controller.signal, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: connection.refreshToken, + client_id: env.LINEAR_CLIENT_ID, + client_secret: env.LINEAR_CLIENT_SECRET, + }), + }); + } finally { + clearTimeout(timeout); + } - const data = linearTokenResponseSchema.parse(await response.json()); - const tokenExpiresAt = new Date(Date.now() + data.expires_in * 1000); + if (!response.ok) { + throw new TokenRefreshError( + response.status, + await response.json().catch(() => ({})), + `Linear token refresh failed: ${response.status} ${response.statusText}`, + ); + } - await tx - .update(integrationConnections) - .set({ + const data = linearTokenResponseSchema.parse(await response.json()); + return { accessToken: data.access_token, refreshToken: data.refresh_token, - tokenExpiresAt, - disconnectedAt: null, - disconnectReason: null, - }) - .where(eq(integrationConnections.id, connectionId)); - - return { disconnected: false, accessToken: data.access_token }; + tokenExpiresAt: new Date(Date.now() + data.expires_in * 1000), + }; + }, + revokedWhen: (error) => + error instanceof TokenRefreshError && + (error.body as { error?: string }).error === "invalid_grant" + ? "invalid_grant" + : null, }); } @@ -126,7 +95,7 @@ export async function callLinear( }); if (!connection) return null; if (!connection.refreshToken) { - await markConnectionDisconnected(connection.id, "no_refresh_token"); + await markDisconnected(connection.id, "no_refresh_token"); return null; } diff --git a/packages/trpc/src/router/integration/linear/utils.ts b/packages/trpc/src/router/integration/linear/utils.ts index b9945fdeebb..89988f065b2 100644 --- a/packages/trpc/src/router/integration/linear/utils.ts +++ b/packages/trpc/src/router/integration/linear/utils.ts @@ -2,7 +2,7 @@ import { LinearClient } from "@linear/sdk"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; -import { REFRESH_BUFFER_MS } from "./constants"; +import { markDisconnected, REFRESH_BUFFER_MS } from "../token-refresh"; import { isLinearAuthError, refreshLinearToken } from "./refresh"; type Priority = "urgent" | "high" | "medium" | "low" | "none"; @@ -57,7 +57,7 @@ export async function getLinearClient( if (expiresSoon) { if (!connection.refreshToken) { - await markConnectionDisconnected(connection.id, "no_refresh_token"); + await markDisconnected(connection.id, "no_refresh_token"); return null; } try { @@ -77,13 +77,3 @@ export async function getLinearClient( return new LinearClient({ accessToken: connection.accessToken }); } - -export async function markConnectionDisconnected( - connectionId: string, - reason: string, -): Promise { - await db - .update(integrationConnections) - .set({ disconnectedAt: new Date(), disconnectReason: reason }) - .where(eq(integrationConnections.id, connectionId)); -} diff --git a/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts b/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts index e125e8764aa..8bbcff2f25a 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts @@ -1,35 +1,18 @@ -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; -import { protectedProcedure } from "../../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; +import { disconnectProcedure, getConnectionProcedure } from "../connections"; import { deleteTeamsSubscriptions } from "./subscriptions"; export const microsoftTeamsRouter = { - getConnection: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "microsoft_teams"), - ), - columns: { - id: true, - externalOrgId: true, - externalOrgName: true, - config: true, - createdAt: true, - disconnectedAt: true, - disconnectReason: true, - }, - }); - if (!connection || connection.disconnectedAt) return null; - + getConnection: getConnectionProcedure( + "microsoft_teams", + { + id: true, + externalOrgId: true, + externalOrgName: true, + config: true, + createdAt: true, + }, + (connection) => { const config = connection.config?.provider === "microsoft_teams" ? connection.config @@ -46,31 +29,12 @@ export const microsoftTeamsRouter = { channels: config?.subscriptions.channels ?? null, }, }; - }), - - disconnect: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .mutation(async ({ ctx, input }) => { - await verifyOrgAdmin(ctx.session.user.id, input.organizationId); - - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "microsoft_teams"), - ), - columns: { id: true }, - }); - if (!connection) { - return { success: false, error: "No connection found" }; - } - - // Before the row goes: the subscription ids live on it, and Graph - // would otherwise keep posting to the notify route for two more days. - await deleteTeamsSubscriptions(connection.id); - await db - .delete(integrationConnections) - .where(eq(integrationConnections.id, connection.id)); + }, + ), - return { success: true }; - }), + // Before the row goes: the subscription ids live on it, and Graph would + // otherwise keep posting to the notify route for two more days. + disconnect: disconnectProcedure("microsoft_teams", (connectionId) => + deleteTeamsSubscriptions(connectionId), + ), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/notion/notion.ts b/packages/trpc/src/router/integration/notion/notion.ts index be61023253f..37aa8e031dd 100644 --- a/packages/trpc/src/router/integration/notion/notion.ts +++ b/packages/trpc/src/router/integration/notion/notion.ts @@ -1,54 +1,16 @@ -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; -import { and, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; -import { protectedProcedure } from "../../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; +import { disconnectProcedure, getConnectionProcedure } from "../connections"; export const notionRouter = { - getConnection: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "notion"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { id: true, externalOrgName: true, createdAt: true }, - }); - - if (!connection) return null; - - return { - id: connection.id, - externalOrgName: connection.externalOrgName, - connectedAt: connection.createdAt, - }; + getConnection: getConnectionProcedure( + "notion", + { id: true, externalOrgName: true, createdAt: true }, + (connection) => ({ + id: connection.id, + externalOrgName: connection.externalOrgName, + connectedAt: connection.createdAt, }), + ), - disconnect: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .mutation(async ({ ctx, input }) => { - await verifyOrgAdmin(ctx.session.user.id, input.organizationId); - - const result = await db - .delete(integrationConnections) - .where( - and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "notion"), - ), - ) - .returning({ id: integrationConnections.id }); - - if (result.length === 0) { - return { success: false, error: "No connection found" }; - } - - return { success: true }; - }), + disconnect: disconnectProcedure("notion"), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/notion/trigger-options.ts b/packages/trpc/src/router/integration/notion/trigger-options.ts index 1e5d481345e..d6d1bc3e452 100644 --- a/packages/trpc/src/router/integration/notion/trigger-options.ts +++ b/packages/trpc/src/router/integration/notion/trigger-options.ts @@ -4,9 +4,7 @@ import { isFullUser, iteratePaginatedAPI, } from "@notionhq/client"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { activeConnection } from "../connections"; import type { TriggerOption, TriggerOptionSource } from "../trigger-options"; import { notionClient, plainText } from "./client"; @@ -20,13 +18,8 @@ const MAX_OPTIONS = 500; * the chip keeps its "anyone / me" entries rather than the editor going red. */ async function connectedClient(organizationId: string): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "notion"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { accessToken: true }, + const connection = await activeConnection(organizationId, "notion", { + accessToken: true, }); return connection ? notionClient(connection.accessToken) : null; } diff --git a/packages/trpc/src/router/integration/sentry/sentry.ts b/packages/trpc/src/router/integration/sentry/sentry.ts index ae4423ccdbb..657caaddd66 100644 --- a/packages/trpc/src/router/integration/sentry/sentry.ts +++ b/packages/trpc/src/router/integration/sentry/sentry.ts @@ -1,10 +1,5 @@ -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; -import { and, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; -import { protectedProcedure } from "../../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; +import { disconnectProcedure, getConnectionProcedure } from "../connections"; /** * A Sentry connection is a public integration the org installs through Sentry's @@ -13,54 +8,16 @@ import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; * settings page use. */ export const sentryRouter = { - getConnection: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "sentry"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { - id: true, - externalOrgId: true, - externalOrgName: true, - createdAt: true, - }, - }); - - if (!connection) return null; - - return { - id: connection.id, - organizationSlug: connection.externalOrgId, - organizationName: connection.externalOrgName, - connectedAt: connection.createdAt, - }; + getConnection: getConnectionProcedure( + "sentry", + { id: true, externalOrgId: true, externalOrgName: true, createdAt: true }, + (connection) => ({ + id: connection.id, + organizationSlug: connection.externalOrgId, + organizationName: connection.externalOrgName, + connectedAt: connection.createdAt, }), + ), - disconnect: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .mutation(async ({ ctx, input }) => { - await verifyOrgAdmin(ctx.session.user.id, input.organizationId); - - const result = await db - .delete(integrationConnections) - .where( - and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "sentry"), - ), - ) - .returning({ id: integrationConnections.id }); - - if (result.length === 0) { - return { success: false, error: "No connection found" }; - } - - return { success: true }; - }), + disconnect: disconnectProcedure("sentry"), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/sentry/trigger-options.ts b/packages/trpc/src/router/integration/sentry/trigger-options.ts index c97cd353dab..a7ea53b3169 100644 --- a/packages/trpc/src/router/integration/sentry/trigger-options.ts +++ b/packages/trpc/src/router/integration/sentry/trigger-options.ts @@ -1,6 +1,5 @@ -import { db } from "@superset/db/client"; -import { integrationConnections, type SentryConfig } from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import type { SentryConfig } from "@superset/db/schema"; +import { activeConnection } from "../connections"; import type { TriggerOptionSource } from "../trigger-options"; import { fetchSentryProjects, getSentryAccessToken, SENTRY_URL } from "./utils"; @@ -9,13 +8,10 @@ import { fetchSentryProjects, getSentryAccessToken, SENTRY_URL } from "./utils"; * stop matching the moment someone renames the project. The slug is the label. */ const projects: TriggerOptionSource = async ({ organizationId }) => { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "sentry"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { id: true, externalOrgId: true, config: true }, + const connection = await activeConnection(organizationId, "sentry", { + id: true, + externalOrgId: true, + config: true, }); if (!connection?.externalOrgId) return []; diff --git a/packages/trpc/src/router/integration/sentry/utils.ts b/packages/trpc/src/router/integration/sentry/utils.ts index 88be4da0c0d..b742c24b849 100644 --- a/packages/trpc/src/router/integration/sentry/utils.ts +++ b/packages/trpc/src/router/integration/sentry/utils.ts @@ -1,10 +1,13 @@ -import { db } from "@superset/db/client"; -import { integrationConnections, type SentryConfig } from "@superset/db/schema"; -import { withConnectionLock } from "@superset/db/utils"; +import type { SentryConfig } from "@superset/db/schema"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; import { z } from "zod"; import { env } from "../../../env"; +import { + markDisconnected, + type RefreshedToken, + TokenRefreshError, + withRefreshedToken, +} from "../token-refresh"; /** * The public Sentry integration's REST surface, as far as this provider needs @@ -17,9 +20,6 @@ import { env } from "../../../env"; */ export const SENTRY_URL = "https://sentry.io"; -/** Refresh a token this many ms before it actually expires. */ -const REFRESH_BUFFER_MS = 5 * 60 * 1000; - export type SentryProject = { id: string; slug: string; name: string }; export type SentryOrganization = { @@ -127,98 +127,62 @@ export async function verifySentryInstall( } } -/** A revoked or already-used refresh token comes back as 400 invalid_grant. */ -async function isInvalidGrant(response: Response): Promise { - try { - const body = (await response.json()) as { error?: unknown }; - return body?.error === "invalid_grant"; - } catch { - return false; - } -} - -type TokenResult = - | { disconnected: true } - | { disconnected: false; accessToken: string }; - /** * A usable access token for a connection, refreshing it first when it is within * the buffer of expiry. Public-app tokens live ~8h, so anything cached longer - * than a session needs this. Serialized per connection so two callers do not - * both burn the one-time refresh token. + * than a session needs this. */ export async function getSentryAccessToken( connectionId: string, -): Promise { - return withConnectionLock(connectionId, async (tx) => { - const [connection] = await tx - .select({ - accessToken: integrationConnections.accessToken, - refreshToken: integrationConnections.refreshToken, - tokenExpiresAt: integrationConnections.tokenExpiresAt, - disconnectedAt: integrationConnections.disconnectedAt, - config: integrationConnections.config, - }) - .from(integrationConnections) - .where(eq(integrationConnections.id, connectionId)) - .limit(1); - - if (!connection || connection.disconnectedAt) return { disconnected: true }; - if ( - connection.tokenExpiresAt && - connection.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS - ) { - return { disconnected: false, accessToken: connection.accessToken }; - } - if (!connection.refreshToken || !env.SENTRY_CLIENT_ID) { - return { disconnected: false, accessToken: connection.accessToken }; - } - - // The install uuid is the token endpoint's path segment; without it there - // is nothing to refresh against, so the current token is all there is. - const installationUuid = (connection.config as SentryConfig | null) - ?.installationUuid; - if (!installationUuid) { - return { disconnected: false, accessToken: connection.accessToken }; - } +): Promise { + return withRefreshedToken(connectionId, { + exchange: async (connection) => { + if (!connection.refreshToken || !env.SENTRY_CLIENT_ID) { + return { keep: true }; + } - const response = await fetch(authorizationsUrl(installationUuid), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: connection.refreshToken, - client_id: env.SENTRY_CLIENT_ID, - client_secret: env.SENTRY_CLIENT_SECRET, - }), - }); - if (!response.ok) { - if ( - response.status === 401 || - response.status === 403 || - (response.status === 400 && (await isInvalidGrant(response))) - ) { - await tx - .update(integrationConnections) - .set({ - disconnectedAt: new Date(), - disconnectReason: "invalid_grant", - }) - .where(eq(integrationConnections.id, connectionId)); - return { disconnected: true }; + // The install uuid is the token endpoint's path segment; without it + // there is nothing to refresh against, so the current token is all + // there is. + const installationUuid = (connection.config as SentryConfig | null) + ?.installationUuid; + if (!installationUuid) return { keep: true }; + + const response = await fetch(authorizationsUrl(installationUuid), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: connection.refreshToken, + client_id: env.SENTRY_CLIENT_ID, + client_secret: env.SENTRY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + throw new TokenRefreshError( + response.status, + await response.json().catch(() => null), + `Sentry token refresh failed: ${response.status}`, + ); } - throw new Error(`Sentry token refresh failed: ${response.status}`); - } - const data = sentryTokenResponseSchema.parse(await response.json()); - await tx - .update(integrationConnections) - .set({ + const data = sentryTokenResponseSchema.parse(await response.json()); + return { accessToken: data.token, refreshToken: data.refreshToken, tokenExpiresAt: new Date(data.expiresAt), - }) - .where(eq(integrationConnections.id, connectionId)); - return { disconnected: false, accessToken: data.token }; + }; + }, + // A revoked or already-used refresh token comes back as 400 invalid_grant. + revokedWhen: (error) => { + if (!(error instanceof TokenRefreshError)) return null; + const invalidGrant = + (error.body as { error?: unknown } | null)?.error === "invalid_grant"; + return error.status === 401 || + error.status === 403 || + (error.status === 400 && invalidGrant) + ? "invalid_grant" + : null; + }, }); } @@ -274,13 +238,5 @@ export async function disconnectSentry( connectionId: string, reason: string, ): Promise { - await db - .update(integrationConnections) - .set({ - disconnectedAt: new Date(), - disconnectReason: reason, - accessToken: "", - refreshToken: null, - }) - .where(eq(integrationConnections.id, connectionId)); + await markDisconnected(connectionId, reason, { clearTokens: true }); } diff --git a/packages/trpc/src/router/integration/slack/index.ts b/packages/trpc/src/router/integration/slack/index.ts index cc59ff4cd82..530ba221983 100644 --- a/packages/trpc/src/router/integration/slack/index.ts +++ b/packages/trpc/src/router/integration/slack/index.ts @@ -1,2 +1 @@ export { slackRouter } from "./slack"; -export { getSlackConnection } from "./utils"; diff --git a/packages/trpc/src/router/integration/slack/slack.ts b/packages/trpc/src/router/integration/slack/slack.ts index 156f4ec8e30..8ddb122bba0 100644 --- a/packages/trpc/src/router/integration/slack/slack.ts +++ b/packages/trpc/src/router/integration/slack/slack.ts @@ -1,57 +1,16 @@ -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; -import { protectedProcedure } from "../../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; +import { disconnectProcedure, getConnectionProcedure } from "../connections"; export const slackRouter = { - getConnection: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "slack"), - ), - columns: { - id: true, - externalOrgName: true, - createdAt: true, - }, - }); - - if (!connection) return null; - - return { - id: connection.id, - externalOrgName: connection.externalOrgName, - connectedAt: connection.createdAt, - }; + getConnection: getConnectionProcedure( + "slack", + { id: true, externalOrgName: true, createdAt: true }, + (connection) => ({ + id: connection.id, + externalOrgName: connection.externalOrgName, + connectedAt: connection.createdAt, }), + ), - disconnect: protectedProcedure - .input(z.object({ organizationId: z.uuid() })) - .mutation(async ({ ctx, input }) => { - await verifyOrgAdmin(ctx.session.user.id, input.organizationId); - - const result = await db - .delete(integrationConnections) - .where( - and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "slack"), - ), - ) - .returning({ id: integrationConnections.id }); - - if (result.length === 0) { - return { success: false, error: "No connection found" }; - } - - return { success: true }; - }), + disconnect: disconnectProcedure("slack"), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/slack/trigger-options.ts b/packages/trpc/src/router/integration/slack/trigger-options.ts index 84b58811ea2..95433371d7d 100644 --- a/packages/trpc/src/router/integration/slack/trigger-options.ts +++ b/packages/trpc/src/router/integration/slack/trigger-options.ts @@ -3,9 +3,7 @@ import { type UsersListResponse, WebClient, } from "@slack/web-api"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { activeConnection } from "../connections"; import type { TriggerOption, TriggerOptionSource } from "../trigger-options"; /** A hard stop, not a page size: enough for any workspace this is pointed at. */ @@ -19,13 +17,8 @@ const MAX_PEOPLE = 1000; * procedure, which shows an empty list. */ async function slackClient(organizationId: string): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "slack"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { accessToken: true }, + const connection = await activeConnection(organizationId, "slack", { + accessToken: true, }); if (!connection) return null; return new WebClient(connection.accessToken, { diff --git a/packages/trpc/src/router/integration/slack/utils.ts b/packages/trpc/src/router/integration/slack/utils.ts deleted file mode 100644 index bc4e2a3eec3..00000000000 --- a/packages/trpc/src/router/integration/slack/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; - -export async function getSlackConnection(organizationId: string) { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "slack"), - ), - }); - - return connection ?? null; -} diff --git a/packages/trpc/src/router/integration/token-refresh.ts b/packages/trpc/src/router/integration/token-refresh.ts new file mode 100644 index 00000000000..807b8bf5e82 --- /dev/null +++ b/packages/trpc/src/router/integration/token-refresh.ts @@ -0,0 +1,128 @@ +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { withConnectionLock } from "@superset/db/utils"; +import { eq } from "drizzle-orm"; + +/** Refresh a token this many ms before it actually expires. */ +export const REFRESH_BUFFER_MS = 5 * 60 * 1000; + +export type RefreshedToken = + | { disconnected: true } + | { disconnected: false; accessToken: string }; + +export type RefreshableConnection = { + accessToken: string; + refreshToken: string | null; + tokenExpiresAt: Date | null; + config: unknown; +}; + +/** A token endpoint's non-ok answer, kept for `revokedWhen` to classify. */ +export class TokenRefreshError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + message: string, + ) { + super(message); + this.name = "TokenRefreshError"; + } +} + +type TokenExchange = ( + connection: RefreshableConnection, +) => Promise< + | { accessToken: string; refreshToken: string; tokenExpiresAt: Date } + | { keep: true } + | { revoked: string } +>; + +/** + * A usable access token for a connection, refreshed under the connection's + * advisory lock when it is within `REFRESH_BUFFER_MS` of expiry — serialized + * so two callers do not both burn a one-time refresh token. + * + * `exchange` performs the provider's refresh call: new tokens, `{keep: true}` + * when refresh is impossible and the current token is all there is, or + * `{revoked: reason}` when the grant is known gone. A thrown error goes + * through `revokedWhen`: a reason marks the connection disconnected, null + * rethrows (transient). Success clears any disconnected marker. + */ +export async function withRefreshedToken( + connectionId: string, + opts: { + exchange: TokenExchange; + revokedWhen?: (error: unknown) => string | null; + }, +): Promise { + return withConnectionLock(connectionId, async (tx) => { + const [connection] = await tx + .select({ + accessToken: integrationConnections.accessToken, + refreshToken: integrationConnections.refreshToken, + tokenExpiresAt: integrationConnections.tokenExpiresAt, + disconnectedAt: integrationConnections.disconnectedAt, + config: integrationConnections.config, + }) + .from(integrationConnections) + .where(eq(integrationConnections.id, connectionId)) + .limit(1); + + if (!connection || connection.disconnectedAt) return { disconnected: true }; + if ( + connection.tokenExpiresAt && + connection.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS + ) { + return { disconnected: false, accessToken: connection.accessToken }; + } + + const disconnect = async (reason: string): Promise => { + await tx + .update(integrationConnections) + .set({ disconnectedAt: new Date(), disconnectReason: reason }) + .where(eq(integrationConnections.id, connectionId)); + return { disconnected: true }; + }; + + let result: Awaited>; + try { + result = await opts.exchange(connection); + } catch (error) { + const reason = opts.revokedWhen?.(error); + if (reason) return disconnect(reason); + throw error; + } + if ("keep" in result) { + return { disconnected: false, accessToken: connection.accessToken }; + } + if ("revoked" in result) return disconnect(result.revoked); + + await tx + .update(integrationConnections) + .set({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + tokenExpiresAt: result.tokenExpiresAt, + disconnectedAt: null, + disconnectReason: null, + }) + .where(eq(integrationConnections.id, connectionId)); + return { disconnected: false, accessToken: result.accessToken }; + }); +} + +/** Mark a connection disconnected; `clearTokens` also drops its token pair. */ +export async function markDisconnected( + connectionId: string, + reason: string, + opts: { clearTokens?: boolean } = {}, +): Promise { + await db + .update(integrationConnections) + .set({ + disconnectedAt: new Date(), + disconnectReason: reason, + ...(opts.clearTokens ? { accessToken: "", refreshToken: null } : {}), + }) + .where(eq(integrationConnections.id, connectionId)); +} From 38adb9504bb86f44de8b5e2b9c6225ece8205141 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 17:39:18 -0700 Subject: [PATCH 14/15] refactor(automations): unify actor into scope, drop null scopes and identity matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actor is just a scope now: triggerActorSchema/TriggerActor are gone, every actor/assignee/attendee/mentionedUser field is a triggerScopeSchema, and the scope union loses null — {mode:"any"} | {mode:"list", ids}, defaulting to any. Matchers drop MatchContext/actorAllows/identityProvider and take (config, event); Google's per-account isolation moves off the matchable (ownsAccount, accountEmail deleted) onto the dispatcher, which grows an optional ownerUserId narrowing that the Google normalizers set from connection.connectedByUserId — without it one member's calendar and mail events would fan across the org. describeTriggerProblems keeps its signature and wording but its "Specify at least one …" rules become a REQUIREMENTS data table; the schedule COUNT/UNTIL rule stays code. saveTriggerSet refuses kinds absent from the new LAUNCHED_TRIGGER_KINDS list in shared constants. Desktop's ActorChip is deleted in favour of ScopeChip (anyLabel "Anyone"), whose untick-any path now emits {mode:"list", ids:[]}; grammar defaults follow. Data-only migration 0087 rewrites stored configs: null/"anyone"/"me" → {"mode":"any"}, {"ids":[…]} → {"mode":"list","ids":[…]} (generated, not applied). Claude-Session: https://claude.ai/code/session_01MUPLfPFVm34EJmVrVr2pxu --- .../google/calendar/scheduled/route.ts | 6 +- .../api/integrations/google/callback/route.ts | 6 +- .../integrations/google/lib/calendarEvents.ts | 10 - .../integrations/google/lib/syncCalendar.ts | 6 +- .../integrations/google/lib/syncMailbox.ts | 6 +- .../api/integrations/linear/callback/route.ts | 4 +- .../automations/dispatchMatchingTriggers.ts | 58 +- .../lib/automations/ingestAutomationEvent.ts | 1 + .../lib/automations/redispatchUndispatched.ts | 1 + .../src/lib/integrations/upsertIdentity.ts | 4 +- .../components/ActorChip/ActorChip.tsx | 138 - .../components/ActorChip/index.ts | 1 - .../components/ScopeChip/ScopeChip.tsx | 13 +- .../providers/circleback/circleback.tsx | 10 +- .../components/providers/github/github.tsx | 29 +- .../components/providers/github/grammar.ts | 18 +- .../components/providers/google/gmail.tsx | 7 +- .../providers/google/googleCalendar.tsx | 9 +- .../components/providers/google/grammar.ts | 12 +- .../components/providers/linear/grammar.ts | 10 +- .../components/providers/linear/linear.tsx | 25 +- .../providers/microsoftTeams/grammar.ts | 14 +- .../microsoftTeams/microsoftTeams.tsx | 9 +- .../components/providers/notion/grammar.ts | 17 +- .../components/providers/notion/notion.tsx | 22 +- .../components/providers/sentry/grammar.ts | 6 +- .../components/providers/sentry/sentry.tsx | 8 +- .../components/providers/slack/grammar.ts | 19 +- .../components/providers/slack/slack.tsx | 14 +- .../db/drizzle/0087_scope_unification.sql | 65 + packages/db/drizzle/meta/0087_snapshot.json | 7011 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/types.ts | 8 +- .../src/automation-matching/circleback.ts | 12 +- .../shared/src/automation-matching/core.ts | 48 +- .../src/automation-matching/github.test.ts | 70 +- .../shared/src/automation-matching/github.ts | 26 +- .../shared/src/automation-matching/google.ts | 68 +- .../shared/src/automation-matching/index.ts | 15 +- .../shared/src/automation-matching/linear.ts | 33 +- .../automation-matching/microsoft-teams.ts | 11 +- .../shared/src/automation-matching/notion.ts | 28 +- .../shared/src/automation-matching/sentry.ts | 9 +- .../shared/src/automation-matching/slack.ts | 18 +- packages/shared/src/automation-triggers.ts | 322 +- packages/shared/src/constants.ts | 21 + .../trpc/src/router/automation/triggerSet.ts | 14 + 47 files changed, 7570 insertions(+), 699 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ActorChip/ActorChip.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ActorChip/index.ts create mode 100644 packages/db/drizzle/0087_scope_unification.sql create mode 100644 packages/db/drizzle/meta/0087_snapshot.json diff --git a/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts b/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts index ebfdcf83851..23c31a1e0d2 100644 --- a/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts +++ b/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts @@ -15,7 +15,6 @@ import { import { verifyQstashRequest } from "@/lib/verifyQstash"; import { accountDomain, - accountEmail, calendarPayload, matchableCalendarEvent, resourceKeyFor, @@ -91,7 +90,6 @@ function normalizeFire( fire.fire === "starting_soon" ? "event.starting_soon" : "event.ended"; const matchable = matchableCalendarEvent({ eventType, - accountEmail: accountEmail(connection), calendarId: fire.calendarId, event, domain: accountDomain(connection), @@ -115,6 +113,8 @@ function normalizeFire( expectedAt: fire.expectedAt, }), }, - dispatch: { event: matchable }, + // The connection is one member's calendar, so only that member's + // automations may match its events. + dispatch: { event: matchable, ownerUserId: connection.connectedByUserId }, }; } diff --git a/apps/api/src/app/api/integrations/google/callback/route.ts b/apps/api/src/app/api/integrations/google/callback/route.ts index df4d93836e0..adb648eac69 100644 --- a/apps/api/src/app/api/integrations/google/callback/route.ts +++ b/apps/api/src/app/api/integrations/google/callback/route.ts @@ -107,10 +107,8 @@ export async function GET(request: Request) { }); if (result.conflict) return fail("account_already_linked"); - // The linked identity is what lets an attendee filter of "me" resolve to - // this person. Its external id is the address rather than Google's subject - // id because the matcher compares owner ids against what events carry, and - // calendar events name people by address. + // The identity's external id is the address rather than Google's subject + // id, because calendar events and mail headers name people by address. await upsertIdentity({ userId, organizationId, diff --git a/apps/api/src/app/api/integrations/google/lib/calendarEvents.ts b/apps/api/src/app/api/integrations/google/lib/calendarEvents.ts index 99492390f23..d36f9f1c441 100644 --- a/apps/api/src/app/api/integrations/google/lib/calendarEvents.ts +++ b/apps/api/src/app/api/integrations/google/lib/calendarEvents.ts @@ -7,13 +7,6 @@ import { } from "@superset/trpc/integrations/google"; /** The domain of the connected account: what "external" is measured against. */ -/** The connected account's address, as the identity table stores it. */ -export function accountEmail( - connection: Pick, -): string { - return (connection.externalOrgId ?? "").toLowerCase(); -} - export function accountDomain( connection: Pick, ): string | null { @@ -36,7 +29,6 @@ export function resourceKeyFor( */ export function matchableCalendarEvent(params: { eventType: GoogleCalendarTriggerEvent; - accountEmail: string; calendarId: string; event: GoogleCalendarEvent; domain: string | null; @@ -46,9 +38,7 @@ export function matchableCalendarEvent(params: { const organizer = params.event.organizer?.email?.toLowerCase() ?? null; return { provider: "google_calendar", - identityProvider: "google", eventType: params.eventType, - accountEmail: params.accountEmail, actorId: organizer, actorLogin: organizer, body: params.event.description ?? null, diff --git a/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts b/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts index 6547f06aad9..e67c1c76270 100644 --- a/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts +++ b/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts @@ -20,7 +20,6 @@ import { } from "@/lib/automations/ingestAutomationEvent"; import { accountDomain, - accountEmail, calendarPayload, matchableCalendarEvent, resourceKeyFor, @@ -258,7 +257,6 @@ function normalizeChange(params: { const matchable = matchableCalendarEvent({ eventType, - accountEmail: accountEmail(connection), calendarId, event, domain: params.domain, @@ -279,6 +277,8 @@ function normalizeChange(params: { actorIsExternal: matchable.hasExternalAttendee, payload: calendarPayload(calendarId, event, matchable), }, - dispatch: { event: matchable }, + // The connection is one member's calendar, so only that member's + // automations may match its events. + dispatch: { event: matchable, ownerUserId: connection.connectedByUserId }, }; } diff --git a/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts b/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts index 49320c44db1..98600911dcd 100644 --- a/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts +++ b/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts @@ -95,9 +95,7 @@ export function normalizeMessage( const fromAddress = parseAddresses(from)[0] ?? null; const matchable: GmailMatchableEvent = { provider: "gmail", - identityProvider: "google", eventType: "message.received", - accountEmail: (connection.externalOrgId ?? "").toLowerCase(), actorId: fromAddress, actorLogin: fromAddress, // The body stays in the mailbox; the subject is the filterable text. @@ -138,6 +136,8 @@ export function normalizeMessage( hasAttachment: matchable.hasAttachment, }, }, - dispatch: { event: matchable }, + // The connection is one member's mailbox, so only that member's + // automations may match its messages. + dispatch: { event: matchable, ownerUserId: connection.connectedByUserId }, }; } diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts index 20054363e48..f5727812676 100644 --- a/apps/api/src/app/api/integrations/linear/callback/route.ts +++ b/apps/api/src/app/api/integrations/linear/callback/route.ts @@ -58,8 +58,8 @@ export async function GET(request: Request) { } // The person who connected is the one Linear account we know for certain - // belongs to a Superset user, so link it: it is what automation triggers - // resolve "me" through. Linear user ids are scoped to the Linear workspace. + // belongs to a Superset user, so link it. Linear user ids are scoped to + // the Linear workspace. await upsertIdentity({ userId, organizationId, diff --git a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts index 1d03d418417..72e84f4d1d2 100644 --- a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts +++ b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts @@ -3,7 +3,6 @@ import { automationEvents, automations, automationTriggers, - userIdentities, } from "@superset/db/schema"; import { type MatchableEvent, @@ -22,11 +21,10 @@ const qstash = new Client({ * Finds the triggers an event satisfies and enqueues a run for each. * * Provider-agnostic: the caller has already normalized its payload into a - * `MatchableEvent`, whose `provider` selects both the trigger kind to consider - * and the identity provider `me` resolves through. Every inbound route — - * GitHub, Slack, Linear, a raw webhook — ends in this one function, so the - * candidate query, the owner-identity lookup, and the QStash publish exist - * exactly once. + * `MatchableEvent`, whose `provider` selects the trigger kind to consider. + * Every inbound route — GitHub, Slack, Linear, a raw webhook — ends in this + * one function, so the candidate query and the QStash publish exist exactly + * once. * * Only automations that are enabled are considered — that toggle is the gate a * person actually controls, so an automation someone paused stops firing @@ -47,6 +45,13 @@ export async function dispatchMatchingTriggers(params: { */ automationId?: string; triggerId?: string; + /** + * Restrict candidates to one member's automations. This is the per-user + * isolation for providers whose connection is per member: a Google + * connection is one person's calendar and mailbox, and without this + * narrowing their events would match every org member's triggers. + */ + ownerUserId?: string; }): Promise<{ matched: number; considered: number }> { const { event } = params; @@ -55,7 +60,6 @@ export async function dispatchMatchingTriggers(params: { triggerId: automationTriggers.id, config: automationTriggers.config, automationId: automations.id, - ownerUserId: automations.ownerUserId, }) .from(automationTriggers) .innerJoin(automations, eq(automations.id, automationTriggers.automationId)) @@ -73,48 +77,16 @@ export async function dispatchMatchingTriggers(params: { params.triggerId ? eq(automationTriggers.id, params.triggerId) : undefined, + params.ownerUserId + ? eq(automations.ownerUserId, params.ownerUserId) + : undefined, ), ); if (candidates.length === 0) return { matched: 0, considered: 0 }; - // Every identity linked in this org for this provider, so `me` can resolve - // to the automation owner. A person may link more than one account — work - // and personal — so this is a set per user, not a value. - // - // Not scoped by external_scope_id. That is safe only because - // integration_connections is unique on (organization_id, provider): one - // Slack workspace per org means every Slack identity here shares one scope, - // so ids cannot collide across workspaces. The day a provider allows more - // than one connection per org — per-user Google is the first candidate — - // this must also filter by the connection's scope, or a Slack user id from - // workspace A will match `me` for workspace B. - const identities = await dbWs - .select({ - userId: userIdentities.userId, - externalId: userIdentities.externalId, - }) - .from(userIdentities) - .where( - and( - eq(userIdentities.organizationId, params.organizationId), - eq(userIdentities.provider, event.identityProvider ?? event.provider), - ), - ); - const idsByUser = new Map(); - for (const row of identities) { - const existing = idsByUser.get(row.userId); - if (existing) existing.push(row.externalId); - else idsByUser.set(row.userId, [row.externalId]); - } - const matched = candidates.filter( - (candidate) => - triggerMatches(candidate.config, event, { - // Resolved per candidate: two automations can watch the same event - // on behalf of different owners. - ownerIds: idsByUser.get(candidate.ownerUserId) ?? [], - }).matches, + (candidate) => triggerMatches(candidate.config, event).matches, ); if (matched.length === 0) { diff --git a/apps/api/src/lib/automations/ingestAutomationEvent.ts b/apps/api/src/lib/automations/ingestAutomationEvent.ts index 8b9495c8998..4c93fcfee15 100644 --- a/apps/api/src/lib/automations/ingestAutomationEvent.ts +++ b/apps/api/src/lib/automations/ingestAutomationEvent.ts @@ -63,6 +63,7 @@ export async function ingestAutomationEvent( event: delivery.dispatch.event, automationId: delivery.dispatch.automationId, triggerId: delivery.dispatch.triggerId, + ownerUserId: delivery.dispatch.ownerUserId, }); return { status: "dispatched", eventId: inserted.id, ...result }; } catch (error) { diff --git a/apps/api/src/lib/automations/redispatchUndispatched.ts b/apps/api/src/lib/automations/redispatchUndispatched.ts index 7ba2f946d87..377f96c4615 100644 --- a/apps/api/src/lib/automations/redispatchUndispatched.ts +++ b/apps/api/src/lib/automations/redispatchUndispatched.ts @@ -45,6 +45,7 @@ export async function redispatchUndispatched(): Promise<{ event: row.dispatchInput.event, automationId: row.dispatchInput.automationId, triggerId: row.dispatchInput.triggerId, + ownerUserId: row.dispatchInput.ownerUserId, }); } catch (error) { failed++; diff --git a/apps/api/src/lib/integrations/upsertIdentity.ts b/apps/api/src/lib/integrations/upsertIdentity.ts index 7a47eb3cfa8..d2756e33bc3 100644 --- a/apps/api/src/lib/integrations/upsertIdentity.ts +++ b/apps/api/src/lib/integrations/upsertIdentity.ts @@ -14,8 +14,8 @@ type UpsertIdentityInput = { }; /** - * Links an external account to a Superset user — what lets "me" on a trigger - * resolve. Re-linking claims the account for whoever linked it last. + * Links an external account to a Superset user. Re-linking claims the account + * for whoever linked it last. */ export async function upsertIdentity( input: UpsertIdentityInput, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ActorChip/ActorChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ActorChip/ActorChip.tsx deleted file mode 100644 index ef1d6824d11..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ActorChip/ActorChip.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { TriggerActor } from "@superset/shared/automation-triggers"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import { Input } from "@superset/ui/input"; -import { useState } from "react"; -import type { ScopeOption } from "../../scopeOption"; -import { ChipButton } from "../ChipButton"; - -function actorLabel(actor: TriggerActor, people: ScopeOption[]): string { - if (actor === "anyone") return "Anyone"; - // Legacy rows only; the picker no longer offers "me". - if (actor === "me") return "Me"; - if (actor.ids.length === 0) return "Select people"; - if (actor.ids.length === 1) { - const match = people.find((p) => p.id === actor.ids[0]); - return match?.label ?? actor.ids[0] ?? "Select people"; - } - return `${actor.ids.length} people`; -} - -/** - * Multi-select over the provider's own people, plus "Anyone". - * - * `allowCustom` adds a field for values that are not pickable — an email - * address — which then sit in the list like any chosen person. - */ -export function ActorChip({ - actor, - onChange, - people, - allowCustom, - disabled, - className, -}: { - actor: TriggerActor; - onChange: (next: TriggerActor) => void; - people: ScopeOption[]; - allowCustom?: { placeholder: string }; - disabled?: boolean; - className?: string; -}) { - const ids = typeof actor === "string" ? [] : actor.ids; - const empty = typeof actor !== "string" && ids.length === 0; - const [custom, setCustom] = useState(""); - - const toggle = (id: string) => { - const next = ids.includes(id) ? ids.filter((p) => p !== id) : [...ids, id]; - onChange(next.length ? { ids: next } : "anyone"); - }; - - const addCustom = () => { - const value = custom.trim(); - if (!value) return; - if (!ids.includes(value)) { - onChange({ ids: [...ids, value] }); - } - setCustom(""); - }; - - // Typed values that no person describes still need a row to be unticked. - const customSelected = ids.filter( - (id) => !people.some((person) => person.id === id), - ); - - return ( - - - - - - - - onChange("anyone")} - > - Anyone - - {people.map((person) => ( - toggle(person.id)} - > - {person.label} - - ))} - {allowCustom && - customSelected.map((id) => ( - toggle(id)} - > - {id} - - ))} - {people.length === 0 && !allowCustom && ( - No people to choose yet - )} - {allowCustom && ( - <> - -
- setCustom(event.target.value)} - // The menu owns arrow keys and typeahead; the field keeps - // what it types. - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - addCustom(); - } - }} - className="h-7 text-[13px]" - /> -
- - )} -
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ActorChip/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ActorChip/index.ts deleted file mode 100644 index 112ac2d6cd4..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ActorChip/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ActorChip } from "./ActorChip"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.tsx index 88cfa1e9e33..f7940a92be6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.tsx @@ -18,7 +18,6 @@ function scopeLabel( emptyLabel: string, anyLabel: string, ): string { - if (scope === null) return emptyLabel; if (scope.mode === "any") return anyLabel; if (scope.ids.length === 0) return emptyLabel; if (scope.ids.length === 1) { @@ -57,16 +56,16 @@ export function ScopeChip({ disabled?: boolean; className?: string; }) { - const selected = scope !== null && scope.mode === "list" ? scope.ids : []; - const isAny = scope !== null && scope.mode === "any"; - const empty = scope === null || (scope.mode === "list" && !scope.ids.length); + const selected = scope.mode === "list" ? scope.ids : []; + const isAny = scope.mode === "any"; + const empty = scope.mode === "list" && !scope.ids.length; const [custom, setCustom] = useState(""); const toggle = (id: string) => { const next = selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]; - onChange(next.length ? { mode: "list", ids: next } : null); + onChange({ mode: "list", ids: next }); }; const addCustom = () => { @@ -98,7 +97,9 @@ export function ScopeChip({ onChange(isAny ? null : { mode: "any" })} + onCheckedChange={() => + onChange(isAny ? { mode: "list", ids: [] } : { mode: "any" }) + } > {anyLabel} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/circleback.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/circleback.tsx index 5acea536321..e8453a267bf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/circleback.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/circleback.tsx @@ -1,3 +1,4 @@ +import { isEmptyScope } from "@superset/shared/automation-triggers"; import { LuMic } from "react-icons/lu"; import { env } from "renderer/env.renderer"; import { EndpointChip } from "../../TriggerSentence/components/EndpointChip"; @@ -30,8 +31,9 @@ function renderSlot( key={index} scope={config.tags} // Clearing an optional filter means "any", not "none": the chip - // says "Any tag" either way, and null would make that a lie. - onChange={(v) => set({ tags: v ?? { mode: "any" } })} + // says "Any tag" either way, and an empty list would make that a + // lie. + onChange={(v) => set({ tags: isEmptyScope(v) ? { mode: "any" } : v })} options={[]} emptyLabel="Any tag" anyLabel="Any tag" @@ -44,7 +46,9 @@ function renderSlot( set({ attendees: v ?? { mode: "any" } })} + onChange={(v) => + set({ attendees: isEmptyScope(v) ? { mode: "any" } : v }) + } options={[]} emptyLabel="Any attendee" anyLabel="Any attendee" diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx index a9688f88605..d23e29d3d7c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx @@ -1,5 +1,5 @@ +import { isEmptyScope } from "@superset/shared/automation-triggers"; import { FaGithub } from "react-icons/fa"; -import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; import { Sentence } from "../components/Sentence"; @@ -44,8 +44,11 @@ function renderSlot( key={index} scope={c.branches} // Clearing an optional filter means "any", not "none": the chip - // says "Any branch" either way, and null would make that a lie. - onChange={(v) => set({ branches: v ?? { mode: "any" } })} + // says "Any branch" either way, and an empty list would make that + // a lie. + onChange={(v) => + set({ branches: isEmptyScope(v) ? { mode: "any" } : v }) + } options={[]} emptyLabel="Any branch" anyLabel="Any branch" @@ -57,7 +60,9 @@ function renderSlot( set({ labels: v ?? { mode: "any" } })} + onChange={(v) => + set({ labels: isEmptyScope(v) ? { mode: "any" } : v }) + } options={[]} emptyLabel="Any label" anyLabel="Any label" @@ -66,23 +71,27 @@ function renderSlot( ); case "actor": return ( - set({ actor: v })} className={mark("actor")} - people={options.github?.people ?? []} + options={options.github?.people ?? []} + emptyLabel="Select people" + anyLabel="Anyone" disabled={disabled} /> ); case "subjectAuthor": return ( - set({ subjectAuthor: v })} className={mark("subjectAuthor")} - people={options.github?.people ?? []} + options={options.github?.people ?? []} + emptyLabel="Select people" + anyLabel="Anyone" disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/grammar.ts index a40388bd7c8..4547a4d3550 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/grammar.ts @@ -212,17 +212,17 @@ const COMMENT_EVENTS = new Set([ export function createGithubConfig(event: GithubTriggerEvent) { const base = { kind: "github" as const, - // Null matches nothing, which is the safety property for repositories: an - // unfinished trigger must not fire on every repo, and the form refuses to - // save until one is chosen. - repositories: null, + // An empty list matches nothing, which is the safety property for + // repositories: an unfinished trigger must not fire on every repo, and + // the form refuses to save until one is chosen. + repositories: { mode: "list" as const, ids: [] as string[] }, // Branches and labels are optional narrowings, so they start at "any" — - // shown or not. Null here would render as "Any branch" while matching - // nothing, and nothing validates them, so the trigger would look complete - // and never fire. + // shown or not. An empty list here would render as "Any branch" while + // matching nothing, and nothing validates them, so the trigger would + // look complete and never fire. branches: { mode: "any" as const }, labels: { mode: "any" as const }, - actor: "anyone" as const, + actor: { mode: "any" as const }, includeForks: false as const, }; @@ -232,7 +232,7 @@ export function createGithubConfig(event: GithubTriggerEvent) { return { ...base, event: event as "comment_added" | "issue_comment", - subjectAuthor: "anyone" as const, + subjectAuthor: { mode: "any" as const }, commentFilter: null, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx index 0f6f658cad1..cc19b3d7d60 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx @@ -1,3 +1,4 @@ +import { isEmptyScope } from "@superset/shared/automation-triggers"; import { SiGmail } from "react-icons/si"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { SelectChip } from "../../TriggerSentence/components/SelectChip"; @@ -39,7 +40,7 @@ function renderSlot( key={index} scope={config.to} // Clearing an optional filter means "any", not "none". - onChange={(v) => set({ to: v ?? { mode: "any" } })} + onChange={(v) => set({ to: isEmptyScope(v) ? { mode: "any" } : v })} options={[]} emptyLabel="Any recipient" anyLabel="Any recipient" @@ -63,7 +64,9 @@ function renderSlot( set({ labels: v ?? { mode: "any" } })} + onChange={(v) => + set({ labels: isEmptyScope(v) ? { mode: "any" } : v }) + } options={options.google?.labels ?? []} emptyLabel="Any label" anyLabel="Any label" diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx index dcf259017f6..70e936be354 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx @@ -1,5 +1,4 @@ import { SiGooglecalendar } from "react-icons/si"; -import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { SelectChip } from "../../TriggerSentence/components/SelectChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; @@ -39,12 +38,14 @@ function renderSlot( ); case "attendee": return ( - set({ attendee: v })} className={mark("attendee")} - people={options.google?.people ?? []} + options={options.google?.people ?? []} + emptyLabel="Select people" + anyLabel="Anyone" allowCustom={{ placeholder: "Type an email, press Enter" }} disabled={disabled} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/grammar.ts index c062d6a21f8..0ce0e764824 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/grammar.ts @@ -135,17 +135,17 @@ function leaf(label: string, event: GoogleCalendarTriggerEvent) { } /** - * A new calendar trigger: the calendar still to be chosen (null matches - * nothing, and the form refuses to save until one is picked), every optional - * narrowing wide open. + * A new calendar trigger: the calendar still to be chosen (an empty list + * matches nothing, and the form refuses to save until one is picked), every + * optional narrowing wide open. */ export function createCalendarConfig( event: GoogleCalendarTriggerEvent, ): GoogleCalendarConfig { const base = { kind: "google_calendar" as const, - calendars: null, - attendee: "anyone" as const, + calendars: { mode: "list" as const, ids: [] as string[] }, + attendee: { mode: "any" as const }, titleFilter: null, }; // Branching rather than spreading conditionally: the config is a union on @@ -170,7 +170,7 @@ export function createGmailConfig(): GmailConfig { return { kind: "gmail", event: "message.received", - from: null, + from: { mode: "list", ids: [] }, to: { mode: "any" }, subjectFilter: null, labels: { mode: "any" }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/grammar.ts index 07b9392da51..b4b7213dc35 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/grammar.ts @@ -68,18 +68,18 @@ function leaf(label: string, event: LinearTriggerEvent) { /** * A new trigger of this event: the team still to be chosen, every optional - * filter wide open. Null teams matches nothing, so an unfinished trigger cannot - * fire on every team; the optional narrowings start at "any" because null - * would read as "Any project" while matching nothing. + * filter wide open. An empty team list matches nothing, so an unfinished + * trigger cannot fire on every team; the optional narrowings start at "any" + * because an empty list would read as "Any project" while matching nothing. */ export function createLinearConfig(event: LinearTriggerEvent): LinearConfig { return { kind: "linear", event, - teams: null, + teams: { mode: "list", ids: [] }, projects: { mode: "any" }, labels: { mode: "any" }, toStatus: { mode: "any" }, - assignee: "anyone", + assignee: { mode: "any" }, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx index 5d98bc435a5..87ce5505010 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx @@ -1,5 +1,5 @@ +import { isEmptyScope } from "@superset/shared/automation-triggers"; import { SiLinear } from "react-icons/si"; -import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; @@ -37,8 +37,11 @@ function renderSlot( key={index} scope={config.projects} // Clearing an optional filter means "any", not "none": the chip - // says "Any project" either way, and null would make that a lie. - onChange={(v) => set({ projects: v ?? { mode: "any" } })} + // says "Any project" either way, and an empty list would make + // that a lie. + onChange={(v) => + set({ projects: isEmptyScope(v) ? { mode: "any" } : v }) + } options={options.linear?.projects ?? []} emptyLabel="Any project" anyLabel="Any project" @@ -50,7 +53,9 @@ function renderSlot( set({ labels: v ?? { mode: "any" } })} + onChange={(v) => + set({ labels: isEmptyScope(v) ? { mode: "any" } : v }) + } options={options.linear?.labels ?? []} emptyLabel="Any label" anyLabel="Any label" @@ -62,7 +67,9 @@ function renderSlot( set({ toStatus: v ?? { mode: "any" } })} + onChange={(v) => + set({ toStatus: isEmptyScope(v) ? { mode: "any" } : v }) + } options={options.linear?.statuses ?? []} emptyLabel="Any status" anyLabel="Any status" @@ -71,12 +78,14 @@ function renderSlot( ); case "assignee": return ( - set({ assignee: v })} className={mark("assignee")} - people={options.linear?.people ?? []} + options={options.linear?.people ?? []} + emptyLabel="Select people" + anyLabel="Anyone" disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/grammar.ts index cd974ae2218..b4006852dcd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/grammar.ts @@ -53,8 +53,9 @@ function leaf(label: string, event: MicrosoftTeamsTriggerEvent) { /** * A new trigger of this event: the team still to be chosen, and for a message - * the channel too. Null for both, so a half-built trigger matches nothing - * until someone picks — the same safety property as GitHub's repositories. + * the channel too. Empty lists for both, so a half-built trigger matches + * nothing until someone picks — the same safety property as GitHub's + * repositories. */ export function createTeamsConfig( event: MicrosoftTeamsTriggerEvent, @@ -62,11 +63,14 @@ export function createTeamsConfig( return { kind: "microsoft_teams", event, - teams: null, + teams: { mode: "list", ids: [] }, // A created channel has no channel to filter on; the slot is absent from // its sentence and the matcher ignores the field. - channels: event === "message_in_channel" ? null : { mode: "any" }, - actor: "anyone", + channels: + event === "message_in_channel" + ? { mode: "list", ids: [] } + : { mode: "any" }, + actor: { mode: "any" }, messageFilter: null, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx index e122b615bff..a1abc0a2f76 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx @@ -1,5 +1,4 @@ import { BsMicrosoftTeams } from "react-icons/bs"; -import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; import { Sentence } from "../components/Sentence"; @@ -46,12 +45,14 @@ function renderSlot( ); case "actor": return ( - set({ actor: v })} className={mark("actor")} - people={options.microsoftTeams?.people ?? []} + options={options.microsoftTeams?.people ?? []} + emptyLabel="Select people" + anyLabel="Anyone" disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/grammar.ts index c2b01936cce..d4c7cb77a63 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/grammar.ts @@ -53,27 +53,30 @@ function leaf(label: string, event: NotionTriggerEvent) { * optional filter wide open. */ export function createNotionConfig(event: NotionTriggerEvent): NotionConfig { - // Null matches nothing, so an unfinished trigger cannot fire on every data - // source; the form refuses to save until one is chosen. - const base = { kind: "notion" as const, dataSources: null }; + // An empty list matches nothing, so an unfinished trigger cannot fire on + // every data source; the form refuses to save until one is chosen. + const base = { + kind: "notion" as const, + dataSources: { mode: "list" as const, ids: [] as string[] }, + }; switch (event) { case "data_source.content_updated": return { ...base, event }; case "comment.created": - // Pages are an optional narrowing, so they start at "any" — null here - // would read as "Any page" while matching nothing. + // Pages are an optional narrowing, so they start at "any" — an empty + // list here would read as "Any page" while matching nothing. return { ...base, event, pages: { mode: "any" as const }, - actor: "anyone" as const, + actor: { mode: "any" as const }, }; case "comment.mentioned": return { ...base, event, pages: { mode: "any" as const }, - mentionedUser: "me" as const, + mentionedUser: { mode: "any" as const }, }; } } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx index 05c28db8a5e..7dca9aa7944 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx @@ -1,5 +1,5 @@ +import { isEmptyScope } from "@superset/shared/automation-triggers"; import { SiNotion } from "react-icons/si"; -import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; @@ -39,7 +39,9 @@ function renderSlot( key={index} scope={c.pages} // Clearing an optional filter means "any", not "none". - onChange={(v) => set({ pages: v ?? { mode: "any" } })} + onChange={(v) => + set({ pages: isEmptyScope(v) ? { mode: "any" } : v }) + } options={[]} emptyLabel="Any page" anyLabel="Any page" @@ -48,23 +50,27 @@ function renderSlot( ); case "actor": return ( - set({ actor: v })} className={mark("actor")} - people={options.notion?.people ?? []} + options={options.notion?.people ?? []} + emptyLabel="Select people" + anyLabel="Anyone" disabled={disabled} /> ); case "mentionedUser": return ( - set({ mentionedUser: v })} className={mark("mentionedUser")} - people={options.notion?.people ?? []} + options={options.notion?.people ?? []} + emptyLabel="Select people" + anyLabel="Anyone" disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/grammar.ts index d7dee6887ce..6c661c94697 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/grammar.ts @@ -64,9 +64,9 @@ export function createSentryConfig(event: SentryTriggerEvent): SentryConfig { return { kind: "sentry", event, - // Null matches nothing: an unfinished trigger must not fire on every - // project, and the form refuses to save until one is chosen. - projects: null, + // An empty list matches nothing: an unfinished trigger must not fire on + // every project, and the form refuses to save until one is chosen. + projects: { mode: "list", ids: [] }, // An optional narrowing, so it starts at "any" — shown or not. level: { mode: "any" }, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx index 5f1cb430eff..e50dc4de6a3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx @@ -1,3 +1,4 @@ +import { isEmptyScope } from "@superset/shared/automation-triggers"; import { SiSentry } from "react-icons/si"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { Sentence } from "../components/Sentence"; @@ -35,8 +36,11 @@ function renderSlot( key={index} scope={config.level} // Clearing an optional filter means "any", not "none": the chip - // says "Any level" either way, and null would make that a lie. - onChange={(v) => set({ level: v ?? { mode: "any" } })} + // says "Any level" either way, and an empty list would make that + // a lie. + onChange={(v) => + set({ level: isEmptyScope(v) ? { mode: "any" } : v }) + } options={options.sentry?.levels ?? []} emptyLabel="Any level" anyLabel="Any level" diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/grammar.ts index e51fe2cdee8..2e71a59843e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/grammar.ts @@ -64,15 +64,16 @@ export function createSlackConfig(event: SlackTriggerEvent): SlackConfig { return { kind: "slack", event, - // Null matches nothing, which is the safety property for channels: an - // unfinished trigger must not fire on every channel, and the form refuses - // to save until one is chosen. A created channel is not "in" one, so that - // event has no channel to choose and the field stays null. - channels: null, - // The reaction is an optional narrowing, so it starts at "any". Null - // here would render as "Any reaction" while matching nothing. - emoji: event === "reaction_added" ? { mode: "any" } : null, - actor: "anyone", + // An empty list matches nothing, which is the safety property for + // channels: an unfinished trigger must not fire on every channel, and + // the form refuses to save until one is chosen. A created channel is not + // "in" one, so that event has no channel to choose and stays wide open. + channels: + event === "channel_created" ? { mode: "any" } : { mode: "list", ids: [] }, + // The reaction is an optional narrowing, so it starts at "any" — an + // empty list would render as "Any reaction" while matching nothing. + emoji: { mode: "any" }, + actor: { mode: "any" }, messageFilter: null, // A busy thread would otherwise fire once per reply. topLevelOnly: true, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx index 3604669f474..f75961b3b9b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx @@ -1,5 +1,4 @@ import { FaSlack } from "react-icons/fa"; -import { ActorChip } from "../../TriggerSentence/components/ActorChip"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { SelectChip } from "../../TriggerSentence/components/SelectChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; @@ -46,9 +45,10 @@ function renderSlot( return ( set({ emoji: names.length @@ -64,12 +64,14 @@ function renderSlot( ); case "actor": return ( - set({ actor: v })} className={mark("actor")} - people={options.slack?.people ?? []} + options={options.slack?.people ?? []} + emptyLabel="Select people" + anyLabel="Anyone" disabled={disabled} /> ); diff --git a/packages/db/drizzle/0087_scope_unification.sql b/packages/db/drizzle/0087_scope_unification.sql new file mode 100644 index 00000000000..fdb983dad12 --- /dev/null +++ b/packages/db/drizzle/0087_scope_unification.sql @@ -0,0 +1,65 @@ +-- Custom SQL migration file, put your code below! -- + +-- Scope unification: every people filter becomes a scope, and null scopes die. +-- For each trigger kind, rewrite its config's scope/actor fields: +-- null -> {"mode":"any"} +-- "anyone" -> {"mode":"any"} +-- "me" -> {"mode":"any"} +-- {"ids":[...]} -> {"mode":"list","ids":[...]} +-- Fields absent from a config (union members that never carried them) are left +-- absent. Each statement is idempotent: already-tagged values match no WHERE. + +-- github: scopes repositories/branches/labels; people actor/subjectAuthor +UPDATE automation_triggers SET config = jsonb_set(config, '{repositories}', '{"mode":"any"}'::jsonb) WHERE kind = 'github' AND config->'repositories' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{branches}', '{"mode":"any"}'::jsonb) WHERE kind = 'github' AND config->'branches' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{labels}', '{"mode":"any"}'::jsonb) WHERE kind = 'github' AND config->'labels' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{actor}', '{"mode":"any"}'::jsonb) WHERE kind = 'github' AND config->'actor' IN ('null'::jsonb, '"anyone"'::jsonb, '"me"'::jsonb);--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{actor}', jsonb_build_object('mode', 'list', 'ids', config->'actor'->'ids')) WHERE kind = 'github' AND jsonb_typeof(config->'actor') = 'object' AND NOT config->'actor' ? 'mode';--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{subjectAuthor}', '{"mode":"any"}'::jsonb) WHERE kind = 'github' AND config->'subjectAuthor' IN ('null'::jsonb, '"anyone"'::jsonb, '"me"'::jsonb);--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{subjectAuthor}', jsonb_build_object('mode', 'list', 'ids', config->'subjectAuthor'->'ids')) WHERE kind = 'github' AND jsonb_typeof(config->'subjectAuthor') = 'object' AND NOT config->'subjectAuthor' ? 'mode';--> statement-breakpoint + +-- slack: scopes channels/emoji; people actor +UPDATE automation_triggers SET config = jsonb_set(config, '{channels}', '{"mode":"any"}'::jsonb) WHERE kind = 'slack' AND config->'channels' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{emoji}', '{"mode":"any"}'::jsonb) WHERE kind = 'slack' AND config->'emoji' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{actor}', '{"mode":"any"}'::jsonb) WHERE kind = 'slack' AND config->'actor' IN ('null'::jsonb, '"anyone"'::jsonb, '"me"'::jsonb);--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{actor}', jsonb_build_object('mode', 'list', 'ids', config->'actor'->'ids')) WHERE kind = 'slack' AND jsonb_typeof(config->'actor') = 'object' AND NOT config->'actor' ? 'mode';--> statement-breakpoint + +-- linear: scopes teams/projects/labels/toStatus; people assignee +UPDATE automation_triggers SET config = jsonb_set(config, '{teams}', '{"mode":"any"}'::jsonb) WHERE kind = 'linear' AND config->'teams' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{projects}', '{"mode":"any"}'::jsonb) WHERE kind = 'linear' AND config->'projects' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{labels}', '{"mode":"any"}'::jsonb) WHERE kind = 'linear' AND config->'labels' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{toStatus}', '{"mode":"any"}'::jsonb) WHERE kind = 'linear' AND config->'toStatus' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{assignee}', '{"mode":"any"}'::jsonb) WHERE kind = 'linear' AND config->'assignee' IN ('null'::jsonb, '"anyone"'::jsonb, '"me"'::jsonb);--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{assignee}', jsonb_build_object('mode', 'list', 'ids', config->'assignee'->'ids')) WHERE kind = 'linear' AND jsonb_typeof(config->'assignee') = 'object' AND NOT config->'assignee' ? 'mode';--> statement-breakpoint + +-- sentry: scopes projects/level +UPDATE automation_triggers SET config = jsonb_set(config, '{projects}', '{"mode":"any"}'::jsonb) WHERE kind = 'sentry' AND config->'projects' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{level}', '{"mode":"any"}'::jsonb) WHERE kind = 'sentry' AND config->'level' = 'null'::jsonb;--> statement-breakpoint + +-- circleback: scopes tags/attendees +UPDATE automation_triggers SET config = jsonb_set(config, '{tags}', '{"mode":"any"}'::jsonb) WHERE kind = 'circleback' AND config->'tags' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{attendees}', '{"mode":"any"}'::jsonb) WHERE kind = 'circleback' AND config->'attendees' = 'null'::jsonb;--> statement-breakpoint + +-- notion: scopes dataSources/pages; people actor/mentionedUser +UPDATE automation_triggers SET config = jsonb_set(config, '{dataSources}', '{"mode":"any"}'::jsonb) WHERE kind = 'notion' AND config->'dataSources' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{pages}', '{"mode":"any"}'::jsonb) WHERE kind = 'notion' AND config->'pages' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{actor}', '{"mode":"any"}'::jsonb) WHERE kind = 'notion' AND config->'actor' IN ('null'::jsonb, '"anyone"'::jsonb, '"me"'::jsonb);--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{actor}', jsonb_build_object('mode', 'list', 'ids', config->'actor'->'ids')) WHERE kind = 'notion' AND jsonb_typeof(config->'actor') = 'object' AND NOT config->'actor' ? 'mode';--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{mentionedUser}', '{"mode":"any"}'::jsonb) WHERE kind = 'notion' AND config->'mentionedUser' IN ('null'::jsonb, '"anyone"'::jsonb, '"me"'::jsonb);--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{mentionedUser}', jsonb_build_object('mode', 'list', 'ids', config->'mentionedUser'->'ids')) WHERE kind = 'notion' AND jsonb_typeof(config->'mentionedUser') = 'object' AND NOT config->'mentionedUser' ? 'mode';--> statement-breakpoint + +-- microsoft_teams: scopes teams/channels; people actor +UPDATE automation_triggers SET config = jsonb_set(config, '{teams}', '{"mode":"any"}'::jsonb) WHERE kind = 'microsoft_teams' AND config->'teams' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{channels}', '{"mode":"any"}'::jsonb) WHERE kind = 'microsoft_teams' AND config->'channels' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{actor}', '{"mode":"any"}'::jsonb) WHERE kind = 'microsoft_teams' AND config->'actor' IN ('null'::jsonb, '"anyone"'::jsonb, '"me"'::jsonb);--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{actor}', jsonb_build_object('mode', 'list', 'ids', config->'actor'->'ids')) WHERE kind = 'microsoft_teams' AND jsonb_typeof(config->'actor') = 'object' AND NOT config->'actor' ? 'mode';--> statement-breakpoint + +-- google_calendar: scope calendars; people attendee +UPDATE automation_triggers SET config = jsonb_set(config, '{calendars}', '{"mode":"any"}'::jsonb) WHERE kind = 'google_calendar' AND config->'calendars' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{attendee}', '{"mode":"any"}'::jsonb) WHERE kind = 'google_calendar' AND config->'attendee' IN ('null'::jsonb, '"anyone"'::jsonb, '"me"'::jsonb);--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{attendee}', jsonb_build_object('mode', 'list', 'ids', config->'attendee'->'ids')) WHERE kind = 'google_calendar' AND jsonb_typeof(config->'attendee') = 'object' AND NOT config->'attendee' ? 'mode';--> statement-breakpoint + +-- gmail: scopes from/to/labels +UPDATE automation_triggers SET config = jsonb_set(config, '{from}', '{"mode":"any"}'::jsonb) WHERE kind = 'gmail' AND config->'from' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{to}', '{"mode":"any"}'::jsonb) WHERE kind = 'gmail' AND config->'to' = 'null'::jsonb;--> statement-breakpoint +UPDATE automation_triggers SET config = jsonb_set(config, '{labels}', '{"mode":"any"}'::jsonb) WHERE kind = 'gmail' AND config->'labels' = 'null'::jsonb; diff --git a/packages/db/drizzle/meta/0087_snapshot.json b/packages/db/drizzle/meta/0087_snapshot.json new file mode 100644 index 00000000000..eefbedfe757 --- /dev/null +++ b/packages/db/drizzle/meta/0087_snapshot.json @@ -0,0 +1,7011 @@ +{ + "id": "85e73ff3-15cf-49d7-b3ba-65f3e871759d", + "prevId": "1c2a32c1-067a-4ed4-86ef-d293adda0693", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "CASE\n\t\t\t\tWHEN metadata IS NULL OR metadata = '' THEN NULL\n\t\t\t\tWHEN NOT (metadata IS JSON OBJECT) THEN NULL\n\t\t\t\tWHEN (metadata::jsonb->>'organizationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'\n\t\t\t\t\tTHEN (metadata::jsonb->>'organizationId')::uuid\n\t\t\t\tELSE NULL\n\t\t\tEND" + } + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "apikeys_organization_id_idx": { + "name": "apikeys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "apikeys_metadata_trgm_idx": { + "name": "apikeys_metadata_trgm_idx", + "columns": [ + { + "expression": "\"metadata\" gin_trgm_ops", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "columnsFrom": [ + "inviter_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "columnsFrom": [ + "team_id" + ], + "tableTo": "teams", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "columnsFrom": [ + "client_id" + ], + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsTo": [ + "client_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "columnsFrom": [ + "refresh_id" + ], + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "columns": [ + "client_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "columnsFrom": [ + "client_id" + ], + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsTo": [ + "client_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "columnsFrom": [ + "client_id" + ], + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsTo": [ + "client_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.team_members": { + "name": "team_members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "team_members_user_id_idx": { + "name": "team_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "team_members_organization_id_idx": { + "name": "team_members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "team_members_team_user_unique": { + "name": "team_members_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "columnsFrom": [ + "team_id" + ], + "tableTo": "teams", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "team_members_organization_id_organizations_id_fk": { + "name": "team_members_organization_id_organizations_id_fk", + "tableFrom": "team_members", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.teams": { + "name": "teams", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "teams_org_slug_unique": { + "name": "teams_org_slug_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletion_requested_at": { + "name": "deletion_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_organization_ids_idx": { + "name": "users_organization_ids_idx", + "columns": [ + { + "expression": "organization_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "columnsFrom": [ + "connected_by_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "columns": [ + "installation_id" + ], + "nullsNotDistinct": false + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "columns": [ + "organization_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "columnsFrom": [ + "repository_id" + ], + "tableTo": "github_repositories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "columns": [ + "repository_id", + "pr_number" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "columnsFrom": [ + "installation_id" + ], + "tableTo": "github_installations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "columns": [ + "repo_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_events": { + "name": "automation_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "integration_connection_id": { + "name": "integration_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_login": { + "name": "actor_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_is_external": { + "name": "actor_is_external", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "webhook_event_id": { + "name": "webhook_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatch_input": { + "name": "dispatch_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_events_undispatched_idx": { + "name": "automation_events_undispatched_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"automation_events\".\"dispatched_at\" IS NULL", + "concurrently": false + }, + "automation_events_org_received_idx": { + "name": "automation_events_org_received_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "automation_events_resource_idx": { + "name": "automation_events_resource_idx", + "columns": [ + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "automation_events_organization_id_organizations_id_fk": { + "name": "automation_events_organization_id_organizations_id_fk", + "tableFrom": "automation_events", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "automation_events_integration_connection_id_integration_connections_id_fk": { + "name": "automation_events_integration_connection_id_integration_connections_id_fk", + "tableFrom": "automation_events", + "columnsFrom": [ + "integration_connection_id" + ], + "tableTo": "integration_connections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automation_events_dedup_unique": { + "name": "automation_events_dedup_unique", + "columns": [ + "integration_connection_id", + "provider", + "external_event_id" + ], + "nullsNotDistinct": true + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_prompt_versions": { + "name": "automation_prompt_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_bucket": { + "name": "window_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "automation_prompt_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "restored_from_version_id": { + "name": "restored_from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_prompt_versions_bucket_uniq": { + "name": "automation_prompt_versions_bucket_uniq", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"automation_prompt_versions\".\"source\" <> 'restore'", + "concurrently": false + }, + "automation_prompt_versions_automation_idx": { + "name": "automation_prompt_versions_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "automation_prompt_versions_automation_id_automations_id_fk": { + "name": "automation_prompt_versions_automation_id_automations_id_fk", + "tableFrom": "automation_prompt_versions", + "columnsFrom": [ + "automation_id" + ], + "tableTo": "automations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "automation_prompt_versions_author_user_id_users_id_fk": { + "name": "automation_prompt_versions_author_user_id_users_id_fk", + "tableFrom": "automation_prompt_versions", + "columnsFrom": [ + "author_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "automation_prompt_versions_restored_from_version_id_fk": { + "name": "automation_prompt_versions_restored_from_version_id_fk", + "tableFrom": "automation_prompt_versions", + "columnsFrom": [ + "restored_from_version_id" + ], + "tableTo": "automation_prompt_versions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_kind": { + "name": "session_kind", + "type": "automation_session_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terminal_session_id": { + "name": "terminal_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_runs_schedule_dedup_idx": { + "name": "automation_runs_schedule_dedup_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "scheduled_for IS NOT NULL", + "concurrently": false + }, + "automation_runs_event_dedup_idx": { + "name": "automation_runs_event_dedup_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "event_id IS NOT NULL", + "concurrently": false + }, + "automation_runs_inflight_resource_idx": { + "name": "automation_runs_inflight_resource_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "status IN ('dispatching', 'dispatched')", + "concurrently": false + }, + "automation_runs_history_idx": { + "name": "automation_runs_history_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "automation_runs_status_idx": { + "name": "automation_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "automation_runs_workspace_idx": { + "name": "automation_runs_workspace_idx", + "columns": [ + { + "expression": "v2_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "automation_runs_organization_id_organizations_id_fk": { + "name": "automation_runs_organization_id_organizations_id_fk", + "tableFrom": "automation_runs", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "automation_runs_trigger_id_automation_triggers_id_fk": { + "name": "automation_runs_trigger_id_automation_triggers_id_fk", + "tableFrom": "automation_runs", + "columnsFrom": [ + "trigger_id" + ], + "tableTo": "automation_triggers", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "automation_runs_event_id_automation_events_id_fk": { + "name": "automation_runs_event_id_automation_events_id_fk", + "tableFrom": "automation_runs", + "columnsFrom": [ + "event_id" + ], + "tableTo": "automation_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "automation_runs_chat_session_id_chat_sessions_id_fk": { + "name": "automation_runs_chat_session_id_chat_sessions_id_fk", + "tableFrom": "automation_runs", + "columnsFrom": [ + "chat_session_id" + ], + "tableTo": "chat_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "automation_runs_automation_org_fk": { + "name": "automation_runs_automation_org_fk", + "tableFrom": "automation_runs", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "tableTo": "automations", + "columnsTo": [ + "id", + "organization_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_triggers": { + "name": "automation_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "automation_trigger_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_prefix": { + "name": "secret_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_triggers_dispatcher_idx": { + "name": "automation_triggers_dispatcher_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "kind = 'schedule'", + "concurrently": false + }, + "automation_triggers_matcher_idx": { + "name": "automation_triggers_matcher_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "automation_triggers_automation_idx": { + "name": "automation_triggers_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "automation_triggers_schedule_idx": { + "name": "automation_triggers_schedule_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "kind = 'schedule'", + "concurrently": false + } + }, + "foreignKeys": { + "automation_triggers_organization_id_organizations_id_fk": { + "name": "automation_triggers_organization_id_organizations_id_fk", + "tableFrom": "automation_triggers", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "automation_triggers_automation_org_fk": { + "name": "automation_triggers_automation_org_fk", + "tableFrom": "automation_triggers", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "tableTo": "automations", + "columnsTo": [ + "id", + "organization_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "automation_triggers_kind_matches_config": { + "name": "automation_triggers_kind_matches_config", + "value": "config->>'kind' = kind::text" + } + }, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_host_id": { + "name": "target_host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_project_id": { + "name": "v2_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automations_owner_idx": { + "name": "automations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "automations_organization_idx": { + "name": "automations_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "automations_organization_id_organizations_id_fk": { + "name": "automations_organization_id_organizations_id_fk", + "tableFrom": "automations", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "automations_owner_user_id_users_id_fk": { + "name": "automations_owner_user_id_users_id_fk", + "tableFrom": "automations", + "columnsFrom": [ + "owner_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automations_id_org_unique": { + "name": "automations_id_org_unique", + "columns": [ + "id", + "organization_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blob_pathname": { + "name": "blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_attachments_session_idx": { + "name": "chat_attachments_session_idx", + "columns": [ + { + "expression": "chat_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "chat_attachments_created_by_idx": { + "name": "chat_attachments_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chat_attachments_chat_session_id_chat_sessions_id_fk": { + "name": "chat_attachments_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_attachments", + "columnsFrom": [ + "chat_session_id" + ], + "tableTo": "chat_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "chat_attachments_created_by_users_id_fk": { + "name": "chat_attachments_created_by_users_id_fk", + "tableFrom": "chat_attachments", + "columnsFrom": [ + "created_by" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "columnsFrom": [ + "created_by" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "workspaces", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspaces": { + "name": "cloud_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blaxel'" + }, + "provider_sandbox_id": { + "name": "provider_sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_url": { + "name": "sandbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "cloud_workspace_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloud_workspaces_organization_id_idx": { + "name": "cloud_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "cloud_workspaces_project_id_idx": { + "name": "cloud_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "cloud_workspaces_organization_id_organizations_id_fk": { + "name": "cloud_workspaces_organization_id_organizations_id_fk", + "tableFrom": "cloud_workspaces", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "cloud_workspaces_project_id_v2_projects_id_fk": { + "name": "cloud_workspaces_project_id_v2_projects_id_fk", + "tableFrom": "cloud_workspaces", + "columnsFrom": [ + "project_id" + ], + "tableTo": "v2_projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "cloud_workspaces_created_by_user_id_users_id_fk": { + "name": "cloud_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "cloud_workspaces", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cloud_workspaces_provider_sandbox_id_unique": { + "name": "cloud_workspaces_provider_sandbox_id_unique", + "columns": [ + "provider", + "provider_sandbox_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.desktop_notices": { + "name": "desktop_notices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "severity": { + "name": "severity", + "type": "desktop_notice_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "desktop_notice_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'immediate'" + }, + "min_version": { + "name": "min_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_version": { + "name": "max_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platforms": { + "name": "platforms", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "channels": { + "name": "channels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta_label": { + "name": "cta_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cta_action": { + "name": "cta_action", + "type": "desktop_notice_cta_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cta_url": { + "name": "cta_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissible": { + "name": "dismissible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "desktop_notices_active_idx": { + "name": "desktop_notices_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_provider_unique": { + "name": "integration_connections_org_provider_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"integration_connections\".\"provider\" <> 'google'", + "concurrently": false + }, + "integration_connections_google_user_unique": { + "name": "integration_connections_google_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"integration_connections\".\"provider\" = 'google'", + "concurrently": false + }, + "integration_connections_slack_external_org_active_unique": { + "name": "integration_connections_slack_external_org_active_unique", + "columns": [ + { + "expression": "external_org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"integration_connections\".\"provider\" = 'slack' AND \"integration_connections\".\"disconnected_at\" IS NULL", + "concurrently": false + }, + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "columnsFrom": [ + "connected_by_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "columnsFrom": [ + "github_repository_id" + ], + "tableTo": "github_repositories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "columns": [ + "organization_id", + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submitted_prompts": { + "name": "submitted_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt_text": { + "name": "prompt_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submitter_name": { + "name": "submitter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submitted_prompts_user_id_idx": { + "name": "submitted_prompts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "submitted_prompts_organization_id_idx": { + "name": "submitted_prompts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "submitted_prompts_created_at_idx": { + "name": "submitted_prompts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "submitted_prompts_user_id_users_id_fk": { + "name": "submitted_prompts_user_id_users_id_fk", + "tableFrom": "submitted_prompts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "submitted_prompts_organization_id_organizations_id_fk": { + "name": "submitted_prompts_organization_id_organizations_id_fk", + "tableFrom": "submitted_prompts", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "columnsFrom": [ + "reference_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "columns": [ + "organization_id", + "external_provider", + "external_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_project_id": { + "name": "external_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_project_name": { + "name": "external_project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_id": { + "name": "external_cycle_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_name": { + "name": "external_cycle_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_external_project_id_idx": { + "name": "tasks_external_project_id_idx", + "columns": [ + { + "expression": "external_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_external_project_name_idx": { + "name": "tasks_external_project_name_idx", + "columns": [ + { + "expression": "external_project_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_external_cycle_id_idx": { + "name": "tasks_external_cycle_id_idx", + "columns": [ + { + "expression": "external_cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "status_id" + ], + "tableTo": "task_statuses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "assignee_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "creator_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "columns": [ + "organization_id", + "external_provider", + "external_id" + ], + "nullsNotDistinct": false + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "columns": [ + "organization_id", + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_scope_id": { + "name": "external_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_identities_user_idx": { + "name": "user_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_identities_org_provider_idx": { + "name": "user_identities_org_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_identities_organization_id_organizations_id_fk": { + "name": "user_identities_organization_id_organizations_id_fk", + "tableFrom": "user_identities", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_identities_account_unique": { + "name": "user_identities_account_unique", + "columns": [ + "organization_id", + "provider", + "external_scope_id", + "external_id" + ], + "nullsNotDistinct": true + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "v2_clients_organization_id_user_id_machine_id_pk": { + "name": "v2_clients_organization_id_user_id_machine_id_pk", + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_online": { + "name": "is_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "wake_command": { + "name": "wake_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": { + "v2_hosts_organization_id_machine_id_pk": { + "name": "v2_hosts_organization_id_machine_id_pk", + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_clone_url": { + "name": "repo_clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "columnsFrom": [ + "github_repository_id" + ], + "tableTo": "github_repositories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "columns": [ + "organization_id", + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "v2_users_hosts_host_fk": { + "name": "v2_users_hosts_host_fk", + "tableFrom": "v2_users_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "tableTo": "v2_hosts", + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "v2_users_hosts_organization_id_user_id_host_id_pk": { + "name": "v2_users_hosts_organization_id_user_id_host_id_pk", + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'worktree'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "v2_workspaces_task_id_idx": { + "name": "v2_workspaces_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "v2_workspaces_one_main_per_host": { + "name": "v2_workspaces_one_main_per_host", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"v2_workspaces\".\"type\" = 'main'", + "concurrently": false + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "v2_workspaces_task_id_tasks_id_fk": { + "name": "v2_workspaces_task_id_tasks_id_fk", + "tableFrom": "v2_workspaces", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "v2_workspaces_host_fk": { + "name": "v2_workspaces_host_fk", + "tableFrom": "v2_workspaces", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "tableTo": "v2_hosts", + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organizations", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.automation_prompt_source": { + "name": "automation_prompt_source", + "schema": "public", + "values": [ + "human", + "agent", + "restore" + ] + }, + "public.automation_run_status": { + "name": "automation_run_status", + "schema": "public", + "values": [ + "dispatching", + "dispatched", + "skipped_offline", + "dispatch_failed", + "debounced", + "rejected" + ] + }, + "public.automation_session_kind": { + "name": "automation_session_kind", + "schema": "public", + "values": [ + "chat", + "terminal" + ] + }, + "public.automation_trigger_kind": { + "name": "automation_trigger_kind", + "schema": "public", + "values": [ + "schedule", + "webhook", + "github", + "slack", + "linear", + "sentry", + "microsoft_teams", + "google_calendar", + "gmail", + "notion", + "circleback" + ] + }, + "public.cloud_workspace_status": { + "name": "cloud_workspace_status", + "schema": "public", + "values": [ + "provisioning", + "ready", + "failed", + "deleted" + ] + }, + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.desktop_notice_cta_action": { + "name": "desktop_notice_cta_action", + "schema": "public", + "values": [ + "install-update", + "open-url" + ] + }, + "public.desktop_notice_severity": { + "name": "desktop_notice_severity", + "schema": "public", + "values": [ + "info", + "warning", + "blocking" + ] + }, + "public.desktop_notice_trigger": { + "name": "desktop_notice_trigger", + "schema": "public", + "values": [ + "immediate", + "pre-update", + "post-update" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack", + "sentry", + "microsoft_teams", + "google", + "notion" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.v2_workspace_type": { + "name": "v2_workspace_type", + "schema": "public", + "values": [ + "main", + "worktree" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 01a5238340c..6d401f35995 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -610,6 +610,13 @@ "when": 1787094486824, "tag": "0086_drop_trigger_enabled", "breakpoints": true + }, + { + "idx": 87, + "version": "7", + "when": 1787099734268, + "tag": "0087_scope_unification", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/types.ts b/packages/db/src/schema/types.ts index 0b290e2298a..fcec4fc4d2f 100644 --- a/packages/db/src/schema/types.ts +++ b/packages/db/src/schema/types.ts @@ -1,6 +1,5 @@ import type { MatchableEvent } from "@superset/shared/automation-matching"; import type { - TriggerActor as TriggerConfigActor, TriggerConfigInput, TriggerScope as TriggerConfigScope, } from "@superset/shared/automation-triggers"; @@ -14,6 +13,12 @@ export type AutomationEventDispatchInput = { event: MatchableEvent; automationId?: string; triggerId?: string; + /** + * Restricts matching to one member's automations. Set by providers whose + * connection is per member (Google): a member's calendar and mail events + * must fire only that member's automations, never fan across the org. + */ + ownerUserId?: string; }; export type LinearConfig = { @@ -111,7 +116,6 @@ export type IntegrationConfig = */ export type TriggerConfig = TriggerConfigInput; export type TriggerScope = TriggerConfigScope; -export type TriggerActor = TriggerConfigActor; export type ScheduleTriggerConfig = Extract< TriggerConfig, diff --git a/packages/shared/src/automation-matching/circleback.ts b/packages/shared/src/automation-matching/circleback.ts index f0d169e126c..067f40a8253 100644 --- a/packages/shared/src/automation-matching/circleback.ts +++ b/packages/shared/src/automation-matching/circleback.ts @@ -2,8 +2,8 @@ import type { TextFilter, TriggerScope } from "../automation-triggers"; import { type BaseMatchableEvent, bodyMatches, - type MatchContext, type MatchResult, + no, scopeAllowsAny, } from "./core"; @@ -20,18 +20,13 @@ export type CirclebackMatchableEvent = BaseMatchableEvent & { attendeeEmails: string[]; }; -const no = (reason: string): MatchResult => ({ matches: false, reason }); - function lower(values: string[]): string[] { return values.map((v) => v.trim().toLowerCase()); } -/** - * Same null semantics as GitHub's branches and labels: null means the trigger - * author never chose to narrow on this, which for an optional filter is "any". - */ +/** `scopeAllowsAny`, with both sides folded to one case first. */ function narrows(scope: TriggerScope, values: string[]): boolean { - if (scope === null || scope.mode === "any") return true; + if (scope.mode === "any") return true; return scopeAllowsAny({ mode: "list", ids: lower(scope.ids) }, lower(values)); } @@ -44,7 +39,6 @@ export function circlebackTriggerMatches( nameFilter?: TextFilter | null; }, event: CirclebackMatchableEvent, - _context: MatchContext, ): MatchResult { if (config.event !== event.eventType) return no("event"); if (!narrows(config.tags, event.tags)) return no("tags"); diff --git a/packages/shared/src/automation-matching/core.ts b/packages/shared/src/automation-matching/core.ts index 5ba1ba0a3d2..ca87f3eb120 100644 --- a/packages/shared/src/automation-matching/core.ts +++ b/packages/shared/src/automation-matching/core.ts @@ -1,4 +1,4 @@ -import type { TriggerActor, TriggerScope } from "../automation-triggers"; +import type { TriggerScope } from "../automation-triggers"; /** * Decides whether a recorded event satisfies a trigger's config. @@ -18,13 +18,6 @@ import type { TriggerActor, TriggerScope } from "../automation-triggers"; */ export type BaseMatchableEvent = { provider: string; - /** - * Which `user_identities.provider` resolves `me` for this event, when it - * differs from `provider`. Google Calendar and Gmail are two trigger kinds - * behind one Google identity, so both set this to "google". Defaults to - * `provider`; every other provider leaves it unset. - */ - identityProvider?: string; /** Qualified with its action, e.g. `pull_request.opened`. */ eventType: string; /** The provider's id for whoever caused the event; what people filters compare against. */ @@ -35,57 +28,34 @@ export type BaseMatchableEvent = { body: string | null; }; -/** - * What matchers need beyond the event: the automation owner's linked ids for - * this provider, so `me` resolves. One shape for every provider — anything - * provider-specific belongs on that provider's event type, not here. - */ -export type MatchContext = { - ownerIds: string[]; -}; - export type MatchResult = | { matches: true } | { matches: false; reason: string }; +/** The non-match every matcher builds its refusals from. */ +export const no = (reason: string): MatchResult => ({ matches: false, reason }); + /** - * `null` matches nothing — an unconfigured filter should never fire. That is the - * opposite of the usual "empty means unrestricted" convention, and deliberate: - * a half-built trigger silently matching every repository is the worst - * available failure. + * An empty list matches nothing — an unconfigured filter should never fire. + * That is the opposite of the usual "empty means unrestricted" convention, and + * deliberate: a half-built trigger silently matching every repository is the + * worst available failure. `{mode:"any"}` is how wide open is said. */ export function scopeAllows( scope: TriggerScope, value: string | null, ): boolean { - if (scope === null) return false; if (scope.mode === "any") return true; if (value === null) return false; return scope.ids.includes(value); } -/** Same, but for filters that are only meaningful on some events. */ +/** Same, over the event's list of values — labels, attendees. */ export function scopeAllowsAny(scope: TriggerScope, values: string[]): boolean { - if (scope === null) return false; if (scope.mode === "any") return true; return values.some((v) => scope.ids.includes(v)); } -/** - * `ownerIds` is a set: a person may link both a work and a personal account, and - * a pull request opened from either is still theirs. - */ -export function actorAllows( - actor: TriggerActor, - actorId: string | null, - ownerIds: string[], -): boolean { - if (actor === "anyone") return true; - if (actorId === null) return false; - if (actor === "me") return ownerIds.includes(actorId); - return actor.ids.includes(actorId); -} - /** * The body a filter is tested against is truncated first. * diff --git a/packages/shared/src/automation-matching/github.test.ts b/packages/shared/src/automation-matching/github.test.ts index 88b59ce165f..becaade3bb1 100644 --- a/packages/shared/src/automation-matching/github.test.ts +++ b/packages/shared/src/automation-matching/github.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { githubEventNames } from "./github"; +import type { TriggerScope } from "../automation-triggers"; +import { + type GithubMatchableEvent, + githubEventNames, + githubTriggerMatches, +} from "./github"; const names = ( eventType: string, @@ -37,3 +42,66 @@ describe("githubEventNames", () => { ]); }); }); + +const event = (overrides: Partial = {}) => + ({ + provider: "github", + eventType: "pull_request.opened", + actorId: "1234", + actorLogin: "someone", + body: null, + repositoryId: "42", + ref: null, + actorIsExternal: null, + labels: [], + isFork: false, + subjectAuthorId: null, + names: ["pull_request.opened"], + ...overrides, + }) satisfies GithubMatchableEvent; + +const config = (actor: TriggerScope) => ({ + event: "pull_request.opened", + repositories: { mode: "any" } as const, + branches: { mode: "any" } as const, + labels: { mode: "any" } as const, + actor, + includeForks: false, +}); + +describe("githubTriggerMatches actor scope", () => { + it("matches anyone with {mode:'any'}, even with no actor id", () => { + expect(githubTriggerMatches(config({ mode: "any" }), event()).matches).toBe( + true, + ); + expect( + githubTriggerMatches(config({ mode: "any" }), event({ actorId: null })) + .matches, + ).toBe(true); + }); + + it("matches a listed actor id and refuses others", () => { + expect( + githubTriggerMatches(config({ mode: "list", ids: ["1234"] }), event()) + .matches, + ).toBe(true); + expect( + githubTriggerMatches(config({ mode: "list", ids: ["9999"] }), event()), + ).toEqual({ matches: false, reason: "actor" }); + }); + + it("refuses a list scope when the event names no actor", () => { + expect( + githubTriggerMatches( + config({ mode: "list", ids: ["1234"] }), + event({ actorId: null }), + ), + ).toEqual({ matches: false, reason: "actor" }); + }); + + it("refuses an empty list — a half-built trigger matches nothing", () => { + expect( + githubTriggerMatches(config({ mode: "list", ids: [] }), event()), + ).toEqual({ matches: false, reason: "actor" }); + }); +}); diff --git a/packages/shared/src/automation-matching/github.ts b/packages/shared/src/automation-matching/github.ts index 3deb876998a..375182a013d 100644 --- a/packages/shared/src/automation-matching/github.ts +++ b/packages/shared/src/automation-matching/github.ts @@ -1,14 +1,9 @@ -import type { - GithubTriggerEvent, - TriggerActor, - TriggerScope, -} from "../automation-triggers"; +import type { GithubTriggerEvent, TriggerScope } from "../automation-triggers"; import { - actorAllows, type BaseMatchableEvent, bodyMatches, - type MatchContext, type MatchResult, + no, scopeAllows, scopeAllowsAny, } from "./core"; @@ -28,8 +23,6 @@ export type GithubMatchableEvent = BaseMatchableEvent & { names: GithubTriggerEvent[]; }; -const no = (reason: string): MatchResult => ({ matches: false, reason }); - /** * Maps a GitHub delivery to the event a trigger names. GitHub's wire events are * coarser than the product's: `pull_request.opened` is a draft opening or a @@ -104,13 +97,12 @@ export function githubTriggerMatches( repositories: TriggerScope; branches: TriggerScope; labels: TriggerScope; - actor: TriggerActor; - subjectAuthor?: TriggerActor; + actor: TriggerScope; + subjectAuthor?: TriggerScope; commentFilter?: { pattern: string; isRegex: boolean } | null; includeForks: boolean; }, event: GithubMatchableEvent, - context: MatchContext, ): MatchResult { if (!event.names.includes(config.event as GithubTriggerEvent)) { return no("event"); @@ -118,20 +110,18 @@ export function githubTriggerMatches( if (!scopeAllows(config.repositories, event.repositoryId)) { return no("repository"); } - // Branches and labels only narrow when configured; null means the trigger - // author did not choose to filter on them, which for these is "any". - if (config.branches !== null && !scopeAllows(config.branches, event.ref)) { + if (!scopeAllows(config.branches, event.ref)) { return no("branch"); } - if (config.labels !== null && !scopeAllowsAny(config.labels, event.labels)) { + if (!scopeAllowsAny(config.labels, event.labels)) { return no("label"); } - if (!actorAllows(config.actor, event.actorId, context.ownerIds)) { + if (!scopeAllows(config.actor, event.actorId)) { return no("actor"); } if ( config.subjectAuthor !== undefined && - !actorAllows(config.subjectAuthor, event.subjectAuthorId, context.ownerIds) + !scopeAllows(config.subjectAuthor, event.subjectAuthorId) ) { return no("subjectAuthor"); } diff --git a/packages/shared/src/automation-matching/google.ts b/packages/shared/src/automation-matching/google.ts index c14ce76f7e8..89af7293967 100644 --- a/packages/shared/src/automation-matching/google.ts +++ b/packages/shared/src/automation-matching/google.ts @@ -1,36 +1,27 @@ import type { GmailTriggerEvent, GoogleCalendarTriggerEvent, - TriggerActor, TriggerScope, } from "../automation-triggers"; import { - actorAllows, type BaseMatchableEvent, bodyMatches, - type MatchContext, type MatchResult, + no, scopeAllows, scopeAllowsAny, } from "./core"; -const no = (reason: string): MatchResult => ({ matches: false, reason }); - /** * A synced calendar change or a fire we scheduled off one, normalized to what * the triggers filter on. Emails are lower-cased at record time so the - * comparison here is exact. + * comparison here is exact. A Google connection is one member's, so the + * dispatcher — not the matcher — narrows candidates to the connection owner's + * automations. */ export type GoogleCalendarMatchableEvent = BaseMatchableEvent & { provider: "google_calendar"; - /** Both Google kinds share one connection, so `me` resolves through it. */ - identityProvider: "google"; eventType: GoogleCalendarTriggerEvent; - /** - * The Google account the event came from, lower-cased. Connections are - * per member, so a trigger only sees events from its owner's account. - */ - accountEmail: string; calendarId: string; /** Organizer, creator and invitees together — everyone on the event. */ attendeeEmails: string[]; @@ -44,9 +35,7 @@ export type GoogleCalendarMatchableEvent = BaseMatchableEvent & { /** An arriving mail, normalized from its headers and label ids. */ export type GmailMatchableEvent = BaseMatchableEvent & { provider: "gmail"; - identityProvider: "google"; eventType: GmailTriggerEvent; - accountEmail: string; fromAddress: string | null; toAddresses: string[]; subject: string | null; @@ -54,38 +43,22 @@ export type GmailMatchableEvent = BaseMatchableEvent & { hasAttachment: boolean; }; -/** - * `context.ownerIds` are addresses here: a calendar event names people by - * email, so the Google identity's external id is the account address and `me` - * resolves to the automation owner's linked addresses. The same ids say whose - * account the event came from — a Google connection is one member's, and only - * that member's automations see its events. - */ -export function ownsAccount( - event: { accountEmail: string }, - context: MatchContext, -): boolean { - return context.ownerIds.includes(event.accountEmail); -} - export function googleCalendarTriggerMatches( config: { event: string; calendars: TriggerScope; - attendee: TriggerActor; + attendee: TriggerScope; titleFilter: { pattern: string; isRegex: boolean } | null; hasExternalAttendee?: boolean; minutesBefore?: number; }, event: GoogleCalendarMatchableEvent, - context: MatchContext, ): MatchResult { if (config.event !== event.eventType) return no("event"); - if (!ownsAccount(event, context)) return no("account"); if (!scopeAllows(config.calendars, event.calendarId)) return no("calendar"); - if ( - !attendeeAllows(config.attendee, event.attendeeEmails, context.ownerIds) - ) { + // The attendee filter is over a list of people rather than one: an event is + // a match when any of its attendees satisfies it. + if (!scopeAllowsAny(config.attendee, event.attendeeEmails)) { return no("attendee"); } if (!bodyMatches(config.titleFilter, event.title)) return no("titleFilter"); @@ -103,19 +76,6 @@ export function googleCalendarTriggerMatches( return { matches: true }; } -/** - * The actor filter over a list of people rather than one: an event is a match - * when any of its attendees satisfies it. - */ -function attendeeAllows( - actor: TriggerActor, - attendeeEmails: string[], - ownerEmails: string[], -): boolean { - if (actor === "anyone") return true; - return attendeeEmails.some((email) => actorAllows(actor, email, ownerEmails)); -} - export function gmailTriggerMatches( config: { event: string; @@ -126,10 +86,8 @@ export function gmailTriggerMatches( hasAttachment: boolean; }, event: GmailMatchableEvent, - context: MatchContext, ): MatchResult { if (config.event !== event.eventType) return no("event"); - if (!ownsAccount(event, context)) return no("account"); if ( !addressScopeAllows( config.from, @@ -138,18 +96,13 @@ export function gmailTriggerMatches( ) { return no("from"); } - // Recipients and labels only narrow when configured; null means the trigger - // author did not choose to filter on them, which for these is "any". - if (config.to !== null && !addressScopeAllows(config.to, event.toAddresses)) { + if (!addressScopeAllows(config.to, event.toAddresses)) { return no("to"); } if (!bodyMatches(config.subjectFilter, event.subject)) { return no("subjectFilter"); } - if ( - config.labels !== null && - !scopeAllowsAny(config.labels, event.labelIds) - ) { + if (!scopeAllowsAny(config.labels, event.labelIds)) { return no("label"); } if (config.hasAttachment && !event.hasAttachment) return no("hasAttachment"); @@ -165,7 +118,6 @@ export function addressScopeAllows( scope: TriggerScope, addresses: string[], ): boolean { - if (scope === null) return false; if (scope.mode === "any") return true; const wanted = scope.ids.map((id) => id.trim().toLowerCase()); return addresses.some((address) => diff --git a/packages/shared/src/automation-matching/index.ts b/packages/shared/src/automation-matching/index.ts index a7aac93471f..97563e2b3c4 100644 --- a/packages/shared/src/automation-matching/index.ts +++ b/packages/shared/src/automation-matching/index.ts @@ -3,7 +3,7 @@ import { type CirclebackMatchableEvent, circlebackTriggerMatches, } from "./circleback"; -import type { BaseMatchableEvent, MatchContext, MatchResult } from "./core"; +import type { BaseMatchableEvent, MatchResult } from "./core"; import { type GithubMatchableEvent, githubTriggerMatches } from "./github"; import { type GmailMatchableEvent, @@ -38,8 +38,7 @@ export * from "./webhook"; * Adding a provider: define `MatchableEvent = BaseMatchableEvent & { * provider: ""; …fields its filters compare against }` in its own module, * add it to this union, and add a `case` below. Provider-specific fields live - * on the event, never on `MatchContext` — a Slack matcher must not need to - * know that a GitHub context key exists. + * on the event — a Slack matcher must not need to know a GitHub field exists. */ export type MatchableEvent = | GithubMatchableEvent @@ -65,7 +64,6 @@ export type MatchableEvent = export function triggerMatches( config: TriggerConfigInput, event: MatchableEvent, - context: MatchContext, ): MatchResult { if (config.kind !== event.provider) { return { @@ -81,13 +79,11 @@ export function triggerMatches( return githubTriggerMatches( config as Extract, event, - context, ); case "sentry": return sentryTriggerMatches( config as Extract, event, - context, ); case "webhook": return webhookTriggerMatches(); @@ -95,43 +91,36 @@ export function triggerMatches( return notionTriggerMatches( config as Extract, event, - context, ); case "linear": return linearTriggerMatches( config as Extract, event, - context, ); case "slack": return slackTriggerMatches( config as Extract, event, - context, ); case "circleback": return circlebackTriggerMatches( config as Extract, event, - context, ); case "microsoft_teams": return microsoftTeamsTriggerMatches( config as Extract, event, - context, ); case "google_calendar": return googleCalendarTriggerMatches( config as Extract, event, - context, ); case "gmail": return gmailTriggerMatches( config as Extract, event, - context, ); } // Reached only when a provider is in the union but has no case above. diff --git a/packages/shared/src/automation-matching/linear.ts b/packages/shared/src/automation-matching/linear.ts index d8977fff89e..1da15f72543 100644 --- a/packages/shared/src/automation-matching/linear.ts +++ b/packages/shared/src/automation-matching/linear.ts @@ -1,13 +1,8 @@ -import type { - LinearTriggerEvent, - TriggerActor, - TriggerScope, -} from "../automation-triggers"; +import type { LinearTriggerEvent, TriggerScope } from "../automation-triggers"; import { - actorAllows, type BaseMatchableEvent, - type MatchContext, type MatchResult, + no, scopeAllows, scopeAllowsAny, } from "./core"; @@ -27,8 +22,6 @@ export type LinearMatchableEvent = BaseMatchableEvent & { names: LinearTriggerEvent[]; }; -const no = (reason: string): MatchResult => ({ matches: false, reason }); - /** * Maps a Linear delivery to the events a trigger names. Linear's wire events * are entity × action; what actually changed is only visible in `updatedFrom`, @@ -83,10 +76,9 @@ export function linearTriggerMatches( projects: TriggerScope; labels: TriggerScope; toStatus: TriggerScope; - assignee: TriggerActor; + assignee: TriggerScope; }, event: LinearMatchableEvent, - context: MatchContext, ): MatchResult { if (!event.names.includes(config.event as LinearTriggerEvent)) { return no("event"); @@ -94,27 +86,16 @@ export function linearTriggerMatches( if (!scopeAllows(config.teams, event.teamId)) { return no("team"); } - // Projects, labels and status only narrow when configured; null means the - // trigger author did not choose to filter on them, which for these is "any". - if ( - config.projects !== null && - !scopeAllows(config.projects, event.projectId) - ) { + if (!scopeAllows(config.projects, event.projectId)) { return no("project"); } - if ( - config.labels !== null && - !scopeAllowsAny(config.labels, event.labelIds) - ) { + if (!scopeAllowsAny(config.labels, event.labelIds)) { return no("label"); } - if ( - config.toStatus !== null && - !scopeAllows(config.toStatus, event.stateId) - ) { + if (!scopeAllows(config.toStatus, event.stateId)) { return no("status"); } - if (!actorAllows(config.assignee, event.assigneeId, context.ownerIds)) { + if (!scopeAllows(config.assignee, event.assigneeId)) { return no("assignee"); } return { matches: true }; diff --git a/packages/shared/src/automation-matching/microsoft-teams.ts b/packages/shared/src/automation-matching/microsoft-teams.ts index 7705ac51837..e81bd49891e 100644 --- a/packages/shared/src/automation-matching/microsoft-teams.ts +++ b/packages/shared/src/automation-matching/microsoft-teams.ts @@ -1,14 +1,12 @@ import type { MicrosoftTeamsTriggerEvent, - TriggerActor, TriggerScope, } from "../automation-triggers"; import { - actorAllows, type BaseMatchableEvent, bodyMatches, - type MatchContext, type MatchResult, + no, scopeAllows, } from "./core"; @@ -24,19 +22,16 @@ export type MicrosoftTeamsMatchableEvent = BaseMatchableEvent & { channelId: string | null; }; -const no = (reason: string): MatchResult => ({ matches: false, reason }); - /** Whether a Teams trigger config accepts this event. */ export function microsoftTeamsTriggerMatches( config: { event: MicrosoftTeamsTriggerEvent; teams: TriggerScope; channels: TriggerScope; - actor: TriggerActor; + actor: TriggerScope; messageFilter?: { pattern: string; isRegex: boolean } | null; }, event: MicrosoftTeamsMatchableEvent, - context: MatchContext, ): MatchResult { if (event.eventType !== config.event) return no("event"); if (!scopeAllows(config.teams, event.teamId)) return no("team"); @@ -45,7 +40,7 @@ export function microsoftTeamsTriggerMatches( // is named as the actor: the sentence has neither slot. if (config.event === "message_in_channel") { if (!scopeAllows(config.channels, event.channelId)) return no("channel"); - if (!actorAllows(config.actor, event.actorId, context.ownerIds)) { + if (!scopeAllows(config.actor, event.actorId)) { return no("actor"); } } diff --git a/packages/shared/src/automation-matching/notion.ts b/packages/shared/src/automation-matching/notion.ts index 0b3ebdebd34..fb530fb48b2 100644 --- a/packages/shared/src/automation-matching/notion.ts +++ b/packages/shared/src/automation-matching/notion.ts @@ -1,15 +1,14 @@ import type { NotionTriggerEvent, - TriggerActor, TriggerConfigInput, TriggerScope, } from "../automation-triggers"; import { - actorAllows, type BaseMatchableEvent, - type MatchContext, type MatchResult, + no, scopeAllows, + scopeAllowsAny, } from "./core"; /** @@ -27,8 +26,6 @@ export type NotionMatchableEvent = BaseMatchableEvent & { mentionedUserIds: string[]; }; -const no = (reason: string): MatchResult => ({ matches: false, reason }); - /** * Maps a Notion delivery to the events a trigger names. A comment that * mentions someone is both a comment and a mention. @@ -48,7 +45,6 @@ export function notionEventNames(eventType: string): NotionTriggerEvent[] { export function notionTriggerMatches( config: Extract, event: NotionMatchableEvent, - context: MatchContext, ): MatchResult { if (!notionEventNames(event.eventType).includes(config.event)) { return no("event"); @@ -56,33 +52,19 @@ export function notionTriggerMatches( if (!scopeAllows(config.dataSources, event.dataSourceId)) { return no("dataSource"); } - // Pages only narrow when configured; null means the author did not choose - // to filter on them, which here is "any". const pages: TriggerScope | undefined = "pages" in config ? config.pages : undefined; - if ( - pages !== undefined && - pages !== null && - !scopeAllows(pages, event.pageId) - ) { + if (pages !== undefined && !scopeAllows(pages, event.pageId)) { return no("page"); } - if ( - "actor" in config && - !actorAllows(config.actor, event.actorId, context.ownerIds) - ) { + if ("actor" in config && !scopeAllows(config.actor, event.actorId)) { return no("actor"); } if ("mentionedUser" in config) { - const mentionedUser: TriggerActor = config.mentionedUser; // A comment that mentions nobody is not a mention, whoever the trigger // is watching for. if (event.mentionedUserIds.length === 0) return no("mention"); - if ( - !event.mentionedUserIds.some((id) => - actorAllows(mentionedUser, id, context.ownerIds), - ) - ) { + if (!scopeAllowsAny(config.mentionedUser, event.mentionedUserIds)) { return no("mentionedUser"); } } diff --git a/packages/shared/src/automation-matching/sentry.ts b/packages/shared/src/automation-matching/sentry.ts index e299fb054ab..7f0216ac5ea 100644 --- a/packages/shared/src/automation-matching/sentry.ts +++ b/packages/shared/src/automation-matching/sentry.ts @@ -1,8 +1,8 @@ import type { SentryTriggerEvent, TriggerScope } from "../automation-triggers"; import { type BaseMatchableEvent, - type MatchContext, type MatchResult, + no, scopeAllows, } from "./core"; @@ -17,8 +17,6 @@ export type SentryMatchableEvent = BaseMatchableEvent & { names: SentryTriggerEvent[]; }; -const no = (reason: string): MatchResult => ({ matches: false, reason }); - /** * Maps a Sentry issue webhook to the events a trigger can name. The wire event * is `issue.`; every one of them also satisfies `issue.any`. @@ -47,7 +45,6 @@ export function sentryTriggerMatches( level: TriggerScope; }, event: SentryMatchableEvent, - _context: MatchContext, ): MatchResult { if (!event.names.includes(config.event as SentryTriggerEvent)) { return no("event"); @@ -55,9 +52,7 @@ export function sentryTriggerMatches( if (!scopeAllows(config.projects, event.projectId)) { return no("project"); } - // Level only narrows when configured; null means the trigger author did not - // choose to filter on it, which for this is "any". - if (config.level !== null && !scopeAllows(config.level, event.level)) { + if (!scopeAllows(config.level, event.level)) { return no("level"); } return { matches: true }; diff --git a/packages/shared/src/automation-matching/slack.ts b/packages/shared/src/automation-matching/slack.ts index 48ff1c07005..e188bfc3e91 100644 --- a/packages/shared/src/automation-matching/slack.ts +++ b/packages/shared/src/automation-matching/slack.ts @@ -1,14 +1,9 @@ -import type { - SlackTriggerEvent, - TriggerActor, - TriggerScope, -} from "../automation-triggers"; +import type { SlackTriggerEvent, TriggerScope } from "../automation-triggers"; import { - actorAllows, type BaseMatchableEvent, bodyMatches, - type MatchContext, type MatchResult, + no, scopeAllows, } from "./core"; @@ -29,8 +24,6 @@ export type SlackMatchableEvent = BaseMatchableEvent & { names: SlackTriggerEvent[]; }; -const no = (reason: string): MatchResult => ({ matches: false, reason }); - /** * One spelling of an emoji name for both sides of an emoji filter: `bug`, * `:bug:` and `bug::skin-tone-2` (how a skin-toned reaction arrives) all read @@ -65,12 +58,11 @@ export function slackTriggerMatches( event: string; channels: TriggerScope; emoji: TriggerScope; - actor: TriggerActor; + actor: TriggerScope; messageFilter?: { pattern: string; isRegex: boolean } | null; topLevelOnly?: boolean; }, event: SlackMatchableEvent, - context: MatchContext, ): MatchResult { if (!event.names.includes(config.event as SlackTriggerEvent)) { return no("event"); @@ -86,7 +78,7 @@ export function slackTriggerMatches( if ( config.event === "reaction_added" && !scopeAllows( - config.emoji?.mode === "list" + config.emoji.mode === "list" ? { ...config.emoji, ids: config.emoji.ids.map(slackEmojiName) } : config.emoji, event.reaction, @@ -102,7 +94,7 @@ export function slackTriggerMatches( ) { return no("threadReply"); } - if (!actorAllows(config.actor, event.actorId, context.ownerIds)) { + if (!scopeAllows(config.actor, event.actorId)) { return no("actor"); } if (!bodyMatches(config.messageFilter ?? null, event.body)) { diff --git a/packages/shared/src/automation-triggers.ts b/packages/shared/src/automation-triggers.ts index 038006b6eb3..1596e7e301b 100644 --- a/packages/shared/src/automation-triggers.ts +++ b/packages/shared/src/automation-triggers.ts @@ -17,37 +17,28 @@ import { hasFiniteRecurrence } from "./rrule"; */ /** - * Three states, tagged rather than inferred from shape: `null` matches nothing, - * `{mode:"any"}` matches everything, a list matches those ids. Every id space - * here is user-controlled — a GitHub label really can be named "any" — so a bare - * `string[] | "any"` would collide with legal values. + * Tagged rather than inferred from shape: `{mode:"any"}` matches everything, a + * list matches those ids, and an empty list matches nothing — the "Select + * Repos" state a half-built trigger sits in. Every id space here is + * user-controlled — a GitHub label really can be named "any" — so a bare + * `string[] | "any"` would collide with legal values. People filters (actor, + * assignee, attendee) are scopes too; the ids are whatever the provider names + * people by. */ -export const triggerScopeSchema = z.union([ - z.null(), - z.object({ mode: z.literal("any") }), - z.object({ - mode: z.literal("list"), - ids: z.array(z.string().min(1)).max(200), - }), -]); +export const triggerScopeSchema = z + .union([ + z.object({ mode: z.literal("any") }), + z.object({ + mode: z.literal("list"), + ids: z.array(z.string().min(1)).max(200), + }), + ]) + .default({ mode: "any" }); export type TriggerScope = z.infer; -export const triggerActorSchema = z.union([ - z.literal("anyone"), - // Resolves to the automation's owner at match time rather than being - // expanded to an id on save, so it survives the owner being renamed. - z.literal("me"), - z.object({ ids: z.array(z.string().min(1)).max(200) }), -]); -export type TriggerActor = z.infer; - /** A scope that is set but selects nothing — the "Select Repos" empty state. */ export function isEmptyScope(scope: TriggerScope): boolean { - return scope === null || (scope.mode === "list" && scope.ids.length === 0); -} - -export function isEmptyActor(actor: TriggerActor): boolean { - return typeof actor !== "string" && actor.ids.length === 0; + return scope.mode === "list" && scope.ids.length === 0; } const rrule = z.string().min(1).max(500); @@ -135,7 +126,7 @@ const githubSimpleEvent = z.object({ "workflow_run.cancelled", "workflow_run.any", ]), - actor: triggerActorSchema, + actor: triggerScopeSchema, }); /** @@ -145,8 +136,8 @@ const githubSimpleEvent = z.object({ const githubCommentEvent = z.object({ ...githubCommon, event: z.enum(["comment_added", "issue_comment"]), - actor: triggerActorSchema, - subjectAuthor: triggerActorSchema, + actor: triggerScopeSchema, + subjectAuthor: triggerScopeSchema, commentFilter: textFilterSchema.nullable().default(null), }); @@ -180,13 +171,13 @@ export const slackTriggerConfigSchema = z.object({ kind: z.literal("slack"), event: z.enum(slackTriggerEventValues), // The channel a message or reaction lands in. Not meaningful for - // channel_created — the channel does not exist yet — so null there. + // channel_created — the channel does not exist yet — so "any" there. channels: triggerScopeSchema, - // Only meaningful for reaction_added; null elsewhere. The ids are emoji + // Only meaningful for reaction_added; "any" elsewhere. The ids are emoji // short names typed by the person, so a workspace's custom emoji work // without any list of them existing. emoji: triggerScopeSchema, - actor: triggerActorSchema, + actor: triggerScopeSchema, // A pattern over the message text, or over the channel name for // channel_created. messageFilter: textFilterSchema.nullable().default(null), @@ -220,7 +211,7 @@ export const linearTriggerConfigSchema = z.object({ // issue.status_changed; "any" elsewhere. toStatus: triggerScopeSchema, // The issue's assignee, not who made the change. Ids are Linear user ids. - assignee: triggerActorSchema, + assignee: triggerScopeSchema, }); export const sentryTriggerEventValues = [ @@ -288,16 +279,16 @@ const notionCommentCreatedEvent = z.object({ ...notionCommon, event: z.literal("comment.created"), pages: triggerScopeSchema, - actor: triggerActorSchema, + actor: triggerScopeSchema, }); const notionCommentMentionedEvent = z.object({ ...notionCommon, event: z.literal("comment.mentioned"), pages: triggerScopeSchema, - // Who has to be @-mentioned for the comment to count. "me" is the common - // case; "anyone" fires on any comment that mentions somebody. - mentionedUser: triggerActorSchema, + // Who has to be @-mentioned for the comment to count; "any" fires on any + // comment that mentions somebody. + mentionedUser: triggerScopeSchema, }); export const notionTriggerConfigSchema = z.union([ @@ -316,17 +307,17 @@ export type MicrosoftTeamsTriggerEvent = /** * Teams triggers scope by team, then by channel within it. `channel_created` * has no channel to filter on — the channel is the thing being created — so it - * carries `channels: null` and reads `messageFilter` as a pattern over the new - * channel's name. + * carries `channels: {mode:"any"}` and reads `messageFilter` as a pattern over + * the new channel's name. */ export const microsoftTeamsTriggerConfigSchema = z.object({ kind: z.literal("microsoft_teams"), event: z.enum(microsoftTeamsTriggerEventValues), teams: triggerScopeSchema, - // Only meaningful for message_in_channel; null elsewhere. + // Only meaningful for message_in_channel; "any" elsewhere. channels: triggerScopeSchema, - // Only meaningful for message_in_channel; "anyone" elsewhere. - actor: triggerActorSchema, + // Only meaningful for message_in_channel; "any" elsewhere. + actor: triggerScopeSchema, messageFilter: textFilterSchema.nullable().default(null), }); @@ -350,7 +341,7 @@ const googleCalendarCommon = { calendars: triggerScopeSchema, // Anyone on the event: organizer, creator or invitee. Ids are email // addresses, since that is what a calendar event names people by. - attendee: triggerActorSchema, + attendee: triggerScopeSchema, titleFilter: textFilterSchema.nullable().default(null), }; @@ -429,150 +420,129 @@ export type TriggerProblem = { }; /** - * The rules a draft must satisfy before it can be saved. Kept as explicit checks - * rather than schema refinements so each one carries a message the form can put - * next to the field it belongs to. + * One "Specify at least one …" rule: the scope field that must not be empty, + * the noun the message names it by, and — for fields whose chip offers an + * explicit wide-open entry — what that entry is called. `when` limits the rule + * to the events whose sentence shows the chip; a rule for a field the config + * member does not carry skips itself. + */ +type ScopeRequirement = { + field: string; + noun: string; + orChoose?: string; + when?: (config: TriggerConfigInput) => boolean; +}; + +const person = ( + field: string, + when?: (config: TriggerConfigInput) => boolean, +): ScopeRequirement => ({ field, noun: "person", orChoose: "Anyone", when }); + +const REQUIREMENTS: Partial< + Record +> = { + github: [ + { field: "repositories", noun: "repository" }, + person("actor"), + person("subjectAuthor"), + ], + slack: [ + { + field: "channels", + noun: "channel", + when: (config) => + config.kind === "slack" && config.event !== "channel_created", + }, + { + field: "emoji", + noun: "reaction", + when: (config) => + config.kind === "slack" && config.event === "reaction_added", + }, + person("actor"), + ], + notion: [ + { field: "dataSources", noun: "data source" }, + person("actor"), + person("mentionedUser"), + ], + linear: [ + { field: "teams", noun: "team" }, + // Only the events whose sentence shows an assignee; a created or cycle + // trigger has no chip to clear such a problem with. + person( + "assignee", + (config) => + config.kind === "linear" && + (config.event === "issue.status_changed" || + config.event === "issue.assigned"), + ), + ], + microsoft_teams: [ + { field: "teams", noun: "team" }, + { + field: "channels", + noun: "channel", + when: (config) => + config.kind === "microsoft_teams" && + config.event === "message_in_channel", + }, + person("actor"), + ], + sentry: [{ field: "projects", noun: "project" }], + google_calendar: [ + { field: "calendars", noun: "calendar" }, + person("attendee"), + ], + // The sender is the primary scope, as the repository is for GitHub: a + // mailbox-wide trigger has to be chosen ("Any sender"), never arrived at by + // leaving the chip empty. + gmail: [{ field: "from", noun: "sender", orChoose: "Any sender" }], +}; + +/** + * The rules a draft must satisfy before it can be saved. A data table plus one + * loop rather than schema refinements, so each rule carries a message the form + * can put next to the field it belongs to — and so the draft/savable split + * survives: the schema stays satisfiable by a half-configured trigger. */ export function describeTriggerProblems( triggers: DraftTrigger[], ): TriggerProblem[] { const problems: TriggerProblem[] = []; - const add = (index: number, field: string, message: string) => - problems.push({ index, field, message }); if (triggers.length === 0) { - add(-1, "triggers", "Add at least one trigger."); + problems.push({ + index: -1, + field: "triggers", + message: "Add at least one trigger.", + }); } triggers.forEach((trigger, index) => { const config = trigger.config; - switch (config.kind) { - case "github": { - if (isEmptyScope(config.repositories)) { - add(index, "repositories", "Specify at least one repository."); - } - if (isEmptyActor(config.actor)) { - add(index, "actor", "Specify at least one person, or choose Anyone."); - } - if ("subjectAuthor" in config && isEmptyActor(config.subjectAuthor)) { - add( - index, - "subjectAuthor", - "Specify at least one person, or choose Anyone.", - ); - } - break; - } - case "slack": { - if ( - config.event !== "channel_created" && - isEmptyScope(config.channels) - ) { - add(index, "channels", "Specify at least one channel."); - } - if (config.event === "reaction_added" && isEmptyScope(config.emoji)) { - add(index, "emoji", "Specify at least one reaction."); - } - if (isEmptyActor(config.actor)) { - add(index, "actor", "Specify at least one person, or choose Anyone."); - } - break; - } - case "notion": { - if (isEmptyScope(config.dataSources)) { - add(index, "dataSources", "Specify at least one data source."); - } - if ("actor" in config && isEmptyActor(config.actor)) { - add(index, "actor", "Specify at least one person, or choose Anyone."); - } - if ("mentionedUser" in config && isEmptyActor(config.mentionedUser)) { - add( - index, - "mentionedUser", - "Specify at least one person, or choose Anyone.", - ); - } - break; - } - case "linear": { - if (isEmptyScope(config.teams)) { - add(index, "teams", "Specify at least one team."); - } - // Only the events whose sentence shows an assignee; a created or - // cycle trigger has no chip to clear such a problem with. - if ( - (config.event === "issue.status_changed" || - config.event === "issue.assigned") && - isEmptyActor(config.assignee) - ) { - add( - index, - "assignee", - "Specify at least one person, or choose Anyone.", - ); - } - break; - } - case "microsoft_teams": { - if (isEmptyScope(config.teams)) { - add(index, "teams", "Specify at least one team."); - } - if ( - config.event === "message_in_channel" && - isEmptyScope(config.channels) - ) { - add(index, "channels", "Specify at least one channel."); - } - if (isEmptyActor(config.actor)) { - add(index, "actor", "Specify at least one person, or choose Anyone."); - } - break; - } - case "sentry": { - if (isEmptyScope(config.projects)) { - add(index, "projects", "Specify at least one project."); - } - break; - } - case "google_calendar": { - if (isEmptyScope(config.calendars)) { - add(index, "calendars", "Specify at least one calendar."); - } - if (isEmptyActor(config.attendee)) { - add( - index, - "attendee", - "Specify at least one person, or choose Anyone.", - ); - } - break; - } - case "gmail": { - // The sender is the primary scope, as the repository is for GitHub: - // a mailbox-wide trigger has to be chosen ("Any sender"), never - // arrived at by leaving the chip empty. - if (isEmptyScope(config.from)) { - add( - index, - "from", - "Specify at least one sender, or choose Any sender.", - ); - } - break; - } - case "circleback": - break; - case "schedule": - if (hasFiniteRecurrence(config.rrule)) { - problems.push({ - index, - field: "rrule", - message: "Schedules repeat — remove COUNT or UNTIL.", - }); - } - break; - case "webhook": - break; + for (const rule of REQUIREMENTS[config.kind] ?? []) { + if (rule.when && !rule.when(config)) continue; + const scope = (config as Record)[rule.field] as + | TriggerScope + | undefined; + if (scope === undefined || !isEmptyScope(scope)) continue; + problems.push({ + index, + field: rule.field, + message: rule.orChoose + ? `Specify at least one ${rule.noun}, or choose ${rule.orChoose}.` + : `Specify at least one ${rule.noun}.`, + }); + } + + // A schedule's rule is about its recurrence, not a scope, so it stays code. + if (config.kind === "schedule" && hasFiniteRecurrence(config.rrule)) { + problems.push({ + index, + field: "rrule", + message: "Schedules repeat — remove COUNT or UNTIL.", + }); } }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9fa251ac785..7c0803c2bbe 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,3 +1,5 @@ +import type { TriggerConfigInput } from "./automation-triggers"; + // Auth export const AUTH_PROVIDERS = ["github", "google"] as const; export type AuthProvider = (typeof AUTH_PROVIDERS)[number]; @@ -201,6 +203,25 @@ export const FEATURE_FLAGS = { CLOUD_WORKSPACES: "cloud-workspaces", } as const; +/** + * The trigger kinds the server accepts on save. The AUTOMATION_EVENT_TRIGGERS + * flag payload gates which of these each user's Add Trigger menu offers; + * flipping a provider off for everyone is deleting its line here. + */ +export const LAUNCHED_TRIGGER_KINDS = [ + "schedule", + "webhook", + "github", + "slack", + "linear", + "sentry", + "notion", + "circleback", + "microsoft_teams", + "google_calendar", + "gmail", +] as const satisfies readonly TriggerConfigInput["kind"][]; + /** * What a cloud workspace sandbox holds in place of a real model API key. The * provider's egress proxy substitutes the real one after the request leaves, diff --git a/packages/trpc/src/router/automation/triggerSet.ts b/packages/trpc/src/router/automation/triggerSet.ts index 5e977f584e6..85718927074 100644 --- a/packages/trpc/src/router/automation/triggerSet.ts +++ b/packages/trpc/src/router/automation/triggerSet.ts @@ -4,6 +4,7 @@ import { describeTriggerProblems, summarizeTriggerProblems, } from "@superset/shared/automation-triggers"; +import { LAUNCHED_TRIGGER_KINDS } from "@superset/shared/constants"; import { nextOccurrenceAfter } from "@superset/shared/rrule"; import { TRPCError } from "@trpc/server"; import { and, eq, inArray, notInArray } from "drizzle-orm"; @@ -52,6 +53,19 @@ export async function saveTriggerSet( triggers: DraftTrigger[]; }, ) { + // The schema accepts every kind that exists in code; this list is which of + // them have launched. The PostHog payload only hides menu entries, so the + // server has to be the one refusing an unlaunched kind. + const launched: ReadonlySet = new Set(LAUNCHED_TRIGGER_KINDS); + for (const trigger of params.triggers) { + if (!launched.has(trigger.config.kind)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${trigger.config.kind} triggers are not available`, + }); + } + } + const problems = describeTriggerProblems(params.triggers); if (problems.length > 0) { throw new TRPCError({ From 0ff3ae183d9d391534ec00f8c6bdd70bf63a72a3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 18 Aug 2026 20:53:56 -0700 Subject: [PATCH 15/15] =?UTF-8?q?fix(automations):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20sweep=20on=20quiet=20ticks,=20mark=20zero-candidate?= =?UTF-8?q?=20events,=20stream=20the=20body=20cap,=20reject=20unparseable?= =?UTF-8?q?=20rrules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/api/automations/evaluate/route.ts | 8 +++++- .../automations/dispatchMatchingTriggers.ts | 6 +++- apps/api/src/lib/webhooks/body.ts | 28 +++++++++++++------ packages/shared/src/automation-triggers.ts | 22 ++++++++++----- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/api/src/app/api/automations/evaluate/route.ts b/apps/api/src/app/api/automations/evaluate/route.ts index bad8fc5474b..f2f9551ffdc 100644 --- a/apps/api/src/app/api/automations/evaluate/route.ts +++ b/apps/api/src/app/api/automations/evaluate/route.ts @@ -117,7 +117,13 @@ export async function POST(request: Request): Promise { } if (planned.length === 0) { - return Response.json({ enqueued: 0, unusable: unusable.length }); + // The event-handoff sweep still runs on a tick with no due schedules. + const redispatched = await redispatchUndispatched(); + return Response.json({ + enqueued: 0, + unusable: unusable.length, + redispatched, + }); } await qstash.batchJSON( diff --git a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts index 72e84f4d1d2..0d406f2f897 100644 --- a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts +++ b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts @@ -83,7 +83,11 @@ export async function dispatchMatchingTriggers(params: { ), ); - if (candidates.length === 0) return { matched: 0, considered: 0 }; + if (candidates.length === 0) { + // Done, not stuck: without the mark the sweep would retry it forever. + await markDispatched(params.eventId); + return { matched: 0, considered: 0 }; + } const matched = candidates.filter( (candidate) => triggerMatches(candidate.config, event).matches, diff --git a/apps/api/src/lib/webhooks/body.ts b/apps/api/src/lib/webhooks/body.ts index 39c0040a954..526242474ab 100644 --- a/apps/api/src/lib/webhooks/body.ts +++ b/apps/api/src/lib/webhooks/body.ts @@ -1,22 +1,34 @@ const DEFAULT_MAX_BYTES = 1024 * 1024; /** - * Reads the raw body with a size cap. The Content-Length check is cheap and - * refuses before reading; the byte check after reading is the one that holds, - * since the header is sender-controlled. + * Reads the raw body with a size cap enforced while streaming, so an + * oversized body is refused without ever being held in memory. The + * Content-Length pre-check just refuses honest senders cheaply. */ export async function cappedBody( request: Request, maxBytes = DEFAULT_MAX_BYTES, ): Promise { + const tooLarge = () => + Response.json({ error: "Body too large" }, { status: 413 }); if (Number(request.headers.get("content-length")) > maxBytes) { - return Response.json({ error: "Body too large" }, { status: 413 }); + return tooLarge(); } - const body = await request.text(); - if (Buffer.byteLength(body) > maxBytes) { - return Response.json({ error: "Body too large" }, { status: 413 }); + if (!request.body) return ""; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel(); + return tooLarge(); + } + chunks.push(value); } - return body; + return Buffer.concat(chunks).toString("utf8"); } /** JSON.parse that answers 400 instead of throwing. */ diff --git a/packages/shared/src/automation-triggers.ts b/packages/shared/src/automation-triggers.ts index 1596e7e301b..39041b0cbf9 100644 --- a/packages/shared/src/automation-triggers.ts +++ b/packages/shared/src/automation-triggers.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { hasFiniteRecurrence } from "./rrule"; +import { hasFiniteRecurrence, rruleProblem } from "./rrule"; /** * Trigger validation, shared by the editor and the API so a form can block Save @@ -537,12 +537,20 @@ export function describeTriggerProblems( } // A schedule's rule is about its recurrence, not a scope, so it stays code. - if (config.kind === "schedule" && hasFiniteRecurrence(config.rrule)) { - problems.push({ - index, - field: "rrule", - message: "Schedules repeat — remove COUNT or UNTIL.", - }); + if (config.kind === "schedule") { + if (rruleProblem(config.rrule) === "unparseable") { + problems.push({ + index, + field: "rrule", + message: "Enter a valid recurrence rule.", + }); + } else if (hasFiniteRecurrence(config.rrule)) { + problems.push({ + index, + field: "rrule", + message: "Schedules repeat — remove COUNT or UNTIL.", + }); + } } });