| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- import { db } from '@/lib/db'
- import { safeUploadPath } from '@/lib/resolve-upload-path'
- import { unlink, rm } from 'fs/promises'
- import path from 'path'
- import { billingRequest, isTorqvoiceComBillingConfigured } from '@/lib/torqvoice-com'
- import { uploadsRoot } from './upload-root'
- /**
- * Delete an organization completely: cancels its Stripe subscription, deletes
- * the org row (cascading all data), and removes its upload files from disk.
- * The single implementation behind admin deletion, account deletion and the
- * owner's "delete workshop" — org deletion has ordering constraints (see the
- * inspections note below), so new deletion paths must call this rather than
- * `db.organization.delete` directly.
- *
- * Pass `userId` when the caller is removing that user entirely, so their
- * membership row (which does not cascade from user deletion) goes too.
- */
- export async function deleteOrganizationWithData(organizationId: string, userId?: string) {
- // Collect file paths to clean up from disk
- const filePaths: string[] = []
- const attachments = await db.serviceAttachment.findMany({
- where: { serviceRecord: { organizationId } },
- select: { fileUrl: true },
- })
- for (const att of attachments) {
- const attPath = safeUploadPath(att.fileUrl)
- if (attPath) filePaths.push(attPath)
- }
- const inventoryParts = await db.inventoryPart.findMany({
- where: { organizationId },
- select: { imageUrl: true },
- })
- for (const part of inventoryParts) {
- const partPath = safeUploadPath(part.imageUrl)
- if (partPath) filePaths.push(partPath)
- }
- const vehicles = await db.vehicle.findMany({
- where: { organizationId },
- select: { imageUrl: true },
- })
- for (const v of vehicles) {
- const vehiclePath = safeUploadPath(v.imageUrl)
- if (vehiclePath) filePaths.push(vehiclePath)
- }
- // End the Stripe subscription before deleting org data. torqvoice.com holds
- // the Stripe keys; a self-hosted install has no Stripe-backed row here.
- const subscription = await db.subscription.findUnique({
- where: { organizationId },
- select: { stripeSubscriptionId: true },
- })
- if (subscription?.stripeSubscriptionId && isTorqvoiceComBillingConfigured()) {
- try {
- await billingRequest('end', { organizationId })
- } catch (error) {
- // Already canceled on Stripe's side, or torqvoice.com unreachable: the
- // daily sync ends an orphan either way, and the deletion must go on.
- console.error('[delete-user-data] could not end the subscription:', error)
- }
- }
- // Delete the caller's membership first (not auto-cascaded from user deletion)
- if (userId) {
- await db.organizationMember.deleteMany({
- where: { userId, organizationId },
- })
- }
- // Delete the organization — cascades all org data (vehicles, customers,
- // quotes, inventory, custom fields, settings, roles, invitations,
- // subscription). Inspections must go first: their templateId FK is
- // ON DELETE RESTRICT, which blocks the cascade from resolving templates
- // and inspections in one statement.
- await db.$transaction([
- db.inspection.deleteMany({ where: { organizationId } }),
- db.organization.delete({ where: { id: organizationId } }),
- ])
- // Clean up files from disk (best effort)
- for (const filePath of filePaths) {
- try {
- await unlink(filePath)
- } catch {
- // File may already be missing
- }
- }
- // Try to remove the org upload directory
- try {
- const orgUploadDir = path.join(uploadsRoot(), organizationId)
- await rm(orgUploadDir, { recursive: true, force: true })
- } catch {
- // Directory may not exist
- }
- }
- /**
- * Reassign org data from one user to another member, then remove membership.
- */
- async function reassignOrgData(organizationId: string, userId: string) {
- const otherMember = await db.organizationMember.findFirst({
- where: { organizationId, NOT: { userId } },
- select: { userId: true },
- })
- if (otherMember) {
- const newOwnerId = otherMember.userId
- await db.$transaction([
- db.vehicle.updateMany({
- where: { userId, organizationId },
- data: { userId: newOwnerId },
- }),
- db.customer.updateMany({
- where: { userId, organizationId },
- data: { userId: newOwnerId },
- }),
- db.quote.updateMany({
- where: { userId, organizationId },
- data: { userId: newOwnerId },
- }),
- db.inventoryPart.updateMany({
- where: { userId, organizationId },
- data: { userId: newOwnerId },
- }),
- db.customFieldDefinition.updateMany({
- where: { userId, organizationId },
- data: { userId: newOwnerId },
- }),
- db.appSetting.updateMany({
- where: { userId, organizationId },
- data: { userId: newOwnerId },
- }),
- ])
- }
- await db.organizationMember.deleteMany({
- where: { userId, organizationId },
- })
- }
- /**
- * Handle all organization cleanup for a user being deleted.
- * For each org the user belongs to:
- * - If last member: delete the entire org and its data
- * - If not last member: reassign data to another member
- */
- export async function deleteUserOrganizations(userId: string) {
- const memberships = await db.organizationMember.findMany({
- where: { userId },
- select: { organizationId: true },
- })
- for (const { organizationId } of memberships) {
- const memberCount = await db.organizationMember.count({
- where: { organizationId },
- })
- if (memberCount <= 1) {
- await deleteOrganizationWithData(organizationId, userId)
- } else {
- await reassignOrgData(organizationId, userId)
- }
- }
- }
|