diff --git a/apps/backend/prisma/migrations/20260317000000_add_team_permission_invitation_sequence_columns/migration.sql b/apps/backend/prisma/migrations/20260317000000_add_team_permission_invitation_sequence_columns/migration.sql new file mode 100644 index 000000000..b13d10e6c --- /dev/null +++ b/apps/backend/prisma/migrations/20260317000000_add_team_permission_invitation_sequence_columns/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "TeamMemberDirectPermission" ADD COLUMN "sequenceId" BIGINT, +ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT true; + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMemberDirectPermission_sequenceId_key" ON "TeamMemberDirectPermission"("sequenceId"); + +-- CreateIndex +CREATE INDEX "TeamMemberDirectPermission_shouldUpdateSequenceId_idx" ON "TeamMemberDirectPermission"("shouldUpdateSequenceId", "tenancyId"); + +-- CreateIndex +CREATE INDEX "TeamMemberDirectPermission_tenancyId_sequenceId_idx" ON "TeamMemberDirectPermission"("tenancyId", "sequenceId"); + +-- AlterTable +ALTER TABLE "VerificationCode" ADD COLUMN "sequenceId" BIGINT, +ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT true; + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationCode_sequenceId_key" ON "VerificationCode"("sequenceId"); + +-- CreateIndex +CREATE INDEX "VerificationCode_shouldUpdateSequenceId_type_idx" ON "VerificationCode"("shouldUpdateSequenceId", "type"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index dad91817f..6de14c35e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -251,7 +251,12 @@ model TeamMemberDirectPermission { teamMember TeamMember @relation(fields: [tenancyId, projectUserId, teamId], references: [tenancyId, projectUserId, teamId], onDelete: Cascade) + sequenceId BigInt? @unique + shouldUpdateSequenceId Boolean @default(true) + @@unique([tenancyId, projectUserId, teamId, permissionId]) + @@index([shouldUpdateSequenceId, tenancyId], name: "TeamMemberDirectPermission_shouldUpdateSequenceId_idx") + @@index([tenancyId, sequenceId], name: "TeamMemberDirectPermission_tenancyId_sequenceId_idx") } model ProjectUser { @@ -647,9 +652,13 @@ model VerificationCode { data Json attemptCount Int @default(0) + sequenceId BigInt? @unique + shouldUpdateSequenceId Boolean @default(true) + @@id([projectId, branchId, id]) @@unique([projectId, branchId, code]) @@index([data(ops: JsonbPathOps)], type: Gin) + @@index([shouldUpdateSequenceId, type], name: "VerificationCode_shouldUpdateSequenceId_type_idx") } enum VerificationCodeType { diff --git a/apps/backend/scripts/clickhouse-migrations.ts b/apps/backend/scripts/clickhouse-migrations.ts index 15cb720dd..5c2c70c97 100644 --- a/apps/backend/scripts/clickhouse-migrations.ts +++ b/apps/backend/scripts/clickhouse-migrations.ts @@ -20,8 +20,12 @@ export async function runClickhouseMigrations() { await client.exec({ query: CONTACT_CHANNELS_VIEW_SQL }); await client.exec({ query: TEAMS_TABLE_BASE_SQL }); await client.exec({ query: TEAMS_VIEW_SQL }); - await client.exec({ query: TEAM_MEMBERS_TABLE_BASE_SQL }); - await client.exec({ query: TEAM_MEMBERS_VIEW_SQL }); + await client.exec({ query: TEAM_MEMBER_PROFILES_TABLE_BASE_SQL }); + await client.exec({ query: TEAM_MEMBER_PROFILES_VIEW_SQL }); + await client.exec({ query: TEAM_PERMISSIONS_TABLE_BASE_SQL }); + await client.exec({ query: TEAM_PERMISSIONS_VIEW_SQL }); + await client.exec({ query: TEAM_INVITATIONS_TABLE_BASE_SQL }); + await client.exec({ query: TEAM_INVITATIONS_VIEW_SQL }); await client.exec({ query: EMAIL_OUTBOXES_TABLE_BASE_SQL }); await client.exec({ query: EMAIL_OUTBOXES_VIEW_SQL }); await client.exec({ query: EVENTS_ADD_REPLAY_COLUMNS_SQL }); @@ -37,7 +41,9 @@ export async function runClickhouseMigrations() { "GRANT SELECT ON default.users TO limited_user;", "GRANT SELECT ON default.contact_channels TO limited_user;", "GRANT SELECT ON default.teams TO limited_user;", - "GRANT SELECT ON default.team_members TO limited_user;", + "GRANT SELECT ON default.team_member_profiles TO limited_user;", + "GRANT SELECT ON default.team_permissions TO limited_user;", + "GRANT SELECT ON default.team_invitations TO limited_user;", "GRANT SELECT ON default.email_outboxes TO limited_user;", ]; await client.exec({ @@ -53,7 +59,13 @@ export async function runClickhouseMigrations() { query: "CREATE ROW POLICY IF NOT EXISTS teams_project_isolation ON default.teams FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", }); await client.exec({ - query: "CREATE ROW POLICY IF NOT EXISTS team_members_project_isolation ON default.team_members FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", + query: "CREATE ROW POLICY IF NOT EXISTS team_member_profiles_project_isolation ON default.team_member_profiles FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", + }); + await client.exec({ + query: "CREATE ROW POLICY IF NOT EXISTS team_permissions_project_isolation ON default.team_permissions FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", + }); + await client.exec({ + query: "CREATE ROW POLICY IF NOT EXISTS team_invitations_project_isolation ON default.team_invitations FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", }); await client.exec({ query: "CREATE ROW POLICY IF NOT EXISTS email_outboxes_project_isolation ON default.email_outboxes FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", @@ -301,14 +313,15 @@ FINAL WHERE sync_is_deleted = 0; `; -const TEAM_MEMBERS_TABLE_BASE_SQL = ` -CREATE TABLE IF NOT EXISTS analytics_internal.team_members ( +const TEAM_MEMBER_PROFILES_TABLE_BASE_SQL = ` +CREATE TABLE IF NOT EXISTS analytics_internal.team_member_profiles ( project_id String, branch_id String, team_id UUID, user_id UUID, display_name Nullable(String), profile_image_url Nullable(String), + user JSON, created_at DateTime64(3, 'UTC'), sync_sequence_id Int64, sync_is_deleted UInt8, @@ -319,8 +332,8 @@ PARTITION BY toYYYYMM(created_at) ORDER BY (project_id, branch_id, team_id, user_id); `; -const TEAM_MEMBERS_VIEW_SQL = ` -CREATE OR REPLACE VIEW default.team_members +const TEAM_MEMBER_PROFILES_VIEW_SQL = ` +CREATE OR REPLACE VIEW default.team_member_profiles SQL SECURITY DEFINER AS SELECT @@ -330,8 +343,79 @@ SELECT user_id, display_name, profile_image_url, + user, created_at -FROM analytics_internal.team_members +FROM analytics_internal.team_member_profiles +FINAL +WHERE sync_is_deleted = 0; +`; + +const TEAM_PERMISSIONS_TABLE_BASE_SQL = ` +CREATE TABLE IF NOT EXISTS analytics_internal.team_permissions ( + project_id String, + branch_id String, + team_id UUID, + user_id UUID, + permission_id String, + created_at DateTime64(3, 'UTC'), + sync_sequence_id Int64, + sync_is_deleted UInt8, + sync_created_at DateTime64(3, 'UTC') DEFAULT now64(3) +) +ENGINE ReplacingMergeTree(sync_sequence_id) +PARTITION BY toYYYYMM(created_at) +ORDER BY (project_id, branch_id, team_id, user_id, permission_id); +`; + +const TEAM_PERMISSIONS_VIEW_SQL = ` +CREATE OR REPLACE VIEW default.team_permissions +SQL SECURITY DEFINER +AS +SELECT + project_id, + branch_id, + team_id, + user_id, + permission_id, + created_at +FROM analytics_internal.team_permissions +FINAL +WHERE sync_is_deleted = 0; +`; + +const TEAM_INVITATIONS_TABLE_BASE_SQL = ` +CREATE TABLE IF NOT EXISTS analytics_internal.team_invitations ( + project_id String, + branch_id String, + id UUID, + team_id UUID, + team_display_name String, + recipient_email String, + expires_at_millis Int64, + created_at DateTime64(3, 'UTC'), + sync_sequence_id Int64, + sync_is_deleted UInt8, + sync_created_at DateTime64(3, 'UTC') DEFAULT now64(3) +) +ENGINE ReplacingMergeTree(sync_sequence_id) +PARTITION BY toYYYYMM(created_at) +ORDER BY (project_id, branch_id, id); +`; + +const TEAM_INVITATIONS_VIEW_SQL = ` +CREATE OR REPLACE VIEW default.team_invitations +SQL SECURITY DEFINER +AS +SELECT + project_id, + branch_id, + id, + team_id, + team_display_name, + recipient_email, + expires_at_millis, + created_at +FROM analytics_internal.team_invitations FINAL WHERE sync_is_deleted = 0; `; diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts index a7d30bdc1..f727ec514 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts @@ -1,5 +1,6 @@ import { getExternalDbSyncFusebox } from "@/lib/external-db-sync-metadata"; import { enqueueExternalDbSyncBatch } from "@/lib/external-db-sync-queue"; +import { Prisma } from "@/generated/prisma/client"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { traceSpan } from "@/utils/telemetry"; @@ -78,6 +79,23 @@ async function backfillSequenceIds(batchSize: number): Promise { if (projectUserTenants.length > 0) { await enqueueExternalDbSyncBatch(projectUserTenants.map(t => t.tenancyId)); didUpdate = true; + + // Cascade: when a user changes, mark their TeamMember rows for re-sync + // so the embedded user JSON in team_member_profiles stays fresh + await globalPrismaClient.$executeRaw` + UPDATE "TeamMember" + SET "shouldUpdateSequenceId" = TRUE + FROM ( + SELECT DISTINCT "tenancyId", "projectUserId" + FROM "ProjectUser" + WHERE "tenancyId" IN (${Prisma.join(projectUserTenants.map(t => t.tenancyId))}) + AND "shouldUpdateSequenceId" = FALSE + AND "sequenceId" IS NOT NULL + ) AS changed_users + WHERE "TeamMember"."tenancyId" = changed_users."tenancyId" + AND "TeamMember"."projectUserId" = changed_users."projectUserId" + AND "TeamMember"."shouldUpdateSequenceId" = FALSE + `; } const contactChannelTenants = await globalPrismaClient.$queryRaw<{ tenancyId: string }[]>` @@ -135,6 +153,25 @@ async function backfillSequenceIds(batchSize: number): Promise { if (teamTenants.length > 0) { await enqueueExternalDbSyncBatch(teamTenants.map(t => t.tenancyId)); didUpdate = true; + + // Cascade: when a team changes, mark related TEAM_INVITATION verification codes for re-sync + // so the team_display_name in team_invitations stays fresh + await globalPrismaClient.$executeRaw` + UPDATE "VerificationCode" + SET "shouldUpdateSequenceId" = TRUE + FROM ( + SELECT DISTINCT "Tenancy"."projectId", "Tenancy"."branchId" + FROM "Team" + JOIN "Tenancy" ON "Tenancy"."id" = "Team"."tenancyId" + WHERE "Team"."tenancyId" IN (${Prisma.join(teamTenants.map(t => t.tenancyId))}) + AND "Team"."shouldUpdateSequenceId" = FALSE + AND "Team"."sequenceId" IS NOT NULL + ) AS changed_teams + WHERE "VerificationCode"."projectId" = changed_teams."projectId" + AND "VerificationCode"."branchId" = changed_teams."branchId" + AND "VerificationCode"."type" = 'TEAM_INVITATION' + AND "VerificationCode"."shouldUpdateSequenceId" = FALSE + `; } const teamMemberTenants = await globalPrismaClient.$queryRaw<{ tenancyId: string }[]>` @@ -166,6 +203,66 @@ async function backfillSequenceIds(batchSize: number): Promise { didUpdate = true; } + const teamPermissionTenants = await globalPrismaClient.$queryRaw<{ tenancyId: string }[]>` + WITH rows_to_update AS ( + SELECT "id" + FROM "TeamMemberDirectPermission" + WHERE "shouldUpdateSequenceId" = TRUE + ORDER BY "tenancyId" + LIMIT ${batchSize} + FOR UPDATE SKIP LOCKED + ), + updated_rows AS ( + UPDATE "TeamMemberDirectPermission" tp + SET "sequenceId" = nextval('global_seq_id'), + "shouldUpdateSequenceId" = FALSE + FROM rows_to_update r + WHERE tp."id" = r."id" + RETURNING tp."tenancyId" + ) + SELECT DISTINCT "tenancyId" FROM updated_rows + `; + + span.setAttribute("stack.external-db-sync.team-permission-tenants", teamPermissionTenants.length); + + if (teamPermissionTenants.length > 0) { + await enqueueExternalDbSyncBatch(teamPermissionTenants.map(t => t.tenancyId)); + didUpdate = true; + } + + const teamInvitationTenants = await globalPrismaClient.$queryRaw<{ tenancyId: string }[]>` + WITH rows_to_update AS ( + SELECT "projectId", "branchId", "id" + FROM "VerificationCode" + WHERE "shouldUpdateSequenceId" = TRUE + AND "type" = 'TEAM_INVITATION' + ORDER BY "projectId", "branchId" + LIMIT ${batchSize} + FOR UPDATE SKIP LOCKED + ), + updated_rows AS ( + UPDATE "VerificationCode" vc + SET "sequenceId" = nextval('global_seq_id'), + "shouldUpdateSequenceId" = FALSE + FROM rows_to_update r + WHERE vc."projectId" = r."projectId" + AND vc."branchId" = r."branchId" + AND vc."id" = r."id" + RETURNING vc."projectId", vc."branchId" + ) + SELECT DISTINCT "Tenancy"."id" AS "tenancyId" + FROM updated_rows + JOIN "Tenancy" ON "Tenancy"."projectId" = updated_rows."projectId" + AND "Tenancy"."branchId" = updated_rows."branchId" + `; + + span.setAttribute("stack.external-db-sync.team-invitation-tenants", teamInvitationTenants.length); + + if (teamInvitationTenants.length > 0) { + await enqueueExternalDbSyncBatch(teamInvitationTenants.map(t => t.tenancyId)); + didUpdate = true; + } + const emailOutboxTenants = await globalPrismaClient.$queryRaw<{ tenancyId: string }[]>` WITH rows_to_update AS ( SELECT "tenancyId", "id" @@ -223,7 +320,7 @@ async function backfillSequenceIds(batchSize: number): Promise { span.setAttribute("stack.external-db-sync.did-update", didUpdate); if (didUpdate) { - console.log(`[Sequencer] Backfilled sequence IDs: USR=${projectUserTenants.length}, CC=${contactChannelTenants.length}, TM=${teamTenants.length}, TMB=${teamMemberTenants.length}, EO=${emailOutboxTenants.length}, DR=${deletedRowTenants.length}`); + console.log(`[Sequencer] Backfilled sequence IDs: USR=${projectUserTenants.length}, CC=${contactChannelTenants.length}, TM=${teamTenants.length}, TMB=${teamMemberTenants.length}, TP=${teamPermissionTenants.length}, TI=${teamInvitationTenants.length}, EO=${emailOutboxTenants.length}, DR=${deletedRowTenants.length}`); } return didUpdate; diff --git a/apps/backend/src/app/api/latest/team-memberships/crud.tsx b/apps/backend/src/app/api/latest/team-memberships/crud.tsx index ae5dbfd43..ae3e6f117 100644 --- a/apps/backend/src/app/api/latest/team-memberships/crud.tsx +++ b/apps/backend/src/app/api/latest/team-memberships/crud.tsx @@ -1,4 +1,4 @@ -import { recordExternalDbSyncDeletion, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; +import { recordExternalDbSyncDeletion, recordExternalDbSyncTeamPermissionDeletionsForTeamMember, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; import { grantDefaultTeamPermissions } from "@/lib/permissions"; import { ensureTeamExists, ensureTeamMembershipDoesNotExist, ensureTeamMembershipExists, ensureUserExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { Tenancy } from "@/lib/tenancies"; @@ -139,6 +139,12 @@ export const teamMembershipsCrudHandlers = createLazyProxy(() => createCrudHandl userId: params.user_id, }); + await recordExternalDbSyncTeamPermissionDeletionsForTeamMember(tx, { + tenancyId: auth.tenancy.id, + projectUserId: params.user_id, + teamId: params.team_id, + }); + await recordExternalDbSyncDeletion(tx, { tableName: "TeamMember", tenancyId: auth.tenancy.id, diff --git a/apps/backend/src/app/api/latest/teams/crud.tsx b/apps/backend/src/app/api/latest/teams/crud.tsx index aab670506..6ae06487f 100644 --- a/apps/backend/src/app/api/latest/teams/crud.tsx +++ b/apps/backend/src/app/api/latest/teams/crud.tsx @@ -1,4 +1,4 @@ -import { recordExternalDbSyncDeletion, recordExternalDbSyncTeamMemberDeletionsForTeam, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; +import { recordExternalDbSyncDeletion, recordExternalDbSyncTeamInvitationDeletionsForTeam, recordExternalDbSyncTeamMemberDeletionsForTeam, recordExternalDbSyncTeamPermissionDeletionsForTeam, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; import { ensureTeamExists, ensureTeamMembershipExists, ensureUserExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { sendTeamCreatedWebhook, sendTeamDeletedWebhook, sendTeamUpdatedWebhook } from "@/lib/webhooks"; import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; @@ -195,6 +195,16 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC } await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId: params.team_id }); + await recordExternalDbSyncTeamPermissionDeletionsForTeam(tx, { + tenancyId: auth.tenancy.id, + teamId: params.team_id, + }); + + await recordExternalDbSyncTeamInvitationDeletionsForTeam(tx, { + tenancyId: auth.tenancy.id, + teamId: params.team_id, + }); + await recordExternalDbSyncTeamMemberDeletionsForTeam(tx, { tenancyId: auth.tenancy.id, teamId: params.team_id, diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index fc7e56006..61c694e57 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -2,7 +2,7 @@ import { BooleanTrue, Prisma } from "@/generated/prisma/client"; import { getRenderedOrganizationConfigQuery, getRenderedProjectConfigQuery } from "@/lib/config"; import { demoteAllContactChannelsToNonPrimary, setContactChannelAsPrimaryByValue } from "@/lib/contact-channel"; import { normalizeEmail } from "@/lib/emails"; -import { recordExternalDbSyncContactChannelDeletionsForUser, recordExternalDbSyncDeletion, recordExternalDbSyncTeamMemberDeletionsForUser, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; +import { recordExternalDbSyncContactChannelDeletionsForUser, recordExternalDbSyncDeletion, recordExternalDbSyncTeamMemberDeletionsForUser, recordExternalDbSyncTeamPermissionDeletionsForUser, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; import { grantDefaultProjectPermissions } from "@/lib/permissions"; import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks"; import { Tenancy } from "@/lib/tenancies"; @@ -1207,6 +1207,11 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC projectUserId: params.user_id, }); + await recordExternalDbSyncTeamPermissionDeletionsForUser(tx, { + tenancyId: auth.tenancy.id, + projectUserId: params.user_id, + }); + await tx.projectUser.delete({ where: { tenancyId_projectUserId: { diff --git a/apps/backend/src/lib/external-db-sync.ts b/apps/backend/src/lib/external-db-sync.ts index 336e1e8f0..6ce96ca72 100644 --- a/apps/backend/src/lib/external-db-sync.ts +++ b/apps/backend/src/lib/external-db-sync.ts @@ -52,6 +52,18 @@ type ExternalDbSyncTarget = tenancyId: string, projectUserId: string, teamId: string, + } + | { + tableName: "TeamMemberDirectPermission", + tenancyId: string, + permissionDbId: string, + } + | { + tableName: "VerificationCode_TEAM_INVITATION", + tenancyId: string, + verificationCodeProjectId: string, + verificationCodeBranchId: string, + verificationCodeId: string, }; type ExternalDbType = NonNullable["type"]>; @@ -203,8 +215,7 @@ export async function recordExternalDbSyncDeletion( return; } - { - const _teamMemberTarget: { tableName: "TeamMember" } = target; + if (target.tableName === "TeamMember") { assertUuid(target.projectUserId, "projectUserId"); assertUuid(target.teamId, "teamId"); const insertedCount = await tx.$executeRaw(Prisma.sql` @@ -239,6 +250,85 @@ export async function recordExternalDbSyncDeletion( } return; } + + if (target.tableName === "TeamMemberDirectPermission") { + assertUuid(target.permissionDbId, "permissionDbId"); + const insertedCount = await tx.$executeRaw(Prisma.sql` + INSERT INTO "DeletedRow" ( + "id", + "tenancyId", + "tableName", + "primaryKey", + "data", + "deletedAt", + "shouldUpdateSequenceId" + ) + SELECT + gen_random_uuid(), + "tenancyId", + 'TeamMemberDirectPermission', + jsonb_build_object( + 'tenancyId', "tenancyId", + 'projectUserId', "projectUserId", + 'teamId', "teamId", + 'permissionId', "permissionId" + ), + to_jsonb("TeamMemberDirectPermission".*), + NOW(), + TRUE + FROM "TeamMemberDirectPermission" + WHERE "id" = ${target.permissionDbId}::uuid + FOR UPDATE + `); + + if (insertedCount !== 1) { + throw new StackAssertionError( + `Expected to insert 1 DeletedRow entry for TeamMemberDirectPermission, got ${insertedCount}.` + ); + } + return; + } + + { + const _verificationCodeTarget: { tableName: "VerificationCode_TEAM_INVITATION" } = target; + assertNonEmptyString(target.verificationCodeProjectId, "verificationCodeProjectId"); + assertNonEmptyString(target.verificationCodeBranchId, "verificationCodeBranchId"); + assertUuid(target.verificationCodeId, "verificationCodeId"); + const insertedCount = await tx.$executeRaw(Prisma.sql` + INSERT INTO "DeletedRow" ( + "id", + "tenancyId", + "tableName", + "primaryKey", + "data", + "deletedAt", + "shouldUpdateSequenceId" + ) + SELECT + gen_random_uuid(), + "Tenancy"."id", + 'VerificationCode_TEAM_INVITATION', + jsonb_build_object('id', "VerificationCode"."id"), + to_jsonb("VerificationCode".*), + NOW(), + TRUE + FROM "VerificationCode" + JOIN "Tenancy" ON "Tenancy"."projectId" = "VerificationCode"."projectId" + AND "Tenancy"."branchId" = "VerificationCode"."branchId" + WHERE "VerificationCode"."projectId" = ${target.verificationCodeProjectId} + AND "VerificationCode"."branchId" = ${target.verificationCodeBranchId} + AND "VerificationCode"."id" = ${target.verificationCodeId}::uuid + AND "VerificationCode"."type" = 'TEAM_INVITATION' + FOR UPDATE OF "VerificationCode" + `); + + if (insertedCount !== 1) { + throw new StackAssertionError( + `Expected to insert 1 DeletedRow entry for VerificationCode_TEAM_INVITATION, got ${insertedCount}.` + ); + } + return; + } } export async function recordExternalDbSyncContactChannelDeletionsForUser( @@ -318,6 +408,167 @@ export async function recordExternalDbSyncTeamMemberDeletionsForTeam( `); } +export async function recordExternalDbSyncTeamPermissionDeletionsForTeamMember( + tx: ExternalDbSyncClient, + options: { + tenancyId: string, + projectUserId: string, + teamId: string, + }, +): Promise { + assertUuid(options.tenancyId, "tenancyId"); + assertUuid(options.projectUserId, "projectUserId"); + assertUuid(options.teamId, "teamId"); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "DeletedRow" ( + "id", + "tenancyId", + "tableName", + "primaryKey", + "data", + "deletedAt", + "shouldUpdateSequenceId" + ) + SELECT + gen_random_uuid(), + "tenancyId", + 'TeamMemberDirectPermission', + jsonb_build_object( + 'tenancyId', "tenancyId", + 'projectUserId', "projectUserId", + 'teamId', "teamId", + 'permissionId', "permissionId" + ), + to_jsonb("TeamMemberDirectPermission".*), + NOW(), + TRUE + FROM "TeamMemberDirectPermission" + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND "projectUserId" = ${options.projectUserId}::uuid + AND "teamId" = ${options.teamId}::uuid + FOR UPDATE + `); +} + +export async function recordExternalDbSyncTeamPermissionDeletionsForTeam( + tx: ExternalDbSyncClient, + options: { + tenancyId: string, + teamId: string, + }, +): Promise { + assertUuid(options.tenancyId, "tenancyId"); + assertUuid(options.teamId, "teamId"); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "DeletedRow" ( + "id", + "tenancyId", + "tableName", + "primaryKey", + "data", + "deletedAt", + "shouldUpdateSequenceId" + ) + SELECT + gen_random_uuid(), + "tenancyId", + 'TeamMemberDirectPermission', + jsonb_build_object( + 'tenancyId', "tenancyId", + 'projectUserId', "projectUserId", + 'teamId', "teamId", + 'permissionId', "permissionId" + ), + to_jsonb("TeamMemberDirectPermission".*), + NOW(), + TRUE + FROM "TeamMemberDirectPermission" + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND "teamId" = ${options.teamId}::uuid + FOR UPDATE + `); +} + +export async function recordExternalDbSyncTeamPermissionDeletionsForUser( + tx: ExternalDbSyncClient, + options: { + tenancyId: string, + projectUserId: string, + }, +): Promise { + assertUuid(options.tenancyId, "tenancyId"); + assertUuid(options.projectUserId, "projectUserId"); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "DeletedRow" ( + "id", + "tenancyId", + "tableName", + "primaryKey", + "data", + "deletedAt", + "shouldUpdateSequenceId" + ) + SELECT + gen_random_uuid(), + "tenancyId", + 'TeamMemberDirectPermission', + jsonb_build_object( + 'tenancyId', "tenancyId", + 'projectUserId', "projectUserId", + 'teamId', "teamId", + 'permissionId', "permissionId" + ), + to_jsonb("TeamMemberDirectPermission".*), + NOW(), + TRUE + FROM "TeamMemberDirectPermission" + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND "projectUserId" = ${options.projectUserId}::uuid + FOR UPDATE + `); +} + +export async function recordExternalDbSyncTeamInvitationDeletionsForTeam( + tx: ExternalDbSyncClient, + options: { + tenancyId: string, + teamId: string, + }, +): Promise { + assertUuid(options.tenancyId, "tenancyId"); + assertUuid(options.teamId, "teamId"); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "DeletedRow" ( + "id", + "tenancyId", + "tableName", + "primaryKey", + "data", + "deletedAt", + "shouldUpdateSequenceId" + ) + SELECT + gen_random_uuid(), + "Tenancy"."id", + 'VerificationCode_TEAM_INVITATION', + jsonb_build_object('id', "VerificationCode"."id"), + to_jsonb("VerificationCode".*), + NOW(), + TRUE + FROM "VerificationCode" + JOIN "Tenancy" ON "Tenancy"."projectId" = "VerificationCode"."projectId" + AND "Tenancy"."branchId" = "VerificationCode"."branchId" + WHERE "Tenancy"."id" = ${options.tenancyId}::uuid + AND "VerificationCode"."type" = 'TEAM_INVITATION' + AND "VerificationCode"."data"->>'team_id' = ${options.teamId} + FOR UPDATE OF "VerificationCode" + `); +} + export async function recordExternalDbSyncTeamMemberDeletionsForUser( tx: ExternalDbSyncClient, options: { @@ -481,7 +732,7 @@ async function pushRowsToExternalDb( } } -function getInternalDbFetchQuery(mapping: DbSyncMapping, dbType: ExternalDbType) { +function getInternalDbFetchQuery(mapping: DbSyncMapping) { return mapping.internalDbFetchQuery; } @@ -510,7 +761,7 @@ function parseSequenceId(value: unknown, mappingId: string): number | null { if (value == null) { return null; } - const seqNum = typeof value === "bigint" ? Number(value) : Number(value); + const seqNum = Number(value); if (!Number.isFinite(seqNum)) { throw new StackAssertionError( `Invalid sequence_id for mapping ${mappingId}: ${JSON.stringify(value)}` @@ -536,8 +787,8 @@ async function ensureClickhouseSchema( } // Map of target table name -> column normalizers for ClickHouse -// 'json' columns get JSON.stringify, 'boolean' columns get normalizeClickhouseBoolean -const CLICKHOUSE_COLUMN_NORMALIZERS: Record> = { +// 'json' columns get JSON.stringify, 'boolean' columns get normalizeClickhouseBoolean, 'bigint' columns get Number() +const CLICKHOUSE_COLUMN_NORMALIZERS: Record> = { users: { client_metadata: 'json', client_read_only_metadata: 'json', @@ -559,7 +810,14 @@ const CLICKHOUSE_COLUMN_NORMALIZERS: Record createCodeObjectFromPrismaCode(code)); }, - async revokeCode(options) { - const { project, branchId } = parseProjectBranchCombo(options); + async revokeCode(revokeOptions) { + const { project, branchId } = parseProjectBranchCombo(revokeOptions); const tenancy = await getSoleTenancyFromProjectBranch(project.id, branchId); + // Record deletion for external DB sync if this is a TEAM_INVITATION code + if (options.type === 'TEAM_INVITATION') { + await recordExternalDbSyncDeletion(globalPrismaClient, { + tableName: "VerificationCode_TEAM_INVITATION", + tenancyId: tenancy.id, + verificationCodeProjectId: project.id, + verificationCodeBranchId: branchId, + verificationCodeId: revokeOptions.id, + }); + } + await globalPrismaClient.verificationCode.delete({ where: { projectId_branchId_id: { projectId: project.id, branchId, - id: options.id, + id: revokeOptions.id, }, }, }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index 8e847c7d8..dbd757e0d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts @@ -522,7 +522,12 @@ it("has limited grants", async ({ expect }) => { { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "REVOKE TABLE ENGINE ON SQLite FROM limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "REVOKE TABLE ENGINE ON URL FROM limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW DATABASES ON default.* TO limited_user" }, + { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.contact_channels TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.events TO limited_user" }, + { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.team_invitations TO limited_user" }, + { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.team_member_profiles TO limited_user" }, + { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.team_permissions TO limited_user" }, + { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.teams TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.users TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SELECT ON system.aggregate_function_combinators TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SELECT ON system.collations TO limited_user" }, @@ -561,10 +566,30 @@ it("can see only some tables", async ({ expect }) => { "status": 200, "body": { "result": [ + { + "database": "default", + "name": "contact_channels", + }, { "database": "default", "name": "events", }, + { + "database": "default", + "name": "team_invitations", + }, + { + "database": "default", + "name": "team_member_profiles", + }, + { + "database": "default", + "name": "team_permissions", + }, + { + "database": "default", + "name": "teams", + }, { "database": "default", "name": "users", @@ -586,7 +611,12 @@ it("SHOW TABLES should have the correct tables", async ({ expect }) => { "status": 200, "body": { "result": [ + { "name": "contact_channels" }, { "name": "events" }, + { "name": "team_invitations" }, + { "name": "team_member_profiles" }, + { "name": "team_permissions" }, + { "name": "teams" }, { "name": "users" }, ], }, @@ -1068,7 +1098,12 @@ it("shows grants", async ({ expect }) => { "status": 200, "body": { "result": [ + { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.contact_channels TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.events TO limited_user" }, + { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.team_invitations TO limited_user" }, + { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.team_member_profiles TO limited_user" }, + { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.team_permissions TO limited_user" }, + { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.teams TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.users TO limited_user" }, ], }, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts index 763c7eb97..4f6dad4ec 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts @@ -18,8 +18,12 @@ import { waitForSyncedEmailOutboxByStatus, waitForSyncedTeam, waitForSyncedTeamDeletion, + waitForSyncedTeamInvitation, + waitForSyncedTeamInvitationDeletion, waitForSyncedTeamMember, waitForSyncedTeamMemberDeletion, + waitForSyncedTeamPermission, + waitForSyncedTeamPermissionDeletion, waitForTable } from './external-db-sync-utils'; @@ -714,7 +718,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { await waitForSyncedTeamMember(client, teamId, user.userId); - const res1 = await client.query(`SELECT * FROM "team_members" WHERE "team_id" = $1 AND "user_id" = $2`, [teamId, user.userId]); + const res1 = await client.query(`SELECT * FROM "team_member_profiles" WHERE "team_id" = $1 AND "user_id" = $2`, [teamId, user.userId]); expect(res1.rows.length).toBe(1); // Remove member @@ -840,6 +844,259 @@ describe.sequential('External DB Sync - Basic Tests', () => { await waitForSyncedTeamMemberDeletion(client, teamId, user.userId); }, TEST_TIMEOUT); + /** + * What it does: + * - Creates a team, adds a member, grants a permission, verifies in external DB, + * revokes the permission, and verifies removal. + */ + test('TeamPermission CRUD sync (Postgres)', async () => { + const dbName = 'team_permission_crud_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ primary_email: 'tp-crud@example.com' }); + const createTeamResponse = await niceBackendFetch('/api/v1/teams', { + accessType: 'admin', + method: 'POST', + body: { display_name: 'TP CRUD Team' }, + }); + expect(createTeamResponse.status).toBe(201); + const teamId = createTeamResponse.body.id; + + // Add user as team member + const addMemberResponse = await niceBackendFetch(`/api/v1/team-memberships/${teamId}/${user.userId}`, { + accessType: 'admin', + method: 'POST', + body: {}, + }); + expect(addMemberResponse.status).toBe(201); + + // Grant a permission + const grantResponse = await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${user.userId}/$read_members`, { + accessType: 'admin', + method: 'POST', + body: {}, + }); + expect(grantResponse.status).toBe(201); + + await waitForSyncedTeamPermission(client, teamId, user.userId, '$read_members'); + + const res1 = await client.query(`SELECT * FROM "team_permissions" WHERE "team_id" = $1 AND "user_id" = $2 AND "permission_id" = $3`, [teamId, user.userId, '$read_members']); + expect(res1.rows.length).toBe(1); + + // Revoke the permission + await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${user.userId}/$read_members`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForSyncedTeamPermissionDeletion(client, teamId, user.userId, '$read_members'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Creates a team + member + permission, queries ClickHouse analytics API to verify. + */ + test('TeamPermission sync (ClickHouse)', async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + + const user = await User.create({ primary_email: 'tp-ch@example.com' }); + const createTeamResponse = await niceBackendFetch('/api/v1/teams', { + accessType: 'admin', + method: 'POST', + body: { display_name: 'TP CH Team' }, + }); + expect(createTeamResponse.status).toBe(201); + const teamId = createTeamResponse.body.id; + + await niceBackendFetch(`/api/v1/team-memberships/${teamId}/${user.userId}`, { + accessType: 'admin', + method: 'POST', + body: {}, + }); + + await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${user.userId}/$read_members`, { + accessType: 'admin', + method: 'POST', + body: {}, + }); + + await InternalApiKey.createAndSetProjectKeys(); + + const timeoutMs = 180_000; + const intervalMs = 2_000; + const start = performance.now(); + + let response; + while (performance.now() - start < timeoutMs) { + response = await runQueryForCurrentProject({ + query: "SELECT team_id, user_id, permission_id FROM team_permissions WHERE permission_id = {perm:String}", + params: { perm: '$read_members' }, + }); + expect(response.status).toBe(200); + if (response.body.result.length === 1) { + break; + } + await wait(intervalMs); + } + + expect(response!.body.result.length).toBe(1); + expect(response!.body.result[0].permission_id).toBe('$read_members'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Sends a team invitation, verifies in external DB, revokes it, verifies removal. + */ + test('TeamInvitation sync (Postgres)', async () => { + const dbName = 'team_invitation_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }, { display_name: 'Invitation Test Project' }); + + const client = dbManager.getClient(dbName); + + const createTeamResponse = await niceBackendFetch('/api/v1/teams', { + accessType: 'admin', + method: 'POST', + body: { display_name: 'Invitation Team' }, + }); + expect(createTeamResponse.status).toBe(201); + const teamId = createTeamResponse.body.id; + + // Send a team invitation + const inviteResponse = await niceBackendFetch('/api/v1/team-invitations/send-code', { + accessType: 'admin', + method: 'POST', + body: { team_id: teamId, email: 'invited@example.com', callback_url: 'http://localhost:12345/callback' }, + }); + expect(inviteResponse.status).toBe(200); + + await waitForSyncedTeamInvitation(client, 'invited@example.com'); + + const res1 = await client.query(`SELECT * FROM "team_invitations" WHERE "recipient_email" = $1`, ['invited@example.com']); + expect(res1.rows.length).toBe(1); + expect(res1.rows[0].team_display_name).toBe('Invitation Team'); + const invitationId = res1.rows[0].id; + + // Revoke the invitation + await niceBackendFetch(`/api/v1/team-invitations/${invitationId}?team_id=${teamId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForSyncedTeamInvitationDeletion(client, invitationId); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Sends a team invitation, queries ClickHouse analytics API to verify. + */ + test('TeamInvitation sync (ClickHouse)', async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + + const createTeamResponse = await niceBackendFetch('/api/v1/teams', { + accessType: 'admin', + method: 'POST', + body: { display_name: 'CH Invitation Team' }, + }); + expect(createTeamResponse.status).toBe(201); + const teamId = createTeamResponse.body.id; + + await niceBackendFetch('/api/v1/team-invitations/send-code', { + accessType: 'admin', + method: 'POST', + body: { team_id: teamId, email: 'ch-invited@example.com', callback_url: 'http://localhost:12345/callback' }, + }); + + await InternalApiKey.createAndSetProjectKeys(); + + const timeoutMs = 180_000; + const intervalMs = 2_000; + const start = performance.now(); + + let response; + while (performance.now() - start < timeoutMs) { + response = await runQueryForCurrentProject({ + query: "SELECT recipient_email, team_display_name FROM team_invitations WHERE recipient_email = {email:String}", + params: { email: 'ch-invited@example.com' }, + }); + expect(response.status).toBe(200); + if (response.body.result.length === 1) { + break; + } + await wait(intervalMs); + } + + expect(response!.body.result.length).toBe(1); + expect(response!.body.result[0].recipient_email).toBe('ch-invited@example.com'); + expect(response!.body.result[0].team_display_name).toBe('CH Invitation Team'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Creates a team with a member and permission, deletes the team, + * verifies team, member, and permissions are all gone. + */ + test('Cascade: Team delete removes permissions and invitations from external DB', async () => { + const dbName = 'cascade_team_perm_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ primary_email: 'cascade-perm@example.com' }); + const createTeamResponse = await niceBackendFetch('/api/v1/teams', { + accessType: 'admin', + method: 'POST', + body: { display_name: 'Cascade Perm Team' }, + }); + const teamId = createTeamResponse.body.id; + + await niceBackendFetch(`/api/v1/team-memberships/${teamId}/${user.userId}`, { + accessType: 'admin', + method: 'POST', + body: {}, + }); + + await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${user.userId}/$read_members`, { + accessType: 'admin', + method: 'POST', + body: {}, + }); + + await waitForSyncedTeamPermission(client, teamId, user.userId, '$read_members'); + await waitForSyncedTeam(client, 'Cascade Perm Team'); + + // Delete the team — should cascade-delete permissions too + await niceBackendFetch(`/api/v1/teams/${teamId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForSyncedTeamDeletion(client, teamId); + await waitForSyncedTeamPermissionDeletion(client, teamId, user.userId, '$read_members'); + }, TEST_TIMEOUT); + /** * What it does: * - Creates a project with email config, sends an email, and verifies diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts index 73a2f29b9..b256cdf34 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts @@ -287,14 +287,14 @@ export async function waitForSyncedTeamDeletion(client: Client, teamId: string) } export async function waitForSyncedTeamMember(client: Client, teamId: string, userId: string) { - await waitForExternalDbRow(client, `SELECT * FROM "team_members" WHERE "team_id" = $1 AND "user_id" = $2`, [teamId, userId], { + await waitForExternalDbRow(client, `SELECT * FROM "team_member_profiles" WHERE "team_id" = $1 AND "user_id" = $2`, [teamId, userId], { shouldExist: true, description: `team member (team=${teamId}, user=${userId}) to appear in external DB`, }); } export async function waitForSyncedTeamMemberDeletion(client: Client, teamId: string, userId: string) { - await waitForExternalDbRow(client, `SELECT * FROM "team_members" WHERE "team_id" = $1 AND "user_id" = $2`, [teamId, userId], { + await waitForExternalDbRow(client, `SELECT * FROM "team_member_profiles" WHERE "team_id" = $1 AND "user_id" = $2`, [teamId, userId], { shouldExist: false, description: `team member (team=${teamId}, user=${userId}) to be removed from external DB`, }); @@ -314,6 +314,34 @@ export async function waitForSyncedContactChannelDeletion(client: Client, value: }); } +export async function waitForSyncedTeamPermission(client: Client, teamId: string, userId: string, permissionId: string) { + await waitForExternalDbRow(client, `SELECT * FROM "team_permissions" WHERE "team_id" = $1 AND "user_id" = $2 AND "permission_id" = $3`, [teamId, userId, permissionId], { + shouldExist: true, + description: `team permission (team=${teamId}, user=${userId}, perm=${permissionId}) to appear in external DB`, + }); +} + +export async function waitForSyncedTeamPermissionDeletion(client: Client, teamId: string, userId: string, permissionId: string) { + await waitForExternalDbRow(client, `SELECT * FROM "team_permissions" WHERE "team_id" = $1 AND "user_id" = $2 AND "permission_id" = $3`, [teamId, userId, permissionId], { + shouldExist: false, + description: `team permission (team=${teamId}, user=${userId}, perm=${permissionId}) to be removed from external DB`, + }); +} + +export async function waitForSyncedTeamInvitation(client: Client, recipientEmail: string) { + await waitForExternalDbRow(client, `SELECT * FROM "team_invitations" WHERE "recipient_email" = $1`, [recipientEmail], { + shouldExist: true, + description: `team invitation for "${recipientEmail}" to appear in external DB`, + }); +} + +export async function waitForSyncedTeamInvitationDeletion(client: Client, invitationId: string) { + await waitForExternalDbRow(client, `SELECT * FROM "team_invitations" WHERE "id" = $1`, [invitationId], { + shouldExist: false, + description: `team invitation ${invitationId} to be removed from external DB`, + }); +} + export async function waitForSyncedEmailOutbox(client: Client, emailId: string, expectedStatus?: string) { await waitForExternalDbRow( client, diff --git a/packages/stack-shared/src/config/db-sync-mappings.ts b/packages/stack-shared/src/config/db-sync-mappings.ts index f05df8b80..9d13e7e5b 100644 --- a/packages/stack-shared/src/config/db-sync-mappings.ts +++ b/packages/stack-shared/src/config/db-sync-mappings.ts @@ -663,21 +663,22 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { `.trim(), }, }, - "team_members": { - sourceTables: { "TeamMember": "TeamMember" }, - targetTable: "team_members", + "team_member_profiles": { + sourceTables: { "TeamMember": "TeamMember", "ProjectUser": "ProjectUser" }, + targetTable: "team_member_profiles", targetTableSchemas: { postgres: ` - CREATE TABLE IF NOT EXISTS "team_members" ( + CREATE TABLE IF NOT EXISTS "team_member_profiles" ( "team_id" uuid NOT NULL, "user_id" uuid NOT NULL, "display_name" text, "profile_image_url" text, + "user" jsonb NOT NULL DEFAULT '{}'::jsonb, "created_at" timestamp without time zone NOT NULL, PRIMARY KEY ("team_id", "user_id") ); - REVOKE ALL ON "team_members" FROM PUBLIC; - GRANT SELECT ON "team_members" TO PUBLIC; + REVOKE ALL ON "team_member_profiles" FROM PUBLIC; + GRANT SELECT ON "team_member_profiles" TO PUBLIC; CREATE TABLE IF NOT EXISTS "_stack_sync_metadata" ( "mapping_name" text PRIMARY KEY NOT NULL, @@ -686,13 +687,14 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { ); `.trim(), clickhouse: ` - CREATE TABLE IF NOT EXISTS analytics_internal.team_members ( + CREATE TABLE IF NOT EXISTS analytics_internal.team_member_profiles ( project_id String, branch_id String, team_id UUID, user_id UUID, display_name Nullable(String), profile_image_url Nullable(String), + user JSON, created_at DateTime64(3, 'UTC'), sync_sequence_id Int64, sync_is_deleted UInt8, @@ -714,12 +716,46 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { "TeamMember"."projectUserId" AS "user_id", "TeamMember"."displayName" AS "display_name", "TeamMember"."profileImageUrl" AS "profile_image_url", + jsonb_build_object( + 'id', "ProjectUser"."projectUserId", + 'display_name', "ProjectUser"."displayName", + 'primary_email', ( + SELECT "ContactChannel"."value" + FROM "ContactChannel" + WHERE "ContactChannel"."projectUserId" = "ProjectUser"."projectUserId" + AND "ContactChannel"."tenancyId" = "ProjectUser"."tenancyId" + AND "ContactChannel"."type" = 'EMAIL' + AND "ContactChannel"."isPrimary" = 'TRUE' + LIMIT 1 + ), + 'primary_email_verified', COALESCE( + ( + SELECT "ContactChannel"."isVerified" + FROM "ContactChannel" + WHERE "ContactChannel"."projectUserId" = "ProjectUser"."projectUserId" + AND "ContactChannel"."tenancyId" = "ProjectUser"."tenancyId" + AND "ContactChannel"."type" = 'EMAIL' + AND "ContactChannel"."isPrimary" = 'TRUE' + LIMIT 1 + ), + false + ), + 'profile_image_url', "ProjectUser"."profileImageUrl", + 'signed_up_at_millis', EXTRACT(EPOCH FROM "ProjectUser"."createdAt") * 1000, + 'client_metadata', COALESCE("ProjectUser"."clientMetadata", '{}'::jsonb), + 'client_read_only_metadata', COALESCE("ProjectUser"."clientReadOnlyMetadata", '{}'::jsonb), + 'server_metadata', COALESCE("ProjectUser"."serverMetadata", '{}'::jsonb), + 'is_anonymous', "ProjectUser"."isAnonymous", + 'last_active_at_millis', CASE WHEN "ProjectUser"."lastActiveAt" IS NOT NULL THEN EXTRACT(EPOCH FROM "ProjectUser"."lastActiveAt") * 1000 ELSE NULL END + ) AS "user", "TeamMember"."createdAt" AS "created_at", "TeamMember"."sequenceId" AS "sync_sequence_id", "TeamMember"."tenancyId" AS "tenancyId", false AS "sync_is_deleted" FROM "TeamMember" JOIN "Tenancy" ON "Tenancy"."id" = "TeamMember"."tenancyId" + JOIN "ProjectUser" ON "ProjectUser"."projectUserId" = "TeamMember"."projectUserId" + AND "ProjectUser"."tenancyId" = "TeamMember"."tenancyId" WHERE "TeamMember"."tenancyId" = $1::uuid UNION ALL @@ -731,6 +767,7 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { ("DeletedRow"."primaryKey"->>'projectUserId')::uuid AS "user_id", NULL::text AS "display_name", NULL::text AS "profile_image_url", + '{}'::jsonb AS "user", "DeletedRow"."deletedAt"::timestamp without time zone AS "created_at", "DeletedRow"."sequenceId" AS "sync_sequence_id", "DeletedRow"."tenancyId" AS "tenancyId", @@ -755,11 +792,45 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { "TeamMember"."projectUserId" AS "user_id", "TeamMember"."displayName" AS "display_name", "TeamMember"."profileImageUrl" AS "profile_image_url", + jsonb_build_object( + 'id', "ProjectUser"."projectUserId", + 'display_name', "ProjectUser"."displayName", + 'primary_email', ( + SELECT "ContactChannel"."value" + FROM "ContactChannel" + WHERE "ContactChannel"."projectUserId" = "ProjectUser"."projectUserId" + AND "ContactChannel"."tenancyId" = "ProjectUser"."tenancyId" + AND "ContactChannel"."type" = 'EMAIL' + AND "ContactChannel"."isPrimary" = 'TRUE' + LIMIT 1 + ), + 'primary_email_verified', COALESCE( + ( + SELECT "ContactChannel"."isVerified" + FROM "ContactChannel" + WHERE "ContactChannel"."projectUserId" = "ProjectUser"."projectUserId" + AND "ContactChannel"."tenancyId" = "ProjectUser"."tenancyId" + AND "ContactChannel"."type" = 'EMAIL' + AND "ContactChannel"."isPrimary" = 'TRUE' + LIMIT 1 + ), + false + ), + 'profile_image_url', "ProjectUser"."profileImageUrl", + 'signed_up_at_millis', EXTRACT(EPOCH FROM "ProjectUser"."createdAt") * 1000, + 'client_metadata', COALESCE("ProjectUser"."clientMetadata", '{}'::jsonb), + 'client_read_only_metadata', COALESCE("ProjectUser"."clientReadOnlyMetadata", '{}'::jsonb), + 'server_metadata', COALESCE("ProjectUser"."serverMetadata", '{}'::jsonb), + 'is_anonymous', "ProjectUser"."isAnonymous", + 'last_active_at_millis', CASE WHEN "ProjectUser"."lastActiveAt" IS NOT NULL THEN EXTRACT(EPOCH FROM "ProjectUser"."createdAt") * 1000 ELSE NULL END + ) AS "user", "TeamMember"."createdAt" AS "created_at", "TeamMember"."sequenceId" AS "sequence_id", "TeamMember"."tenancyId", false AS "is_deleted" FROM "TeamMember" + JOIN "ProjectUser" ON "ProjectUser"."projectUserId" = "TeamMember"."projectUserId" + AND "ProjectUser"."tenancyId" = "TeamMember"."tenancyId" WHERE "TeamMember"."tenancyId" = $1::uuid UNION ALL @@ -769,6 +840,7 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { ("DeletedRow"."primaryKey"->>'projectUserId')::uuid AS "user_id", NULL::text AS "display_name", NULL::text AS "profile_image_url", + '{}'::jsonb AS "user", "DeletedRow"."deletedAt"::timestamp without time zone AS "created_at", "DeletedRow"."sequenceId" AS "sequence_id", "DeletedRow"."tenancyId", @@ -791,23 +863,25 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { $2::uuid AS "user_id", $3::text AS "display_name", $4::text AS "profile_image_url", - $5::timestamp without time zone AS "created_at", - $6::bigint AS "sequence_id", - $7::boolean AS "is_deleted", - $8::text AS "mapping_name" + $5::jsonb AS "user", + $6::timestamp without time zone AS "created_at", + $7::bigint AS "sequence_id", + $8::boolean AS "is_deleted", + $9::text AS "mapping_name" ), deleted AS ( - DELETE FROM "team_members" tm + DELETE FROM "team_member_profiles" tm USING params p WHERE p."is_deleted" = true AND tm."team_id" = p."team_id" AND tm."user_id" = p."user_id" RETURNING 1 ), upserted AS ( - INSERT INTO "team_members" ( + INSERT INTO "team_member_profiles" ( "team_id", "user_id", "display_name", "profile_image_url", + "user", "created_at" ) SELECT @@ -815,12 +889,360 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { p."user_id", p."display_name", p."profile_image_url", + p."user", p."created_at" FROM params p WHERE p."is_deleted" = false ON CONFLICT ("team_id", "user_id") DO UPDATE SET "display_name" = EXCLUDED."display_name", "profile_image_url" = EXCLUDED."profile_image_url", + "user" = EXCLUDED."user", + "created_at" = EXCLUDED."created_at" + RETURNING 1 + ) + INSERT INTO "_stack_sync_metadata" ("mapping_name", "last_synced_sequence_id", "updated_at") + SELECT p."mapping_name", p."sequence_id", now() FROM params p + ON CONFLICT ("mapping_name") DO UPDATE SET + "last_synced_sequence_id" = GREATEST("_stack_sync_metadata"."last_synced_sequence_id", EXCLUDED."last_synced_sequence_id"), + "updated_at" = now(); + `.trim(), + }, + }, + "team_permissions": { + sourceTables: { "TeamMemberDirectPermission": "TeamMemberDirectPermission" }, + targetTable: "team_permissions", + targetTableSchemas: { + postgres: ` + CREATE TABLE IF NOT EXISTS "team_permissions" ( + "team_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "permission_id" text NOT NULL, + "created_at" timestamp without time zone NOT NULL, + PRIMARY KEY ("team_id", "user_id", "permission_id") + ); + REVOKE ALL ON "team_permissions" FROM PUBLIC; + GRANT SELECT ON "team_permissions" TO PUBLIC; + + CREATE TABLE IF NOT EXISTS "_stack_sync_metadata" ( + "mapping_name" text PRIMARY KEY NOT NULL, + "last_synced_sequence_id" bigint NOT NULL DEFAULT -1, + "updated_at" timestamp without time zone NOT NULL DEFAULT now() + ); + `.trim(), + clickhouse: ` + CREATE TABLE IF NOT EXISTS analytics_internal.team_permissions ( + project_id String, + branch_id String, + team_id UUID, + user_id UUID, + permission_id String, + created_at DateTime64(3, 'UTC'), + sync_sequence_id Int64, + sync_is_deleted UInt8, + sync_created_at DateTime64(3, 'UTC') DEFAULT now64(3) + ) + ENGINE ReplacingMergeTree(sync_sequence_id) + PARTITION BY toYYYYMM(created_at) + ORDER BY (project_id, branch_id, team_id, user_id, permission_id); + `.trim(), + }, + internalDbFetchQueries: { + clickhouse: ` + SELECT * + FROM ( + SELECT + "Tenancy"."projectId" AS "project_id", + "Tenancy"."branchId" AS "branch_id", + "TeamMemberDirectPermission"."teamId" AS "team_id", + "TeamMemberDirectPermission"."projectUserId" AS "user_id", + "TeamMemberDirectPermission"."permissionId" AS "permission_id", + "TeamMemberDirectPermission"."createdAt" AS "created_at", + "TeamMemberDirectPermission"."sequenceId" AS "sync_sequence_id", + "TeamMemberDirectPermission"."tenancyId" AS "tenancyId", + false AS "sync_is_deleted" + FROM "TeamMemberDirectPermission" + JOIN "Tenancy" ON "Tenancy"."id" = "TeamMemberDirectPermission"."tenancyId" + WHERE "TeamMemberDirectPermission"."tenancyId" = $1::uuid + + UNION ALL + + SELECT + "Tenancy"."projectId" AS "project_id", + "Tenancy"."branchId" AS "branch_id", + ("DeletedRow"."primaryKey"->>'teamId')::uuid AS "team_id", + ("DeletedRow"."primaryKey"->>'projectUserId')::uuid AS "user_id", + "DeletedRow"."primaryKey"->>'permissionId' AS "permission_id", + "DeletedRow"."deletedAt"::timestamp without time zone AS "created_at", + "DeletedRow"."sequenceId" AS "sync_sequence_id", + "DeletedRow"."tenancyId" AS "tenancyId", + true AS "sync_is_deleted" + FROM "DeletedRow" + JOIN "Tenancy" ON "Tenancy"."id" = "DeletedRow"."tenancyId" + WHERE + "DeletedRow"."tenancyId" = $1::uuid + AND "DeletedRow"."tableName" = 'TeamMemberDirectPermission' + ) AS "_src" + WHERE "sync_sequence_id" IS NOT NULL + AND "sync_sequence_id" > $2::bigint + ORDER BY "sync_sequence_id" ASC + LIMIT 1000 + `.trim(), + }, + internalDbFetchQuery: ` + SELECT * + FROM ( + SELECT + "TeamMemberDirectPermission"."teamId" AS "team_id", + "TeamMemberDirectPermission"."projectUserId" AS "user_id", + "TeamMemberDirectPermission"."permissionId" AS "permission_id", + "TeamMemberDirectPermission"."createdAt" AS "created_at", + "TeamMemberDirectPermission"."sequenceId" AS "sequence_id", + "TeamMemberDirectPermission"."tenancyId", + false AS "is_deleted" + FROM "TeamMemberDirectPermission" + WHERE "TeamMemberDirectPermission"."tenancyId" = $1::uuid + + UNION ALL + + SELECT + ("DeletedRow"."primaryKey"->>'teamId')::uuid AS "team_id", + ("DeletedRow"."primaryKey"->>'projectUserId')::uuid AS "user_id", + "DeletedRow"."primaryKey"->>'permissionId' AS "permission_id", + "DeletedRow"."deletedAt"::timestamp without time zone AS "created_at", + "DeletedRow"."sequenceId" AS "sequence_id", + "DeletedRow"."tenancyId", + true AS "is_deleted" + FROM "DeletedRow" + WHERE + "DeletedRow"."tenancyId" = $1::uuid + AND "DeletedRow"."tableName" = 'TeamMemberDirectPermission' + ) AS "_src" + WHERE "sequence_id" IS NOT NULL + AND "sequence_id" > $2::bigint + ORDER BY "sequence_id" ASC + LIMIT 1000 + `.trim(), + externalDbUpdateQueries: { + postgres: ` + WITH params AS ( + SELECT + $1::uuid AS "team_id", + $2::uuid AS "user_id", + $3::text AS "permission_id", + $4::timestamp without time zone AS "created_at", + $5::bigint AS "sequence_id", + $6::boolean AS "is_deleted", + $7::text AS "mapping_name" + ), + deleted AS ( + DELETE FROM "team_permissions" tp + USING params p + WHERE p."is_deleted" = true AND tp."team_id" = p."team_id" AND tp."user_id" = p."user_id" AND tp."permission_id" = p."permission_id" + RETURNING 1 + ), + upserted AS ( + INSERT INTO "team_permissions" ( + "team_id", + "user_id", + "permission_id", + "created_at" + ) + SELECT + p."team_id", + p."user_id", + p."permission_id", + p."created_at" + FROM params p + WHERE p."is_deleted" = false + ON CONFLICT ("team_id", "user_id", "permission_id") DO UPDATE SET + "created_at" = EXCLUDED."created_at" + RETURNING 1 + ) + INSERT INTO "_stack_sync_metadata" ("mapping_name", "last_synced_sequence_id", "updated_at") + SELECT p."mapping_name", p."sequence_id", now() FROM params p + ON CONFLICT ("mapping_name") DO UPDATE SET + "last_synced_sequence_id" = GREATEST("_stack_sync_metadata"."last_synced_sequence_id", EXCLUDED."last_synced_sequence_id"), + "updated_at" = now(); + `.trim(), + }, + }, + "team_invitations": { + sourceTables: { "VerificationCode": "VerificationCode" }, + targetTable: "team_invitations", + targetTableSchemas: { + postgres: ` + CREATE TABLE IF NOT EXISTS "team_invitations" ( + "id" uuid PRIMARY KEY NOT NULL, + "team_id" uuid NOT NULL, + "team_display_name" text NOT NULL, + "recipient_email" text NOT NULL, + "expires_at_millis" bigint NOT NULL, + "created_at" timestamp without time zone NOT NULL + ); + REVOKE ALL ON "team_invitations" FROM PUBLIC; + GRANT SELECT ON "team_invitations" TO PUBLIC; + + CREATE TABLE IF NOT EXISTS "_stack_sync_metadata" ( + "mapping_name" text PRIMARY KEY NOT NULL, + "last_synced_sequence_id" bigint NOT NULL DEFAULT -1, + "updated_at" timestamp without time zone NOT NULL DEFAULT now() + ); + `.trim(), + clickhouse: ` + CREATE TABLE IF NOT EXISTS analytics_internal.team_invitations ( + project_id String, + branch_id String, + id UUID, + team_id UUID, + team_display_name String, + recipient_email String, + expires_at_millis Int64, + created_at DateTime64(3, 'UTC'), + sync_sequence_id Int64, + sync_is_deleted UInt8, + sync_created_at DateTime64(3, 'UTC') DEFAULT now64(3) + ) + ENGINE ReplacingMergeTree(sync_sequence_id) + PARTITION BY toYYYYMM(created_at) + ORDER BY (project_id, branch_id, id); + `.trim(), + }, + internalDbFetchQueries: { + clickhouse: ` + SELECT * + FROM ( + SELECT + "Tenancy"."projectId" AS "project_id", + "Tenancy"."branchId" AS "branch_id", + "VerificationCode"."id"::uuid AS "id", + ("VerificationCode"."data"->>'team_id')::uuid AS "team_id", + "Team"."displayName" AS "team_display_name", + "VerificationCode"."method"->>'email' AS "recipient_email", + FLOOR(EXTRACT(EPOCH FROM "VerificationCode"."expiresAt") * 1000)::bigint AS "expires_at_millis", + "VerificationCode"."createdAt" AS "created_at", + "VerificationCode"."sequenceId" AS "sync_sequence_id", + "Tenancy"."id" AS "tenancyId", + false AS "sync_is_deleted" + FROM "VerificationCode" + JOIN "Tenancy" ON "Tenancy"."projectId" = "VerificationCode"."projectId" + AND "Tenancy"."branchId" = "VerificationCode"."branchId" + LEFT JOIN "Team" ON "Team"."teamId" = ("VerificationCode"."data"->>'team_id')::uuid + AND "Team"."tenancyId" = "Tenancy"."id" + WHERE "Tenancy"."id" = $1::uuid + AND "VerificationCode"."type" = 'TEAM_INVITATION' + + UNION ALL + + SELECT + "Tenancy"."projectId" AS "project_id", + "Tenancy"."branchId" AS "branch_id", + ("DeletedRow"."primaryKey"->>'id')::uuid AS "id", + '00000000-0000-0000-0000-000000000000'::uuid AS "team_id", + ''::text AS "team_display_name", + ''::text AS "recipient_email", + 0::bigint AS "expires_at_millis", + "DeletedRow"."deletedAt"::timestamp without time zone AS "created_at", + "DeletedRow"."sequenceId" AS "sync_sequence_id", + "DeletedRow"."tenancyId" AS "tenancyId", + true AS "sync_is_deleted" + FROM "DeletedRow" + JOIN "Tenancy" ON "Tenancy"."id" = "DeletedRow"."tenancyId" + WHERE + "DeletedRow"."tenancyId" = $1::uuid + AND "DeletedRow"."tableName" = 'VerificationCode_TEAM_INVITATION' + ) AS "_src" + WHERE "sync_sequence_id" IS NOT NULL + AND "sync_sequence_id" > $2::bigint + ORDER BY "sync_sequence_id" ASC + LIMIT 1000 + `.trim(), + }, + internalDbFetchQuery: ` + SELECT * + FROM ( + SELECT + "VerificationCode"."id"::uuid AS "id", + ("VerificationCode"."data"->>'team_id')::uuid AS "team_id", + "Team"."displayName" AS "team_display_name", + "VerificationCode"."method"->>'email' AS "recipient_email", + FLOOR(EXTRACT(EPOCH FROM "VerificationCode"."expiresAt") * 1000)::bigint AS "expires_at_millis", + "VerificationCode"."createdAt" AS "created_at", + "VerificationCode"."sequenceId" AS "sequence_id", + "Tenancy"."id" AS "tenancyId", + false AS "is_deleted" + FROM "VerificationCode" + JOIN "Tenancy" ON "Tenancy"."projectId" = "VerificationCode"."projectId" + AND "Tenancy"."branchId" = "VerificationCode"."branchId" + LEFT JOIN "Team" ON "Team"."teamId" = ("VerificationCode"."data"->>'team_id')::uuid + AND "Team"."tenancyId" = "Tenancy"."id" + WHERE "Tenancy"."id" = $1::uuid + AND "VerificationCode"."type" = 'TEAM_INVITATION' + + UNION ALL + + SELECT + ("DeletedRow"."primaryKey"->>'id')::uuid AS "id", + '00000000-0000-0000-0000-000000000000'::uuid AS "team_id", + ''::text AS "team_display_name", + ''::text AS "recipient_email", + 0::bigint AS "expires_at_millis", + "DeletedRow"."deletedAt"::timestamp without time zone AS "created_at", + "DeletedRow"."sequenceId" AS "sequence_id", + "DeletedRow"."tenancyId" AS "tenancyId", + true AS "is_deleted" + FROM "DeletedRow" + WHERE + "DeletedRow"."tenancyId" = $1::uuid + AND "DeletedRow"."tableName" = 'VerificationCode_TEAM_INVITATION' + ) AS "_src" + WHERE "sequence_id" IS NOT NULL + AND "sequence_id" > $2::bigint + ORDER BY "sequence_id" ASC + LIMIT 1000 + `.trim(), + externalDbUpdateQueries: { + postgres: ` + WITH params AS ( + SELECT + $1::uuid AS "id", + $2::uuid AS "team_id", + $3::text AS "team_display_name", + $4::text AS "recipient_email", + $5::bigint AS "expires_at_millis", + $6::timestamp without time zone AS "created_at", + $7::bigint AS "sequence_id", + $8::boolean AS "is_deleted", + $9::text AS "mapping_name" + ), + deleted AS ( + DELETE FROM "team_invitations" ti + USING params p + WHERE p."is_deleted" = true AND ti."id" = p."id" + RETURNING 1 + ), + upserted AS ( + INSERT INTO "team_invitations" ( + "id", + "team_id", + "team_display_name", + "recipient_email", + "expires_at_millis", + "created_at" + ) + SELECT + p."id", + p."team_id", + p."team_display_name", + p."recipient_email", + p."expires_at_millis", + p."created_at" + FROM params p + WHERE p."is_deleted" = false + ON CONFLICT ("id") DO UPDATE SET + "team_id" = EXCLUDED."team_id", + "team_display_name" = EXCLUDED."team_display_name", + "recipient_email" = EXCLUDED."recipient_email", + "expires_at_millis" = EXCLUDED."expires_at_millis", "created_at" = EXCLUDED."created_at" RETURNING 1 )