From 6a870adec92046a3ffb995eaf2d578db89c020fb Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 23 May 2026 13:06:47 +0100 Subject: [PATCH] fix(integrations): partial unique index so user-link doesn't clobber workspace row --- .../repos/integration-connection.repo.ts | 31 +++++----- ...ntegration-connections-user-kind-unique.ts | 56 +++++++++++++++++++ 2 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/database/migrations/20260524T020000-integration-connections-user-kind-unique.ts diff --git a/apps/server/src/core/integration/repos/integration-connection.repo.ts b/apps/server/src/core/integration/repos/integration-connection.repo.ts index 841edcbe5..a1c85dda2 100644 --- a/apps/server/src/core/integration/repos/integration-connection.repo.ts +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectKysely } from 'nestjs-kysely'; +import { sql } from 'kysely'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { IntegrationConnection, @@ -124,14 +125,9 @@ export class IntegrationConnectionRepo { ); } - // Clear any stale non-workspace row for the same (integration, user) to - // avoid the uq(integration_id, user_id) constraint blocking the insert. - await db - .deleteFrom('integrationConnections') - .where('integrationId', '=', input.integrationId) - .where('userId', '=', input.userId) - .where('kind', '!=', 'workspace') - .execute(); + // No need to clear other rows: the migration 20260524T020000 made the + // (integration_id, user_id) constraint partial-on-kind='user', so a + // workspace insert never conflicts with the installer's user-link row. return db .insertInto('integrationConnections') @@ -286,6 +282,11 @@ export class IntegrationConnectionRepo { trx?: KyselyTransaction, ): Promise { const db = dbOrTx(this.db, trx); + // Target the partial unique index uq_integration_connections_user_per_integration + // (integration_id, user_id) WHERE kind = 'user'. Without the .where() hint, + // ON CONFLICT can't match a partial index. The kind discriminator means a + // workspace bot row sharing (integration_id, user_id) with this user-link + // is no longer a conflict, so we cannot flip its kind. return await db .insertInto('integrationConnections') .values({ @@ -298,12 +299,14 @@ export class IntegrationConnectionRepo { accessToken: null, }) .onConflict((oc) => - oc.columns(['integrationId', 'userId']).doUpdateSet({ - providerUserId: input.providerUserId, - metadata: input.metadata as any, - kind: 'user', - updatedAt: new Date(), - }), + oc + .columns(['integrationId', 'userId']) + .where(sql.ref('kind'), '=', 'user') + .doUpdateSet({ + providerUserId: input.providerUserId, + metadata: input.metadata as any, + updatedAt: new Date(), + }), ) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/src/database/migrations/20260524T020000-integration-connections-user-kind-unique.ts b/apps/server/src/database/migrations/20260524T020000-integration-connections-user-kind-unique.ts new file mode 100644 index 000000000..fb4723256 --- /dev/null +++ b/apps/server/src/database/migrations/20260524T020000-integration-connections-user-kind-unique.ts @@ -0,0 +1,56 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // The original (integration_id, user_id) unique constraint predates the + // `kind` discriminator. For workspace-scoped integrations (Slack), the + // installer's user_id appears on BOTH the workspace bot row and their + // personal user-link row. The constraint blocks that, and the resulting + // upsert-conflict in user-link flow flipped the existing workspace row + // to kind='user' (clobbering the bot connection). + // + // Replace with a partial unique index that only constrains kind='user' + // rows. Workspace rows already have their own partial unique index on + // (integration_id) WHERE kind = 'workspace'. + + await sql`ALTER TABLE integration_connections DROP CONSTRAINT uq_integration_connections_integration_user`.execute( + db, + ); + + await db.schema + .createIndex('uq_integration_connections_user_per_integration') + .on('integration_connections') + .columns(['integration_id', 'user_id']) + .where(sql.ref('kind'), '=', 'user') + .unique() + .execute(); + + // Repair Slack workspace rows that got flipped to kind='user' by the + // earlier upsertUserLink bug. User-link rows have NULL access_token by + // design; any kind='user' row that still has access_token AND scopes + // populated for a Slack integration is the corrupted bot row. + await sql` + UPDATE integration_connections ic + SET kind = 'workspace' + FROM integrations i + WHERE ic.integration_id = i.id + AND i.type = 'slack' + AND ic.kind = 'user' + AND ic.access_token IS NOT NULL + AND ic.scopes IS NOT NULL + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await db.schema + .dropIndex('uq_integration_connections_user_per_integration') + .ifExists() + .execute(); + + // Re-adding the full constraint will fail on any DB that now legitimately + // has both a kind='workspace' and kind='user' row for the same + // (integration_id, user_id). Operators rolling back should clean those + // up first. We don't try to be clever; the constraint name is preserved. + await sql`ALTER TABLE integration_connections ADD CONSTRAINT uq_integration_connections_integration_user UNIQUE (integration_id, user_id)`.execute( + db, + ); +}