Przeglądaj źródła

Add SMS messaging with Twilio, Vonage, and Telnyx support (#7)

* add sms integration for the most common vendors

* new message page

* add pagination to message loading

* sms templates
Bernt Christian Egeland 7 miesięcy temu
rodzic
commit
bcb60e7ee9
40 zmienionych plików z 3475 dodań i 96 usunięć
  1. 34 0
      prisma/migrations/20260221061849_sms_gateway/migration.sql
  2. 48 20
      prisma/schema.prisma
  3. 131 46
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  4. 30 2
      src/app/(authenticated)/customers/[id]/page.tsx
  5. 15 2
      src/app/(authenticated)/inspections/[id]/page.tsx
  6. 31 0
      src/app/(authenticated)/messages/page.tsx
  7. 7 0
      src/app/(authenticated)/quotes/[id]/page.tsx
  8. 8 0
      src/app/(authenticated)/settings/settings-nav.tsx
  9. 34 0
      src/app/(authenticated)/settings/sms/page.tsx
  10. 24 0
      src/app/(authenticated)/settings/templates/page.tsx
  11. 169 5
      src/app/(authenticated)/settings/templates/template-settings.tsx
  12. 2 0
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx
  13. 8 1
      src/app/(authenticated)/work-orders/page.tsx
  14. 54 4
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  15. 101 0
      src/app/api/webhooks/sms/telnyx/route.ts
  16. 91 0
      src/app/api/webhooks/sms/twilio/route.ts
  17. 83 0
      src/app/api/webhooks/sms/vonage/route.ts
  18. 3 0
      src/components/app-sidebar.tsx
  19. 175 0
      src/components/notify-customer-dialog.tsx
  20. 146 0
      src/features/email/Actions/emailActions.ts
  21. 8 0
      src/features/inspections/Components/InspectionPageClient.tsx
  22. 132 8
      src/features/inspections/Components/InspectionShareDialog.tsx
  23. 26 5
      src/features/notifications/hooks/useNotificationWebSocket.ts
  24. 7 0
      src/features/quotes/Components/QuotePageClient.tsx
  25. 111 1
      src/features/quotes/Components/QuoteShareDialog.tsx
  26. 7 0
      src/features/settings/Schema/settingsSchema.ts
  27. 268 0
      src/features/sms/Actions/smsActions.ts
  28. 94 0
      src/features/sms/Actions/smsSettingsActions.ts
  29. 333 0
      src/features/sms/Components/MessagesPageClient.tsx
  30. 119 0
      src/features/sms/Components/SendSmsDialog.tsx
  31. 327 0
      src/features/sms/Components/SmsConversation.tsx
  32. 381 0
      src/features/sms/Components/SmsSettingsForm.tsx
  33. 20 0
      src/features/sms/Schema/smsSettingsSchema.ts
  34. 9 0
      src/features/sms/activeSmsView.ts
  35. 1 1
      src/features/vehicles/Actions/serviceActions.ts
  36. 123 1
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  37. 60 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  38. 5 0
      src/lib/features.ts
  39. 30 0
      src/lib/sms-templates.ts
  40. 220 0
      src/lib/sms.ts

+ 34 - 0
prisma/migrations/20260221061849_sms_gateway/migration.sql

@@ -0,0 +1,34 @@
+-- CreateTable
+CREATE TABLE "sms_messages" (
+    "id" TEXT NOT NULL,
+    "direction" TEXT NOT NULL,
+    "fromNumber" TEXT NOT NULL,
+    "toNumber" TEXT NOT NULL,
+    "body" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'queued',
+    "providerMsgId" TEXT,
+    "errorMessage" TEXT,
+    "relatedEntityType" TEXT,
+    "relatedEntityId" TEXT,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "customerId" TEXT,
+
+    CONSTRAINT "sms_messages_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "sms_messages_organizationId_customerId_createdAt_idx" ON "sms_messages"("organizationId", "customerId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "sms_messages_organizationId_createdAt_idx" ON "sms_messages"("organizationId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "sms_messages_toNumber_organizationId_idx" ON "sms_messages"("toNumber", "organizationId");
+
+-- AddForeignKey
+ALTER TABLE "sms_messages" ADD CONSTRAINT "sms_messages_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "sms_messages" ADD CONSTRAINT "sms_messages_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 48 - 20
prisma/schema.prisma

@@ -261,8 +261,9 @@ model Customer {
   organizationId String?
   organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
-  vehicles Vehicle[]
-  quotes   Quote[]
+  vehicles    Vehicle[]
+  quotes      Quote[]
+  smsMessages SmsMessage[]
 
   @@index([organizationId])
   @@map("customers")
@@ -364,25 +365,25 @@ model InventoryPart {
 }
 
 model Quote {
-  id             String    @id @default(cuid())
-  quoteNumber    String?
-  title          String
-  description    String?
-  status         String    @default("draft")
-  validUntil     DateTime?
-  subtotal       Float     @default(0)
-  taxRate        Float     @default(0)
-  taxAmount      Float     @default(0)
-  discountType   String?
-  discountValue  Float     @default(0)
-  discountAmount Float     @default(0)
-  totalAmount    Float     @default(0)
-  notes          String?
-  publicToken    String?
+  id              String    @id @default(cuid())
+  quoteNumber     String?
+  title           String
+  description     String?
+  status          String    @default("draft")
+  validUntil      DateTime?
+  subtotal        Float     @default(0)
+  taxRate         Float     @default(0)
+  taxAmount       Float     @default(0)
+  discountType    String?
+  discountValue   Float     @default(0)
+  discountAmount  Float     @default(0)
+  totalAmount     Float     @default(0)
+  notes           String?
+  publicToken     String?
   customerMessage String?
-  convertedToId  String?
-  createdAt      DateTime  @default(now())
-  updatedAt      DateTime  @updatedAt
+  convertedToId   String?
+  createdAt       DateTime  @default(now())
+  updatedAt       DateTime  @updatedAt
 
   userId String
   user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -492,6 +493,7 @@ model Organization {
   subscription           Subscription?
   invitations            TeamInvitation[]
   inspectionTemplates    InspectionTemplate[]
+  smsMessages            SmsMessage[]
 
   @@map("organizations")
 }
@@ -788,3 +790,29 @@ model Notification {
   @@index([organizationId, read])
   @@map("notifications")
 }
+
+model SmsMessage {
+  id                String   @id @default(cuid())
+  direction         String // "inbound" | "outbound"
+  fromNumber        String
+  toNumber          String
+  body              String
+  status            String   @default("queued") // queued | sent | delivered | failed | received
+  providerMsgId     String?
+  errorMessage      String?
+  relatedEntityType String?
+  relatedEntityId   String?
+  createdAt         DateTime @default(now())
+  updatedAt         DateTime @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+
+  @@index([organizationId, customerId, createdAt(sort: Desc)])
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([toNumber, organizationId])
+  @@map("sms_messages")
+}

+ 131 - 46
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -2,9 +2,10 @@
 
 import { useState } from "react";
 import Link from "next/link";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
 import { Card, CardContent } from "@/components/ui/card";
 import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
 import {
   Table,
   TableBody,
@@ -19,10 +20,12 @@ import {
   Car,
   Mail,
   MapPin,
+  MessageSquare,
   Pencil,
   Phone,
   Users,
 } from "lucide-react";
+import { SmsConversation } from "@/features/sms/Components/SmsConversation";
 import { CustomerForm } from "@/features/customers/Components/CustomerForm";
 import { toast } from "sonner";
 
@@ -45,10 +48,47 @@ interface CustomerDetail {
   }[];
 }
 
-export function CustomerDetailClient({ customer, unitSystem = "imperial" }: { customer: CustomerDetail; unitSystem?: "metric" | "imperial" }) {
+interface SmsMessage {
+  id: string;
+  direction: string;
+  body: string;
+  status: string;
+  createdAt: string | Date;
+  fromNumber: string;
+  toNumber: string;
+}
+
+export function CustomerDetailClient({
+  customer,
+  unitSystem = "imperial",
+  smsEnabled = false,
+  smsMessages = [],
+  smsNextCursor = null,
+}: {
+  customer: CustomerDetail;
+  unitSystem?: "metric" | "imperial";
+  smsEnabled?: boolean;
+  smsMessages?: SmsMessage[];
+  smsNextCursor?: string | null;
+}) {
   const router = useRouter();
+  const searchParams = useSearchParams();
   const [showEditForm, setShowEditForm] = useState(false);
 
+  const tabParam = searchParams.get("tab");
+  const activeTab = tabParam === "messages" && smsEnabled ? "messages" : "vehicles";
+
+  const setActiveTab = (tab: "vehicles" | "messages") => {
+    const params = new URLSearchParams(searchParams.toString());
+    if (tab === "vehicles") {
+      params.delete("tab");
+    } else {
+      params.set("tab", tab);
+    }
+    const qs = params.toString();
+    router.replace(`/customers/${customer.id}${qs ? `?${qs}` : ""}`, { scroll: false });
+  };
+
   const hasContactInfo = customer.email || customer.phone || customer.address || customer.company || customer.notes;
 
   return (
@@ -124,54 +164,99 @@ export function CustomerDetailClient({ customer, unitSystem = "imperial" }: { cu
         </div>
       </div>
 
-      {/* Vehicles */}
+      {/* Tabs */}
       <div>
-        <h2 className="mb-4 text-lg font-semibold">
-          Vehicles ({customer.vehicles.length})
-        </h2>
+        <div className="flex gap-1 border-b mb-4">
+          <button
+            type="button"
+            onClick={() => setActiveTab("vehicles")}
+            className={cn(
+              "flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors",
+              activeTab === "vehicles"
+                ? "border-primary text-primary"
+                : "border-transparent text-muted-foreground hover:text-foreground",
+            )}
+          >
+            <Car className="h-4 w-4" />
+            Vehicles ({customer.vehicles.length})
+          </button>
+          {smsEnabled && (
+            <button
+              type="button"
+              onClick={() => setActiveTab("messages")}
+              className={cn(
+                "flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors",
+                activeTab === "messages"
+                  ? "border-primary text-primary"
+                  : "border-transparent text-muted-foreground hover:text-foreground",
+              )}
+            >
+              <MessageSquare className="h-4 w-4" />
+              Messages
+            </button>
+          )}
+        </div>
 
-        {customer.vehicles.length === 0 ? (
-          <Card className="border-dashed">
-            <CardContent className="flex flex-col items-center py-12">
-              <Car className="mb-3 h-10 w-10 text-muted-foreground/40" />
-              <p className="text-sm text-muted-foreground">
-                No vehicles assigned to this customer
-              </p>
+        {activeTab === "vehicles" && (
+          <>
+            {customer.vehicles.length === 0 ? (
+              <Card className="border-dashed">
+                <CardContent className="flex flex-col items-center py-12">
+                  <Car className="mb-3 h-10 w-10 text-muted-foreground/40" />
+                  <p className="text-sm text-muted-foreground">
+                    No vehicles assigned to this customer
+                  </p>
+                </CardContent>
+              </Card>
+            ) : (
+              <div className="rounded-lg border">
+                <Table>
+                  <TableHeader>
+                    <TableRow>
+                      <TableHead className="w-[120px]">Plate</TableHead>
+                      <TableHead>Vehicle</TableHead>
+                      <TableHead className="hidden sm:table-cell w-[100px] text-right">Mileage</TableHead>
+                      <TableHead className="w-[80px] text-center">Services</TableHead>
+                    </TableRow>
+                  </TableHeader>
+                  <TableBody>
+                    {customer.vehicles.map((v) => (
+                      <TableRow
+                        key={v.id}
+                        className="cursor-pointer"
+                        onClick={() => router.push(`/vehicles/${v.id}`)}
+                      >
+                        <TableCell className="font-mono text-sm">
+                          {v.licensePlate || "-"}
+                        </TableCell>
+                        <TableCell className="font-medium">
+                          {v.year} {v.make} {v.model}
+                        </TableCell>
+                        <TableCell className="hidden sm:table-cell text-right font-mono text-sm">
+                          {v.mileage.toLocaleString()} {unitSystem === "metric" ? "km" : "mi"}
+                        </TableCell>
+                        <TableCell className="text-center">{v._count.serviceRecords}</TableCell>
+                      </TableRow>
+                    ))}
+                  </TableBody>
+                </Table>
+              </div>
+            )}
+          </>
+        )}
+
+        {activeTab === "messages" && smsEnabled && (
+          <Card>
+            <CardContent className="p-0">
+              <SmsConversation
+                customerId={customer.id}
+                customerName={customer.name}
+                customerPhone={customer.phone}
+                initialMessages={smsMessages}
+                initialNextCursor={smsNextCursor}
+              />
             </CardContent>
           </Card>
-        ) : (
-          <div className="rounded-lg border">
-            <Table>
-              <TableHeader>
-                <TableRow>
-                  <TableHead className="w-[120px]">Plate</TableHead>
-                  <TableHead>Vehicle</TableHead>
-                  <TableHead className="hidden sm:table-cell w-[100px] text-right">Mileage</TableHead>
-                  <TableHead className="w-[80px] text-center">Services</TableHead>
-                </TableRow>
-              </TableHeader>
-              <TableBody>
-                {customer.vehicles.map((v) => (
-                  <TableRow
-                    key={v.id}
-                    className="cursor-pointer"
-                    onClick={() => router.push(`/vehicles/${v.id}`)}
-                  >
-                    <TableCell className="font-mono text-sm">
-                      {v.licensePlate || "-"}
-                    </TableCell>
-                    <TableCell className="font-medium">
-                      {v.year} {v.make} {v.model}
-                    </TableCell>
-                    <TableCell className="hidden sm:table-cell text-right font-mono text-sm">
-                      {v.mileage.toLocaleString()} {unitSystem === "metric" ? "km" : "mi"}
-                    </TableCell>
-                    <TableCell className="text-center">{v._count.serviceRecords}</TableCell>
-                  </TableRow>
-                ))}
-              </TableBody>
-            </Table>
-          </div>
         )}
       </div>
       <CustomerForm

+ 30 - 2
src/app/(authenticated)/customers/[id]/page.tsx

@@ -1,6 +1,9 @@
 import { getCustomer } from "@/features/customers/Actions/customerActions";
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { getConversation } from "@/features/sms/Actions/smsActions";
+import { getLayoutData } from "@/lib/get-layout-data";
+import { getFeatures } from "@/lib/features";
 import { CustomerDetailClient } from "./customer-detail-client";
 import { PageHeader } from "@/components/page-header";
 
@@ -10,9 +13,10 @@ export default async function CustomerDetailPage({
   params: Promise<{ id: string }>;
 }) {
   const { id } = await params;
-  const [result, settingsResult] = await Promise.all([
+  const [result, settingsResult, layoutData] = await Promise.all([
     getCustomer(id),
     getSettings([SETTING_KEYS.UNIT_SYSTEM]),
+    getLayoutData(),
   ]);
 
   if (!result.success || !result.data) {
@@ -28,11 +32,35 @@ export default async function CustomerDetailPage({
     );
   }
 
+  // Load SMS features and conversation if SMS is enabled
+  let smsEnabled = false;
+  let smsMessages: { id: string; direction: string; body: string; status: string; createdAt: string | Date; fromNumber: string; toNumber: string }[] = [];
+  let smsNextCursor: string | null = null;
+
+  if (layoutData.status === "ok") {
+    const features = await getFeatures(layoutData.organizationId);
+    smsEnabled = features.sms;
+
+    if (smsEnabled && result.data.phone) {
+      const convResult = await getConversation(id);
+      if (convResult.success && convResult.data) {
+        smsMessages = convResult.data.messages;
+        smsNextCursor = convResult.data.nextCursor;
+      }
+    }
+  }
+
   return (
     <>
       <PageHeader />
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
-        <CustomerDetailClient customer={result.data} unitSystem={(settingsResult.success && settingsResult.data?.[SETTING_KEYS.UNIT_SYSTEM] || "imperial") as "metric" | "imperial"} />
+        <CustomerDetailClient
+          customer={result.data}
+          unitSystem={(settingsResult.success && settingsResult.data?.[SETTING_KEYS.UNIT_SYSTEM] || "imperial") as "metric" | "imperial"}
+          smsEnabled={smsEnabled}
+          smsMessages={smsMessages}
+          smsNextCursor={smsNextCursor}
+        />
       </div>
     </>
   );

+ 15 - 2
src/app/(authenticated)/inspections/[id]/page.tsx

@@ -1,6 +1,8 @@
 import { getInspection } from "@/features/inspections/Actions/inspectionActions";
 import { InspectionPageClient, type InspectionData } from "@/features/inspections/Components/InspectionPageClient";
 import { PageHeader } from "@/components/page-header";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { getFeatures } from "@/lib/features";
 
 export default async function InspectionDetailPage({
   params,
@@ -9,7 +11,10 @@ export default async function InspectionDetailPage({
 }) {
   const { id } = await params;
 
-  const result = await getInspection(id);
+  const [result, authContext] = await Promise.all([
+    getInspection(id),
+    getAuthContext(),
+  ]);
 
   if (!result.success || !result.data) {
     return (
@@ -24,11 +29,19 @@ export default async function InspectionDetailPage({
     );
   }
 
+  const features = authContext?.organizationId
+    ? await getFeatures(authContext.organizationId)
+    : null;
+
   return (
     <>
       <PageHeader />
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
-        <InspectionPageClient inspection={result.data as InspectionData} />
+        <InspectionPageClient
+          inspection={result.data as InspectionData}
+          smsEnabled={features?.sms ?? false}
+          emailEnabled={features?.smtp ?? false}
+        />
       </div>
     </>
   );

+ 31 - 0
src/app/(authenticated)/messages/page.tsx

@@ -0,0 +1,31 @@
+import { getAuthContext } from "@/lib/get-auth-context";
+import { getFeatures } from "@/lib/features";
+import { redirect } from "next/navigation";
+import { getRecentSmsThreads } from "@/features/sms/Actions/smsActions";
+import { MessagesPageClient } from "@/features/sms/Components/MessagesPageClient";
+import { PageHeader } from "@/components/page-header";
+
+export default async function MessagesPage() {
+  const ctx = await getAuthContext();
+  if (!ctx) redirect("/auth/sign-in");
+
+  const features = await getFeatures(ctx.organizationId);
+  if (!features.sms) {
+    redirect("/");
+  }
+
+  const threadsResult = await getRecentSmsThreads();
+  const threads =
+    threadsResult.success && threadsResult.data ? threadsResult.data.threads : [];
+  const hasMore =
+    threadsResult.success && threadsResult.data ? threadsResult.data.hasMore : false;
+
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col p-4 pt-0">
+        <MessagesPageClient initialThreads={threads} initialHasMore={hasMore} />
+      </div>
+    </>
+  );
+}

+ 7 - 0
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -4,6 +4,7 @@ import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { getCustomers } from "@/features/customers/Actions/customerActions";
 import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
 import { getAuthContext } from "@/lib/get-auth-context";
+import { getFeatures } from "@/lib/features";
 import { PageHeader } from "@/components/page-header";
 import { QuotePageClient } from "@/features/quotes/Components/QuotePageClient";
 
@@ -27,6 +28,10 @@ export default async function QuoteDetailPage({
       getAuthContext(),
     ]);
 
+  const features = authContext?.organizationId
+    ? await getFeatures(authContext.organizationId)
+    : null;
+
   if (!result.success || !result.data) {
     return (
       <div className="flex h-svh flex-col overflow-hidden">
@@ -69,6 +74,8 @@ export default async function QuoteDetailPage({
         defaultTaxRate={defaultTaxRate}
         taxEnabled={taxEnabled}
         defaultLaborRate={defaultLaborRate}
+        smsEnabled={features?.sms ?? false}
+        emailEnabled={features?.smtp ?? false}
         customers={customers.map(
           (c: {
             id: string;

+ 8 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -17,6 +17,7 @@ import {
   Layout,
   ListPlus,
   Mail,
+  MessageSquare,
   Palette,
   UserCog,
   UsersRound,
@@ -99,6 +100,13 @@ const settingsNav: SettingsNavItem[] = [
     description: "Email provider & sending",
     gate: "smtp",
   },
+  {
+    title: "SMS",
+    href: "/settings/sms",
+    icon: MessageSquare,
+    description: "SMS provider & messaging",
+    gate: "sms",
+  },
   {
     title: "Workshop",
     href: "/settings/workshop",

+ 34 - 0
src/app/(authenticated)/settings/sms/page.tsx

@@ -0,0 +1,34 @@
+import { getSmsSettings } from "@/features/sms/Actions/smsSettingsActions";
+import { getLayoutData } from "@/lib/get-layout-data";
+import { getFeatures, isCloudMode } from "@/lib/features";
+import { SmsSettingsForm } from "@/features/sms/Components/SmsSettingsForm";
+import { FeatureLockedMessage } from "../feature-locked-message";
+import { redirect } from "next/navigation";
+
+export default async function SmsSettingsPage() {
+  const data = await getLayoutData();
+
+  if (data.status === "unauthenticated") redirect("/auth/sign-in");
+  if (data.status === "no-organization") redirect("/onboarding");
+
+  const features = await getFeatures(data.organizationId);
+
+  if (!features.sms) {
+    return (
+      <FeatureLockedMessage
+        feature="SMS Messaging"
+        description="Send and receive SMS messages with your customers directly from Torqvoice."
+        isCloud={isCloudMode()}
+      />
+    );
+  }
+
+  const result = await getSmsSettings();
+  const settings = result.success && result.data ? result.data : {};
+
+  const appUrl =
+    process.env.NEXT_PUBLIC_APP_URL ||
+    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
+
+  return <SmsSettingsForm initial={settings} appUrl={appUrl} />;
+}

+ 24 - 0
src/app/(authenticated)/settings/templates/page.tsx

@@ -6,6 +6,7 @@ import { getFeatures, isCloudMode } from "@/lib/features";
 import { FeatureLockedMessage } from "../feature-locked-message";
 import { redirect } from "next/navigation";
 import { getTemplates, seedDefaultTemplate } from "@/features/inspections/Actions/templateActions";
+import { SMS_TEMPLATE_DEFAULTS } from "@/lib/sms-templates";
 
 export default async function TemplatePage() {
   const data = await getLayoutData();
@@ -36,6 +37,13 @@ export default async function TemplatePage() {
       SETTING_KEYS.QUOTE_PRIMARY_COLOR,
       SETTING_KEYS.QUOTE_FONT_FAMILY,
       SETTING_KEYS.QUOTE_HEADER_STYLE,
+      SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+      SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
+      SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
+      SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
+      SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
+      SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
+      SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
     ]),
     getTemplates(),
   ]);
@@ -45,6 +53,20 @@ export default async function TemplatePage() {
     ? inspectionTemplatesResult.data
     : [];
 
+  const smsTemplates: Record<string, string> = {};
+  const smsKeys = [
+    SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+    SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
+    SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
+    SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
+    SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
+    SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
+    SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
+  ] as const;
+  for (const key of smsKeys) {
+    smsTemplates[key] = settings[key] || SMS_TEMPLATE_DEFAULTS[key] || "";
+  }
+
   return (
     <TemplateSettings
       initialInvoiceValues={{
@@ -58,6 +80,8 @@ export default async function TemplatePage() {
         headerStyle: settings[SETTING_KEYS.QUOTE_HEADER_STYLE] || "standard",
       }}
       inspectionTemplates={inspectionTemplates}
+      smsEnabled={features.sms ?? false}
+      initialSmsTemplates={smsTemplates}
     />
   );
 }

+ 169 - 5
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -16,10 +16,12 @@ import { toast } from "sonner";
 import { setSetting } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { templatePresets } from "@/features/settings/Schema/templatePresets";
-import { Check, Loader2, Palette } from "lucide-react";
+import { Check, Loader2, Palette, MessageSquare, RotateCcw } from "lucide-react";
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
 import { cn } from "@/lib/utils";
 import { TemplateListClient } from "@/features/inspections/Components/TemplateListClient";
+import { Textarea } from "@/components/ui/textarea";
+import { SMS_TEMPLATE_DEFAULTS } from "@/lib/sms-templates";
 
 interface TemplateValues {
   primaryColor: string;
@@ -27,7 +29,7 @@ interface TemplateValues {
   headerStyle: string;
 }
 
-type TabType = "invoice" | "quotation" | "inspections";
+type TabType = "invoice" | "quotation" | "inspections" | "sms";
 
 const fontMap: Record<string, string> = {
   Helvetica: "Helvetica, Arial, sans-serif",
@@ -451,6 +453,125 @@ function TemplateTab({
   );
 }
 
+const smsTemplateFields = [
+  {
+    key: SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+    label: "Invoice Ready",
+    description: "Sent when sharing an invoice with a customer.",
+    variables: ["{share_link}", "{company_name}", "{customer_name}", "{current_user}"],
+  },
+  {
+    key: SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
+    label: "Inspection Ready",
+    description: "Sent when sharing an inspection report with a customer.",
+    variables: ["{share_link}", "{company_name}", "{customer_name}", "{current_user}"],
+  },
+  {
+    key: SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
+    label: "Status: In Progress",
+    description: "Sent when a work order status changes to In Progress.",
+    variables: ["{company_name}", "{customer_name}", "{current_user}", "{vehicle}"],
+  },
+  {
+    key: SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
+    label: "Status: Waiting for Parts",
+    description: "Sent when a work order status changes to Waiting for Parts.",
+    variables: ["{company_name}", "{customer_name}", "{current_user}", "{vehicle}"],
+  },
+  {
+    key: SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
+    label: "Status: Ready for Pickup",
+    description: "Sent when a work order status changes to Ready.",
+    variables: ["{company_name}", "{customer_name}", "{current_user}", "{vehicle}"],
+  },
+  {
+    key: SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
+    label: "Status: Completed",
+    description: "Sent when a work order status changes to Completed.",
+    variables: ["{company_name}", "{customer_name}", "{current_user}", "{vehicle}"],
+  },
+  {
+    key: SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
+    label: "Payment Received",
+    description: "Sent when a payment is recorded for an invoice.",
+    variables: ["{amount}", "{invoice_number}", "{company_name}", "{customer_name}", "{current_user}"],
+  },
+];
+
+function SmsTemplateTab({
+  values,
+  setValues,
+}: {
+  values: Record<string, string>;
+  setValues: (v: Record<string, string>) => void;
+}) {
+  const handleReset = (key: string) => {
+    const defaultVal = SMS_TEMPLATE_DEFAULTS[key] || "";
+    setValues({ ...values, [key]: defaultVal });
+  };
+
+  return (
+    <div className="space-y-4">
+      <Card className="border-0 shadow-sm">
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2 text-base">
+            <MessageSquare className="h-4 w-4" /> SMS Message Templates
+          </CardTitle>
+        </CardHeader>
+        <CardContent>
+          <p className="mb-4 text-sm text-muted-foreground">
+            Customize the text messages sent to customers. Use variables like{" "}
+            <code className="rounded bg-muted px-1 py-0.5 text-xs">{"{company_name}"}</code> to
+            insert dynamic values.
+          </p>
+          <div className="space-y-6">
+            {smsTemplateFields.map((field) => (
+              <div key={field.key} className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <Label className="text-sm font-medium">{field.label}</Label>
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="sm"
+                    className="h-7 px-2 text-xs text-muted-foreground"
+                    onClick={() => handleReset(field.key)}
+                  >
+                    <RotateCcw className="mr-1 h-3 w-3" />
+                    Reset
+                  </Button>
+                </div>
+                <p className="text-xs text-muted-foreground">{field.description}</p>
+                <Textarea
+                  value={values[field.key] || ""}
+                  onChange={(e) => setValues({ ...values, [field.key]: e.target.value })}
+                  rows={2}
+                  className="resize-none font-mono text-sm"
+                />
+                <div className="flex flex-wrap gap-1">
+                  {field.variables.map((v) => (
+                    <button
+                      key={v}
+                      type="button"
+                      onClick={() => {
+                        navigator.clipboard.writeText(v);
+                        toast.success(`Copied ${v}`);
+                      }}
+                      className="cursor-pointer rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground"
+                      title={`Click to copy ${v}`}
+                    >
+                      {v}
+                    </button>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
 interface InspectionTemplateSection {
   id: string;
   name: string;
@@ -470,15 +591,20 @@ export function TemplateSettings({
   initialInvoiceValues,
   initialQuoteValues,
   inspectionTemplates = [],
+  smsEnabled = false,
+  initialSmsTemplates = {},
 }: {
   initialInvoiceValues: TemplateValues;
   initialQuoteValues: TemplateValues;
   inspectionTemplates?: InspectionTemplate[];
+  smsEnabled?: boolean;
+  initialSmsTemplates?: Record<string, string>;
 }) {
   const [tab, setTab] = useState<TabType>("invoice");
   const [saving, setSaving] = useState(false);
   const [invoiceValues, setInvoiceValues] = useState(initialInvoiceValues);
   const [quoteValues, setQuoteValues] = useState(initialQuoteValues);
+  const [smsValues, setSmsValues] = useState<Record<string, string>>(initialSmsTemplates);
 
   const handleSave = async () => {
     setSaving(true);
@@ -489,14 +615,22 @@ export function TemplateSettings({
           setSetting(SETTING_KEYS.INVOICE_FONT_FAMILY, invoiceValues.fontFamily),
           setSetting(SETTING_KEYS.INVOICE_HEADER_STYLE, invoiceValues.headerStyle),
         ]);
-      } else {
+        toast.success("Invoice template settings saved");
+      } else if (tab === "quotation") {
         await Promise.all([
           setSetting(SETTING_KEYS.QUOTE_PRIMARY_COLOR, quoteValues.primaryColor),
           setSetting(SETTING_KEYS.QUOTE_FONT_FAMILY, quoteValues.fontFamily),
           setSetting(SETTING_KEYS.QUOTE_HEADER_STYLE, quoteValues.headerStyle),
         ]);
+        toast.success("Quotation template settings saved");
+      } else if (tab === "sms") {
+        await Promise.all(
+          Object.entries(smsValues).map(([key, value]) =>
+            setSetting(key as Parameters<typeof setSetting>[0], value),
+          ),
+        );
+        toast.success("SMS template settings saved");
       }
-      toast.success(`${tab === "invoice" ? "Invoice" : "Quotation"} template settings saved`);
     } catch {
       toast.error("Failed to save settings");
     }
@@ -511,7 +645,9 @@ export function TemplateSettings({
         <p className="text-sm text-muted-foreground">
           {tab === "inspections"
             ? "Manage inspection checklists and multi-point templates."
-            : "Customize the appearance of your PDF invoices and quotes."}
+            : tab === "sms"
+              ? "Customize the SMS messages sent to customers."
+              : "Customize the appearance of your PDF invoices and quotes."}
         </p>
       </div>
 
@@ -553,10 +689,38 @@ export function TemplateSettings({
         >
           Inspections
         </button>
+        {smsEnabled && (
+          <button
+            type="button"
+            onClick={() => setTab("sms")}
+            className={cn(
+              "flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors",
+              tab === "sms"
+                ? "bg-background text-foreground shadow-sm"
+                : "text-muted-foreground hover:text-foreground"
+            )}
+          >
+            SMS
+          </button>
+        )}
       </div>
 
       {tab === "inspections" ? (
         <TemplateListClient templates={inspectionTemplates} />
+      ) : tab === "sms" ? (
+        <>
+          <ReadOnlyWrapper>
+            <SmsTemplateTab values={smsValues} setValues={setSmsValues} />
+          </ReadOnlyWrapper>
+          <SaveButton>
+            <div className="flex justify-end">
+              <Button onClick={handleSave} disabled={saving}>
+                {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                Save SMS Templates
+              </Button>
+            </div>
+          </SaveButton>
+        </>
       ) : (
         <>
           <ReadOnlyWrapper>

+ 2 - 0
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx

@@ -167,6 +167,8 @@ export default async function ServiceDetailPage({
         maxImagesPerService={features?.maxImagesPerService ?? 999999}
         maxDiagnosticsPerService={features?.maxDiagnosticsPerService ?? 999999}
         maxDocumentsPerService={features?.maxDocumentsPerService ?? 999999}
+        smsEnabled={features?.sms ?? false}
+        emailEnabled={features?.smtp ?? false}
       />
     </div>
   );

+ 8 - 1
src/app/(authenticated)/work-orders/page.tsx

@@ -3,6 +3,8 @@ import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
 import { getCustomersList } from "@/features/customers/Actions/customerActions";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { getFeatures } from "@/lib/features";
 import { WorkOrdersClient } from "./work-orders-client";
 import { PageHeader } from "@/components/page-header";
 
@@ -17,7 +19,7 @@ export default async function WorkOrdersPage({
   }>;
 }) {
   const params = await searchParams;
-  const [result, settingsResult, vehiclesResult, customersResult] = await Promise.all([
+  const [result, settingsResult, vehiclesResult, customersResult, authCtx] = await Promise.all([
     getWorkOrders({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
@@ -27,8 +29,11 @@ export default async function WorkOrdersPage({
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
     getVehicles(),
     getCustomersList(),
+    getAuthContext(),
   ]);
 
+  const features = authCtx ? await getFeatures(authCtx.organizationId) : null;
+
   if (!result.success || !result.data) {
     return (
       <>
@@ -69,6 +74,8 @@ export default async function WorkOrdersPage({
           currencyCode={currencyCode}
           search={params.search || ""}
           statusFilter={params.status || "all"}
+          smsEnabled={features?.sms ?? false}
+          emailEnabled={features?.smtp ?? false}
         />
       </div>
     </>

+ 54 - 4
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -32,6 +32,10 @@ import {
 } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
 import { VehiclePickerDialog } from "@/components/vehicle-picker-dialog";
+import { NotifyCustomerDialog } from "@/components/notify-customer-dialog";
+import { getSmsTemplates } from "@/features/sms/Actions/smsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from "@/lib/sms-templates";
 
 interface WorkOrder {
   id: string;
@@ -49,7 +53,7 @@ interface WorkOrder {
     model: string;
     year: number;
     licensePlate: string | null;
-    customer: { id: string; name: string } | null;
+    customer: { id: string; name: string; email: string | null; phone: string | null } | null;
   };
 }
 
@@ -87,6 +91,13 @@ const statusTabs = [
   { key: "completed", label: "Completed" },
 ];
 
+const statusTemplateKeys: Record<string, string> = {
+  "in-progress": SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
+  "waiting-parts": SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
+  "ready": SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
+  "completed": SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
+};
+
 const statusTransitions: Record<string, { label: string; target: string }[]> = {
   pending: [
     { label: "Start Work", target: "in-progress" },
@@ -115,6 +126,8 @@ export function WorkOrdersClient({
   currencyCode = "USD",
   search,
   statusFilter,
+  smsEnabled = false,
+  emailEnabled = false,
 }: {
   data: PaginatedData;
   vehicles?: VehicleOption[];
@@ -122,6 +135,8 @@ export function WorkOrdersClient({
   currencyCode?: string;
   search: string;
   statusFilter: string;
+  smsEnabled?: boolean;
+  emailEnabled?: boolean;
 }) {
   const router = useRouter();
   const { formatDate } = useFormatDate();
@@ -131,6 +146,10 @@ export function WorkOrdersClient({
   const [searchInput, setSearchInput] = useState(search);
   const [navigatingId, setNavigatingId] = useState<string | null>(null);
   const [showPicker, setShowPicker] = useState(false);
+  const [showNotifyDialog, setShowNotifyDialog] = useState(false);
+  const [notifyCustomer, setNotifyCustomer] = useState<{ id: string; name: string; email: string | null; phone: string | null } | null>(null);
+  const [notifyMessage, setNotifyMessage] = useState("");
+  const [notifyStatus, setNotifyStatus] = useState("");
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
@@ -160,10 +179,28 @@ export function WorkOrdersClient({
     [navigate, searchInput]
   );
 
-  const handleStatusChange = async (recordId: string, newStatus: string) => {
-    await updateServiceStatus(recordId, newStatus);
+  const handleStatusChange = async (workOrder: WorkOrder, newStatus: string) => {
+    await updateServiceStatus(workOrder.id, newStatus);
     toast.success("Status updated");
     router.refresh();
+
+    const templateKey = statusTemplateKeys[newStatus];
+    if (workOrder.vehicle.customer && templateKey) {
+      const tplResult = await getSmsTemplates();
+      const tplData = tplResult.success && tplResult.data ? tplResult.data : null;
+      const tpl = tplData?.templates[templateKey] || SMS_TEMPLATE_DEFAULTS[templateKey] || "";
+      const vehicle = `${workOrder.vehicle.year} ${workOrder.vehicle.make} ${workOrder.vehicle.model}`;
+      const message = interpolateSmsTemplate(tpl, {
+        customer_name: workOrder.vehicle.customer.name,
+        vehicle,
+        company_name: tplData?.companyName || "",
+        current_user: tplData?.currentUser || "",
+      });
+      setNotifyCustomer(workOrder.vehicle.customer);
+      setNotifyMessage(message);
+      setNotifyStatus(newStatus);
+      setShowNotifyDialog(true);
+    }
   };
 
   return (
@@ -298,7 +335,7 @@ export function WorkOrdersClient({
                                 key={t.target}
                                 onClick={(e) => {
                                   e.stopPropagation();
-                                  handleStatusChange(r.id, t.target);
+                                  handleStatusChange(r, t.target);
                                 }}
                               >
                                 {t.label}
@@ -331,6 +368,19 @@ export function WorkOrdersClient({
         customers={customers}
         title="Select Vehicle"
       />
+
+      {notifyCustomer && (
+        <NotifyCustomerDialog
+          open={showNotifyDialog}
+          onOpenChange={setShowNotifyDialog}
+          customer={notifyCustomer}
+          defaultMessage={notifyMessage}
+          emailSubject={`Vehicle Status Update: ${notifyStatus}`}
+          smsEnabled={smsEnabled}
+          emailEnabled={emailEnabled}
+          relatedEntityType="work-order"
+        />
+      )}
     </div>
   );
 }

+ 101 - 0
src/app/api/webhooks/sms/telnyx/route.ts

@@ -0,0 +1,101 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { ORG_SMS_KEYS } from "@/features/sms/Schema/smsSettingsSchema";
+import { notify } from "@/lib/notify";
+
+export async function POST(request: Request) {
+  try {
+    const url = new URL(request.url);
+    const orgSecret = url.searchParams.get("org_secret");
+
+    if (!orgSecret) {
+      return NextResponse.json({ error: "Missing org_secret" }, { status: 400 });
+    }
+
+    const secretSetting = await db.appSetting.findFirst({
+      where: {
+        key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
+        value: orgSecret,
+      },
+      select: { organizationId: true },
+    });
+
+    if (!secretSetting?.organizationId) {
+      return NextResponse.json({ error: "Invalid org_secret" }, { status: 403 });
+    }
+
+    const organizationId = secretSetting.organizationId;
+
+    // Telnyx sends JSON with a data wrapper
+    const payload = (await request.json()) as {
+      data?: {
+        event_type?: string;
+        payload?: {
+          from?: { phone_number?: string };
+          to?: { phone_number?: string }[] | string;
+          text?: string;
+          id?: string;
+        };
+      };
+    };
+
+    const eventType = payload.data?.event_type;
+
+    // Only process inbound messages
+    if (eventType !== "message.received") {
+      return NextResponse.json({ received: true });
+    }
+
+    const msgPayload = payload.data?.payload;
+    const from = msgPayload?.from?.phone_number || "";
+    const toRaw = msgPayload?.to;
+    const to = Array.isArray(toRaw)
+      ? toRaw[0]?.phone_number || ""
+      : typeof toRaw === "string"
+        ? toRaw
+        : "";
+    const body = msgPayload?.text || "";
+    const messageId = msgPayload?.id;
+
+    if (!from || !body) {
+      return NextResponse.json({ received: true });
+    }
+
+    const customer = await db.customer.findFirst({
+      where: { organizationId, phone: from },
+      select: { id: true, name: true },
+    });
+
+    const message = await db.smsMessage.create({
+      data: {
+        direction: "inbound",
+        fromNumber: from,
+        toNumber: to,
+        body,
+        status: "received",
+        providerMsgId: messageId || undefined,
+        organizationId,
+        customerId: customer?.id,
+      },
+    });
+
+    await notify({
+      organizationId,
+      type: "sms_inbound",
+      title: "New SMS received",
+      message: customer
+        ? `${customer.name}: ${body.slice(0, 100)}`
+        : `${from}: ${body.slice(0, 100)}`,
+      entityType: "sms_message",
+      entityId: message.id,
+      entityUrl: customer
+        ? `/customers/${customer.id}?tab=messages`
+        : "/settings/sms",
+    });
+
+    return NextResponse.json({ received: true });
+  } catch (error) {
+    console.error("[webhook/sms/telnyx] Error:", error);
+    return NextResponse.json({ received: true });
+  }
+}

+ 91 - 0
src/app/api/webhooks/sms/twilio/route.ts

@@ -0,0 +1,91 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { ORG_SMS_KEYS } from "@/features/sms/Schema/smsSettingsSchema";
+import { notify } from "@/lib/notify";
+
+export async function POST(request: Request) {
+  try {
+    const url = new URL(request.url);
+    const orgSecret = url.searchParams.get("org_secret");
+
+    if (!orgSecret) {
+      return NextResponse.json({ error: "Missing org_secret" }, { status: 400 });
+    }
+
+    // Look up org by webhook secret
+    const secretSetting = await db.appSetting.findFirst({
+      where: {
+        key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
+        value: orgSecret,
+      },
+      select: { organizationId: true },
+    });
+
+    if (!secretSetting?.organizationId) {
+      return NextResponse.json({ error: "Invalid org_secret" }, { status: 403 });
+    }
+
+    const organizationId = secretSetting.organizationId;
+
+    // Parse Twilio form-encoded payload
+    const formData = await request.formData();
+    const from = formData.get("From") as string;
+    const to = formData.get("To") as string;
+    const body = formData.get("Body") as string;
+    const messageSid = formData.get("MessageSid") as string;
+
+    if (!from || !body) {
+      return new Response(
+        '<?xml version="1.0" encoding="UTF-8"?><Response/>',
+        { headers: { "Content-Type": "text/xml" } },
+      );
+    }
+
+    // Match incoming phone number to a customer
+    const customer = await db.customer.findFirst({
+      where: { organizationId, phone: from },
+      select: { id: true, name: true },
+    });
+
+    // Create inbound SMS record
+    const message = await db.smsMessage.create({
+      data: {
+        direction: "inbound",
+        fromNumber: from,
+        toNumber: to || "",
+        body,
+        status: "received",
+        providerMsgId: messageSid || undefined,
+        organizationId,
+        customerId: customer?.id,
+      },
+    });
+
+    // Send in-app notification
+    await notify({
+      organizationId,
+      type: "sms_inbound",
+      title: "New SMS received",
+      message: customer
+        ? `${customer.name}: ${body.slice(0, 100)}`
+        : `${from}: ${body.slice(0, 100)}`,
+      entityType: "sms_message",
+      entityId: message.id,
+      entityUrl: customer
+        ? `/customers/${customer.id}?tab=messages`
+        : "/settings/sms",
+    });
+
+    // Return TwiML empty response
+    return new Response(
+      '<?xml version="1.0" encoding="UTF-8"?><Response/>',
+      { headers: { "Content-Type": "text/xml" } },
+    );
+  } catch (error) {
+    console.error("[webhook/sms/twilio] Error:", error);
+    return new Response(
+      '<?xml version="1.0" encoding="UTF-8"?><Response/>',
+      { headers: { "Content-Type": "text/xml" } },
+    );
+  }
+}

+ 83 - 0
src/app/api/webhooks/sms/vonage/route.ts

@@ -0,0 +1,83 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { ORG_SMS_KEYS } from "@/features/sms/Schema/smsSettingsSchema";
+import { notify } from "@/lib/notify";
+
+export async function POST(request: Request) {
+  try {
+    const url = new URL(request.url);
+    const orgSecret = url.searchParams.get("org_secret");
+
+    if (!orgSecret) {
+      return NextResponse.json({ error: "Missing org_secret" }, { status: 400 });
+    }
+
+    const secretSetting = await db.appSetting.findFirst({
+      where: {
+        key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
+        value: orgSecret,
+      },
+      select: { organizationId: true },
+    });
+
+    if (!secretSetting?.organizationId) {
+      return NextResponse.json({ error: "Invalid org_secret" }, { status: 403 });
+    }
+
+    const organizationId = secretSetting.organizationId;
+
+    // Vonage sends JSON
+    const payload = (await request.json()) as {
+      msisdn?: string;
+      to?: string;
+      text?: string;
+      messageId?: string;
+    };
+
+    const from = payload.msisdn || "";
+    const to = payload.to || "";
+    const body = payload.text || "";
+    const messageId = payload.messageId;
+
+    if (!from || !body) {
+      return NextResponse.json({ received: true });
+    }
+
+    const customer = await db.customer.findFirst({
+      where: { organizationId, phone: from },
+      select: { id: true, name: true },
+    });
+
+    const message = await db.smsMessage.create({
+      data: {
+        direction: "inbound",
+        fromNumber: from,
+        toNumber: to,
+        body,
+        status: "received",
+        providerMsgId: messageId || undefined,
+        organizationId,
+        customerId: customer?.id,
+      },
+    });
+
+    await notify({
+      organizationId,
+      type: "sms_inbound",
+      title: "New SMS received",
+      message: customer
+        ? `${customer.name}: ${body.slice(0, 100)}`
+        : `${from}: ${body.slice(0, 100)}`,
+      entityType: "sms_message",
+      entityId: message.id,
+      entityUrl: customer
+        ? `/customers/${customer.id}?tab=messages`
+        : "/settings/sms",
+    });
+
+    return NextResponse.json({ received: true });
+  } catch (error) {
+    console.error("[webhook/sms/vonage] Error:", error);
+    return NextResponse.json({ received: true });
+  }
+}

+ 3 - 0
src/components/app-sidebar.tsx

@@ -39,6 +39,7 @@ import {
   ClipboardCheck,
   ClipboardList,
   FileText,
+  MessageSquare,
   LayoutDashboard,
   Loader2,
   LogOut,
@@ -62,6 +63,7 @@ const baseNavItems = [
   { title: 'Vehicles', url: '/vehicles', icon: Car },
   { title: 'Customers', url: '/customers', icon: Users },
   { title: 'Work Orders', url: '/work-orders', icon: ClipboardList },
+  { title: 'Messages', url: '/messages', icon: MessageSquare },
   { title: 'Inspections', url: '/inspections', icon: ClipboardCheck },
   { title: 'Calendar', url: '/calendar', icon: CalendarDays },
   { title: 'Quotes', url: '/quotes', icon: FileText },
@@ -105,6 +107,7 @@ export function AppSidebar({
   const navItems = [
     ...baseNavItems.filter((item) => {
       if (item.title === 'Reports' && (features?.reports === false || !canAccessReports)) return false
+      if (item.title === 'Messages' && features?.sms === false) return false
       return true
     }),
     ...(canAccessSettings ? [{ title: 'Settings', url: '/settings', icon: Settings }] : []),

+ 175 - 0
src/components/notify-customer-dialog.tsx

@@ -0,0 +1,175 @@
+"use client";
+
+import { useState } from "react";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogDescription,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+import { Loader2, Mail, MessageSquare, Send } from "lucide-react";
+import { toast } from "sonner";
+import { sendSmsToCustomer } from "@/features/sms/Actions/smsActions";
+import { sendNotificationEmail } from "@/features/email/Actions/emailActions";
+
+interface NotifyCustomerDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  customer: {
+    id: string;
+    name: string;
+    email: string | null;
+    phone: string | null;
+  };
+  defaultMessage: string;
+  emailSubject: string;
+  smsEnabled: boolean;
+  emailEnabled: boolean;
+  relatedEntityType?: string;
+  relatedEntityId?: string;
+}
+
+export function NotifyCustomerDialog({
+  open,
+  onOpenChange,
+  customer,
+  defaultMessage,
+  emailSubject,
+  smsEnabled,
+  emailEnabled,
+  relatedEntityType,
+  relatedEntityId,
+}: NotifyCustomerDialogProps) {
+  const [message, setMessage] = useState(defaultMessage);
+  const [sendSms, setSendSms] = useState(false);
+  const [sendEmail, setSendEmail] = useState(false);
+  const [sending, setSending] = useState(false);
+
+  const hasPhone = !!customer.phone;
+  const hasEmail = !!customer.email;
+
+  // Reset state when dialog opens with new message
+  const handleOpenChange = (next: boolean) => {
+    if (next) {
+      setMessage(defaultMessage);
+      setSendSms(false);
+      setSendEmail(false);
+    }
+    onOpenChange(next);
+  };
+
+  const handleSend = async () => {
+    if (!sendSms && !sendEmail) return;
+    setSending(true);
+
+    const results: string[] = [];
+
+    if (sendSms && hasPhone) {
+      const res = await sendSmsToCustomer({
+        customerId: customer.id,
+        body: message,
+        relatedEntityType,
+        relatedEntityId,
+      });
+      if (res.success) results.push("SMS sent");
+      else toast.error(res.error || "Failed to send SMS");
+    }
+
+    if (sendEmail && hasEmail) {
+      const res = await sendNotificationEmail({
+        recipientEmail: customer.email!,
+        subject: emailSubject,
+        body: message,
+      });
+      if (res.success) results.push("Email sent");
+      else toast.error(res.error || "Failed to send email");
+    }
+
+    if (results.length > 0) {
+      toast.success(results.join(" & "));
+    }
+
+    setSending(false);
+    onOpenChange(false);
+  };
+
+  const canSend = (sendSms || sendEmail) && message.trim();
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>Notify {customer.name}</DialogTitle>
+          <DialogDescription>
+            Send a notification to the customer about this update.
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4">
+          <Textarea
+            value={message}
+            onChange={(e) => setMessage(e.target.value)}
+            rows={4}
+            className="resize-none"
+          />
+
+          <div className="space-y-2">
+            <div className="flex items-center gap-2">
+              <Checkbox
+                id="notify-sms"
+                checked={sendSms}
+                onCheckedChange={(v) => setSendSms(v === true)}
+                disabled={!smsEnabled || !hasPhone}
+              />
+              <Label
+                htmlFor="notify-sms"
+                className={`flex items-center gap-1.5 text-sm ${!smsEnabled || !hasPhone ? "text-muted-foreground/50" : ""}`}
+              >
+                <MessageSquare className="h-3.5 w-3.5" />
+                SMS
+                {!hasPhone && <span className="text-xs">(no phone on file)</span>}
+                {hasPhone && !smsEnabled && <span className="text-xs">(not available)</span>}
+              </Label>
+            </div>
+            <div className="flex items-center gap-2">
+              <Checkbox
+                id="notify-email"
+                checked={sendEmail}
+                onCheckedChange={(v) => setSendEmail(v === true)}
+                disabled={!emailEnabled || !hasEmail}
+              />
+              <Label
+                htmlFor="notify-email"
+                className={`flex items-center gap-1.5 text-sm ${!emailEnabled || !hasEmail ? "text-muted-foreground/50" : ""}`}
+              >
+                <Mail className="h-3.5 w-3.5" />
+                Email
+                {!hasEmail && <span className="text-xs">(no email on file)</span>}
+                {hasEmail && !emailEnabled && <span className="text-xs">(not available)</span>}
+              </Label>
+            </div>
+          </div>
+
+          <div className="flex justify-end gap-2">
+            <Button variant="ghost" onClick={() => onOpenChange(false)}>
+              Skip
+            </Button>
+            <Button onClick={handleSend} disabled={!canSend || sending}>
+              {sending ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Send className="mr-2 h-4 w-4" />
+              )}
+              Send
+            </Button>
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 146 - 0
src/features/email/Actions/emailActions.ts

@@ -9,6 +9,9 @@ import { readFile } from "fs/promises";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { QuotePDF } from "@/features/quotes/Components/QuotePDF";
 import { InvoicePDF } from "@/features/vehicles/Components/InvoicePDF";
+import { InspectionPDF } from "@/features/inspections/Components/InspectionPDF";
+import { getFeatures } from "@/lib/features";
+import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { requireFeature } from "@/lib/features";
 
@@ -157,6 +160,41 @@ export async function sendQuoteEmail(input: {
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.QUOTES }] });
 }
 
+export async function sendNotificationEmail(input: {
+  recipientEmail: string;
+  subject: string;
+  body: string;
+}) {
+  return withAuth(async ({ organizationId }) => {
+    await requireFeature(organizationId, "smtp");
+
+    const settings = await getWorkshopSettings(organizationId);
+    if (settings["workshop.emailEnabled"] === "false") {
+      throw new Error("Email sending is disabled. Enable it in Settings.");
+    }
+
+    const fromName = settings["workshop.emailFromName"] || settings["workshop.name"] || "Workshop";
+    const from = await getOrgFromAddress(organizationId);
+
+    await sendOrgMail(organizationId, {
+      from,
+      to: input.recipientEmail,
+      subject: input.subject,
+      html: `
+        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
+          <p style="white-space: pre-line;">${input.body}</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>
+      `,
+    });
+
+    return { sent: true };
+  });
+}
+
 export async function sendInvoiceEmail(input: {
   serviceRecordId: string;
   recipientEmail: string;
@@ -248,3 +286,111 @@ export async function sendInvoiceEmail(input: {
     return { sent: true };
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES }] });
 }
+
+export async function sendInspectionEmail(input: {
+  inspectionId: string;
+  recipientEmail: string;
+  message?: string;
+}) {
+  return withAuth(async ({ organizationId }) => {
+    await requireFeature(organizationId, "smtp");
+
+    const { inspectionId, recipientEmail, message } = input;
+
+    const [inspection, org] = await Promise.all([
+      db.inspection.findFirst({
+        where: { id: inspectionId, organizationId },
+        include: {
+          vehicle: {
+            select: {
+              make: true, model: true, year: true, vin: true,
+              licensePlate: true, mileage: true,
+              customer: { select: { name: true, email: true, phone: true } },
+            },
+          },
+          template: { select: { name: true } },
+          items: { orderBy: { sortOrder: "asc" } },
+        },
+      }),
+      db.organization.findUnique({
+        where: { id: organizationId },
+        select: { name: true },
+      }),
+    ]);
+    if (!inspection) throw new Error("Inspection not found");
+
+    const settings = await getWorkshopSettings(organizationId);
+    if (settings["workshop.emailEnabled"] === "false") {
+      throw new Error("Email sending is disabled. Enable it in Settings.");
+    }
+
+    const logoDataUri = await loadLogoDataUri(settings["workshop.logo"]);
+    const fromName = settings["workshop.emailFromName"] || settings["workshop.name"] || "Workshop";
+
+    const features = await getFeatures(organizationId);
+    let torqvoiceLogoDataUri: string | undefined;
+    if (!features.brandingRemoved) {
+      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri();
+    }
+
+    const template = {
+      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",
+    };
+
+    const element = React.createElement(InspectionPDF, {
+      data: inspection,
+      workshop: {
+        name: org?.name || "",
+        address: settings["workshop.address"] || "",
+        phone: settings["workshop.phone"] || "",
+        email: settings["workshop.email"] || "",
+      },
+      logoDataUri,
+      torqvoiceLogoDataUri,
+      dateFormat: settings["workshop.dateFormat"] || undefined,
+      timezone: settings["workshop.timezone"] || undefined,
+      template,
+    }) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+    const pdfBuffer = await renderToBuffer(element);
+
+    const vehicleName = `${inspection.vehicle.year} ${inspection.vehicle.make} ${inspection.vehicle.model}`;
+    const fileName = `Inspection-${vehicleName}.pdf`;
+
+    // Build public link if token exists
+    const publicLink = inspection.publicToken
+      ? `${process.env.NEXT_PUBLIC_APP_URL || process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000"}/share/inspection/${organizationId}/${inspection.publicToken}`
+      : null;
+
+    const from = await getOrgFromAddress(organizationId);
+
+    await sendOrgMail(organizationId, {
+      from,
+      to: recipientEmail,
+      subject: `Vehicle Inspection - ${vehicleName}`,
+      html: `
+        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
+          <h2>Vehicle Inspection Report</h2>
+          <p>Please find the inspection report for your ${vehicleName} attached.</p>
+          ${message ? `<p>${message}</p>` : ""}
+          ${publicLink ? `<p><a href="${publicLink}" style="color: #2563eb;">View Inspection 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: fileName,
+          content: Buffer.from(pdfBuffer),
+        },
+      ],
+    });
+
+    return { sent: true };
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES }] });
+}

+ 8 - 0
src/features/inspections/Components/InspectionPageClient.tsx

@@ -411,8 +411,12 @@ function InspectionItemRow({
 
 export function InspectionPageClient({
   inspection,
+  smsEnabled = false,
+  emailEnabled = false,
 }: {
   inspection: InspectionData;
+  smsEnabled?: boolean;
+  emailEnabled?: boolean;
 }) {
   const router = useRouter();
   const { formatDate } = useFormatDate();
@@ -449,6 +453,7 @@ export function InspectionPageClient({
       if (result.success) {
         toast.success("Inspection completed");
         setShowCompleteDialog(false);
+        setShowShareDialog(true);
         router.refresh();
       } else {
         toast.error(result.error || "Failed to complete inspection");
@@ -739,6 +744,9 @@ export function InspectionPageClient({
         inspectionId={inspection.id}
         organizationId={inspection.organizationId}
         publicToken={inspection.publicToken}
+        customer={inspection.vehicle.customer}
+        smsEnabled={smsEnabled}
+        emailEnabled={emailEnabled}
       />
     </div>
   );

+ 132 - 8
src/features/inspections/Components/InspectionShareDialog.tsx

@@ -7,12 +7,34 @@ import {
 } from "@/components/ui/dialog";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
-import { Check, Copy, Link2, Loader2, Unlink } from "lucide-react";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+import { Check, Copy, Link2, Loader2, Mail, MessageSquare, Unlink } from "lucide-react";
 import { toast } from "sonner";
 import {
   generateInspectionPublicLink,
   revokeInspectionPublicLink,
 } from "../Actions/inspectionShareActions";
+import { sendSmsToCustomer, getSmsTemplates } from "@/features/sms/Actions/smsActions";
+import { sendInspectionEmail } from "@/features/email/Actions/emailActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from "@/lib/sms-templates";
+
+interface InspectionShareDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  inspectionId: string;
+  organizationId: string;
+  publicToken: string | null;
+  customer?: {
+    id: string;
+    name: string;
+    email: string | null;
+    phone: string | null;
+  } | null;
+  smsEnabled?: boolean;
+  emailEnabled?: boolean;
+}
 
 export function InspectionShareDialog({
   open,
@@ -20,17 +42,20 @@ export function InspectionShareDialog({
   inspectionId,
   organizationId,
   publicToken,
-}: {
-  open: boolean;
-  onOpenChange: (open: boolean) => void;
-  inspectionId: string;
-  organizationId: string;
-  publicToken: string | null;
-}) {
+  customer,
+  smsEnabled = false,
+  emailEnabled = false,
+}: InspectionShareDialogProps) {
   const router = useRouter();
   const [isPending, startTransition] = useTransition();
   const [copied, setCopied] = useState(false);
   const [token, setToken] = useState(publicToken);
+  const [notifySms, setNotifySms] = useState(false);
+  const [notifyEmail, setNotifyEmail] = useState(false);
+  const [sending, setSending] = useState(false);
+
+  const hasPhone = !!customer?.phone;
+  const hasEmail = !!customer?.email;
 
   const shareUrl = token
     ? `${typeof window !== "undefined" ? window.location.origin : ""}/share/inspection/${organizationId}/${token}`
@@ -70,6 +95,53 @@ export function InspectionShareDialog({
     setTimeout(() => setCopied(false), 2000);
   };
 
+  const handleNotify = async () => {
+    if (!shareUrl || !customer) return;
+    setSending(true);
+
+    const results: string[] = [];
+
+    if (notifySms && hasPhone) {
+      const tplResult = await getSmsTemplates();
+      const tplData = tplResult.success && tplResult.data ? tplResult.data : null;
+      const tpl = tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]
+        || SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY];
+      const body = interpolateSmsTemplate(tpl || "", {
+        share_link: shareUrl,
+        customer_name: customer.name,
+        company_name: tplData?.companyName || "",
+        current_user: tplData?.currentUser || "",
+      });
+      const res = await sendSmsToCustomer({
+        customerId: customer.id,
+        body,
+        relatedEntityType: "inspection",
+        relatedEntityId: inspectionId,
+      });
+      if (res.success) results.push("SMS sent");
+      else toast.error(res.error || "Failed to send SMS");
+    }
+
+    if (notifyEmail && hasEmail) {
+      const res = await sendInspectionEmail({
+        inspectionId,
+        recipientEmail: customer.email!,
+        message: `Your vehicle inspection report is ready. View it here: ${shareUrl}`,
+      });
+      if (res.success) results.push("Email sent");
+      else toast.error(res.error || "Failed to send email");
+    }
+
+    if (results.length > 0) {
+      toast.success(results.join(" & "));
+      setNotifySms(false);
+      setNotifyEmail(false);
+    }
+    setSending(false);
+  };
+
+  const canNotify = shareUrl && customer && (notifySms || notifyEmail);
+
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
       <DialogContent className="sm:max-w-md">
@@ -93,6 +165,58 @@ export function InspectionShareDialog({
                   )}
                 </Button>
               </div>
+
+              {/* Notify customer */}
+              {customer && (smsEnabled || emailEnabled) && (
+                <div className="space-y-3 rounded-lg border p-3">
+                  <p className="text-sm font-medium">Notify {customer.name}</p>
+                  <div className="space-y-2">
+                    {smsEnabled && (
+                      <div className="flex items-center gap-2">
+                        <Checkbox
+                          id="notify-sms-inspection"
+                          checked={notifySms}
+                          onCheckedChange={(v) => setNotifySms(v === true)}
+                          disabled={!hasPhone}
+                        />
+                        <Label
+                          htmlFor="notify-sms-inspection"
+                          className={`flex items-center gap-1.5 text-sm ${!hasPhone ? "text-muted-foreground/50" : ""}`}
+                        >
+                          <MessageSquare className="h-3.5 w-3.5" />
+                          SMS
+                          {!hasPhone && <span className="text-xs">(no phone on file)</span>}
+                        </Label>
+                      </div>
+                    )}
+                    {emailEnabled && (
+                      <div className="flex items-center gap-2">
+                        <Checkbox
+                          id="notify-email-inspection"
+                          checked={notifyEmail}
+                          onCheckedChange={(v) => setNotifyEmail(v === true)}
+                          disabled={!hasEmail}
+                        />
+                        <Label
+                          htmlFor="notify-email-inspection"
+                          className={`flex items-center gap-1.5 text-sm ${!hasEmail ? "text-muted-foreground/50" : ""}`}
+                        >
+                          <Mail className="h-3.5 w-3.5" />
+                          Email
+                          {!hasEmail && <span className="text-xs">(no email on file)</span>}
+                        </Label>
+                      </div>
+                    )}
+                  </div>
+                  {canNotify && (
+                    <Button size="sm" onClick={handleNotify} disabled={sending} className="w-full">
+                      {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                      Send Notification
+                    </Button>
+                  )}
+                </div>
+              )}
+
               <Button
                 variant="outline"
                 className="w-full text-destructive"

+ 26 - 5
src/features/notifications/hooks/useNotificationWebSocket.ts

@@ -3,7 +3,8 @@
 import { useEffect, useRef } from "react";
 import { toast } from "sonner";
 import { useNotificationStore } from "../store/notificationStore";
-import { getNotifications } from "../Actions/notificationActions";
+import { getNotifications, markNotificationRead } from "../Actions/notificationActions";
+import { getActiveSmsCustomerId } from "@/features/sms/activeSmsView";
 
 export function useNotificationWebSocket() {
   const wsRef = useRef<WebSocket | null>(null);
@@ -40,13 +41,33 @@ export function useNotificationWebSocket() {
         try {
           const msg = JSON.parse(event.data);
           if (msg.type === "notification") {
-            useNotificationStore.getState().addNotification(msg.data);
-            toast(msg.data.title, {
-              description: msg.data.message,
+            const data = msg.data;
+
+            // If user is already viewing SMS for this customer, auto-read and skip toast
+            const activeSmsCid = getActiveSmsCustomerId();
+            if (
+              activeSmsCid &&
+              data.type === "sms_inbound" &&
+              data.entityUrl === `/customers/${activeSmsCid}?tab=messages`
+            ) {
+              // Still add it to the store but immediately mark as read
+              const added = { ...data, read: true };
+              useNotificationStore.getState().addNotification(added);
+              // Decrement the unread count that addNotification just bumped
+              useNotificationStore.setState((s) => ({
+                unreadCount: Math.max(0, s.unreadCount - 1),
+              }));
+              markNotificationRead(data.id);
+              return;
+            }
+
+            useNotificationStore.getState().addNotification(data);
+            toast(data.title, {
+              description: data.message,
               action: {
                 label: "View",
                 onClick: () => {
-                  window.location.href = msg.data.entityUrl;
+                  window.location.href = data.entityUrl;
                 },
               },
             });

+ 7 - 0
src/features/quotes/Components/QuotePageClient.tsx

@@ -148,6 +148,8 @@ export function QuotePageClient({
   defaultLaborRate = 0,
   customers = [],
   vehicles = [],
+  smsEnabled = false,
+  emailEnabled = false,
 }: {
   quote: QuoteRecord;
   organizationId: string;
@@ -157,6 +159,8 @@ export function QuotePageClient({
   defaultLaborRate?: number;
   customers?: CustomerOption[];
   vehicles?: VehicleOption[];
+  smsEnabled?: boolean;
+  emailEnabled?: boolean;
 }) {
   const cs = getCurrencySymbol(currencyCode);
   const router = useRouter();
@@ -694,6 +698,9 @@ export function QuotePageClient({
         quoteId={quote.id}
         organizationId={organizationId}
         initialToken={quote.publicToken}
+        customer={quote.customer}
+        smsEnabled={smsEnabled}
+        emailEnabled={emailEnabled}
       />
 
       <Dialog open={showConvertDialog} onOpenChange={setShowConvertDialog}>

+ 111 - 1
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -3,14 +3,19 @@
 import { useState } from "react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
 import {
   Dialog,
   DialogContent,
   DialogHeader,
   DialogTitle,
 } from "@/components/ui/dialog";
-import { Check, Copy, Link2, Loader2 } from "lucide-react";
+import { Check, Copy, Link2, Loader2, Mail, MessageSquare } from "lucide-react";
+import { toast } from "sonner";
 import { generateQuotePublicLink, revokeQuotePublicLink } from "@/features/quotes/Actions/quoteShareActions";
+import { sendQuoteEmail } from "@/features/email/Actions/emailActions";
+import { sendSmsToCustomer } from "@/features/sms/Actions/smsActions";
 
 interface QuoteShareDialogProps {
   open: boolean;
@@ -18,6 +23,14 @@ interface QuoteShareDialogProps {
   quoteId: string;
   organizationId: string;
   initialToken: string | null;
+  customer?: {
+    id: string;
+    name: string;
+    email: string | null;
+    phone: string | null;
+  } | null;
+  smsEnabled?: boolean;
+  emailEnabled?: boolean;
 }
 
 export function QuoteShareDialog({
@@ -26,10 +39,19 @@ export function QuoteShareDialog({
   quoteId,
   organizationId,
   initialToken,
+  customer,
+  smsEnabled = false,
+  emailEnabled = false,
 }: QuoteShareDialogProps) {
   const [publicToken, setPublicToken] = useState<string | null>(initialToken);
   const [generatingLink, setGeneratingLink] = useState(false);
   const [copied, setCopied] = useState(false);
+  const [notifyEmail, setNotifyEmail] = useState(false);
+  const [notifySms, setNotifySms] = useState(false);
+  const [sending, setSending] = useState(false);
+
+  const hasEmail = !!customer?.email;
+  const hasPhone = !!customer?.phone;
 
   const publicUrl = publicToken && organizationId
     ? `${typeof window !== "undefined" ? window.location.origin : ""}/share/quote/${organizationId}/${publicToken}`
@@ -55,6 +77,42 @@ export function QuoteShareDialog({
     }
   };
 
+  const handleNotify = async () => {
+    if (!publicUrl || !customer) return;
+    setSending(true);
+
+    const results: string[] = [];
+
+    if (notifyEmail && hasEmail) {
+      const res = await sendQuoteEmail({
+        quoteId,
+        recipientEmail: customer.email!,
+      });
+      if (res.success) results.push("Email sent");
+      else toast.error(res.error || "Failed to send email");
+    }
+
+    if (notifySms && hasPhone) {
+      const res = await sendSmsToCustomer({
+        customerId: customer.id,
+        body: `Your quote is ready for review. View it here: ${publicUrl}`,
+        relatedEntityType: "quote",
+        relatedEntityId: quoteId,
+      });
+      if (res.success) results.push("SMS sent");
+      else toast.error(res.error || "Failed to send SMS");
+    }
+
+    if (results.length > 0) {
+      toast.success(results.join(" & "));
+      setNotifyEmail(false);
+      setNotifySms(false);
+    }
+    setSending(false);
+  };
+
+  const canNotify = publicUrl && customer && (notifyEmail || notifySms);
+
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
       <DialogContent className="max-w-md">
@@ -76,6 +134,58 @@ export function QuoteShareDialog({
                   {copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
                 </Button>
               </div>
+
+              {/* Notify customer */}
+              {customer && (emailEnabled || smsEnabled) && (
+                <div className="space-y-3 rounded-lg border p-3">
+                  <p className="text-sm font-medium">Notify {customer.name}</p>
+                  <div className="space-y-2">
+                    {emailEnabled && (
+                      <div className="flex items-center gap-2">
+                        <Checkbox
+                          id="notify-email-quote"
+                          checked={notifyEmail}
+                          onCheckedChange={(v) => setNotifyEmail(v === true)}
+                          disabled={!hasEmail}
+                        />
+                        <Label
+                          htmlFor="notify-email-quote"
+                          className={`flex items-center gap-1.5 text-sm ${!hasEmail ? "text-muted-foreground/50" : ""}`}
+                        >
+                          <Mail className="h-3.5 w-3.5" />
+                          Email
+                          {!hasEmail && <span className="text-xs">(no email on file)</span>}
+                        </Label>
+                      </div>
+                    )}
+                    {smsEnabled && (
+                      <div className="flex items-center gap-2">
+                        <Checkbox
+                          id="notify-sms-quote"
+                          checked={notifySms}
+                          onCheckedChange={(v) => setNotifySms(v === true)}
+                          disabled={!hasPhone}
+                        />
+                        <Label
+                          htmlFor="notify-sms-quote"
+                          className={`flex items-center gap-1.5 text-sm ${!hasPhone ? "text-muted-foreground/50" : ""}`}
+                        >
+                          <MessageSquare className="h-3.5 w-3.5" />
+                          SMS
+                          {!hasPhone && <span className="text-xs">(no phone on file)</span>}
+                        </Label>
+                      </div>
+                    )}
+                  </div>
+                  {canNotify && (
+                    <Button size="sm" onClick={handleNotify} disabled={sending} className="w-full">
+                      {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                      Send Notification
+                    </Button>
+                  )}
+                </div>
+              )}
+
               <Button
                 variant="ghost"
                 size="sm"

+ 7 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -61,6 +61,13 @@ export const SETTING_KEYS = {
   MAINTENANCE_SERVICE_INTERVAL: "maintenance.serviceInterval",
   MAINTENANCE_APPROACHING_THRESHOLD: "maintenance.approachingThreshold",
   INVENTORY_MARKUP_MULTIPLIER: "inventory.markupMultiplier",
+  SMS_TEMPLATE_INVOICE_READY: "sms.template.invoiceReady",
+  SMS_TEMPLATE_INSPECTION_READY: "sms.template.inspectionReady",
+  SMS_TEMPLATE_STATUS_IN_PROGRESS: "sms.template.statusInProgress",
+  SMS_TEMPLATE_STATUS_WAITING_PARTS: "sms.template.statusWaitingParts",
+  SMS_TEMPLATE_STATUS_READY: "sms.template.statusReady",
+  SMS_TEMPLATE_STATUS_COMPLETED: "sms.template.statusCompleted",
+  SMS_TEMPLATE_PAYMENT_RECEIVED: "sms.template.paymentReceived",
 } as const;
 
 export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];

+ 268 - 0
src/features/sms/Actions/smsActions.ts

@@ -0,0 +1,268 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { sendOrgSms, getOrgSmsPhoneNumber } from "@/lib/sms";
+import { requireFeature } from "@/lib/features";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { SMS_TEMPLATE_DEFAULTS } from "@/lib/sms-templates";
+
+export async function sendSmsToCustomer(input: {
+  customerId: string;
+  body: string;
+  relatedEntityType?: string;
+  relatedEntityId?: string;
+}) {
+  return withAuth(
+    async ({ organizationId }) => {
+      await requireFeature(organizationId, "sms");
+
+      const customer = await db.customer.findFirst({
+        where: { id: input.customerId, organizationId },
+        select: { id: true, phone: true, name: true },
+      });
+
+      if (!customer) throw new Error("Customer not found");
+      if (!customer.phone) throw new Error("Customer has no phone number");
+
+      const fromNumber = await getOrgSmsPhoneNumber(organizationId);
+      if (!fromNumber) throw new Error("SMS phone number is not configured");
+
+      // Create the message record first
+      const message = await db.smsMessage.create({
+        data: {
+          direction: "outbound",
+          fromNumber,
+          toNumber: customer.phone,
+          body: input.body,
+          status: "queued",
+          organizationId,
+          customerId: customer.id,
+          relatedEntityType: input.relatedEntityType,
+          relatedEntityId: input.relatedEntityId,
+        },
+      });
+
+      try {
+        const result = await sendOrgSms(organizationId, {
+          to: customer.phone,
+          body: input.body,
+        });
+
+        await db.smsMessage.update({
+          where: { id: message.id },
+          data: {
+            status: "sent",
+            providerMsgId: result.providerMsgId,
+          },
+        });
+
+        return { id: message.id, status: "sent" as const };
+      } catch (error) {
+        const errorMessage =
+          error instanceof Error ? error.message : "Unknown error";
+
+        await db.smsMessage.update({
+          where: { id: message.id },
+          data: { status: "failed", errorMessage },
+        });
+
+        throw new Error(`Failed to send SMS: ${errorMessage}`);
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    },
+  );
+}
+
+export async function getConversation(
+  customerId: string,
+  cursor?: string,
+  limit: number = 50,
+) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const messages = await db.smsMessage.findMany({
+        where: {
+          organizationId,
+          customerId,
+        },
+        orderBy: { createdAt: "desc" },
+        take: limit + 1,
+        ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
+        select: {
+          id: true,
+          direction: true,
+          body: true,
+          status: true,
+          createdAt: true,
+          fromNumber: true,
+          toNumber: true,
+        },
+      });
+
+      const hasMore = messages.length > limit;
+      const items = hasMore ? messages.slice(0, limit) : messages;
+
+      return {
+        messages: items.reverse(),
+        nextCursor: hasMore ? items[items.length - 1]?.id : null,
+      };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    },
+  );
+}
+
+export async function deleteSmsMessage(messageId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const message = await db.smsMessage.findFirst({
+        where: { id: messageId, organizationId },
+      });
+      if (!message) throw new Error("Message not found");
+
+      await db.smsMessage.delete({ where: { id: messageId } });
+      return { deleted: true };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    },
+  );
+}
+
+export async function deleteConversation(customerId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const customer = await db.customer.findFirst({
+        where: { id: customerId, organizationId },
+        select: { id: true },
+      });
+      if (!customer) throw new Error("Customer not found");
+
+      const { count } = await db.smsMessage.deleteMany({
+        where: { organizationId, customerId },
+      });
+
+      return { deleted: count };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    },
+  );
+}
+
+export async function getSmsTemplates() {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const smsKeys = [
+        SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+        SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
+        SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
+        SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
+        SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
+        SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
+        SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
+      ];
+      const [settings, org, user] = await Promise.all([
+        db.appSetting.findMany({
+          where: { organizationId, key: { in: smsKeys } },
+        }),
+        db.organization.findUnique({
+          where: { id: organizationId },
+          select: { name: true },
+        }),
+        db.user.findUnique({
+          where: { id: userId },
+          select: { name: true },
+        }),
+      ]);
+
+      const templates: Record<string, string> = {};
+      for (const key of smsKeys) {
+        const found = settings.find((s) => s.key === key);
+        templates[key] = found?.value || SMS_TEMPLATE_DEFAULTS[key] || "";
+      }
+      return {
+        templates,
+        companyName: org?.name || "",
+        currentUser: user?.name || "",
+      };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    },
+  );
+}
+
+export async function getRecentSmsThreads(offset: number = 0, limit: number = 50) {
+  return withAuth(
+    async ({ organizationId }) => {
+      // Fetch one extra to detect if there are more
+      const customers = await db.customer.findMany({
+        where: {
+          organizationId,
+          smsMessages: { some: {} },
+        },
+        select: {
+          id: true,
+          name: true,
+          phone: true,
+          smsMessages: {
+            orderBy: { createdAt: "desc" },
+            take: 1,
+            select: {
+              id: true,
+              body: true,
+              direction: true,
+              status: true,
+              createdAt: true,
+            },
+          },
+        },
+        orderBy: {
+          smsMessages: { _count: "desc" },
+        },
+        skip: offset,
+        take: limit + 1,
+      });
+
+      const filtered = customers.filter((c) => c.smsMessages.length > 0);
+      const hasMore = filtered.length > limit;
+      const items = hasMore ? filtered.slice(0, limit) : filtered;
+
+      const threads = items
+        .map((c) => ({
+          customerId: c.id,
+          customerName: c.name,
+          customerPhone: c.phone,
+          lastMessage: c.smsMessages[0],
+        }))
+        .sort(
+          (a, b) =>
+            new Date(b.lastMessage.createdAt).getTime() -
+            new Date(a.lastMessage.createdAt).getTime(),
+        );
+
+      return { threads, hasMore };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    },
+  );
+}

+ 94 - 0
src/features/sms/Actions/smsSettingsActions.ts

@@ -0,0 +1,94 @@
+"use server";
+
+import { randomBytes } from "crypto";
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { revalidatePath } from "next/cache";
+import { ALL_ORG_SMS_KEYS, ORG_SMS_KEYS } from "../Schema/smsSettingsSchema";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { sendOrgSms } from "@/lib/sms";
+
+export async function getSmsSettings() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const settings = await db.appSetting.findMany({
+        where: { organizationId, key: { in: ALL_ORG_SMS_KEYS } },
+      });
+      const map: Record<string, string> = {};
+      for (const s of settings) {
+        map[s.key] = s.value;
+      }
+      return map;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function setSmsSettings(entries: Record<string, string>) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      // Auto-generate webhook secret if not already set
+      const existing = await db.appSetting.findUnique({
+        where: {
+          organizationId_key: {
+            organizationId,
+            key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
+          },
+        },
+      });
+
+      if (!existing?.value) {
+        entries[ORG_SMS_KEYS.SMS_WEBHOOK_SECRET] = randomBytes(24).toString("hex");
+      }
+
+      await db.$transaction(
+        Object.entries(entries).map(([key, value]) =>
+          db.appSetting.upsert({
+            where: { organizationId_key: { organizationId, key } },
+            update: { value },
+            create: { userId, organizationId, key, value },
+          }),
+        ),
+      );
+      revalidatePath("/settings/sms");
+      return true;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.SETTINGS,
+        },
+      ],
+    },
+  );
+}
+
+export async function testSmsSend(testPhone: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      if (!testPhone?.trim()) {
+        throw new Error("Please enter a phone number to send the test SMS to");
+      }
+
+      await sendOrgSms(organizationId, {
+        to: testPhone.trim(),
+        body: "SMS test from Torqvoice — your SMS provider is configured correctly.",
+      });
+
+      return { sentTo: testPhone.trim() };
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.SETTINGS,
+        },
+      ],
+    },
+  );
+}

+ 333 - 0
src/features/sms/Components/MessagesPageClient.tsx

@@ -0,0 +1,333 @@
+"use client";
+
+import { useState, useEffect, useCallback, useTransition } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { ChevronDown, Loader2, MessageSquare, MoreVertical, Phone, Trash2, User } from "lucide-react";
+import { toast } from "sonner";
+import { getConversation, getRecentSmsThreads, deleteConversation } from "../Actions/smsActions";
+import { SmsConversation } from "./SmsConversation";
+import { useNotificationStore } from "@/features/notifications/store/notificationStore";
+
+interface SmsThread {
+  customerId: string;
+  customerName: string;
+  customerPhone: string | null;
+  lastMessage: {
+    id: string;
+    body: string;
+    direction: string;
+    status: string;
+    createdAt: string | Date;
+  };
+}
+
+interface Message {
+  id: string;
+  direction: string;
+  body: string;
+  status: string;
+  createdAt: string | Date;
+  fromNumber: string;
+  toNumber: string;
+}
+
+const THREADS_PAGE_SIZE = 50;
+
+export function MessagesPageClient({
+  initialThreads,
+  initialHasMore = false,
+}: {
+  initialThreads: SmsThread[];
+  initialHasMore?: boolean;
+}) {
+  const [threads, setThreads] = useState<SmsThread[]>(initialThreads);
+  const [hasMore, setHasMore] = useState(initialHasMore);
+  const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
+  const [conversation, setConversation] = useState<{
+    messages: Message[];
+    nextCursor: string | null;
+    customerName: string;
+    customerPhone: string | null;
+  } | null>(null);
+  const [loadingConversation, setLoadingConversation] = useState(false);
+  const [isLoadingMore, startLoadMore] = useTransition();
+  const [deleteThreadTarget, setDeleteThreadTarget] = useState<SmsThread | null>(null);
+  const [isDeletingThread, setIsDeletingThread] = useState(false);
+
+  const loadConversation = useCallback(async (customerId: string) => {
+    setLoadingConversation(true);
+    const result = await getConversation(customerId);
+    if (result.success && result.data) {
+      const thread = threads.find((t) => t.customerId === customerId);
+      setConversation({
+        messages: result.data.messages.map((m) => ({
+          ...m,
+          createdAt: typeof m.createdAt === "string" ? m.createdAt : m.createdAt.toISOString(),
+        })),
+        nextCursor: result.data.nextCursor,
+        customerName: thread?.customerName || "",
+        customerPhone: thread?.customerPhone || null,
+      });
+    }
+    setLoadingConversation(false);
+  }, [threads]);
+
+  const handleSelectThread = (customerId: string) => {
+    setSelectedCustomerId(customerId);
+    loadConversation(customerId);
+  };
+
+  const handleLoadMore = () => {
+    startLoadMore(async () => {
+      const result = await getRecentSmsThreads(threads.length, THREADS_PAGE_SIZE);
+      if (result.success && result.data) {
+        setThreads((prev) => [...prev, ...result.data!.threads]);
+        setHasMore(result.data.hasMore);
+      }
+    });
+  };
+
+  // Re-fetch threads when inbound SMS arrives
+  const refreshThreads = useCallback(async () => {
+    const result = await getRecentSmsThreads(0, threads.length || THREADS_PAGE_SIZE);
+    if (result.success && result.data) {
+      setThreads(result.data.threads);
+      setHasMore(result.data.hasMore);
+    }
+  }, [threads.length]);
+
+  const handleDeleteConversation = async () => {
+    if (!deleteThreadTarget) return;
+    setIsDeletingThread(true);
+    const result = await deleteConversation(deleteThreadTarget.customerId);
+    if (result.success) {
+      toast.success("Conversation deleted");
+      setThreads((prev) => prev.filter((t) => t.customerId !== deleteThreadTarget.customerId));
+      if (selectedCustomerId === deleteThreadTarget.customerId) {
+        setSelectedCustomerId(null);
+        setConversation(null);
+      }
+    } else {
+      toast.error(result.error || "Failed to delete conversation");
+    }
+    setIsDeletingThread(false);
+    setDeleteThreadTarget(null);
+  };
+
+  useEffect(() => {
+    let lastCount = useNotificationStore.getState().notifications.length;
+
+    const unsub = useNotificationStore.subscribe((state) => {
+      const { notifications } = state;
+      if (notifications.length <= lastCount) {
+        lastCount = notifications.length;
+        return;
+      }
+      lastCount = notifications.length;
+
+      const latest = notifications[0];
+      if (latest?.type === "sms_inbound") {
+        refreshThreads();
+      }
+    });
+
+    return unsub;
+  }, [refreshThreads]);
+
+  const formatRelativeTime = (date: string | Date) => {
+    const now = new Date();
+    const d = new Date(date);
+    const diffMs = now.getTime() - d.getTime();
+    const diffMins = Math.floor(diffMs / 60000);
+    const diffHours = Math.floor(diffMs / 3600000);
+    const diffDays = Math.floor(diffMs / 86400000);
+
+    if (diffMins < 1) return "now";
+    if (diffMins < 60) return `${diffMins}m`;
+    if (diffHours < 24) return `${diffHours}h`;
+    if (diffDays < 7) return `${diffDays}d`;
+    return d.toLocaleDateString([], { month: "short", day: "numeric" });
+  };
+
+  return (
+    <div className="flex h-[calc(100vh-8rem)] rounded-lg border overflow-hidden bg-background">
+      {/* Thread list */}
+      <div className="w-80 shrink-0 border-r flex flex-col">
+        <div className="shrink-0 border-b px-4 py-3">
+          <h2 className="text-sm font-semibold">Conversations</h2>
+        </div>
+        <div className="flex-1 min-h-0 overflow-y-auto">
+          {threads.length === 0 ? (
+            <div className="flex flex-col items-center justify-center py-12 px-4">
+              <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
+              <p className="text-sm text-muted-foreground text-center">
+                No conversations yet. Send an SMS from a customer page to start.
+              </p>
+            </div>
+          ) : (
+            <>
+              {threads.map((thread) => {
+                const isSelected = selectedCustomerId === thread.customerId;
+                return (
+                  <button
+                    key={thread.customerId}
+                    type="button"
+                    onClick={() => handleSelectThread(thread.customerId)}
+                    className={cn(
+                      "w-full text-left px-4 py-3 border-b transition-colors hover:bg-muted/50",
+                      isSelected && "bg-muted",
+                    )}
+                  >
+                    <div className="flex items-center gap-3">
+                      <div className={cn(
+                        "flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
+                        isSelected ? "bg-primary text-primary-foreground" : "bg-muted",
+                      )}>
+                        <User className="h-4 w-4" />
+                      </div>
+                      <div className="min-w-0 flex-1">
+                        <div className="flex items-baseline justify-between gap-2">
+                          <span className="font-medium text-sm truncate">
+                            {thread.customerName}
+                          </span>
+                          <span className="text-[10px] text-muted-foreground shrink-0">
+                            {formatRelativeTime(thread.lastMessage.createdAt)}
+                          </span>
+                        </div>
+                        <p className="text-xs text-muted-foreground truncate mt-0.5">
+                          {thread.lastMessage.direction === "outbound" ? "You: " : ""}
+                          {thread.lastMessage.body}
+                        </p>
+                      </div>
+                    </div>
+                  </button>
+                );
+              })}
+              {hasMore && (
+                <div className="p-3">
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    className="w-full"
+                    onClick={handleLoadMore}
+                    disabled={isLoadingMore}
+                  >
+                    {isLoadingMore ? (
+                      <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
+                    ) : (
+                      <ChevronDown className="mr-2 h-3.5 w-3.5" />
+                    )}
+                    Load more
+                  </Button>
+                </div>
+              )}
+            </>
+          )}
+        </div>
+      </div>
+
+      {/* Conversation panel */}
+      <div className="flex-1 flex flex-col min-w-0">
+        {selectedCustomerId && conversation && !loadingConversation ? (
+          <>
+            {/* Conversation header */}
+            <div className="shrink-0 border-b px-4 py-3 flex items-center gap-3">
+              <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
+                <User className="h-4 w-4" />
+              </div>
+              <div className="min-w-0 flex-1">
+                <p className="text-sm font-semibold truncate">{conversation.customerName}</p>
+                {conversation.customerPhone && (
+                  <p className="flex items-center gap-1 text-xs text-muted-foreground">
+                    <Phone className="h-3 w-3" />
+                    {conversation.customerPhone}
+                  </p>
+                )}
+              </div>
+              <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                  <Button variant="ghost" size="icon" className="shrink-0 h-8 w-8">
+                    <MoreVertical className="h-4 w-4" />
+                  </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="end">
+                  <DropdownMenuItem
+                    className="text-destructive"
+                    onClick={() => {
+                      const thread = threads.find((t) => t.customerId === selectedCustomerId);
+                      if (thread) setDeleteThreadTarget(thread);
+                    }}
+                  >
+                    <Trash2 className="mr-2 h-4 w-4" />
+                    Delete conversation
+                  </DropdownMenuItem>
+                </DropdownMenuContent>
+              </DropdownMenu>
+            </div>
+            {/* Conversation body — fills remaining height, input pinned to bottom */}
+            <SmsConversation
+              key={selectedCustomerId}
+              customerId={selectedCustomerId}
+              customerName={conversation.customerName}
+              customerPhone={conversation.customerPhone}
+              initialMessages={conversation.messages}
+              initialNextCursor={conversation.nextCursor}
+              className="h-auto flex-1 min-h-0"
+            />
+          </>
+        ) : loadingConversation ? (
+          <div className="flex flex-1 items-center justify-center">
+            <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
+          </div>
+        ) : (
+          <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
+            <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
+            <p className="text-sm">Select a conversation</p>
+          </div>
+        )}
+      </div>
+
+      {/* Delete conversation confirmation */}
+      <AlertDialog open={!!deleteThreadTarget} onOpenChange={(open) => !open && setDeleteThreadTarget(null)}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Delete conversation?</AlertDialogTitle>
+            <AlertDialogDescription>
+              This will permanently delete all messages with{" "}
+              <span className="font-medium text-foreground">{deleteThreadTarget?.customerName}</span>.
+              This action cannot be undone.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel disabled={isDeletingThread}>Cancel</AlertDialogCancel>
+            <AlertDialogAction
+              onClick={handleDeleteConversation}
+              disabled={isDeletingThread}
+              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+            >
+              {isDeletingThread && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              Delete
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </div>
+  );
+}

+ 119 - 0
src/features/sms/Components/SendSmsDialog.tsx

@@ -0,0 +1,119 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Loader2, MessageSquare, Send } from "lucide-react";
+import { useGlassModal } from "@/components/glass-modal";
+import { sendSmsToCustomer } from "../Actions/smsActions";
+
+interface SendSmsDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  customerId: string;
+  customerName: string;
+  customerPhone: string;
+  entityLabel?: string;
+  defaultMessage?: string;
+  relatedEntityType?: string;
+  relatedEntityId?: string;
+}
+
+export function SendSmsDialog({
+  open,
+  onOpenChange,
+  customerId,
+  customerName,
+  customerPhone,
+  entityLabel = "SMS",
+  defaultMessage = "",
+  relatedEntityType,
+  relatedEntityId,
+}: SendSmsDialogProps) {
+  const modal = useGlassModal();
+  const [sending, setSending] = useState(false);
+  const [message, setMessage] = useState(defaultMessage);
+
+  const handleSend = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!message.trim()) return;
+    setSending(true);
+
+    const result = await sendSmsToCustomer({
+      customerId,
+      body: message.trim(),
+      relatedEntityType,
+      relatedEntityId,
+    });
+
+    if (result.success) {
+      modal.open(
+        "success",
+        "SMS Sent",
+        `${entityLabel} has been sent to ${customerName} (${customerPhone})`,
+      );
+      onOpenChange(false);
+      setMessage("");
+    } else {
+      modal.open("error", "Error", result.error || "Failed to send SMS");
+    }
+    setSending(false);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <MessageSquare className="h-5 w-5" />
+            Send {entityLabel}
+          </DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleSend} className="space-y-4">
+          <div className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
+            <span className="text-sm font-medium">{customerName}</span>
+            <span className="text-sm text-muted-foreground">{customerPhone}</span>
+          </div>
+          <div className="space-y-2">
+            <Label htmlFor="sms-message">Message</Label>
+            <Textarea
+              id="sms-message"
+              placeholder="Type your message..."
+              value={message}
+              onChange={(e) => setMessage(e.target.value)}
+              rows={4}
+              required
+            />
+            <p className="text-xs text-muted-foreground">
+              {message.length} / 1600 characters
+            </p>
+          </div>
+          <div className="flex justify-end gap-2">
+            <Button
+              type="button"
+              variant="ghost"
+              onClick={() => onOpenChange(false)}
+            >
+              Cancel
+            </Button>
+            <Button type="submit" disabled={sending || !message.trim()}>
+              {sending ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Send className="mr-2 h-4 w-4" />
+              )}
+              Send SMS
+            </Button>
+          </div>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 327 - 0
src/features/sms/Components/SmsConversation.tsx

@@ -0,0 +1,327 @@
+"use client";
+
+import { useState, useTransition, useRef, useEffect, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Loader2, Send, MessageSquare, ChevronUp, Trash2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { sendSmsToCustomer, getConversation, deleteSmsMessage } from "../Actions/smsActions";
+import { useNotificationStore } from "@/features/notifications/store/notificationStore";
+import { setActiveSmsCustomerId } from "../activeSmsView";
+import { toast } from "sonner";
+
+interface Message {
+  id: string;
+  direction: string;
+  body: string;
+  status: string;
+  createdAt: string | Date;
+  fromNumber: string;
+  toNumber: string;
+}
+
+interface SmsConversationProps {
+  customerId: string;
+  customerName: string;
+  customerPhone: string | null;
+  initialMessages: Message[];
+  initialNextCursor: string | null;
+  className?: string;
+}
+
+export function SmsConversation({
+  customerId,
+  customerName,
+  customerPhone,
+  initialMessages,
+  initialNextCursor,
+  className,
+}: SmsConversationProps) {
+  const [messages, setMessages] = useState<Message[]>(initialMessages);
+  const [nextCursor, setNextCursor] = useState<string | null>(initialNextCursor);
+  const [newMessage, setNewMessage] = useState("");
+  const [mounted, setMounted] = useState(false);
+  const [deleteTarget, setDeleteTarget] = useState<Message | null>(null);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  useEffect(() => {
+    setMounted(true);
+  }, []);
+  const [isSending, startSend] = useTransition();
+  const [isLoadingMore, startLoadMore] = useTransition();
+  const bottomRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    setActiveSmsCustomerId(customerId);
+    return () => setActiveSmsCustomerId(null);
+  }, [customerId]);
+
+  useEffect(() => {
+    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+  }, [messages.length]);
+
+  // Refetch conversation when a matching inbound SMS notification arrives via WS
+  const refreshConversation = useCallback(async () => {
+    const conv = await getConversation(customerId);
+    if (conv.success && conv.data) {
+      setMessages(conv.data.messages.map((m) => ({
+        ...m,
+        createdAt: typeof m.createdAt === "string" ? m.createdAt : m.createdAt.toISOString(),
+      })));
+      setNextCursor(conv.data.nextCursor);
+    }
+  }, [customerId]);
+
+  useEffect(() => {
+    // Track the last notification count so we only react to new ones
+    let lastCount = useNotificationStore.getState().notifications.length;
+
+    const unsub = useNotificationStore.subscribe((state) => {
+      const { notifications } = state;
+      if (notifications.length <= lastCount) {
+        lastCount = notifications.length;
+        return;
+      }
+      lastCount = notifications.length;
+
+      // Check if the newest notification is an inbound SMS for this customer
+      const latest = notifications[0];
+      if (
+        latest?.type === "sms_inbound" &&
+        latest.entityUrl === `/customers/${customerId}?tab=messages`
+      ) {
+        refreshConversation();
+      }
+    });
+
+    return unsub;
+  }, [customerId, refreshConversation]);
+
+  const handleSend = () => {
+    if (!newMessage.trim() || !customerPhone) return;
+    const body = newMessage.trim();
+    setNewMessage("");
+
+    startSend(async () => {
+      const result = await sendSmsToCustomer({ customerId, body });
+      if (result.success) {
+        // Refresh conversation
+        const conv = await getConversation(customerId);
+        if (conv.success && conv.data) {
+          setMessages(conv.data.messages.map((m) => ({
+            ...m,
+            createdAt: typeof m.createdAt === "string" ? m.createdAt : m.createdAt.toISOString(),
+          })));
+          setNextCursor(conv.data.nextCursor);
+        }
+      } else {
+        toast.error(result.error ?? "Failed to send SMS");
+        setNewMessage(body);
+      }
+    });
+  };
+
+  const handleLoadOlder = () => {
+    if (!nextCursor) return;
+    startLoadMore(async () => {
+      const result = await getConversation(customerId, nextCursor);
+      if (result.success && result.data) {
+        const older = result.data.messages.map((m) => ({
+          ...m,
+          createdAt: typeof m.createdAt === "string" ? m.createdAt : m.createdAt.toISOString(),
+        }));
+        setMessages((prev) => [...older, ...prev]);
+        setNextCursor(result.data.nextCursor);
+      }
+    });
+  };
+
+  const handleDeleteMessage = async () => {
+    if (!deleteTarget) return;
+    setIsDeleting(true);
+    const result = await deleteSmsMessage(deleteTarget.id);
+    if (result.success) {
+      setMessages((prev) => prev.filter((m) => m.id !== deleteTarget.id));
+      toast.success("Message deleted");
+    } else {
+      toast.error(result.error || "Failed to delete message");
+    }
+    setIsDeleting(false);
+    setDeleteTarget(null);
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key === "Enter" && !e.shiftKey) {
+      e.preventDefault();
+      handleSend();
+    }
+  };
+
+  if (!customerPhone) {
+    return (
+      <div className="flex flex-col items-center justify-center py-12">
+        <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
+        <p className="text-sm text-muted-foreground">
+          No phone number on file for {customerName}. Add a phone number to start messaging.
+        </p>
+      </div>
+    );
+  }
+
+  return (
+    <div className={cn("flex flex-col h-[500px]", className)}>
+      {/* Messages area — scrollable, fills available space */}
+      <div className="min-h-0 flex-1 overflow-y-auto p-4 space-y-3">
+        {nextCursor && (
+          <div className="flex justify-center">
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={handleLoadOlder}
+              disabled={isLoadingMore}
+            >
+              {isLoadingMore ? (
+                <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+              ) : (
+                <ChevronUp className="mr-1 h-3 w-3" />
+              )}
+              Load older
+            </Button>
+          </div>
+        )}
+
+        {messages.length === 0 && (
+          <div className="flex flex-col items-center justify-center py-12">
+            <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
+            <p className="text-sm text-muted-foreground">
+              No messages yet. Send the first message to {customerName}.
+            </p>
+          </div>
+        )}
+
+        {messages.map((msg) => {
+          const isOutbound = msg.direction === "outbound";
+          const time = mounted
+            ? new Date(msg.createdAt).toLocaleTimeString([], {
+                hour: "2-digit",
+                minute: "2-digit",
+              })
+            : "";
+          return (
+            <div
+              key={msg.id}
+              className={cn(
+                "group flex",
+                isOutbound ? "justify-end" : "justify-start",
+              )}
+            >
+              {/* Delete button — before bubble for outbound, after for inbound */}
+              {isOutbound && (
+                <button
+                  type="button"
+                  onClick={() => setDeleteTarget(msg)}
+                  className="mr-2 self-center opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
+                  title="Delete message"
+                >
+                  <Trash2 className="h-3.5 w-3.5" />
+                </button>
+              )}
+              <div
+                className={cn(
+                  "max-w-[75%] rounded-2xl px-4 py-2",
+                  isOutbound
+                    ? "bg-primary text-primary-foreground"
+                    : "bg-muted",
+                )}
+              >
+                <p className="text-sm whitespace-pre-wrap">{msg.body}</p>
+                <div
+                  className={cn(
+                    "mt-1 flex items-center gap-1.5 text-[10px]",
+                    isOutbound
+                      ? "text-primary-foreground/70 justify-end"
+                      : "text-muted-foreground",
+                  )}
+                >
+                  <span>{time}</span>
+                  {isOutbound && (
+                    <span className="capitalize">{msg.status}</span>
+                  )}
+                </div>
+              </div>
+              {!isOutbound && (
+                <button
+                  type="button"
+                  onClick={() => setDeleteTarget(msg)}
+                  className="ml-2 self-center opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
+                  title="Delete message"
+                >
+                  <Trash2 className="h-3.5 w-3.5" />
+                </button>
+              )}
+            </div>
+          );
+        })}
+        <div ref={bottomRef} />
+      </div>
+
+      {/* Input area — pinned to bottom */}
+      <div className="shrink-0 border-t bg-background p-4">
+        <div className="flex items-end gap-2">
+          <Textarea
+            placeholder={`Message ${customerName}...`}
+            value={newMessage}
+            onChange={(e) => setNewMessage(e.target.value)}
+            onKeyDown={handleKeyDown}
+            rows={2}
+            className="resize-none"
+          />
+          <Button
+            onClick={handleSend}
+            disabled={isSending || !newMessage.trim()}
+            size="icon"
+          >
+            {isSending ? (
+              <Loader2 className="h-4 w-4 animate-spin" />
+            ) : (
+              <Send className="h-4 w-4" />
+            )}
+          </Button>
+        </div>
+      </div>
+
+      {/* Delete confirmation dialog */}
+      <AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Delete message?</AlertDialogTitle>
+            <AlertDialogDescription>
+              This will permanently delete this message from the conversation. This action cannot be undone.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
+            <AlertDialogAction
+              onClick={handleDeleteMessage}
+              disabled={isDeleting}
+              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+            >
+              {isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              Delete
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </div>
+  );
+}

+ 381 - 0
src/features/sms/Components/SmsSettingsForm.tsx

@@ -0,0 +1,381 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from "@/components/ui/card";
+import { Loader2, Send, Info, Copy, Check } from "lucide-react";
+import { ORG_SMS_KEYS } from "../Schema/smsSettingsSchema";
+import {
+  setSmsSettings,
+  testSmsSend,
+} from "../Actions/smsSettingsActions";
+import {
+  ReadOnlyBanner,
+  SaveButton,
+  ReadOnlyWrapper,
+} from "@/app/(authenticated)/settings/read-only-guard";
+
+type SmsProviderType = "twilio" | "vonage" | "telnyx";
+
+export function SmsSettingsForm({
+  initial,
+  appUrl,
+}: {
+  initial: Record<string, string>;
+  appUrl: string;
+}) {
+  const router = useRouter();
+  const [isPending, startTransition] = useTransition();
+  const [isTesting, setIsTesting] = useState(false);
+  const [copied, setCopied] = useState(false);
+  const [testPhone, setTestPhone] = useState("");
+
+  const hasProvider = !!initial[ORG_SMS_KEYS.SMS_PROVIDER];
+  const [enabled, setEnabled] = useState(hasProvider);
+
+  const [smsProvider, setSmsProvider] = useState<SmsProviderType>(
+    (initial[ORG_SMS_KEYS.SMS_PROVIDER] as SmsProviderType) || "twilio",
+  );
+
+  // Shared
+  const [phoneNumber, setPhoneNumber] = useState(
+    initial[ORG_SMS_KEYS.SMS_PHONE_NUMBER] || "",
+  );
+
+  // Twilio
+  const [twilioAccountSid, setTwilioAccountSid] = useState(
+    initial[ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID] || "",
+  );
+  const [twilioAuthToken, setTwilioAuthToken] = useState(
+    initial[ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN] || "",
+  );
+
+  // Vonage
+  const [vonageApiKey, setVonageApiKey] = useState(
+    initial[ORG_SMS_KEYS.SMS_VONAGE_API_KEY] || "",
+  );
+  const [vonageApiSecret, setVonageApiSecret] = useState(
+    initial[ORG_SMS_KEYS.SMS_VONAGE_API_SECRET] || "",
+  );
+
+  // Telnyx
+  const [telnyxApiKey, setTelnyxApiKey] = useState(
+    initial[ORG_SMS_KEYS.SMS_TELNYX_API_KEY] || "",
+  );
+
+  const webhookSecret = initial[ORG_SMS_KEYS.SMS_WEBHOOK_SECRET] || "";
+  const webhookUrl = webhookSecret
+    ? `${appUrl}/api/webhooks/sms/${smsProvider}?org_secret=${webhookSecret}`
+    : "";
+
+  const handleCopyWebhook = () => {
+    if (!webhookUrl) return;
+    navigator.clipboard.writeText(webhookUrl);
+    setCopied(true);
+    toast.success("Webhook URL copied");
+    setTimeout(() => setCopied(false), 2000);
+  };
+
+  const handleSave = () => {
+    startTransition(async () => {
+      if (!enabled) {
+        const result = await setSmsSettings({
+          [ORG_SMS_KEYS.SMS_PROVIDER]: "",
+        });
+        if (result.success) {
+          toast.success("SMS settings saved — SMS disabled");
+          router.refresh();
+        } else {
+          toast.error(result.error ?? "Failed to save settings");
+        }
+        return;
+      }
+
+      const data: Record<string, string> = {
+        [ORG_SMS_KEYS.SMS_PROVIDER]: smsProvider,
+        [ORG_SMS_KEYS.SMS_PHONE_NUMBER]: phoneNumber,
+        // Twilio
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: twilioAccountSid,
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: twilioAuthToken,
+        // Vonage
+        [ORG_SMS_KEYS.SMS_VONAGE_API_KEY]: vonageApiKey,
+        [ORG_SMS_KEYS.SMS_VONAGE_API_SECRET]: vonageApiSecret,
+        // Telnyx
+        [ORG_SMS_KEYS.SMS_TELNYX_API_KEY]: telnyxApiKey,
+      };
+
+      const result = await setSmsSettings(data);
+      if (result.success) {
+        toast.success("SMS settings saved");
+        router.refresh();
+      } else {
+        toast.error(result.error ?? "Failed to save settings");
+      }
+    });
+  };
+
+  const handleTestSms = async () => {
+    if (!testPhone.trim()) {
+      toast.error("Enter a phone number to send the test SMS to");
+      return;
+    }
+    setIsTesting(true);
+    try {
+      const result = await testSmsSend(testPhone.trim());
+      if (result.success) {
+        toast.success(`Test SMS sent to ${result.data?.sentTo}`);
+      } else {
+        toast.error(result.error ?? "SMS test failed");
+      }
+    } finally {
+      setIsTesting(false);
+    }
+  };
+
+  const isTestDisabled =
+    isTesting ||
+    !enabled ||
+    !phoneNumber ||
+    !testPhone.trim() ||
+    (smsProvider === "twilio" && (!twilioAccountSid || !twilioAuthToken)) ||
+    (smsProvider === "vonage" && (!vonageApiKey || !vonageApiSecret)) ||
+    (smsProvider === "telnyx" && !telnyxApiKey);
+
+  return (
+    <div className="space-y-6">
+      <ReadOnlyBanner />
+      <ReadOnlyWrapper>
+        <Card>
+          <CardHeader>
+            <CardTitle>SMS Provider</CardTitle>
+            <CardDescription>
+              Configure your SMS provider for two-way messaging with customers.
+              Each organization uses their own SMS vendor and phone number.
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-6">
+            <div className="flex items-center justify-between">
+              <div className="space-y-0.5">
+                <Label htmlFor="enable-sms">Enable SMS messaging</Label>
+                <p className="text-xs text-muted-foreground">
+                  Send and receive SMS messages with customers
+                </p>
+              </div>
+              <Switch
+                id="enable-sms"
+                checked={enabled}
+                onCheckedChange={setEnabled}
+              />
+            </div>
+
+            {!enabled && (
+              <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
+                <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+                <p className="text-sm text-muted-foreground">
+                  SMS messaging is disabled. Enable it to send and receive text
+                  messages with your customers.
+                </p>
+              </div>
+            )}
+
+            {enabled && (
+              <>
+                {/* Provider selection */}
+                <div className="flex flex-wrap gap-2">
+                  <Button
+                    type="button"
+                    variant={smsProvider === "twilio" ? "default" : "outline"}
+                    onClick={() => setSmsProvider("twilio")}
+                    className="flex-1"
+                  >
+                    Twilio
+                  </Button>
+                  <Button
+                    type="button"
+                    variant={smsProvider === "vonage" ? "default" : "outline"}
+                    onClick={() => setSmsProvider("vonage")}
+                    className="flex-1"
+                  >
+                    Vonage
+                  </Button>
+                  <Button
+                    type="button"
+                    variant={smsProvider === "telnyx" ? "default" : "outline"}
+                    onClick={() => setSmsProvider("telnyx")}
+                    className="flex-1"
+                  >
+                    Telnyx
+                  </Button>
+                </div>
+
+                {/* Phone number (shared) */}
+                <div className="space-y-2">
+                  <Label htmlFor="sms-phone-number">Phone Number</Label>
+                  <Input
+                    id="sms-phone-number"
+                    placeholder="+15551234567"
+                    value={phoneNumber}
+                    onChange={(e) => setPhoneNumber(e.target.value)}
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Your SMS-enabled phone number in E.164 format (e.g.
+                    +15551234567)
+                  </p>
+                </div>
+
+                {/* Twilio fields */}
+                {smsProvider === "twilio" && (
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="twilio-sid">Account SID</Label>
+                      <Input
+                        id="twilio-sid"
+                        type="password"
+                        placeholder="AC••••••••••••••••••••••••••••••••"
+                        value={twilioAccountSid}
+                        onChange={(e) => setTwilioAccountSid(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="twilio-token">Auth Token</Label>
+                      <Input
+                        id="twilio-token"
+                        type="password"
+                        placeholder="••••••••••••••••••••••••••••••••"
+                        value={twilioAuthToken}
+                        onChange={(e) => setTwilioAuthToken(e.target.value)}
+                      />
+                    </div>
+                  </div>
+                )}
+
+                {/* Vonage fields */}
+                {smsProvider === "vonage" && (
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="vonage-key">API Key</Label>
+                      <Input
+                        id="vonage-key"
+                        type="password"
+                        placeholder="••••••••"
+                        value={vonageApiKey}
+                        onChange={(e) => setVonageApiKey(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="vonage-secret">API Secret</Label>
+                      <Input
+                        id="vonage-secret"
+                        type="password"
+                        placeholder="••••••••••••••••"
+                        value={vonageApiSecret}
+                        onChange={(e) => setVonageApiSecret(e.target.value)}
+                      />
+                    </div>
+                  </div>
+                )}
+
+                {/* Telnyx fields */}
+                {smsProvider === "telnyx" && (
+                  <div className="space-y-2">
+                    <Label htmlFor="telnyx-key">API Key</Label>
+                    <Input
+                      id="telnyx-key"
+                      type="password"
+                      placeholder="KEY••••••••••••••••••••••••••••••••"
+                      value={telnyxApiKey}
+                      onChange={(e) => setTelnyxApiKey(e.target.value)}
+                    />
+                  </div>
+                )}
+
+                {/* Webhook URL */}
+                {webhookSecret && (
+                  <div className="space-y-2">
+                    <Label>Webhook URL</Label>
+                    <div className="flex items-center gap-2">
+                      <Input
+                        readOnly
+                        value={webhookUrl}
+                        className="font-mono text-xs"
+                      />
+                      <Button
+                        type="button"
+                        variant="outline"
+                        size="icon"
+                        onClick={handleCopyWebhook}
+                      >
+                        {copied ? (
+                          <Check className="h-4 w-4" />
+                        ) : (
+                          <Copy className="h-4 w-4" />
+                        )}
+                      </Button>
+                    </div>
+                    <p className="text-xs text-muted-foreground">
+                      Configure this URL in your {smsProvider} dashboard to
+                      receive inbound SMS messages.
+                    </p>
+                  </div>
+                )}
+
+                {/* Test SMS */}
+                <div className="space-y-3 pt-2">
+                  <div className="space-y-2">
+                    <Label htmlFor="test-phone">Test Phone Number</Label>
+                    <div className="flex items-center gap-2">
+                      <Input
+                        id="test-phone"
+                        placeholder="+15559876543"
+                        value={testPhone}
+                        onChange={(e) => setTestPhone(e.target.value)}
+                        className="max-w-xs"
+                      />
+                      <Button
+                        type="button"
+                        variant="outline"
+                        onClick={handleTestSms}
+                        disabled={isTestDisabled}
+                      >
+                        {isTesting ? (
+                          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                        ) : (
+                          <Send className="mr-2 h-4 w-4" />
+                        )}
+                        Send Test SMS
+                      </Button>
+                    </div>
+                    <p className="text-xs text-muted-foreground">
+                      Save settings first, then enter a recipient number to
+                      send a test SMS
+                    </p>
+                  </div>
+                </div>
+              </>
+            )}
+          </CardContent>
+        </Card>
+
+        <SaveButton>
+          <div className="flex justify-end">
+            <Button onClick={handleSave} disabled={isPending}>
+              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              Save Settings
+            </Button>
+          </div>
+        </SaveButton>
+      </ReadOnlyWrapper>
+    </div>
+  );
+}

+ 20 - 0
src/features/sms/Schema/smsSettingsSchema.ts

@@ -0,0 +1,20 @@
+export const ORG_SMS_KEYS = {
+  SMS_PROVIDER: "sms.provider",
+  SMS_PHONE_NUMBER: "sms.phoneNumber",
+  SMS_WEBHOOK_SECRET: "sms.webhookSecret",
+
+  // Twilio
+  SMS_TWILIO_ACCOUNT_SID: "sms.twilio.accountSid",
+  SMS_TWILIO_AUTH_TOKEN: "sms.twilio.authToken",
+
+  // Vonage
+  SMS_VONAGE_API_KEY: "sms.vonage.apiKey",
+  SMS_VONAGE_API_SECRET: "sms.vonage.apiSecret",
+
+  // Telnyx
+  SMS_TELNYX_API_KEY: "sms.telnyx.apiKey",
+} as const;
+
+export type OrgSmsKey = (typeof ORG_SMS_KEYS)[keyof typeof ORG_SMS_KEYS];
+
+export const ALL_ORG_SMS_KEYS = Object.values(ORG_SMS_KEYS);

+ 9 - 0
src/features/sms/activeSmsView.ts

@@ -0,0 +1,9 @@
+let activeCustomerId: string | null = null;
+
+export function setActiveSmsCustomerId(id: string | null) {
+  activeCustomerId = id;
+}
+
+export function getActiveSmsCustomerId(): string | null {
+  return activeCustomerId;
+}

+ 1 - 1
src/features/vehicles/Actions/serviceActions.ts

@@ -519,7 +519,7 @@ export async function getWorkOrders(params: {
               model: true,
               year: true,
               licensePlate: true,
-              customer: { select: { id: true, name: true } },
+              customer: { select: { id: true, name: true, email: true, phone: true } },
             },
           },
         },

+ 123 - 1
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -3,14 +3,21 @@
 import { useState } from "react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
 import {
   Dialog,
   DialogContent,
   DialogHeader,
   DialogTitle,
 } from "@/components/ui/dialog";
-import { Check, Copy, Link2, Loader2 } from "lucide-react";
+import { Check, Copy, Link2, Loader2, Mail, MessageSquare } from "lucide-react";
+import { toast } from "sonner";
 import { generatePublicLink, revokePublicLink } from "@/features/vehicles/Actions/serviceActions";
+import { sendInvoiceEmail } from "@/features/email/Actions/emailActions";
+import { sendSmsToCustomer, getSmsTemplates } from "@/features/sms/Actions/smsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from "@/lib/sms-templates";
 
 interface ShareDialogProps {
   open: boolean;
@@ -18,6 +25,14 @@ interface ShareDialogProps {
   recordId: string;
   organizationId: string;
   initialToken: string | null;
+  customer?: {
+    id: string;
+    name: string;
+    email: string | null;
+    phone: string | null;
+  } | null;
+  smsEnabled?: boolean;
+  emailEnabled?: boolean;
 }
 
 export function ShareDialog({
@@ -26,10 +41,19 @@ export function ShareDialog({
   recordId,
   organizationId,
   initialToken,
+  customer,
+  smsEnabled = false,
+  emailEnabled = false,
 }: ShareDialogProps) {
   const [publicToken, setPublicToken] = useState<string | null>(initialToken);
   const [generatingLink, setGeneratingLink] = useState(false);
   const [copied, setCopied] = useState(false);
+  const [notifyEmail, setNotifyEmail] = useState(false);
+  const [notifySms, setNotifySms] = useState(false);
+  const [sending, setSending] = useState(false);
+
+  const hasEmail = !!customer?.email;
+  const hasPhone = !!customer?.phone;
 
   const publicUrl = publicToken && organizationId
     ? `${typeof window !== "undefined" ? window.location.origin : ""}/share/invoice/${organizationId}/${publicToken}`
@@ -55,6 +79,52 @@ export function ShareDialog({
     }
   };
 
+  const handleNotify = async () => {
+    if (!publicUrl || !customer) return;
+    setSending(true);
+
+    const results: string[] = [];
+
+    if (notifyEmail && hasEmail) {
+      const res = await sendInvoiceEmail({
+        serviceRecordId: recordId,
+        recipientEmail: customer.email!,
+      });
+      if (res.success) results.push("Email sent");
+      else toast.error(res.error || "Failed to send email");
+    }
+
+    if (notifySms && hasPhone) {
+      const tplResult = await getSmsTemplates();
+      const tplData = tplResult.success && tplResult.data ? tplResult.data : null;
+      const tpl = tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY]
+        || SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY];
+      const body = interpolateSmsTemplate(tpl || "", {
+        share_link: publicUrl,
+        customer_name: customer.name,
+        company_name: tplData?.companyName || "",
+        current_user: tplData?.currentUser || "",
+      });
+      const res = await sendSmsToCustomer({
+        customerId: customer.id,
+        body,
+        relatedEntityType: "invoice",
+        relatedEntityId: recordId,
+      });
+      if (res.success) results.push("SMS sent");
+      else toast.error(res.error || "Failed to send SMS");
+    }
+
+    if (results.length > 0) {
+      toast.success(results.join(" & "));
+      setNotifyEmail(false);
+      setNotifySms(false);
+    }
+    setSending(false);
+  };
+
+  const canNotify = publicUrl && customer && (notifyEmail || notifySms);
+
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
       <DialogContent className="max-w-md">
@@ -76,6 +146,58 @@ export function ShareDialog({
                   {copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
                 </Button>
               </div>
+
+              {/* Notify customer */}
+              {customer && (emailEnabled || smsEnabled) && (
+                <div className="space-y-3 rounded-lg border p-3">
+                  <p className="text-sm font-medium">Notify {customer.name}</p>
+                  <div className="space-y-2">
+                    {emailEnabled && (
+                      <div className="flex items-center gap-2">
+                        <Checkbox
+                          id="notify-email"
+                          checked={notifyEmail}
+                          onCheckedChange={(v) => setNotifyEmail(v === true)}
+                          disabled={!hasEmail}
+                        />
+                        <Label
+                          htmlFor="notify-email"
+                          className={`flex items-center gap-1.5 text-sm ${!hasEmail ? "text-muted-foreground/50" : ""}`}
+                        >
+                          <Mail className="h-3.5 w-3.5" />
+                          Email
+                          {!hasEmail && <span className="text-xs">(no email on file)</span>}
+                        </Label>
+                      </div>
+                    )}
+                    {smsEnabled && (
+                      <div className="flex items-center gap-2">
+                        <Checkbox
+                          id="notify-sms"
+                          checked={notifySms}
+                          onCheckedChange={(v) => setNotifySms(v === true)}
+                          disabled={!hasPhone}
+                        />
+                        <Label
+                          htmlFor="notify-sms"
+                          className={`flex items-center gap-1.5 text-sm ${!hasPhone ? "text-muted-foreground/50" : ""}`}
+                        >
+                          <MessageSquare className="h-3.5 w-3.5" />
+                          SMS
+                          {!hasPhone && <span className="text-xs">(no phone on file)</span>}
+                        </Label>
+                      </div>
+                    )}
+                  </div>
+                  {canNotify && (
+                    <Button size="sm" onClick={handleNotify} disabled={sending} className="w-full">
+                      {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                      Send Notification
+                    </Button>
+                  )}
+                </div>
+              )}
+
               <Button
                 variant="ghost"
                 size="sm"

+ 60 - 0
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -19,6 +19,11 @@ import { PaymentsSection } from '../service-detail/PaymentsSection'
 import { ServiceAttachments } from '../service-detail/ServiceAttachments'
 import { ImageCarousel } from '../service-detail/ImageCarousel'
 import { ShareDialog } from '../service-detail/ShareDialog'
+import { NotifyCustomerDialog } from '@/components/notify-customer-dialog'
+import { formatCurrency } from '@/lib/format'
+import { getSmsTemplates } from '@/features/sms/Actions/smsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
 
 import { BasicInfoSection } from '../service-edit/BasicInfoSection'
 import { PartsEditor } from '../service-edit/PartsEditor'
@@ -66,6 +71,8 @@ export interface ServicePageClientProps {
   maxImagesPerService: number
   maxDiagnosticsPerService: number
   maxDocumentsPerService: number
+  smsEnabled?: boolean
+  emailEnabled?: boolean
 }
 
 export function ServicePageClient({
@@ -88,6 +95,8 @@ export function ServicePageClient({
   maxImagesPerService,
   maxDiagnosticsPerService,
   maxDocumentsPerService,
+  smsEnabled = false,
+  emailEnabled = false,
 }: ServicePageClientProps) {
   const router = useRouter()
   const modal = useGlassModal()
@@ -145,6 +154,8 @@ export function ServicePageClient({
   const [deletingPayment, setDeletingPayment] = useState<string | null>(null)
   const [showShareDialog, setShowShareDialog] = useState(false)
   const [showEmailDialog, setShowEmailDialog] = useState(false)
+  const [showPaymentNotifyDialog, setShowPaymentNotifyDialog] = useState(false)
+  const [paymentNotifyMessage, setPaymentNotifyMessage] = useState('')
 
   const displayTotal = totalAmount > 0 ? totalAmount : record.cost
   const paidFromPayments = record.payments?.reduce((sum, p) => sum + p.amount, 0) || 0
@@ -308,6 +319,22 @@ export function ServicePageClient({
     if (result.success) {
       toast.success('Payment recorded')
       router.refresh()
+      if (record.vehicle.customer) {
+        const invoiceNum = record.invoiceNumber || `#${record.id.slice(-8).toUpperCase()}`
+        const tplResult = await getSmsTemplates()
+        const tplData = tplResult.success && tplResult.data ? tplResult.data : null
+        const tpl = tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || ''
+        setPaymentNotifyMessage(
+          interpolateSmsTemplate(tpl, {
+            amount: formatCurrency(data.amount, currencyCode),
+            invoice_number: invoiceNum,
+            customer_name: record.vehicle.customer.name,
+            company_name: tplData?.companyName || '',
+            current_user: tplData?.currentUser || '',
+          })
+        )
+        setShowPaymentNotifyDialog(true)
+      }
       return true
     }
     modal.open('error', 'Error', result.error || 'Failed to record payment')
@@ -321,6 +348,22 @@ export function ServicePageClient({
     if (result.success) {
       toast.success(result.data?.manuallyPaid ? 'Marked as paid' : 'Marked as unpaid')
       router.refresh()
+      if (result.data?.manuallyPaid && record.vehicle.customer) {
+        const invoiceNum = record.invoiceNumber || `#${record.id.slice(-8).toUpperCase()}`
+        const tplResult = await getSmsTemplates()
+        const tplData = tplResult.success && tplResult.data ? tplResult.data : null
+        const tpl = tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || ''
+        setPaymentNotifyMessage(
+          interpolateSmsTemplate(tpl, {
+            amount: '',
+            invoice_number: invoiceNum,
+            customer_name: record.vehicle.customer.name,
+            company_name: tplData?.companyName || '',
+            current_user: tplData?.currentUser || '',
+          })
+        )
+        setShowPaymentNotifyDialog(true)
+      }
     } else {
       modal.open('error', 'Error', result.error || 'Failed to update payment status')
     }
@@ -540,7 +583,24 @@ export function ServicePageClient({
         recordId={record.id}
         organizationId={organizationId}
         initialToken={record.publicToken}
+        customer={record.vehicle.customer}
+        smsEnabled={smsEnabled}
+        emailEnabled={emailEnabled}
       />
+
+      {record.vehicle.customer && (
+        <NotifyCustomerDialog
+          open={showPaymentNotifyDialog}
+          onOpenChange={setShowPaymentNotifyDialog}
+          customer={record.vehicle.customer}
+          defaultMessage={paymentNotifyMessage}
+          emailSubject="Payment Confirmation"
+          smsEnabled={smsEnabled}
+          emailEnabled={emailEnabled}
+          relatedEntityType="service-record"
+          relatedEntityId={record.id}
+        />
+      )}
     </div>
   )
 }

+ 5 - 0
src/lib/features.ts

@@ -13,6 +13,7 @@ export type PlanFeatures = {
   api: boolean
   payments: boolean
   customFields: boolean
+  sms: boolean
   brandingRemoved: boolean
   customPlatformName: boolean
   maxImagesPerService: number
@@ -31,6 +32,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     api: false,
     payments: false,
     customFields: false,
+    sms: false,
     brandingRemoved: false,
     customPlatformName: false,
     maxImagesPerService: 5,
@@ -47,6 +49,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     api: true,
     payments: true,
     customFields: true,
+    sms: true,
     brandingRemoved: true,
     customPlatformName: true,
     maxImagesPerService: 30,
@@ -63,6 +66,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     api: true,
     payments: true,
     customFields: true,
+    sms: true,
     brandingRemoved: true,
     customPlatformName: true,
     maxImagesPerService: 100,
@@ -79,6 +83,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     api: true,
     payments: true,
     customFields: true,
+    sms: true,
     brandingRemoved: true,
     customPlatformName: true,
     maxImagesPerService: 999999,

+ 30 - 0
src/lib/sms-templates.ts

@@ -0,0 +1,30 @@
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+
+export const SMS_TEMPLATE_DEFAULTS: Record<string, string> = {
+  [SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY]:
+    "Your invoice is ready. View it here: {share_link}",
+  [SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]:
+    "Your vehicle inspection report is ready. View it here: {share_link}",
+  [SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS]:
+    "Work has started on your vehicle. We'll keep you updated.",
+  [SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS]:
+    "Your vehicle is waiting for parts. We'll notify you when work resumes.",
+  [SETTING_KEYS.SMS_TEMPLATE_STATUS_READY]:
+    "Your vehicle is ready for pickup!",
+  [SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED]:
+    "Your service is complete. Thank you for your business!",
+  [SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED]:
+    "Payment of {amount} received for invoice {invoice_number}. Thank you!",
+};
+
+/**
+ * Replace `{variable_name}` placeholders in a template string with actual values.
+ */
+export function interpolateSmsTemplate(
+  template: string,
+  variables: Record<string, string>,
+): string {
+  return template.replace(/\{(\w+)\}/g, (match, key) => {
+    return key in variables ? variables[key] : match;
+  });
+}

+ 220 - 0
src/lib/sms.ts

@@ -0,0 +1,220 @@
+import { db } from "./db";
+import { ORG_SMS_KEYS } from "@/features/sms/Schema/smsSettingsSchema";
+
+export type SmsProvider = "twilio" | "vonage" | "telnyx";
+
+export interface SendSmsOptions {
+  to: string;
+  body: string;
+}
+
+type SettingsMap = Map<string, string>;
+
+async function getOrgSettings(
+  organizationId: string,
+  keys: string[],
+): Promise<SettingsMap> {
+  const rows = await db.appSetting.findMany({
+    where: { organizationId, key: { in: keys } },
+  });
+  return new Map(rows.map((r) => [r.key, r.value]));
+}
+
+export async function getOrgSmsProvider(
+  organizationId: string,
+): Promise<SmsProvider | null> {
+  const setting = await db.appSetting.findUnique({
+    where: {
+      organizationId_key: {
+        organizationId,
+        key: ORG_SMS_KEYS.SMS_PROVIDER,
+      },
+    },
+  });
+  const value = setting?.value;
+  if (value === "twilio" || value === "vonage" || value === "telnyx") {
+    return value;
+  }
+  return null;
+}
+
+export async function getOrgSmsPhoneNumber(
+  organizationId: string,
+): Promise<string | null> {
+  const setting = await db.appSetting.findUnique({
+    where: {
+      organizationId_key: {
+        organizationId,
+        key: ORG_SMS_KEYS.SMS_PHONE_NUMBER,
+      },
+    },
+  });
+  return setting?.value || null;
+}
+
+// ─── Twilio ──────────────────────────────────────────────────────────────────
+
+interface TwilioResult {
+  sid: string;
+  status: string;
+}
+
+async function sendViaTwilio(
+  settings: SettingsMap,
+  from: string,
+  to: string,
+  body: string,
+): Promise<TwilioResult> {
+  const accountSid = settings.get(ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID);
+  const authToken = settings.get(ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN);
+
+  if (!accountSid || !authToken) {
+    throw new Error("Twilio is not configured. Add your Account SID and Auth Token.");
+  }
+
+  const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
+  const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
+
+  const params = new URLSearchParams({ From: from, To: to, Body: body });
+
+  const res = await fetch(url, {
+    method: "POST",
+    headers: {
+      Authorization: `Basic ${credentials}`,
+      "Content-Type": "application/x-www-form-urlencoded",
+    },
+    body: params.toString(),
+  });
+
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({}));
+    throw new Error(`Twilio error: ${(err as { message?: string }).message || res.statusText}`);
+  }
+
+  const data = (await res.json()) as TwilioResult;
+  return { sid: data.sid, status: data.status };
+}
+
+// ─── Vonage ──────────────────────────────────────────────────────────────────
+
+interface VonageResult {
+  messageId: string;
+  status: string;
+}
+
+async function sendViaVonage(
+  settings: SettingsMap,
+  from: string,
+  to: string,
+  body: string,
+): Promise<VonageResult> {
+  const apiKey = settings.get(ORG_SMS_KEYS.SMS_VONAGE_API_KEY);
+  const apiSecret = settings.get(ORG_SMS_KEYS.SMS_VONAGE_API_SECRET);
+
+  if (!apiKey || !apiSecret) {
+    throw new Error("Vonage is not configured. Add your API Key and Secret.");
+  }
+
+  const res = await fetch("https://rest.nexmo.com/sms/json", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({
+      api_key: apiKey,
+      api_secret: apiSecret,
+      from,
+      to,
+      text: body,
+    }),
+  });
+
+  if (!res.ok) {
+    throw new Error(`Vonage error: ${res.statusText}`);
+  }
+
+  const data = (await res.json()) as {
+    messages: { "message-id": string; status: string; "error-text"?: string }[];
+  };
+  const msg = data.messages?.[0];
+
+  if (msg?.status !== "0") {
+    throw new Error(`Vonage error: ${msg?.["error-text"] || "Unknown error"}`);
+  }
+
+  return { messageId: msg["message-id"], status: msg.status };
+}
+
+// ─── Telnyx ──────────────────────────────────────────────────────────────────
+
+interface TelnyxResult {
+  id: string;
+}
+
+async function sendViaTelnyx(
+  settings: SettingsMap,
+  from: string,
+  to: string,
+  body: string,
+): Promise<TelnyxResult> {
+  const apiKey = settings.get(ORG_SMS_KEYS.SMS_TELNYX_API_KEY);
+
+  if (!apiKey) {
+    throw new Error("Telnyx is not configured. Add your API Key.");
+  }
+
+  const res = await fetch("https://api.telnyx.com/v2/messages", {
+    method: "POST",
+    headers: {
+      Authorization: `Bearer ${apiKey}`,
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({ from, to, text: body }),
+  });
+
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({}));
+    const errors = (err as { errors?: { detail?: string }[] }).errors;
+    throw new Error(`Telnyx error: ${errors?.[0]?.detail || res.statusText}`);
+  }
+
+  const data = (await res.json()) as { data: { id: string } };
+  return { id: data.data.id };
+}
+
+// ─── Main dispatch ───────────────────────────────────────────────────────────
+
+export interface SendSmsResult {
+  providerMsgId: string;
+}
+
+export async function sendOrgSms(
+  organizationId: string,
+  options: SendSmsOptions,
+): Promise<SendSmsResult> {
+  const provider = await getOrgSmsProvider(organizationId);
+  if (!provider) {
+    throw new Error("SMS is not configured. Set up an SMS provider in Settings.");
+  }
+
+  const from = await getOrgSmsPhoneNumber(organizationId);
+  if (!from) {
+    throw new Error("SMS phone number is not configured.");
+  }
+
+  const allKeys = Object.values(ORG_SMS_KEYS);
+  const settings = await getOrgSettings(organizationId, allKeys);
+
+  switch (provider) {
+    case "twilio": {
+      const result = await sendViaTwilio(settings, from, options.to, options.body);
+      return { providerMsgId: result.sid };
+    }
+    case "vonage": {
+      const result = await sendViaVonage(settings, from, options.to, options.body);
+      return { providerMsgId: result.messageId };
+    }
+    case "telnyx": {
+      const result = await sendViaTelnyx(settings, from, options.to, options.body);
+      return { providerMsgId: result.id };
+    }
+  }
+}