deleteContent.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. "use server";
  2. import { db } from "@/lib/db";
  3. import { withAuth } from "@/lib/with-auth";
  4. import { resolveUploadPath } from "@/lib/resolve-upload-path";
  5. import { unlink } from "fs/promises";
  6. import { revalidatePath } from "next/cache";
  7. import { z } from "zod";
  8. const deleteContentSchema = z.object({
  9. vehicles: z.boolean().default(false),
  10. customers: z.boolean().default(false),
  11. quotes: z.boolean().default(false),
  12. inventory: z.boolean().default(false),
  13. inspections: z.boolean().default(false),
  14. technicians: z.boolean().default(false),
  15. inspectionTemplates: z.boolean().default(false),
  16. notifications: z.boolean().default(false),
  17. smsMessages: z.boolean().default(false),
  18. customFields: z.boolean().default(false),
  19. });
  20. export async function deleteContent(input: unknown) {
  21. return withAuth(async ({ organizationId }) => {
  22. if (!organizationId) {
  23. throw new Error("No organization found");
  24. }
  25. const selections = deleteContentSchema.parse(input);
  26. if (!Object.values(selections).some(Boolean)) {
  27. throw new Error("No content types selected");
  28. }
  29. const deleted: string[] = [];
  30. const filesToClean: string[] = [];
  31. // --- Vehicles ---
  32. // Cascades: ServiceRecord (-> PartItem, LaborItem, ServiceAttachment, Payment),
  33. // Note, FuelLog, Reminder, Quote (vehicle-linked), RecurringInvoice
  34. if (selections.vehicles) {
  35. // Collect files to clean up
  36. const vehicleImages = await db.vehicle.findMany({
  37. where: { organizationId },
  38. select: { imageUrl: true },
  39. });
  40. for (const v of vehicleImages) {
  41. if (v.imageUrl) filesToClean.push(resolveUploadPath(v.imageUrl));
  42. }
  43. const attachments = await db.serviceAttachment.findMany({
  44. where: { serviceRecord: { organizationId } },
  45. select: { fileUrl: true },
  46. });
  47. for (const att of attachments) {
  48. filesToClean.push(resolveUploadPath(att.fileUrl));
  49. }
  50. await db.vehicle.deleteMany({ where: { organizationId } });
  51. // Counter sales have no vehicle so the cascade above misses them, but
  52. // their attachment files were already collected for cleanup — delete the
  53. // records too so no orphaned work orders survive.
  54. await db.serviceRecord.deleteMany({ where: { organizationId } });
  55. deleted.push("vehicles");
  56. }
  57. // --- Quotes ---
  58. // Cascades: QuotePart, QuoteLabor
  59. // If vehicles were already deleted, vehicle-linked quotes are gone via cascade.
  60. // This handles org-level quotes (and any remaining if vehicles weren't deleted).
  61. if (selections.quotes) {
  62. await db.quote.deleteMany({ where: { organizationId } });
  63. deleted.push("quotes");
  64. }
  65. // --- Customers ---
  66. // Vehicles with this customer get customerId set to null (SetNull).
  67. // Quotes with this customer get customerId set to null (SetNull).
  68. if (selections.customers) {
  69. await db.customer.deleteMany({ where: { organizationId } });
  70. deleted.push("customers");
  71. }
  72. // --- Inspections ---
  73. // Cascades: InspectionItem, InspectionQuoteRequest
  74. if (selections.inspections) {
  75. await db.inspection.deleteMany({ where: { organizationId } });
  76. deleted.push("inspections");
  77. }
  78. // --- Technicians ---
  79. // ServiceRecords/Inspections with this technician get technicianId set to null (SetNull).
  80. if (selections.technicians) {
  81. await db.technician.deleteMany({ where: { organizationId } });
  82. deleted.push("technicians");
  83. }
  84. // --- Inspection Templates ---
  85. // Cascades: InspectionTemplateSection -> InspectionTemplateItem
  86. // Inspections reference templates (no cascade), so delete remaining inspections first.
  87. if (selections.inspectionTemplates) {
  88. if (!selections.inspections) {
  89. await db.inspection.deleteMany({ where: { organizationId } });
  90. deleted.push("inspections");
  91. }
  92. await db.inspectionTemplate.deleteMany({ where: { organizationId } });
  93. deleted.push("inspectionTemplates");
  94. }
  95. // --- Inventory ---
  96. if (selections.inventory) {
  97. const parts = await db.inventoryPart.findMany({
  98. where: { organizationId },
  99. select: { imageUrl: true },
  100. });
  101. for (const part of parts) {
  102. if (part.imageUrl) filesToClean.push(resolveUploadPath(part.imageUrl));
  103. }
  104. await db.inventoryPart.deleteMany({ where: { organizationId } });
  105. deleted.push("inventory");
  106. }
  107. // --- Notifications ---
  108. if (selections.notifications) {
  109. await db.notification.deleteMany({ where: { organizationId } });
  110. deleted.push("notifications");
  111. }
  112. // --- SMS Messages ---
  113. if (selections.smsMessages) {
  114. await db.smsMessage.deleteMany({ where: { organizationId } });
  115. deleted.push("smsMessages");
  116. }
  117. // --- Custom Fields ---
  118. // Cascades: CustomFieldValue
  119. if (selections.customFields) {
  120. await db.customFieldDefinition.deleteMany({ where: { organizationId } });
  121. deleted.push("customFields");
  122. }
  123. // Clean up files from disk (best effort)
  124. for (const filePath of filesToClean) {
  125. try {
  126. await unlink(filePath);
  127. } catch {
  128. // File may already be missing
  129. }
  130. }
  131. revalidatePath("/");
  132. revalidatePath("/vehicles");
  133. revalidatePath("/customers");
  134. revalidatePath("/quotes");
  135. revalidatePath("/inventory");
  136. revalidatePath("/inspections");
  137. revalidatePath("/technicians");
  138. revalidatePath("/settings");
  139. revalidatePath("/settings/account");
  140. return { deleted };
  141. }, {
  142. audit: ({ result }) => ({
  143. action: "settings.deleteContent",
  144. entity: "Organization",
  145. message: `Deleted all data: ${result.deleted.join(", ")}`,
  146. metadata: { deleted: result.deleted },
  147. }),
  148. });
  149. }
  150. export async function getContentCounts() {
  151. return withAuth(async ({ organizationId }) => {
  152. if (!organizationId) {
  153. return {
  154. vehicles: 0, customers: 0, quotes: 0, inventory: 0,
  155. inspections: 0, technicians: 0, inspectionTemplates: 0,
  156. notifications: 0, smsMessages: 0, customFields: 0,
  157. };
  158. }
  159. const [
  160. vehicles, customers, quotes, inventory,
  161. inspections, technicians, inspectionTemplates,
  162. notifications, smsMessages, customFields,
  163. ] = await Promise.all([
  164. db.vehicle.count({ where: { organizationId } }),
  165. db.customer.count({ where: { organizationId } }),
  166. db.quote.count({ where: { organizationId } }),
  167. db.inventoryPart.count({ where: { organizationId } }),
  168. db.inspection.count({ where: { organizationId } }),
  169. db.technician.count({ where: { organizationId } }),
  170. db.inspectionTemplate.count({ where: { organizationId } }),
  171. db.notification.count({ where: { organizationId } }),
  172. db.smsMessage.count({ where: { organizationId } }),
  173. db.customFieldDefinition.count({ where: { organizationId } }),
  174. ]);
  175. return {
  176. vehicles, customers, quotes, inventory,
  177. inspections, technicians, inspectionTemplates,
  178. notifications, smsMessages, customFields,
  179. };
  180. });
  181. }