diff --git a/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts b/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts index 8595980566e..85fc46eed90 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts @@ -91,8 +91,13 @@ export function normalizeLinearDelivery(params: { connection: { id: string; organizationId: string; - connectedByUserId: string; }; + /** + * One delivery is recorded once per connection, and each Superset + * organization has one lead connection that also fires unpinned triggers. + * The others fire only the triggers pinned to them, so a trigger runs once. + */ + leadsOrganization: boolean; webhookEventId: string; }): NormalizedDelivery { const { delivery, event, connection } = params; @@ -110,6 +115,8 @@ export function normalizeLinearDelivery(params: { payload: delivery, webhookEventId: params.webhookEventId, }, - dispatch: { event, ownerUserId: connection.connectedByUserId }, + dispatch: params.leadsOrganization + ? { event } + : { event, pinnedOnly: true }, }; } diff --git a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts index 3a69098731a..ae3cc9a1f10 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts @@ -58,6 +58,7 @@ const ingestCalls: Array<{ connectionId: string | null; externalEventId: string; ownerUserId: string | null; + pinnedOnly: boolean; }> = []; stub(automationEvent, { ingestAutomationEvent: mock(async (_db: unknown, delivery: never) => { @@ -67,13 +68,14 @@ stub(automationEvent, { integrationConnectionId: string | null; externalEventId: string; }; - dispatch?: { ownerUserId?: string } | null; + dispatch?: { ownerUserId?: string; pinnedOnly?: boolean } | null; }; if (d.skip) return { status: "skipped", reason: d.skip }; ingestCalls.push({ connectionId: d.event?.integrationConnectionId ?? null, externalEventId: d.event?.externalEventId ?? "", ownerUserId: d.dispatch?.ownerUserId ?? null, + pinnedOnly: d.dispatch?.pinnedOnly ?? false, }); return { status: "dispatched", eventId: "e" }; }), @@ -118,8 +120,22 @@ describe("one Linear delivery, two connections in the same organization", () => expect(ids.size).toBe(1); }); - test("narrows each dispatch to the member who owns that connection", async () => { + test("only the first connection fires unpinned triggers", async () => { await processDelivery({ payload: DELIVERY, deliveryId: "delivery-1" }); - expect(ingestCalls.map((c) => c.ownerUserId)).toEqual(["user-a", "user-b"]); + expect(ingestCalls.map((c) => c.pinnedOnly)).toEqual([false, true]); + }); + + test("never narrows to the member who connected", async () => { + await processDelivery({ payload: DELIVERY, deliveryId: "delivery-1" }); + expect(ingestCalls.map((c) => c.ownerUserId)).toEqual([null, null]); + }); + + test("each organization has its own lead connection", async () => { + subscribers = [ + connection("conn-a", "user-a"), + { ...connection("conn-b", "user-b"), organizationId: "org-2" }, + ]; + await processDelivery({ payload: DELIVERY, deliveryId: "delivery-1" }); + expect(ingestCalls.map((c) => c.pinnedOnly)).toEqual([false, false]); }); }); diff --git a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.ts b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.ts index 2187f006094..71937e2c7ad 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.ts @@ -105,16 +105,26 @@ export async function processDelivery({ // Caught per connection, and the loop runs to the end regardless: one // organization's broken token or missing workflow state must not cost the // other sixteen their delivery. + const leadByOrganization = new Map(); + for (const connection of subscribers) { + if (!leadByOrganization.has(connection.organizationId)) { + leadByOrganization.set(connection.organizationId, connection.id); + } + } + const leadIds = new Set(leadByOrganization.values()); const results: ConnectionResult[] = []; for (const connection of subscribers) { results.push( - await processForConnection(payload, deliveryId, connection).catch( - (error) => ({ - connectionId: connection.id, - outcome: "failed" as const, - error: errorMessage(error), - }), - ), + await processForConnection( + payload, + deliveryId, + connection, + leadIds.has(connection.id), + ).catch((error) => ({ + connectionId: connection.id, + outcome: "failed" as const, + error: errorMessage(error), + })), ); } @@ -130,6 +140,7 @@ async function processForConnection( payload: LinearWebhookPayload, deliveryId: string | null, connection: SelectConnection, + leadsOrganization: boolean, ): Promise { // One webhookEvents row per (Linear event × Superset connection) so each // tenant's processing status is independently retryable, and so a retried @@ -163,7 +174,13 @@ async function processForConnection( if (isEntityDelivery(payload)) { try { - await ingest(payload, deliveryId, connection, webhookEvent.id); + await ingest( + payload, + deliveryId, + connection, + leadsOrganization, + webhookEvent.id, + ); } catch (error) { console.error( "[linear/process-delivery] automation event failed:", @@ -204,6 +221,7 @@ async function ingest( delivery: LinearDelivery, deliveryHeader: string | null, connection: SelectConnection, + leadsOrganization: boolean, webhookEventId: string, ): Promise { const event = matchableFrom(delivery); @@ -224,6 +242,7 @@ async function ingest( event, deliveryId, connection, + leadsOrganization, webhookEventId, }), ); diff --git a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts index e8ec4d18c9c..caf096d6448 100644 --- a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts +++ b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts @@ -62,6 +62,8 @@ export async function dispatchMatchingTriggers(params: { */ ownerUserId?: string; integrationConnectionId?: string | null; + /** Only triggers pinned to `integrationConnectionId`; see `AutomationEventDispatchInput`. */ + pinnedOnly?: boolean; }): Promise<{ matched: number; considered: number }> { const { event } = params; @@ -104,13 +106,18 @@ export async function dispatchMatchingTriggers(params: { ? eq(automations.ownerUserId, params.ownerUserId) : undefined, params.integrationConnectionId - ? or( - isNull(automationTriggers.connectionId), - eq( + ? params.pinnedOnly + ? eq( automationTriggers.connectionId, params.integrationConnectionId, - ), - ) + ) + : or( + isNull(automationTriggers.connectionId), + eq( + automationTriggers.connectionId, + params.integrationConnectionId, + ), + ) : isNull(automationTriggers.connectionId), ), ); diff --git a/apps/api/src/lib/automations/ingestAutomationEvent.ts b/apps/api/src/lib/automations/ingestAutomationEvent.ts index d5c46f6fcfb..42d747e16af 100644 --- a/apps/api/src/lib/automations/ingestAutomationEvent.ts +++ b/apps/api/src/lib/automations/ingestAutomationEvent.ts @@ -70,6 +70,7 @@ export async function ingestAutomationEvent( automationId: delivery.dispatch.automationId, triggerId: delivery.dispatch.triggerId, ownerUserId: delivery.dispatch.ownerUserId, + pinnedOnly: delivery.dispatch.pinnedOnly, integrationConnectionId: delivery.event.integrationConnectionId, }); return { status: "dispatched", eventId: inserted.id, ...result }; diff --git a/apps/api/src/lib/automations/redispatchUndispatched.ts b/apps/api/src/lib/automations/redispatchUndispatched.ts index d59499a4c06..52aa2341930 100644 --- a/apps/api/src/lib/automations/redispatchUndispatched.ts +++ b/apps/api/src/lib/automations/redispatchUndispatched.ts @@ -79,6 +79,7 @@ export async function redispatchUndispatched(tx: SingleFlightTx): Promise<{ automationId: row.dispatchInput.automationId, triggerId: row.dispatchInput.triggerId, ownerUserId: row.dispatchInput.ownerUserId, + pinnedOnly: row.dispatchInput.pinnedOnly, integrationConnectionId: row.integrationConnectionId, }); } catch (error) { diff --git a/packages/db/src/schema/types.ts b/packages/db/src/schema/types.ts index 9698d89e33a..f905173ece7 100644 --- a/packages/db/src/schema/types.ts +++ b/packages/db/src/schema/types.ts @@ -19,6 +19,12 @@ export type AutomationEventDispatchInput = { * that member's automations, never fan across the org. */ ownerUserId?: string; + /** + * Restricts matching to triggers pinned to the event's connection. Set when + * one delivery is recorded once per connection (Linear), so an unpinned + * trigger fires from one of those events only. + */ + pinnedOnly?: boolean; }; export type LinearConfig = {