|
|
@@ -8,7 +8,7 @@ import { isDemoMode } from '@/lib/demo'
|
|
|
import { resolveInvoicePrefix } from '@/lib/invoice-utils'
|
|
|
import { workshopTimeZone } from '@/lib/workshop-timezone'
|
|
|
import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
|
|
|
-import { mkdir, writeFile, stat, readdir, rm } from 'fs/promises'
|
|
|
+import { mkdir, stat, readdir, rm } from 'fs/promises'
|
|
|
import { readFileSync } from 'fs'
|
|
|
import path from 'path'
|
|
|
import os from 'os'
|
|
|
@@ -16,6 +16,8 @@ import { BSON } from 'bson'
|
|
|
import { extractEntry, openZip, spoolUpload, type SpooledUpload } from '@/lib/backup/zip-file'
|
|
|
import { resolveWithinDir } from '@/lib/safe-path'
|
|
|
import { uploadsRoot } from '@/lib/upload-root'
|
|
|
+import { prepareImportedFile, writeImportedFile } from '@/lib/backup/import-photo'
|
|
|
+import { releaseFiles } from '@/lib/files/manager'
|
|
|
|
|
|
// Allow up to 5 minutes for large imports
|
|
|
export const maxDuration = 300
|
|
|
@@ -123,21 +125,6 @@ function determineFuelType(v: LubeLogVehicle): string {
|
|
|
return 'gasoline'
|
|
|
}
|
|
|
|
|
|
-function getMimeType(filename: string): string {
|
|
|
- const ext = filename.split('.').pop()?.toLowerCase()
|
|
|
- const mimeMap: Record<string, string> = {
|
|
|
- jpg: 'image/jpeg',
|
|
|
- jpeg: 'image/jpeg',
|
|
|
- png: 'image/png',
|
|
|
- webp: 'image/webp',
|
|
|
- pdf: 'application/pdf',
|
|
|
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
- csv: 'text/csv',
|
|
|
- txt: 'text/plain',
|
|
|
- }
|
|
|
- return mimeMap[ext || ''] || 'application/octet-stream'
|
|
|
-}
|
|
|
-
|
|
|
/**
|
|
|
* Find the actual backup directory. Accepts either:
|
|
|
* - A direct path to the extracted backup folder (contains data/cartracker.db)
|
|
|
@@ -228,6 +215,8 @@ export async function POST(request: NextRequest) {
|
|
|
const timeZone = await workshopTimeZone(organizationId)
|
|
|
let tmpDir: string | null = null
|
|
|
let upload: SpooledUpload | null = null
|
|
|
+ // Every file this import writes, so a run that fails hands them back.
|
|
|
+ const importedFileUrls: string[] = []
|
|
|
|
|
|
try {
|
|
|
// The zip comes as the raw body and goes to disk as it arrives
|
|
|
@@ -304,14 +293,21 @@ export async function POST(request: NextRequest) {
|
|
|
const uploadsBase = path.join(uploadsRoot(), organizationId)
|
|
|
|
|
|
// ── Copy a file from the backup to the uploads directory ──────────────
|
|
|
+ // A photo is fitted and re-encoded on the way, so it lands at the size an
|
|
|
+ // upload through the app would have, and every file is stored under a
|
|
|
+ // generated name like an upload's, so the file manager keeps track of it.
|
|
|
async function copyFile(
|
|
|
sourcePath: string,
|
|
|
category: string,
|
|
|
filename: string
|
|
|
- ): Promise<{ fileUrl: string; fileSize: number } | null> {
|
|
|
+ ): Promise<{
|
|
|
+ fileUrl: string
|
|
|
+ fileSize: number
|
|
|
+ fileType: string
|
|
|
+ compressed: boolean
|
|
|
+ } | null> {
|
|
|
const targetDir = path.join(uploadsBase, category)
|
|
|
await mkdir(targetDir, { recursive: true })
|
|
|
- const targetPath = path.join(targetDir, filename)
|
|
|
|
|
|
// Normalize source paths: strip leading /
|
|
|
const normalizedSource = sourcePath.replace(/^\//, '')
|
|
|
@@ -330,113 +326,125 @@ export async function POST(request: NextRequest) {
|
|
|
return null
|
|
|
}
|
|
|
|
|
|
- await writeFile(targetPath, fileData)
|
|
|
- const fileUrl = `/api/protected/files/${organizationId}/${category}/${filename}`
|
|
|
- return { fileUrl, fileSize: fileData.length }
|
|
|
+ const prepared = await prepareImportedFile(filename, fileData)
|
|
|
+ const storedName = await writeImportedFile(targetDir, prepared.name, prepared.data)
|
|
|
+ const fileUrl = `/api/protected/files/${organizationId}/${category}/${storedName}`
|
|
|
+ importedFileUrls.push(fileUrl)
|
|
|
+ return {
|
|
|
+ fileUrl,
|
|
|
+ fileSize: prepared.data.length,
|
|
|
+ fileType: prepared.type,
|
|
|
+ compressed: prepared.compressed,
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// ── Database transaction ─────────────────────────────────────────────
|
|
|
- await db.$transaction(async (tx) => {
|
|
|
- // Create vehicles
|
|
|
- for (const v of vehicles) {
|
|
|
- let imageUrl: string | null = null
|
|
|
-
|
|
|
- if (v.ImageLocation) {
|
|
|
- const ext = v.ImageLocation.split('.').pop() || 'jpg'
|
|
|
- const filename = `lubelog-vehicle-${v._id}.${ext}`
|
|
|
- const result = await copyFile(v.ImageLocation, 'vehicles', filename)
|
|
|
- if (result) imageUrl = result.fileUrl
|
|
|
- }
|
|
|
+ // As long as the route may run: the photos are re-encoded inside.
|
|
|
+ await db.$transaction(
|
|
|
+ async (tx) => {
|
|
|
+ // Create vehicles
|
|
|
+ for (const v of vehicles) {
|
|
|
+ let imageUrl: string | null = null
|
|
|
+
|
|
|
+ if (v.ImageLocation) {
|
|
|
+ const ext = v.ImageLocation.split('.').pop() || 'jpg'
|
|
|
+ const filename = `lubelog-vehicle-${v._id}.${ext}`
|
|
|
+ const result = await copyFile(v.ImageLocation, 'vehicles', filename)
|
|
|
+ if (result) imageUrl = result.fileUrl
|
|
|
+ }
|
|
|
|
|
|
- const created = await tx.vehicle.create({
|
|
|
- data: {
|
|
|
- make: v.Make,
|
|
|
- model: v.Model,
|
|
|
- year: v.Year,
|
|
|
- licensePlate: v.LicensePlate || null,
|
|
|
- fuelType: determineFuelType(v),
|
|
|
- mileage: vehicleMileage.get(v._id) || 0,
|
|
|
- purchasePrice: parseDecimal(v.PurchasePrice) || null,
|
|
|
- imageUrl,
|
|
|
- userId,
|
|
|
- organizationId,
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- vehicleIdMap.set(v._id, created.id)
|
|
|
- }
|
|
|
+ const created = await tx.vehicle.create({
|
|
|
+ data: {
|
|
|
+ make: v.Make,
|
|
|
+ model: v.Model,
|
|
|
+ year: v.Year,
|
|
|
+ licensePlate: v.LicensePlate || null,
|
|
|
+ fuelType: determineFuelType(v),
|
|
|
+ mileage: vehicleMileage.get(v._id) || 0,
|
|
|
+ purchasePrice: parseDecimal(v.PurchasePrice) || null,
|
|
|
+ imageUrl,
|
|
|
+ userId,
|
|
|
+ organizationId,
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ vehicleIdMap.set(v._id, created.id)
|
|
|
+ }
|
|
|
|
|
|
- // Create notes (grouped by vehicle)
|
|
|
- for (const n of notes) {
|
|
|
- const vehicleId = vehicleIdMap.get(n.VehicleId)
|
|
|
- if (!vehicleId) continue
|
|
|
-
|
|
|
- await tx.note.create({
|
|
|
- data: {
|
|
|
- title: n.Description,
|
|
|
- content: n.NoteText,
|
|
|
- isPinned: n.Pinned || false,
|
|
|
- vehicleId,
|
|
|
- },
|
|
|
- })
|
|
|
- }
|
|
|
+ // Create notes (grouped by vehicle)
|
|
|
+ for (const n of notes) {
|
|
|
+ const vehicleId = vehicleIdMap.get(n.VehicleId)
|
|
|
+ if (!vehicleId) continue
|
|
|
+
|
|
|
+ await tx.note.create({
|
|
|
+ data: {
|
|
|
+ title: n.Description,
|
|
|
+ content: n.NoteText,
|
|
|
+ isPinned: n.Pinned || false,
|
|
|
+ vehicleId,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
- // Create service records
|
|
|
- for (const sr of serviceRecords) {
|
|
|
- const vehicleId = vehicleIdMap.get(sr.VehicleId)
|
|
|
- if (!vehicleId) continue
|
|
|
-
|
|
|
- const invoiceNumber = `${invoicePrefix}${nextInvoiceNum}`
|
|
|
- nextInvoiceNum++
|
|
|
-
|
|
|
- const cost = parseDecimal(sr.Cost)
|
|
|
- const serviceDate = toSafeWorkshopDate(sr.Date, timeZone) ?? new Date()
|
|
|
-
|
|
|
- const created = await tx.serviceRecord.create({
|
|
|
- data: {
|
|
|
- organizationId,
|
|
|
- title: sr.Description,
|
|
|
- description: sr.Notes || null,
|
|
|
- type: 'repair',
|
|
|
- status: 'completed',
|
|
|
- cost,
|
|
|
- mileage: sr.Mileage || null,
|
|
|
- serviceDate,
|
|
|
- // Reports and the calendar find a job by when it started; a job
|
|
|
- // without a start is in none of them.
|
|
|
- startDateTime: serviceDate,
|
|
|
- invoiceNumber,
|
|
|
- subtotal: cost,
|
|
|
- totalAmount: cost,
|
|
|
- vehicleId,
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- // Copy attached files
|
|
|
- if (sr.Files && sr.Files.length > 0) {
|
|
|
- for (const file of sr.Files) {
|
|
|
- if (!file.Location) continue
|
|
|
-
|
|
|
- const safeName = file.Name.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
|
- const filename = `lubelog-${sr._id}-${safeName}`
|
|
|
-
|
|
|
- const result = await copyFile(file.Location, 'services', filename)
|
|
|
- if (result) {
|
|
|
- await tx.serviceAttachment.create({
|
|
|
- data: {
|
|
|
- fileName: file.Name,
|
|
|
- fileUrl: result.fileUrl,
|
|
|
- fileType: getMimeType(file.Name),
|
|
|
- fileSize: result.fileSize,
|
|
|
- category: file.Name.match(/\.(jpg|jpeg|png|webp)$/i) ? 'image' : 'diagnostic',
|
|
|
- serviceRecordId: created.id,
|
|
|
- },
|
|
|
- })
|
|
|
+ // Create service records
|
|
|
+ for (const sr of serviceRecords) {
|
|
|
+ const vehicleId = vehicleIdMap.get(sr.VehicleId)
|
|
|
+ if (!vehicleId) continue
|
|
|
+
|
|
|
+ const invoiceNumber = `${invoicePrefix}${nextInvoiceNum}`
|
|
|
+ nextInvoiceNum++
|
|
|
+
|
|
|
+ const cost = parseDecimal(sr.Cost)
|
|
|
+ const serviceDate = toSafeWorkshopDate(sr.Date, timeZone) ?? new Date()
|
|
|
+
|
|
|
+ const created = await tx.serviceRecord.create({
|
|
|
+ data: {
|
|
|
+ organizationId,
|
|
|
+ title: sr.Description,
|
|
|
+ description: sr.Notes || null,
|
|
|
+ type: 'repair',
|
|
|
+ status: 'completed',
|
|
|
+ cost,
|
|
|
+ mileage: sr.Mileage || null,
|
|
|
+ serviceDate,
|
|
|
+ // Reports and the calendar find a job by when it started; a job
|
|
|
+ // without a start is in none of them.
|
|
|
+ startDateTime: serviceDate,
|
|
|
+ invoiceNumber,
|
|
|
+ subtotal: cost,
|
|
|
+ totalAmount: cost,
|
|
|
+ vehicleId,
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ // Copy attached files
|
|
|
+ if (sr.Files && sr.Files.length > 0) {
|
|
|
+ for (const file of sr.Files) {
|
|
|
+ if (!file.Location) continue
|
|
|
+
|
|
|
+ const safeName = file.Name.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
|
+ const filename = `lubelog-${sr._id}-${safeName}`
|
|
|
+
|
|
|
+ const result = await copyFile(file.Location, 'services', filename)
|
|
|
+ if (result) {
|
|
|
+ await tx.serviceAttachment.create({
|
|
|
+ data: {
|
|
|
+ // The user's own name, with the extension the stored bytes now have.
|
|
|
+ fileName: result.compressed ? file.Name.replace(/\.[^.]+$/, '.jpg') : file.Name,
|
|
|
+ fileUrl: result.fileUrl,
|
|
|
+ fileType: result.fileType,
|
|
|
+ fileSize: result.fileSize,
|
|
|
+ category: result.fileType.startsWith('image/') ? 'image' : 'diagnostic',
|
|
|
+ serviceRecordId: created.id,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
- }
|
|
|
- })
|
|
|
+ },
|
|
|
+ { timeout: maxDuration * 1000 }
|
|
|
+ )
|
|
|
|
|
|
return NextResponse.json({
|
|
|
success: true,
|
|
|
@@ -449,6 +457,10 @@ export async function POST(request: NextRequest) {
|
|
|
} catch (error) {
|
|
|
console.error('[import-lubelog] Error:', error)
|
|
|
console.error('[backup import]', error)
|
|
|
+ // The rows were rolled back, so nothing uses the files this run wrote.
|
|
|
+ await releaseFiles(importedFileUrls, { organizationId, reason: 'import failed' }).catch((err) =>
|
|
|
+ console.error('[backup import] could not release imported files:', err)
|
|
|
+ )
|
|
|
const message = 'Import failed'
|
|
|
return NextResponse.json({ error: message }, { status: 500 })
|
|
|
} finally {
|