diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index 8654f8a0d69..68dc6174de8 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -4,8 +4,7 @@ import generateUID from '../../generateUID' import {DataLoaderWorker} from '../../graphql/graphql' import isValid from '../../graphql/isValid' import getKysely from '../../postgres/getKysely' -import insertOrgUserAudit from '../../postgres/helpers/insertOrgUserAudit' -import {OrganizationUserAuditEventTypeEnum} from '../../postgres/queries/generated/insertOrgUserAuditQuery' +import {OrganizationUserAudit} from '../../postgres/pg' import {getUserById} from '../../postgres/queries/getUsersByIds' import IUser from '../../postgres/types/IUser' import {Logger} from '../../utils/Logger' @@ -119,7 +118,7 @@ const auditEventTypeLookup = { [InvoiceItemType.PAUSE_USER]: 'inactivated', [InvoiceItemType.REMOVE_USER]: 'removed', [InvoiceItemType.UNPAUSE_USER]: 'activated' -} as {[key in InvoiceItemType]: OrganizationUserAuditEventTypeEnum} +} as {[key in InvoiceItemType]: OrganizationUserAudit['eventType']} /** * Also adds the organization user if not present @@ -130,6 +129,7 @@ export default async function adjustUserCount( type: InvoiceItemType, dataLoader: DataLoaderWorker ) { + const pg = getKysely() const orgIds = Array.isArray(orgInput) ? orgInput : [orgInput] const user = (await getUserById(userId))! @@ -137,7 +137,14 @@ export default async function adjustUserCount( const dbAction = dbActionTypeLookup[type] await dbAction(orgIds, user, dataLoader) const auditEventType = auditEventTypeLookup[type] - await insertOrgUserAudit(orgIds, userId, auditEventType) + await Promise.all( + orgIds.map((orgId) => { + return pg + .insertInto('OrganizationUserAudit') + .values({orgId, userId, eventDate: new Date(), eventType: auditEventType}) + .execute() + }) + ) const organizations = await dataLoader.get('organizations').loadMany(orgIds) const paidOrgs = organizations.filter(isValid).filter((org) => org.stripeSubscriptionId) diff --git a/packages/server/graphql/mutations/helpers/createNewOrg.ts b/packages/server/graphql/mutations/helpers/createNewOrg.ts index 391fa04da43..457c98f2e1c 100644 --- a/packages/server/graphql/mutations/helpers/createNewOrg.ts +++ b/packages/server/graphql/mutations/helpers/createNewOrg.ts @@ -1,7 +1,6 @@ import Organization from '../../../database/types/Organization' import generateUID from '../../../generateUID' import getKysely from '../../../postgres/getKysely' -import insertOrgUserAudit from '../../../postgres/helpers/insertOrgUserAudit' import getDomainFromEmail from '../../../utils/getDomainFromEmail' import {DataLoaderWorker} from '../../graphql' @@ -20,9 +19,13 @@ export default async function createNewOrg( name: orgName, activeDomain }) - await insertOrgUserAudit([orgId], leaderUserId, 'added') await getKysely() .with('Org', (qc) => qc.insertInto('Organization').values({...org, creditCard: null})) + .with('OrgUserAuditInsert', (qc) => + qc + .insertInto('OrganizationUserAudit') + .values({orgId, userId: leaderUserId, eventDate: new Date(), eventType: 'added'}) + ) .insertInto('OrganizationUser') .values({ id: generateUID(), diff --git a/packages/server/graphql/private/mutations/hardDeleteUser.ts b/packages/server/graphql/private/mutations/hardDeleteUser.ts index e830f4d95dc..3ee531aed5e 100644 --- a/packages/server/graphql/private/mutations/hardDeleteUser.ts +++ b/packages/server/graphql/private/mutations/hardDeleteUser.ts @@ -59,17 +59,8 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( const userIdToDelete = user.id // get team ids and meetingIds - const [teamMembers, meetingMembers] = await Promise.all([ - dataLoader.get('teamMembersByUserId').load(userIdToDelete), - dataLoader.get('meetingMembersByUserId').load(userIdToDelete) - ]) + const teamMembers = await dataLoader.get('teamMembersByUserId').load(userIdToDelete) const teamIds = teamMembers.map(({teamId}) => teamId) - const meetingIds = meetingMembers.map(({meetingId}) => meetingId) - - const discussions = teamIds.length - ? await pg.selectFrom('Discussion').select('id').where('id', 'in', teamIds).execute() - : [] - const teamDiscussionIds = discussions.map(({id}) => id) // soft delete first for side effects await softDeleteUser(userIdToDelete, dataLoader) @@ -83,26 +74,6 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( .where('createdBy', '=', userIdToDelete) .execute() - // now postgres, after FKs are added then triggers should take care of children - // TODO when we're done migrating to PG, these should have constraints that ON DELETE CASCADE - await pg - .with('AtlassianAuthDelete', (qb) => - qb.deleteFrom('AtlassianAuth').where('userId', '=', userIdToDelete) - ) - .with('GitHubAuthDelete', (qb) => - qb.deleteFrom('GitHubAuth').where('userId', '=', userIdToDelete) - ) - .with('TaskEstimateDelete', (qb) => - qb - .deleteFrom('TaskEstimate') - .where('userId', '=', userIdToDelete) - .where('meetingId', 'in', meetingIds) - ) - .deleteFrom('Poll') - .where('discussionId', 'in', teamDiscussionIds) - .where('createdById', '=', userIdToDelete) - .execute() - // Send metrics to HubSpot before the user is really deleted in DB await sendAccountRemovedEvent(userIdToDelete, user.email, reasonText ?? '') diff --git a/packages/server/postgres/helpers/insertOrgUserAudit.ts b/packages/server/postgres/helpers/insertOrgUserAudit.ts deleted file mode 100644 index 0aae5fd8149..00000000000 --- a/packages/server/postgres/helpers/insertOrgUserAudit.ts +++ /dev/null @@ -1,19 +0,0 @@ -import getPg from '../getPg' -import { - insertOrgUserAuditQuery, - OrganizationUserAuditEventTypeEnum -} from '../queries/generated/insertOrgUserAuditQuery' - -const insertOrgUserAudit = async ( - orgIds: string[], - userId: string, - eventType: OrganizationUserAuditEventTypeEnum, - eventDate: Date = new Date() -) => { - const pgPool = getPg() - const auditRows = orgIds.map((orgId) => ({orgId, userId, eventType, eventDate})) - const parameters = {auditRows} - await insertOrgUserAuditQuery.run(parameters, pgPool) -} - -export default insertOrgUserAudit diff --git a/packages/server/postgres/migrations/1729110751093_pgSchemaFixups.ts b/packages/server/postgres/migrations/1729110751093_pgSchemaFixups.ts new file mode 100644 index 00000000000..f7ae4b9f392 --- /dev/null +++ b/packages/server/postgres/migrations/1729110751093_pgSchemaFixups.ts @@ -0,0 +1,85 @@ +import {Kysely, PostgresDialect} from 'kysely' +import connectRethinkDB from '../../database/connectRethinkDB' +import getPg from '../getPg' + +export async function up() { + await connectRethinkDB() + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + + const removeRelationalIntegrityViolators = async (table: string, fk: string, fkTable: string) => { + console.log('deleting bad rows', table, fk, fkTable) + const res = await pg + .deleteFrom(table) + .where(fk, 'is not', null) + .where(({not, exists, selectFrom}) => + not( + exists(selectFrom(fkTable).select('id').whereRef(`${table}.${fk}`, '=', `${fkTable}.id`)) + ) + ) + .executeTakeFirst() + console.log(`Deleted ${res.numDeletedRows} rows from ${table} with bad ${fk}`) + } + + const addFKConstraint = async (table: string, fk: string, fkTable: string) => { + console.log('adding constraint', table, fk, fkTable) + try { + await pg.schema + .alterTable(table) + .addForeignKeyConstraint(`fk_${fk}`, [fk], fkTable, ['id']) + .onDelete('cascade') + .execute() + } catch (e) { + console.log('error adding constraint', table, fk, fkTable, e) + return + } + console.log('added constraint', table, fk, fkTable) + } + + const violations = [ + {table: 'AtlassianAuth', fk: 'userId', fkTable: 'User'}, + {table: 'AtlassianAuth', fk: 'teamId', fkTable: 'Team'}, + {table: 'AzureDevOpsDimensionFieldMap', fk: 'teamId', fkTable: 'Team'}, + {table: 'Discussion', fk: 'teamId', fkTable: 'Team'}, + {table: 'Discussion', fk: 'meetingId', fkTable: 'NewMeeting'}, + {table: 'GitHubAuth', fk: 'userId', fkTable: 'User'}, + {table: 'GitHubAuth', fk: 'teamId', fkTable: 'Team'}, + {table: 'GitHubDimensionFieldMap', fk: 'teamId', fkTable: 'Team'}, + {table: 'GitLabDimensionFieldMap', fk: 'teamId', fkTable: 'Team'}, + {table: 'Insight', fk: 'teamId', fkTable: 'Team'}, + {table: 'IntegrationProvider', fk: 'orgId', fkTable: 'Organization'}, + {table: 'IntegrationSearchQuery', fk: 'teamId', fkTable: 'Team'}, + {table: 'JiraDimensionFieldMap', fk: 'teamId', fkTable: 'Team'}, + {table: 'JiraServerDimensionFieldMap', fk: 'teamId', fkTable: 'Team'}, + {table: 'OrganizationApprovedDomain', fk: 'orgId', fkTable: 'Organization'}, + {table: 'OrganizationUserAudit', fk: 'orgId', fkTable: 'Organization'}, + {table: 'OrganizationUserAudit', fk: 'userId', fkTable: 'User'}, + {table: 'Poll', fk: 'meetingId', fkTable: 'NewMeeting'}, + {table: 'RetroReflection', fk: 'promptId', fkTable: 'ReflectPrompt'}, + {table: 'RetroReflection', fk: 'meetingId', fkTable: 'NewMeeting'}, + {table: 'RetroReflectionGroup', fk: 'promptId', fkTable: 'ReflectPrompt'}, + {table: 'RetroReflectionGroup', fk: 'meetingId', fkTable: 'NewMeeting'}, + {table: 'ScheduledJob', fk: 'orgId', fkTable: 'Organization'}, + {table: 'ScheduledJob', fk: 'meetingId', fkTable: 'NewMeeting'}, + {table: 'TaskEstimate', fk: 'taskId', fkTable: 'Task'}, + {table: 'TaskEstimate', fk: 'userId', fkTable: 'User'}, + {table: 'TaskEstimate', fk: 'meetingId', fkTable: 'NewMeeting'}, + {table: 'TaskEstimate', fk: 'discussionId', fkTable: 'Discussion'}, + {table: 'Team', fk: 'orgId', fkTable: 'Organization'}, + {table: 'TeamPromptResponse', fk: 'meetingId', fkTable: 'NewMeeting'}, + {table: 'TimelineEvent', fk: 'orgId', fkTable: 'Organization'}, + {table: 'TimelineEvent', fk: 'meetingId', fkTable: 'NewMeeting'} + ] + for (let i = 0; i < violations.length; i++) { + const {table, fk, fkTable} = violations[i] + await removeRelationalIntegrityViolators(table, fk, fkTable) + await addFKConstraint(table, fk, fkTable) + } +} + +export async function down() { + // noop +} diff --git a/packages/server/postgres/queries/src/insertOrgUserAuditQuery.sql b/packages/server/postgres/queries/src/insertOrgUserAuditQuery.sql deleted file mode 100644 index 5afda527113..00000000000 --- a/packages/server/postgres/queries/src/insertOrgUserAuditQuery.sql +++ /dev/null @@ -1,10 +0,0 @@ -/* - @name insertOrgUserAuditQuery - @param auditRows -> ((orgId, userId, eventDate, eventType)...) -*/ -INSERT INTO "OrganizationUserAudit" ( - "orgId", - "userId", - "eventDate", - "eventType" -) VALUES :auditRows;