delete-user-data.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { db } from '@/lib/db'
  2. import { safeUploadPath } from '@/lib/resolve-upload-path'
  3. import { unlink, rm } from 'fs/promises'
  4. import path from 'path'
  5. import { billingRequest, isTorqvoiceComBillingConfigured } from '@/lib/torqvoice-com'
  6. import { uploadsRoot } from './upload-root'
  7. /**
  8. * Delete an organization completely: cancels its Stripe subscription, deletes
  9. * the org row (cascading all data), and removes its upload files from disk.
  10. * The single implementation behind admin deletion, account deletion and the
  11. * owner's "delete workshop" — org deletion has ordering constraints (see the
  12. * inspections note below), so new deletion paths must call this rather than
  13. * `db.organization.delete` directly.
  14. *
  15. * Pass `userId` when the caller is removing that user entirely, so their
  16. * membership row (which does not cascade from user deletion) goes too.
  17. */
  18. export async function deleteOrganizationWithData(organizationId: string, userId?: string) {
  19. // Collect file paths to clean up from disk
  20. const filePaths: string[] = []
  21. const attachments = await db.serviceAttachment.findMany({
  22. where: { serviceRecord: { organizationId } },
  23. select: { fileUrl: true },
  24. })
  25. for (const att of attachments) {
  26. const attPath = safeUploadPath(att.fileUrl)
  27. if (attPath) filePaths.push(attPath)
  28. }
  29. const inventoryParts = await db.inventoryPart.findMany({
  30. where: { organizationId },
  31. select: { imageUrl: true },
  32. })
  33. for (const part of inventoryParts) {
  34. const partPath = safeUploadPath(part.imageUrl)
  35. if (partPath) filePaths.push(partPath)
  36. }
  37. const vehicles = await db.vehicle.findMany({
  38. where: { organizationId },
  39. select: { imageUrl: true },
  40. })
  41. for (const v of vehicles) {
  42. const vehiclePath = safeUploadPath(v.imageUrl)
  43. if (vehiclePath) filePaths.push(vehiclePath)
  44. }
  45. // End the Stripe subscription before deleting org data. torqvoice.com holds
  46. // the Stripe keys; a self-hosted install has no Stripe-backed row here.
  47. const subscription = await db.subscription.findUnique({
  48. where: { organizationId },
  49. select: { stripeSubscriptionId: true },
  50. })
  51. if (subscription?.stripeSubscriptionId && isTorqvoiceComBillingConfigured()) {
  52. try {
  53. await billingRequest('end', { organizationId })
  54. } catch (error) {
  55. // Already canceled on Stripe's side, or torqvoice.com unreachable: the
  56. // daily sync ends an orphan either way, and the deletion must go on.
  57. console.error('[delete-user-data] could not end the subscription:', error)
  58. }
  59. }
  60. // Delete the caller's membership first (not auto-cascaded from user deletion)
  61. if (userId) {
  62. await db.organizationMember.deleteMany({
  63. where: { userId, organizationId },
  64. })
  65. }
  66. // Delete the organization — cascades all org data (vehicles, customers,
  67. // quotes, inventory, custom fields, settings, roles, invitations,
  68. // subscription). Inspections must go first: their templateId FK is
  69. // ON DELETE RESTRICT, which blocks the cascade from resolving templates
  70. // and inspections in one statement.
  71. await db.$transaction([
  72. db.inspection.deleteMany({ where: { organizationId } }),
  73. db.organization.delete({ where: { id: organizationId } }),
  74. ])
  75. // Clean up files from disk (best effort)
  76. for (const filePath of filePaths) {
  77. try {
  78. await unlink(filePath)
  79. } catch {
  80. // File may already be missing
  81. }
  82. }
  83. // Try to remove the org upload directory
  84. try {
  85. const orgUploadDir = path.join(uploadsRoot(), organizationId)
  86. await rm(orgUploadDir, { recursive: true, force: true })
  87. } catch {
  88. // Directory may not exist
  89. }
  90. }
  91. /**
  92. * Reassign org data from one user to another member, then remove membership.
  93. */
  94. async function reassignOrgData(organizationId: string, userId: string) {
  95. const otherMember = await db.organizationMember.findFirst({
  96. where: { organizationId, NOT: { userId } },
  97. select: { userId: true },
  98. })
  99. if (otherMember) {
  100. const newOwnerId = otherMember.userId
  101. await db.$transaction([
  102. db.vehicle.updateMany({
  103. where: { userId, organizationId },
  104. data: { userId: newOwnerId },
  105. }),
  106. db.customer.updateMany({
  107. where: { userId, organizationId },
  108. data: { userId: newOwnerId },
  109. }),
  110. db.quote.updateMany({
  111. where: { userId, organizationId },
  112. data: { userId: newOwnerId },
  113. }),
  114. db.inventoryPart.updateMany({
  115. where: { userId, organizationId },
  116. data: { userId: newOwnerId },
  117. }),
  118. db.customFieldDefinition.updateMany({
  119. where: { userId, organizationId },
  120. data: { userId: newOwnerId },
  121. }),
  122. db.appSetting.updateMany({
  123. where: { userId, organizationId },
  124. data: { userId: newOwnerId },
  125. }),
  126. ])
  127. }
  128. await db.organizationMember.deleteMany({
  129. where: { userId, organizationId },
  130. })
  131. }
  132. /**
  133. * Handle all organization cleanup for a user being deleted.
  134. * For each org the user belongs to:
  135. * - If last member: delete the entire org and its data
  136. * - If not last member: reassign data to another member
  137. */
  138. export async function deleteUserOrganizations(userId: string) {
  139. const memberships = await db.organizationMember.findMany({
  140. where: { userId },
  141. select: { organizationId: true },
  142. })
  143. for (const { organizationId } of memberships) {
  144. const memberCount = await db.organizationMember.count({
  145. where: { organizationId },
  146. })
  147. if (memberCount <= 1) {
  148. await deleteOrganizationWithData(organizationId, userId)
  149. } else {
  150. await reassignOrgData(organizationId, userId)
  151. }
  152. }
  153. }