diff --git a/backend/scripts/dedupe-auth-tenant-users.ts b/backend/scripts/dedupe-auth-tenant-users.ts new file mode 100644 index 0000000..9bcd1fc --- /dev/null +++ b/backend/scripts/dedupe-auth-tenant-users.ts @@ -0,0 +1,67 @@ +import "dotenv/config" +import { drizzle } from "drizzle-orm/node-postgres" +import { sql } from "drizzle-orm" +import { Pool } from "pg" + +import { loadSecrets, secrets } from "../src/utils/secrets" + +async function run() { + let connectionString = process.env.DATABASE_URL + + if (!connectionString) { + await loadSecrets() + connectionString = secrets.DATABASE_URL + } + + if (!connectionString) { + throw new Error("DATABASE_URL not configured") + } + + const pool = new Pool({ + connectionString, + max: 1, + connectionTimeoutMillis: 10000, + }) + + try { + const db = drizzle(pool) + + const duplicates = await db.execute(sql` + SELECT tenant_id, user_id, count(*)::int AS count + FROM auth_tenant_users + GROUP BY tenant_id, user_id + HAVING count(*) > 1 + ORDER BY count(*) DESC; + `) + + console.log(`Gefundene doppelte Tenant-Zuordnungen: ${duplicates.rows.length}`) + for (const row of duplicates.rows as Record[]) { + console.log(JSON.stringify(row)) + } + + const deleted = await db.execute(sql` + DELETE FROM auth_tenant_users duplicate + USING auth_tenant_users keep + WHERE duplicate.tenant_id = keep.tenant_id + AND duplicate.user_id = keep.user_id + AND duplicate.ctid > keep.ctid; + `) + + console.log(`Entfernte doppelte Zeilen: ${deleted.rowCount || 0}`) + + await db.execute(sql` + CREATE UNIQUE INDEX IF NOT EXISTS auth_tenant_users_tenant_user_unique + ON auth_tenant_users (tenant_id, user_id); + `) + + console.log("Unique Index auth_tenant_users_tenant_user_unique ist vorhanden") + } finally { + await pool.end() + } +} + +run().catch((err) => { + console.error("Dedupe auth_tenant_users failed") + console.error(err) + process.exit(1) +}) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 8bf99fd..0dd7698 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -660,10 +660,16 @@ export default async function adminRoutes(server: FastifyInstance) { }) .from(authUserRoles), ]); + const uniqueMembershipRows = Array.from( + new Map(membershipRows.map((membership) => [ + `${membership.tenant_id}:${membership.user_id}`, + membership, + ])).values() + ); const users = userRows.map((user) => { const profiles = profileRows.filter((profile) => profile.user_id === user.id); - const memberships = membershipRows.filter((membership) => membership.user_id === user.id); + const memberships = uniqueMembershipRows.filter((membership) => membership.user_id === user.id); const roleAssignments = roleAssignmentRows.filter((assignment) => assignment.user_id === user.id); const preferredProfile = profiles.find((profile) => profile.active) || profiles[0]; const fallbackName = deriveNameFromEmail(user.email); @@ -683,7 +689,7 @@ export default async function adminRoutes(server: FastifyInstance) { const tenantsWithCounts = tenantRows.map((tenant) => ({ ...tenant, - user_count: membershipRows.filter((membership) => membership.tenant_id === tenant.id).length, + user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length, })); return { @@ -691,7 +697,7 @@ export default async function adminRoutes(server: FastifyInstance) { tenants: tenantsWithCounts, roles: roleRows, unassignedProfiles: profileRows.filter((profile) => !profile.user_id), - memberships: membershipRows, + memberships: uniqueMembershipRows, roleAssignments: roleAssignmentRows, }; } catch (err) { diff --git a/backend/src/routes/auth/me.ts b/backend/src/routes/auth/me.ts index 3982474..ad013c7 100644 --- a/backend/src/routes/auth/me.ts +++ b/backend/src/routes/auth/me.ts @@ -69,7 +69,9 @@ export default async function meRoutes(server: FastifyInstance) { .innerJoin(tenants, eq(authTenantUsers.tenant_id, tenants.id)) .where(eq(authTenantUsers.user_id, userId)) - const tenantList = tenantRows ?? [] + const tenantList = Array.from( + new Map((tenantRows ?? []).map((tenant) => [tenant.id, tenant])).values() + ) // ---------------------------------------------------- // 3) ACTIVE TENANT diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index f2e4579..61ea64b 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -772,6 +772,37 @@ const insertRows = async (client: any, table: string, rows: Record[ return inserted } +const insertAuthTenantUserRows = async (client: any, rows: Record[], metadata: TableMetadata) => { + if (!rows.length) return 0 + + let inserted = 0 + const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column))) + + for (const row of rows) { + if (!row.tenant_id || !row.user_id) continue + + const existing = await client.query( + `select 1 from ${quoteIdent("auth_tenant_users")} where ${quoteIdent("tenant_id")} = $1 and ${quoteIdent("user_id")} = $2 limit 1`, + [row.tenant_id, row.user_id] + ) + if (existing.rows.length) continue + + const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column)) + if (!rowColumns.length) continue + + const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ") + const values = rowColumns.map((column) => prepareColumnValue(row[column], metadata.jsonColumns.has(column))) + + await client.query( + `insert into ${quoteIdent("auth_tenant_users")} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders})`, + values + ) + inserted += 1 + } + + return inserted +} + const insertIdentityRowsWithRemap = async ( client: any, table: string, @@ -882,6 +913,7 @@ export const importTenantFullExport = async ( ...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(), ].filter((table, index, all) => all.indexOf(table) === index) const specialTables = [ + columnsByTable.has("auth_tenant_users") ? "auth_tenant_users" : null, columnsByTable.has("bankaccounts") ? "bankaccounts" : null, columnsByTable.has("bankstatements") ? "bankstatements" : null, columnsByTable.has("letterheads") ? "letterheads" : null, @@ -905,6 +937,14 @@ export const importTenantFullExport = async ( await reportProgress("Dateien vorbereitet") const importedTables: { table: string; rows: number }[] = [] + const authTenantUsersMetadata = columnsByTable.get("auth_tenant_users") + if (authTenantUsersMetadata) { + const count = await insertAuthTenantUserRows(client, exportData.tables.auth_tenant_users || [], authTenantUsersMetadata) + importedTables.push({ table: "auth_tenant_users", rows: count }) + progressDone += 1 + await reportProgress("Tenant-Zuordnungen importiert") + } + const bankaccountMetadata = columnsByTable.get("bankaccounts") if (bankaccountMetadata) { const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata)