Преглед изворни кода

Merge branch 'desktop-app' of github.com:Torqvoice/torqvoice into desktop-app

Bernt Christian Egeland пре 7 месеци
родитељ
комит
f7d4c78d46

+ 142 - 0
src/app/api/desktop/v1/work-orders/[id]/email/route.ts

@@ -0,0 +1,142 @@
+import { NextResponse } from "next/server";
+import { renderToBuffer } from "@react-pdf/renderer";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { InvoicePDF } from "@/features/vehicles/Components/InvoicePDF";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+import { requireFeature } from "@/lib/features";
+import React from "react";
+import { readFile } from "fs/promises";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+
+async function getWorkshopSettings(organizationId: string) {
+  const settings = await db.appSetting.findMany({ where: { organizationId } });
+  const map: Record<string, string> = {};
+  for (const s of settings) map[s.key] = s.value;
+  return map;
+}
+
+async function loadLogoDataUri(logoPath: string | undefined) {
+  if (!logoPath) return undefined;
+  try {
+    const fullPath = resolveUploadPath(logoPath);
+    const logoBuffer = await readFile(fullPath);
+    const ext = logoPath.split(".").pop()?.toLowerCase() || "png";
+    const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", svg: "image/svg+xml" };
+    return `data:${mimeMap[ext] || "image/png"};base64,${logoBuffer.toString("base64")}`;
+  } catch {
+    return undefined;
+  }
+}
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      await requireFeature(organizationId, "smtp");
+
+      const body = await request.json();
+      const { recipientEmail, message } = body as { recipientEmail: string; message?: string };
+
+      if (!recipientEmail) {
+        return NextResponse.json({ error: "recipientEmail is required" }, { status: 400 });
+      }
+
+      const record = await db.serviceRecord.findFirst({
+        where: { id, vehicle: { customer: { organizationId } } },
+        include: {
+          partItems: true,
+          laborItems: true,
+          vehicle: {
+            include: {
+              customer: { select: { name: true, email: true, phone: true, address: true, company: true } },
+            },
+          },
+        },
+      });
+
+      if (!record) {
+        return NextResponse.json({ error: "Work order not found" }, { status: 404 });
+      }
+
+      const settings = await getWorkshopSettings(organizationId);
+      if (settings["workshop.emailEnabled"] === "false") {
+        return NextResponse.json({ error: "Email sending is disabled. Enable it in Settings." }, { status: 400 });
+      }
+
+      const logoDataUri = await loadLogoDataUri(settings["workshop.logo"]);
+      const fromName = settings["workshop.emailFromName"] || settings["workshop.name"] || "Workshop";
+
+      const invoiceTemplate = {
+        primaryColor: settings["invoice.primaryColor"] || "#d97706",
+        fontFamily: settings["invoice.fontFamily"] || "Helvetica",
+        showLogo: settings["invoice.showLogo"] !== "false",
+        showCompanyName: settings["invoice.showCompanyName"] !== "false",
+        headerStyle: settings["invoice.headerStyle"] || "standard",
+      };
+
+      // Generate PDF
+      const element = React.createElement(InvoicePDF, {
+        data: record,
+        workshop: {
+          name: settings["workshop.name"] || "",
+          address: settings["workshop.address"] || "",
+          phone: settings["workshop.phone"] || "",
+          email: settings["workshop.email"] || "",
+        },
+        invoiceSettings: { currencyCode: settings["workshop.currencyCode"] || "USD" },
+        logoDataUri,
+        template: invoiceTemplate,
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      }) as any;
+      const pdfBuffer = await renderToBuffer(element);
+      const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`;
+
+      // Build public invoice link if token exists
+      const appUrl = process.env.NEXT_PUBLIC_APP_URL
+        || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
+      const publicLink = record.publicToken
+        ? `${appUrl}/share/invoice/${organizationId}/${record.publicToken}`
+        : null;
+
+      const from = await getOrgFromAddress(organizationId);
+
+      await sendOrgMail(organizationId, {
+        from,
+        to: recipientEmail,
+        subject: `Invoice ${invoiceNum} - ${record.title}`,
+        html: `
+          <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
+            <h2>Invoice ${invoiceNum}</h2>
+            <p>Please find your invoice attached.</p>
+            ${message ? `<p>${message}</p>` : ""}
+            ${publicLink ? `<p><a href="${publicLink}" style="color: #2563eb;">View Invoice Online</a></p>` : ""}
+            <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
+            <p style="color: #666; font-size: 14px;">
+              ${fromName}${settings["workshop.phone"] ? ` · ${settings["workshop.phone"]}` : ""}
+            </p>
+          </div>
+        `,
+        attachments: [
+          {
+            filename: `${invoiceNum}.pdf`,
+            content: Buffer.from(pdfBuffer),
+          },
+        ],
+      });
+
+      return NextResponse.json({ sent: true });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    },
+  );
+}

+ 247 - 0
src/app/api/desktop/v1/work-orders/[id]/pdf/route.ts

@@ -0,0 +1,247 @@
+import { NextResponse } from "next/server";
+import { renderToBuffer } from "@react-pdf/renderer";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { InvoicePDF } from "@/features/vehicles/Components/InvoicePDF";
+import React from "react";
+import { readFile } from "fs/promises";
+import { PDFDocument } from "pdf-lib";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { getFeatures } from "@/lib/features";
+import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
+import { formatDateForPdf } from "@/lib/format";
+
+export async function GET(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const [record, settings, org] = await Promise.all([
+        db.serviceRecord.findFirst({
+          where: { id, vehicle: { customer: { organizationId } } },
+          include: {
+            partItems: true,
+            laborItems: true,
+            attachments: true,
+            payments: { orderBy: { date: "desc" } },
+            vehicle: {
+              select: {
+                make: true,
+                model: true,
+                year: true,
+                vin: true,
+                licensePlate: true,
+                mileage: true,
+                customer: {
+                  select: {
+                    name: true,
+                    email: true,
+                    phone: true,
+                    address: true,
+                    company: true,
+                  },
+                },
+              },
+            },
+          },
+        }),
+        db.appSetting.findMany({
+          where: { organizationId },
+        }),
+        db.organization.findUnique({
+          where: { id: organizationId },
+          select: { name: true, portalSlug: true },
+        }),
+      ]);
+
+      if (!record) {
+        return NextResponse.json({ error: "Work order not found" }, { status: 404 });
+      }
+
+      // Build settings map
+      const settingsMap: Record<string, string> = {};
+      for (const s of settings) settingsMap[s.key] = s.value;
+
+      // Load PDF translations (default to English)
+      let pdfMessages: Record<string, Record<string, string>>;
+      try {
+        pdfMessages = (await import("../../../../../../../../messages/en/pdf.json")).default;
+      } catch {
+        pdfMessages = { invoice: {}, common: {} };
+      }
+      const labels = { ...pdfMessages.invoice, ...pdfMessages.common };
+
+      // Process attachments
+      const imageAttachments: { fileName: string; dataUri: string; description?: string }[] = [];
+      const otherAttachments: { fileName: string; fileType: string }[] = [];
+      const pdfAttachments: { fileName: string; buffer: Buffer }[] = [];
+
+      const seenNames = new Set<string>();
+      const uniqueAttachments = record.attachments
+        .filter((att) => att.includeInInvoice !== false)
+        .filter((att) => {
+          if (seenNames.has(att.fileName)) return false;
+          seenNames.add(att.fileName);
+          return true;
+        });
+
+      for (const att of uniqueAttachments) {
+        if (att.fileType.startsWith("image/")) {
+          try {
+            const filePath = resolveUploadPath(att.fileUrl);
+            const buffer = await readFile(filePath);
+            const base64 = buffer.toString("base64");
+            imageAttachments.push({
+              fileName: att.fileName,
+              dataUri: `data:${att.fileType};base64,${base64}`,
+              description: att.description || undefined,
+            });
+          } catch {
+            otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+          }
+        } else if (att.fileType === "application/pdf") {
+          try {
+            const filePath = resolveUploadPath(att.fileUrl);
+            const buffer = await readFile(filePath);
+            pdfAttachments.push({ fileName: att.fileName, buffer });
+          } catch {
+            otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+          }
+        } else {
+          otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+        }
+      }
+
+      // Load company logo
+      let logoDataUri: string | undefined;
+      const logoPath = settingsMap["workshop.logo"];
+      if (logoPath) {
+        try {
+          const fullPath = resolveUploadPath(logoPath);
+          const logoBuffer = await readFile(fullPath);
+          const ext = logoPath.split(".").pop()?.toLowerCase() || "png";
+          const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", svg: "image/svg+xml" };
+          logoDataUri = `data:${mimeMap[ext] || "image/png"};base64,${logoBuffer.toString("base64")}`;
+        } catch {
+          // skip
+        }
+      }
+
+      const invoiceSettings = {
+        bankAccount: settingsMap["invoice.bankAccount"] || "",
+        orgNumber: settingsMap["invoice.orgNumber"] || "",
+        paymentTerms: settingsMap["invoice.paymentTerms"] || "",
+        footerNote: settingsMap["invoice.footerNote"] || "",
+        showBankAccount: settingsMap["invoice.showBankAccount"] === "true",
+        showOrgNumber: settingsMap["invoice.showOrgNumber"] === "true",
+        dueDays: Number(settingsMap["invoice.dueDays"]) || 0,
+        currencyCode: settingsMap["workshop.currencyCode"] || "USD",
+        unitSystem: settingsMap["workshop.unitSystem"] || "imperial",
+        dateFormat: settingsMap["workshop.dateFormat"] || undefined,
+        timezone: settingsMap["workshop.timezone"] || undefined,
+      };
+
+      // Payment summary
+      const pdfDateFormat = settingsMap["workshop.dateFormat"] || undefined;
+      const pdfTimezone = settingsMap["workshop.timezone"] || undefined;
+      const paidFromPayments = record.payments?.reduce((sum: number, p: { amount: number }) => sum + p.amount, 0) ?? 0;
+      const effectiveTotal = record.totalAmount > 0 ? record.totalAmount : record.cost;
+      const totalPaidForPdf = record.manuallyPaid ? effectiveTotal : paidFromPayments;
+
+      const paymentSummary = (record.payments && record.payments.length > 0) || record.manuallyPaid
+        ? {
+            totalPaid: totalPaidForPdf,
+            payments: (record.payments || []).map((p: { amount: number; date: Date; method: string }) => ({
+              amount: p.amount,
+              date: formatDateForPdf(p.date, pdfDateFormat, pdfTimezone),
+              method: p.method,
+            })),
+          }
+        : undefined;
+
+      const template = {
+        primaryColor: settingsMap["invoice.primaryColor"] || "#d97706",
+        fontFamily: settingsMap["invoice.fontFamily"] || "Helvetica",
+        showLogo: (settingsMap["invoice.showLogo"] ?? settingsMap["invoice.template.showLogo"]) !== "false",
+        showCompanyName: settingsMap["invoice.showCompanyName"] !== "false",
+        headerStyle: settingsMap["invoice.headerStyle"] || "standard",
+      };
+
+      const features = await getFeatures(organizationId);
+      let torqvoiceLogoDataUri: string | undefined;
+      if (!features.brandingRemoved) {
+        torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri();
+      }
+
+      const appUrl = process.env.NEXT_PUBLIC_APP_URL
+        || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
+      const portalSlug = org?.portalSlug;
+      const portalEnabled = settingsMap["portal.enabled"] === "true";
+      const portalUrl = portalEnabled
+        ? `${appUrl}/portal/${portalSlug || organizationId}`
+        : undefined;
+
+      const element = React.createElement(InvoicePDF, {
+        data: record,
+        workshop: {
+          name: org?.name || "",
+          address: settingsMap["workshop.address"] || "",
+          phone: settingsMap["workshop.phone"] || "",
+          email: settingsMap["workshop.email"] || "",
+        },
+        invoiceSettings,
+        paymentSummary,
+        imageAttachments,
+        otherAttachments,
+        pdfAttachmentNames: pdfAttachments.map((a) => a.fileName),
+        logoDataUri,
+        template,
+        torqvoiceLogoDataUri,
+        portalUrl,
+        labels,
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      }) as any;
+      const invoiceBuffer = await renderToBuffer(element);
+
+      const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`;
+
+      // Merge attached PDFs
+      let finalBuffer: ArrayBuffer;
+      if (pdfAttachments.length > 0) {
+        const mergedPdf = await PDFDocument.load(invoiceBuffer);
+        for (const att of pdfAttachments) {
+          try {
+            const attachedPdf = await PDFDocument.load(att.buffer);
+            const pages = await mergedPdf.copyPages(attachedPdf, attachedPdf.getPageIndices());
+            for (const page of pages) {
+              mergedPdf.addPage(page);
+            }
+          } catch {
+            // Skip corrupted PDFs
+          }
+        }
+        const saved = await mergedPdf.save();
+        finalBuffer = saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength) as ArrayBuffer;
+      } else {
+        finalBuffer = invoiceBuffer.buffer.slice(invoiceBuffer.byteOffset, invoiceBuffer.byteOffset + invoiceBuffer.byteLength) as ArrayBuffer;
+      }
+
+      return new NextResponse(finalBuffer, {
+        headers: {
+          "Content-Type": "application/pdf",
+          "Content-Disposition": `attachment; filename="${invoiceNum}.pdf"`,
+        },
+      });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SERVICES },
+      ],
+    },
+  );
+}

+ 75 - 0
src/app/api/desktop/v1/work-orders/[id]/share/route.ts

@@ -0,0 +1,75 @@
+import { NextResponse } from "next/server";
+import { randomUUID } from "crypto";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const record = await db.serviceRecord.findFirst({
+        where: { id, vehicle: { customer: { organizationId } } },
+      });
+
+      if (!record) {
+        return NextResponse.json({ error: "Work order not found" }, { status: 404 });
+      }
+
+      // If already has a public token, return it
+      if (record.publicToken) {
+        return NextResponse.json({ token: record.publicToken, organizationId });
+      }
+
+      const token = randomUUID();
+      await db.serviceRecord.update({
+        where: { id },
+        data: { publicToken: token, sharedAt: new Date() },
+      });
+
+      return NextResponse.json({ token, organizationId });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    },
+  );
+}
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const record = await db.serviceRecord.findFirst({
+        where: { id, vehicle: { customer: { organizationId } } },
+      });
+
+      if (!record) {
+        return NextResponse.json({ error: "Work order not found" }, { status: 404 });
+      }
+
+      await db.serviceRecord.update({
+        where: { id },
+        data: { publicToken: null, sharedAt: null, viewCount: 0, lastViewedAt: null },
+      });
+
+      return NextResponse.json({ revoked: true });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    },
+  );
+}