route.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import { NextRequest, NextResponse } from 'next/server'
  2. import { rateLimit } from '@/lib/rate-limit'
  3. import { getAuthContext } from '@/lib/get-auth-context'
  4. import { db } from '@/lib/db'
  5. import JSZip from 'jszip'
  6. import { isDemoMode } from '@/lib/demo'
  7. import { UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
  8. import { readdir, readFile, stat } from 'fs/promises'
  9. import path from 'path'
  10. import { uploadsRoot } from '@/lib/upload-root'
  11. export const maxDuration = 300
  12. interface ExportOptions {
  13. settings: boolean
  14. customers: boolean
  15. vehicles: boolean
  16. quotes: boolean
  17. inventory: boolean
  18. customFields: boolean
  19. files: boolean
  20. technicians: boolean
  21. inspections: boolean
  22. auditLogs: boolean
  23. smsMessages: boolean
  24. scheduledMessages: boolean
  25. notifications: boolean
  26. tireHotel: boolean
  27. /**
  28. * Labour presets, roles, webhooks, report schedules and dashboard layout:
  29. * the settings a workshop builds up that are not key/value AppSettings.
  30. */
  31. workshopConfig: boolean
  32. }
  33. const DEFAULT_OPTIONS: ExportOptions = {
  34. settings: true,
  35. customers: true,
  36. vehicles: true,
  37. quotes: true,
  38. inventory: true,
  39. customFields: true,
  40. files: true,
  41. technicians: true,
  42. inspections: true,
  43. auditLogs: true,
  44. smsMessages: true,
  45. scheduledMessages: true,
  46. notifications: true,
  47. tireHotel: true,
  48. workshopConfig: true,
  49. }
  50. export async function POST(request: NextRequest) {
  51. const limited = rateLimit(request, { limit: 5, windowMs: 60_000 })
  52. if (limited) return limited
  53. if (isDemoMode) {
  54. return NextResponse.json({ error: 'This action is disabled on the demo.' }, { status: 403 })
  55. }
  56. const ctx = await getAuthContext()
  57. if (!ctx) {
  58. return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  59. }
  60. // Reading or replacing the whole workshop is an owner's or admin's call.
  61. if (!ctx.isAdmin) {
  62. return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
  63. }
  64. let options: ExportOptions = DEFAULT_OPTIONS
  65. try {
  66. const body = await request.json()
  67. if (body?.include) {
  68. options = { ...DEFAULT_OPTIONS, ...body.include }
  69. }
  70. } catch {
  71. // If no body or invalid JSON, use defaults
  72. }
  73. const data: Record<string, unknown> = {}
  74. const queries: Promise<void>[] = []
  75. if (options.settings) {
  76. queries.push(
  77. db.appSetting.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
  78. data.settings = result
  79. })
  80. )
  81. }
  82. if (options.settings) {
  83. queries.push(
  84. db.documentDesign
  85. .findMany({ where: { organizationId: ctx.organizationId } })
  86. .then((result) => {
  87. data.documentDesigns = result
  88. })
  89. )
  90. }
  91. if (options.settings) {
  92. queries.push(
  93. db.emailTemplate
  94. .findMany({ where: { organizationId: ctx.organizationId } })
  95. .then((result) => {
  96. data.emailTemplates = result
  97. })
  98. )
  99. }
  100. if (options.customers) {
  101. queries.push(
  102. db.customer.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
  103. data.customers = result
  104. })
  105. )
  106. }
  107. if (options.vehicles) {
  108. // What issued invoices were issued with. The logo bytes travel as base64,
  109. // since the file is JSON.
  110. queries.push(
  111. db.documentDesignSnapshot
  112. .findMany({ where: { organizationId: ctx.organizationId } })
  113. .then((result) => {
  114. data.documentDesignSnapshots = result
  115. }),
  116. db.documentAssetSnapshot
  117. .findMany({ where: { organizationId: ctx.organizationId } })
  118. .then((result) => {
  119. data.documentAssetSnapshots = result.map((row) => ({
  120. ...row,
  121. data: Buffer.from(row.data).toString('base64'),
  122. }))
  123. })
  124. )
  125. }
  126. if (options.customFields) {
  127. queries.push(
  128. db.customFieldDefinition
  129. .findMany({
  130. where: { organizationId: ctx.organizationId },
  131. include: { values: true },
  132. })
  133. .then((result) => {
  134. data.customFieldDefinitions = result
  135. })
  136. )
  137. }
  138. if (options.inventory) {
  139. queries.push(
  140. db.inventoryPart
  141. .findMany({
  142. where: { organizationId: ctx.organizationId },
  143. include: { movements: true, gallery: true },
  144. })
  145. .then((result) => {
  146. data.inventoryParts = result
  147. })
  148. )
  149. }
  150. if (options.vehicles) {
  151. queries.push(
  152. db.vehicle
  153. .findMany({
  154. where: { organizationId: ctx.organizationId },
  155. include: {
  156. notes: true,
  157. fuelLogs: true,
  158. reminders: true,
  159. serviceRequests: true,
  160. inspectionStatus: true,
  161. // What the workshop saw and did not do yet, with the photographs
  162. // that argue for it. Deleted with the vehicle on a restore, so a
  163. // backup without them is a one-way loss. The same goes for the
  164. // three below: every one of them hangs off the vehicle and was
  165. // restored from a key the export never wrote.
  166. findings: true,
  167. // The condition map's marks: the car's dents and scratches, with
  168. // their photos. Deleted with the vehicle on a restore like the rest.
  169. conditionMarks: true,
  170. aiMessages: true,
  171. recurringInvoices: { include: { templateParts: true, templateLabor: true } },
  172. serviceRecords: {
  173. include: {
  174. concerns: true,
  175. partItems: true,
  176. laborItems: true,
  177. attachments: true,
  178. payments: true,
  179. statusReports: true,
  180. timeEntries: true,
  181. },
  182. },
  183. },
  184. })
  185. .then((result) => {
  186. data.vehicles = result
  187. })
  188. )
  189. }
  190. if (options.vehicles) {
  191. // Customer/workshop reminders have no vehicle to nest under.
  192. queries.push(
  193. db.reminder
  194. .findMany({ where: { organizationId: ctx.organizationId, vehicleId: null } })
  195. .then((result) => {
  196. data.orgReminders = result
  197. })
  198. )
  199. }
  200. if (options.vehicles) {
  201. // Counter sales: service records without a vehicle, linked directly to a
  202. // customer. They are not nested under any vehicle, so export them
  203. // separately or they would be lost from backups.
  204. queries.push(
  205. db.serviceRecord
  206. .findMany({
  207. where: { organizationId: ctx.organizationId, vehicleId: null },
  208. include: {
  209. concerns: true,
  210. partItems: true,
  211. laborItems: true,
  212. attachments: true,
  213. payments: true,
  214. timeEntries: true,
  215. },
  216. })
  217. .then((result) => {
  218. data.counterSales = result
  219. })
  220. )
  221. }
  222. if (options.quotes) {
  223. queries.push(
  224. db.quote
  225. .findMany({
  226. where: { organizationId: ctx.organizationId },
  227. include: {
  228. partItems: true,
  229. laborItems: true,
  230. attachments: true,
  231. },
  232. })
  233. .then((result) => {
  234. data.quotes = result
  235. })
  236. )
  237. }
  238. if (options.technicians) {
  239. queries.push(
  240. db.technician.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
  241. data.technicians = result
  242. })
  243. )
  244. queries.push(
  245. db.workBay.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
  246. data.workBays = result
  247. })
  248. )
  249. }
  250. if (options.inspections) {
  251. queries.push(
  252. db.inspectionTemplate
  253. .findMany({
  254. where: { organizationId: ctx.organizationId },
  255. include: {
  256. sections: {
  257. include: { items: true },
  258. },
  259. },
  260. })
  261. .then((result) => {
  262. data.inspectionTemplates = result
  263. })
  264. )
  265. queries.push(
  266. db.inspection
  267. .findMany({
  268. where: { organizationId: ctx.organizationId },
  269. include: {
  270. items: true,
  271. attachments: true,
  272. statusReports: true,
  273. quoteRequests: true,
  274. },
  275. })
  276. .then((result) => {
  277. data.inspections = result
  278. })
  279. )
  280. }
  281. if (options.tireHotel) {
  282. // Shelves first, then what sits on them. The sets reference locations by
  283. // id, so an export missing the warehouses would restore tires with
  284. // nowhere to put them.
  285. queries.push(
  286. db.tireWarehouse
  287. .findMany({
  288. where: { organizationId: ctx.organizationId },
  289. include: { locations: true },
  290. })
  291. .then((result) => {
  292. data.tireWarehouses = result
  293. })
  294. )
  295. queries.push(
  296. db.tireSet
  297. .findMany({
  298. where: { organizationId: ctx.organizationId },
  299. include: {
  300. // The condition photos hang off the reading, so they travel with
  301. // it; a measurement restored without them loses the evidence for
  302. // a worn tire the customer was charged for.
  303. measurements: { include: { images: true } },
  304. movements: true,
  305. treatments: true,
  306. attachments: true,
  307. },
  308. })
  309. .then((result) => {
  310. data.tireSets = result
  311. })
  312. )
  313. }
  314. if (options.workshopConfig) {
  315. // Everything a workshop configures that is not a key/value setting: none
  316. // of it was in a backup before, so a restore rebuilt an empty shop.
  317. queries.push(
  318. // Archived ones too: a finished job still names the status it carried.
  319. db.workOrderStatus
  320. .findMany({ where: { organizationId: ctx.organizationId } })
  321. .then((result) => {
  322. data.workOrderStatuses = result
  323. })
  324. )
  325. queries.push(
  326. db.laborPreset
  327. .findMany({
  328. where: { organizationId: ctx.organizationId },
  329. include: { items: true, parts: true },
  330. })
  331. .then((result) => {
  332. data.laborPresets = result
  333. })
  334. )
  335. queries.push(
  336. db.webhook.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
  337. data.webhooks = result
  338. })
  339. )
  340. queries.push(
  341. db.reportSchedule
  342. .findMany({ where: { organizationId: ctx.organizationId } })
  343. .then((result) => {
  344. data.reportSchedules = result
  345. })
  346. )
  347. queries.push(
  348. db.role
  349. .findMany({
  350. where: { organizationId: ctx.organizationId },
  351. include: { permissions: true },
  352. })
  353. .then((result) => {
  354. data.roles = result
  355. })
  356. )
  357. }
  358. if (options.auditLogs) {
  359. queries.push(
  360. db.auditLog.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
  361. data.auditLogs = result
  362. })
  363. )
  364. }
  365. if (options.smsMessages) {
  366. queries.push(
  367. db.smsMessage.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
  368. data.smsMessages = result
  369. })
  370. )
  371. queries.push(
  372. db.whatsappMessage
  373. .findMany({ where: { organizationId: ctx.organizationId } })
  374. .then((result) => {
  375. data.whatsappMessages = result
  376. })
  377. )
  378. queries.push(
  379. db.telegramMessage
  380. .findMany({ where: { organizationId: ctx.organizationId } })
  381. .then((result) => {
  382. data.telegramMessages = result
  383. })
  384. )
  385. }
  386. if (options.scheduledMessages) {
  387. queries.push(
  388. db.scheduledMessage
  389. .findMany({ where: { organizationId: ctx.organizationId } })
  390. .then((result) => {
  391. data.scheduledMessages = result
  392. })
  393. )
  394. queries.push(
  395. db.inspectionReminderCampaign
  396. .findMany({ where: { organizationId: ctx.organizationId } })
  397. .then((result) => {
  398. data.inspectionReminderCampaigns = result
  399. })
  400. )
  401. queries.push(
  402. db.inspectionReminderSend
  403. .findMany({ where: { organizationId: ctx.organizationId } })
  404. .then((result) => {
  405. data.inspectionReminderSends = result
  406. })
  407. )
  408. }
  409. if (options.notifications) {
  410. queries.push(
  411. db.notification.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
  412. data.notifications = result
  413. })
  414. )
  415. }
  416. await Promise.all(queries)
  417. const backup = {
  418. version: 2,
  419. exportedAt: new Date().toISOString(),
  420. data,
  421. }
  422. const zip = new JSZip()
  423. // Add data.json
  424. zip.file('data.json', JSON.stringify(backup, null, 2))
  425. // Add uploaded files if requested
  426. if (options.files) {
  427. const uploadsDir = path.join(uploadsRoot(), ctx.organizationId)
  428. const categories = UPLOAD_CATEGORIES
  429. for (const category of categories) {
  430. const categoryDir = path.join(uploadsDir, category)
  431. try {
  432. const dirStat = await stat(categoryDir)
  433. if (!dirStat.isDirectory()) continue
  434. const files = await readdir(categoryDir)
  435. for (const file of files) {
  436. const filePath = path.join(categoryDir, file)
  437. const fileStat = await stat(filePath)
  438. if (!fileStat.isFile()) continue
  439. const fileBuffer = await readFile(filePath)
  440. zip.file(`files/${category}/${file}`, fileBuffer)
  441. }
  442. } catch {
  443. // Category directory doesn't exist, skip
  444. }
  445. }
  446. }
  447. const zipBuffer = await zip.generateAsync({
  448. type: 'arraybuffer',
  449. compression: 'DEFLATE',
  450. compressionOptions: { level: 6 },
  451. })
  452. const dateStr = new Date().toISOString().slice(0, 10)
  453. return new Response(zipBuffer, {
  454. headers: {
  455. 'Content-Type': 'application/zip',
  456. 'Content-Disposition': `attachment; filename="torqvoice-backup-${dateStr}.zip"`,
  457. },
  458. })
  459. }
  460. /**
  461. * The older, no-options export.
  462. *
  463. * It used to be a second implementation of the same thing, and it fell behind:
  464. * it predated the tire hotel and never learned about anything added since, so
  465. * whoever called it received a quietly incomplete backup. It now runs the same
  466. * export as everything else, with every option on.
  467. */
  468. export async function GET(request: NextRequest) {
  469. const limited = rateLimit(request, { limit: 5, windowMs: 60_000 })
  470. if (limited) return limited
  471. return POST(
  472. new NextRequest(request.url, {
  473. method: 'POST',
  474. headers: { 'content-type': 'application/json' },
  475. body: JSON.stringify({ include: DEFAULT_OPTIONS }),
  476. })
  477. )
  478. }