Bernt Christian Egeland 1 bulan lalu
induk
melakukan
d67fd86de3

+ 197 - 4
prisma/seed_dummy_data.ts

@@ -573,6 +573,9 @@ async function cleanup() {
   await prisma.aiChat.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.aiChat.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.notification.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.notification.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.telegramMessage.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.telegramMessage.deleteMany({ where: { organizationId: ORG_ID } });
+  // Customer is SetNull here, so these survive the customer wipe further down
+  // and every reset would stack another set of conversations on the inbox.
+  await prisma.whatsappMessage.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.customerMagicLink.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.customerMagicLink.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.customerSmsCode.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.customerSmsCode.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.auditLog.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.auditLog.deleteMany({ where: { organizationId: ORG_ID } });
@@ -683,17 +686,43 @@ async function seed() {
     "portal.enabled": "true",
     "portal.enabled": "true",
     "portal.description": "Book a service, follow your repair and read your invoices online.",
     "portal.description": "Book a service, follow your repair and read your invoices online.",
     "portal.hours": "Mon-Fri 07:00-17:00 · Sat 09:00-13:00 · Closed Sunday",
     "portal.hours": "Mon-Fri 07:00-17:00 · Sat 09:00-13:00 · Closed Sunday",
-    // Placeholder provider config so the messages and Telegram pages render as
-    // a configured workshop rather than an empty setup prompt. The credentials
+    // Placeholder provider config so the messaging pages render as a
+    // configured workshop rather than an empty setup prompt. The credentials
     // are deliberately not real, and nothing can send from the demo anyway:
     // are deliberately not real, and nothing can send from the demo anyway:
-    // assertOutboundAllowed() in lib/email, lib/sms and lib/telegram refuses
-    // every outbound call while DEMO_MODE=true.
+    // assertOutboundAllowed() in lib/email, lib/sms, lib/whatsapp and
+    // lib/telegram refuses every outbound call while DEMO_MODE=true.
     "sms.provider": "twilio",
     "sms.provider": "twilio",
     "sms.phoneNumber": "+15555550100",
     "sms.phoneNumber": "+15555550100",
     "sms.twilio.accountSid": "ACdemo0000000000000000000000000000",
     "sms.twilio.accountSid": "ACdemo0000000000000000000000000000",
     "sms.twilio.authToken": "demo-token-not-a-real-credential",
     "sms.twilio.authToken": "demo-token-not-a-real-credential",
+    // Without the enabled flag the bot token alone gets the channel into the
+    // inbox, but the Telegram tab on a customer never appears, so the seeded
+    // conversations were only half visible.
+    "telegram.enabled": "true",
     "telegram.botToken": "0000000000:DEMO-not-a-real-bot-token",
     "telegram.botToken": "0000000000:DEMO-not-a-real-bot-token",
     "telegram.botUsername": "EgelandAutoDemoBot",
     "telegram.botUsername": "EgelandAutoDemoBot",
+    // WhatsApp reaches a workshop through whichever provider will sell it a
+    // number, so credentials are namespaced per adapter rather than by field.
+    // Meta is the one most visitors will recognise.
+    "whatsapp.enabled": "true",
+    "whatsapp.provider": "meta",
+    "whatsapp.from": "+15555550100",
+    "whatsapp.cred.meta.phoneNumberId": "000000000000000",
+    "whatsapp.cred.meta.accessToken": "DEMO-not-a-real-access-token",
+    "whatsapp.cred.meta.verifyToken": "demo-verify-token",
+    // Two templates, because WhatsApp fixes a template's media type at
+    // approval and one cannot carry both a line of text and a photo.
+    "whatsapp.tpl.meta.text.name": "vehicle_ready",
+    "whatsapp.tpl.meta.text.language": "en",
+    "whatsapp.tpl.meta.text.variables": "customer,vehicle,message",
+    "whatsapp.tpl.meta.media.name": "vehicle_photo_update",
+    "whatsapp.tpl.meta.media.language": "en",
+    // Meta carries the photo as a header parameter rather than a variable, so
+    // the media template's blanks are the same words as the text one.
+    "whatsapp.tpl.meta.media.variables": "customer,vehicle,message",
+    // Proof the webhook plumbing works, which is otherwise invisible from
+    // inside the app and leaves the settings page looking half finished.
+    "whatsapp.webhookSeenAt": hoursAgo(2).toISOString(),
     // Tire hotel is opt-in per workshop, so the sidebar entry and the
     // Tire hotel is opt-in per workshop, so the sidebar entry and the
     // routes stay hidden until this is set.
     // routes stay hidden until this is set.
     "tireHotel.enabled": "true",
     "tireHotel.enabled": "true",
@@ -2085,6 +2114,8 @@ async function seed() {
     prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "sms", body: "Reminder: your Volvo FH 640 brake inspection is booked for tomorrow at 07:30.", customerId: customers[2].id, vehicleId: vehicles[18].id, status: "scheduled", sendAt: at(2, 8) } }),
     prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "sms", body: "Reminder: your Volvo FH 640 brake inspection is booked for tomorrow at 07:30.", customerId: customers[2].id, vehicleId: vehicles[18].id, status: "scheduled", sendAt: at(2, 8) } }),
     prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "email", subject: "Pre-planting service slots", body: "Hi,\n\nWe are holding two slots for the John Deere 6R and the Massey Ferguson ahead of planting. Let us know which week suits.\n\nEgeland Auto", customerId: customers[10].id, status: "scheduled", sendAt: at(3, 10) } }),
     prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "email", subject: "Pre-planting service slots", body: "Hi,\n\nWe are holding two slots for the John Deere 6R and the Massey Ferguson ahead of planting. Let us know which week suits.\n\nEgeland Auto", customerId: customers[10].id, status: "scheduled", sendAt: at(3, 10) } }),
     prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "telegram", body: "Your Tesla Model Y is booked for a brake fluid test on Monday at 08:00.", customerId: customers[13].id, vehicleId: vehicles[7].id, status: "scheduled", sendAt: at(4, 9) } }),
     prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "telegram", body: "Your Tesla Model Y is booked for a brake fluid test on Monday at 08:00.", customerId: customers[13].id, vehicleId: vehicles[7].id, status: "scheduled", sendAt: at(4, 9) } }),
+    prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "whatsapp", body: "Hi Sarah, a reminder that the BMW 330i is booked in on Tuesday at 09:00 for the brake overhaul.", customerId: customers[3].id, vehicleId: vehicles[4].id, status: "scheduled", sendAt: at(5, 8) } }),
+    prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "whatsapp", body: "The water pump is fitted and the A4 is ready whenever suits you.", customerId: customers[7].id, vehicleId: vehicles[10].id, status: "sent", sendAt: at(-1, 14), sentAt: at(-1, 14), lastRunAt: at(-1, 14), runCount: 1 } }),
 
 
     // Further out
     // Further out
     prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "email", subject: "Track day prep - Porsche 911", body: "Hi Sarah,\n\nBooking your 911 in for brake pads, fluid and tire pressures the week before the track day. Reply with a day that suits.\n\nEgeland Auto", customerId: customers[3].id, vehicleId: vehicles[13].id, status: "scheduled", sendAt: at(9, 11) } }),
     prisma.scheduledMessage.create({ data: { organizationId: ORG_ID, createdById: USER_ID, channel: "email", subject: "Track day prep - Porsche 911", body: "Hi Sarah,\n\nBooking your 911 in for brake pads, fluid and tire pressures the week before the track day. Reply with a day that suits.\n\nEgeland Auto", customerId: customers[3].id, vehicleId: vehicles[13].id, status: "scheduled", sendAt: at(9, 11) } }),
@@ -2107,6 +2138,8 @@ async function seed() {
   console.log("\nCreating Telegram messages...");
   console.log("\nCreating Telegram messages...");
   await prisma.customer.update({ where: { id: customers[13].id }, data: { telegramChatId: "784512996" } });
   await prisma.customer.update({ where: { id: customers[13].id }, data: { telegramChatId: "784512996" } });
   await prisma.customer.update({ where: { id: customers[5].id }, data: { telegramChatId: "612447803" } });
   await prisma.customer.update({ where: { id: customers[5].id }, data: { telegramChatId: "612447803" } });
+  await prisma.customer.update({ where: { id: customers[7].id }, data: { telegramChatId: "533901764" } });
+  await prisma.customer.update({ where: { id: customers[11].id }, data: { telegramChatId: "497260118" } });
   const telegram = await Promise.all([
   const telegram = await Promise.all([
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[13].id, direction: "outbound", chatId: "784512996", body: "Hi Jessica, your Tesla Model Y is booked for a brake fluid test on Monday at 08:00.", status: "delivered", createdAt: hoursAgo(72) } }),
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[13].id, direction: "outbound", chatId: "784512996", body: "Hi Jessica, your Tesla Model Y is booked for a brake fluid test on Monday at 08:00.", status: "delivered", createdAt: hoursAgo(72) } }),
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[13].id, direction: "inbound", chatId: "784512996", body: "Perfect. Can you check the tire wear at the same time?", status: "received", createdAt: hoursAgo(71) } }),
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[13].id, direction: "inbound", chatId: "784512996", body: "Perfect. Can you check the tire wear at the same time?", status: "received", createdAt: hoursAgo(71) } }),
@@ -2114,9 +2147,106 @@ async function seed() {
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[5].id, direction: "outbound", chatId: "612447803", body: "Ryan, the 12V auxiliary battery for the Model 3 is in stock. Want it done this week?", status: "delivered", createdAt: hoursAgo(26) } }),
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[5].id, direction: "outbound", chatId: "612447803", body: "Ryan, the 12V auxiliary battery for the Model 3 is in stock. Want it done this week?", status: "delivered", createdAt: hoursAgo(26) } }),
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[5].id, direction: "inbound", chatId: "612447803", body: "Yes please. Thursday afternoon if you have it.", status: "received", createdAt: hoursAgo(25) } }),
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[5].id, direction: "inbound", chatId: "612447803", body: "Yes please. Thursday afternoon if you have it.", status: "received", createdAt: hoursAgo(25) } }),
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[5].id, direction: "outbound", chatId: "612447803", body: "Thursday 13:00 booked. It's a 30 minute job, you can wait if you like.", status: "delivered", createdAt: hoursAgo(24) } }),
     prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[5].id, direction: "outbound", chatId: "612447803", body: "Thursday 13:00 booked. It's a 30 minute job, you can wait if you like.", status: "delivered", createdAt: hoursAgo(24) } }),
+    prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[7].id, direction: "inbound", chatId: "533901764", body: "The Golf is making a rattle on cold start that goes away after a minute. Worth looking at?", status: "received", createdAt: hoursAgo(9) } }),
+    prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[7].id, direction: "outbound", chatId: "533901764", body: "Sounds like the timing chain tensioner, which is worth catching early on that engine. Can you drop it off Friday morning?", status: "delivered", createdAt: hoursAgo(8) } }),
+    prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[7].id, direction: "inbound", chatId: "533901764", body: "Friday works. I'll leave the key in the box if you're busy.", status: "received", createdAt: hoursAgo(7) } }),
+    prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[11].id, direction: "outbound", chatId: "497260118", body: "Mike, the Wrangler is back on the ground. Lift is torqued and aligned, and we road tested it.", status: "delivered", createdAt: hoursAgo(4) } }),
+    prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[11].id, direction: "inbound", chatId: "497260118", body: "Brilliant, thanks. Picking it up around 16:00.", status: "received", createdAt: hoursAgo(3) } }),
+    // A send that did not land, so the failure state is visible somewhere.
+    prisma.telegramMessage.create({ data: { organizationId: ORG_ID, customerId: customers[11].id, direction: "outbound", chatId: "497260118", body: "Invoice is ready whenever you are.", status: "failed", errorMessage: "Bot was blocked by the user", createdAt: hoursAgo(2) } }),
   ]);
   ]);
   console.log(`  Created ${telegram.length} Telegram messages`);
   console.log(`  Created ${telegram.length} Telegram messages`);
 
 
+  // -- WhatsApp --
+  // WhatsApp only lets a business write freely for 24 hours after the
+  // customer's last message, so the conversations below deliberately sit on
+  // both sides of that line: some recent enough to reply to in the composer,
+  // and one older, where the last message out had to go as an approved
+  // template. Without that, the window rule is invisible on the demo and the
+  // "sent as template" label never appears.
+  console.log("\nCreating WhatsApp messages...");
+  const waFrom = "+15555550100";
+  // Customer 1001 is +1 (555) 555-0101 and they run in order from there. The
+  // 555-01xx block is the reserved fictional range, so none of these can ring.
+  const waTo = (index: number) => `+1555555${String(101 + index).padStart(4, "0")}`;
+  type WaExtra = {
+    status?: string;
+    errorMessage?: string;
+    templateName?: string;
+    mediaUrl?: string;
+    mediaType?: string;
+    mediaFilename?: string;
+  };
+  /** `who` is an index into `customers`, or a bare number for a stranger. */
+  const wa = (
+    who: number | string,
+    direction: "inbound" | "outbound",
+    body: string,
+    createdAt: Date,
+    extra: WaExtra = {},
+  ) => {
+    const number = typeof who === "number" ? waTo(who) : who;
+    return prisma.whatsappMessage.create({
+      data: {
+        organizationId: ORG_ID,
+        provider: "meta",
+        direction,
+        fromNumber: direction === "outbound" ? waFrom : number,
+        toNumber: direction === "outbound" ? number : waFrom,
+        body,
+        status: direction === "outbound" ? "read" : "received",
+        customerId: typeof who === "number" ? customers[who].id : null,
+        createdAt,
+        ...extra,
+      },
+    });
+  };
+
+  const whatsapp = await Promise.all([
+    // Sarah Coleman — inside the 24 hour window, so the composer is open and a
+    // visitor can type a reply straight away.
+    wa(3, "outbound", "Hi Sarah, the 911 is done. Pads, discs and a fluid flush, and the alignment came out inside spec.", hoursAgo(5)),
+    wa(3, "inbound", "Wonderful. Did the front tires make it through?", hoursAgo(4)),
+    wa(3, "outbound", "They did, about 8,000 miles left. Here it is back on the ground.", hoursAgo(4), {
+      mediaUrl: img("vehicles", "porsche-911.jpg"),
+      mediaType: "image",
+      mediaFilename: "porsche-911-collected.jpg",
+    }),
+    wa(3, "inbound", "Looks great. I'll collect it tomorrow morning.", minutesAgo(35)),
+
+    // David Chen — a problem reported, collected and quoted inside one thread,
+    // which is the shape most of this traffic actually takes.
+    wa(7, "inbound", "There's a puddle under the A4 this morning. Coolant, I think, it's pink.", hoursAgo(20)),
+    wa(7, "outbound", "Pink is the right colour for that engine, so it is coolant. Don't drive it far. Can we collect it?", hoursAgo(20)),
+    wa(7, "inbound", "Yes please. It's on the driveway, key under the mat.", hoursAgo(19)),
+    wa(7, "outbound", "Picked up. Water pump is weeping at the seal. Quote is $640 all in, and we have the pump on the shelf.", hoursAgo(6)),
+
+    // Summit Construction — fleet traffic, several vehicles in one thread.
+    wa(0, "outbound", "Morning. The Mack Granite passed its DOT inspection, paperwork is in the cab.", hoursAgo(30)),
+    wa(0, "inbound", "Good news. The F-150 on CO-4455 is due too, can it come in Thursday?", hoursAgo(29)),
+    wa(0, "outbound", "Thursday 07:00 works. Send it with the fuel card and we'll top it up.", hoursAgo(29)),
+
+    // Kevin O'Brien — the window has closed, so the last message out had to be
+    // an approved template rather than free text.
+    wa(15, "inbound", "Any chance the Sprinter is ready? I have an event Saturday.", hoursAgo(96)),
+    wa(15, "outbound", "Turbo is fitted and the oil feed line is flushed. Road tested clean, no smoke under boost.", hoursAgo(95)),
+    wa(15, "outbound", "Hi Kevin, your 2022 Mercedes-Benz Sprinter 316 CDI is ready for collection.", hoursAgo(11), {
+      templateName: "vehicle_ready",
+      status: "delivered",
+    }),
+
+    // Amanda Foster — a send that bounced, so the failure state is on screen.
+    wa(9, "outbound", "Hi Amanda, the clutch quote for the Civic is ready to look at.", hoursAgo(13), {
+      status: "failed",
+      errorMessage: "(#131026) Message undeliverable: the recipient is not on WhatsApp",
+    }),
+
+    // Someone the workshop has never dealt with, which is what most first
+    // contact actually looks like. No customer record behind it yet.
+    wa("+15555550188", "inbound", "Hi, do you take walk-ins for a brake noise? Driving past this afternoon.", hoursAgo(2)),
+  ]);
+  console.log(`  Created ${whatsapp.length} WhatsApp messages`);
+
   // -- Notifications --
   // -- Notifications --
   // The bell is empty on a fresh reset, which makes the app look idle.
   // The bell is empty on a fresh reset, which makes the app look idle.
   console.log("\nCreating notifications...");
   console.log("\nCreating notifications...");
@@ -2129,6 +2259,9 @@ async function seed() {
     prisma.notification.create({ data: { organizationId: ORG_ID, type: "quote_response", title: "Quote accepted", message: `Jeep Wrangler - Winch Install (Q-${YEAR}-011) was accepted by Mike Thompson.`, entityType: "quote", entityId: quotes[10].id, entityUrl: `/quotes/${quotes[10].id}`, read: true, createdAt: hoursAgo(30) } }),
     prisma.notification.create({ data: { organizationId: ORG_ID, type: "quote_response", title: "Quote accepted", message: `Jeep Wrangler - Winch Install (Q-${YEAR}-011) was accepted by Mike Thompson.`, entityType: "quote", entityId: quotes[10].id, entityUrl: `/quotes/${quotes[10].id}`, read: true, createdAt: hoursAgo(30) } }),
     prisma.notification.create({ data: { organizationId: ORG_ID, type: "invoice_payment", title: "Payment received", message: "Pacific Freight Lines settled the Kenworth T680 engine service invoice.", entityType: "invoice", entityId: serviceRecords[4].id, entityUrl: `/work-orders/${serviceRecords[4].id}`, read: true, createdAt: hoursAgo(50) } }),
     prisma.notification.create({ data: { organizationId: ORG_ID, type: "invoice_payment", title: "Payment received", message: "Pacific Freight Lines settled the Kenworth T680 engine service invoice.", entityType: "invoice", entityId: serviceRecords[4].id, entityUrl: `/work-orders/${serviceRecords[4].id}`, read: true, createdAt: hoursAgo(50) } }),
     prisma.notification.create({ data: { organizationId: ORG_ID, type: "telegram_inbound", title: "New Telegram message", message: "Jessica Rivera: Can you check the tire wear at the same time?", entityType: "customer", entityId: customers[13].id, entityUrl: `/customers/${customers[13].id}`, read: true, createdAt: hoursAgo(71) } }),
     prisma.notification.create({ data: { organizationId: ORG_ID, type: "telegram_inbound", title: "New Telegram message", message: "Jessica Rivera: Can you check the tire wear at the same time?", entityType: "customer", entityId: customers[13].id, entityUrl: `/customers/${customers[13].id}`, read: true, createdAt: hoursAgo(71) } }),
+    prisma.notification.create({ data: { organizationId: ORG_ID, type: "whatsapp_inbound", title: "New WhatsApp message", message: "Sarah Coleman: Looks great. I'll collect it tomorrow morning.", entityType: "whatsapp_message", entityId: whatsapp[3].id, entityUrl: `/messages?tab=whatsapp&customerId=${customers[3].id}`, read: false, createdAt: minutesAgo(35) } }),
+    // Nobody on file behind this one, which is what the link has to cope with.
+    prisma.notification.create({ data: { organizationId: ORG_ID, type: "whatsapp_inbound", title: "New WhatsApp message", message: "+15555550188: Hi, do you take walk-ins for a brake noise? Driving past this afternoon.", entityType: "whatsapp_message", entityId: whatsapp[whatsapp.length - 1].id, entityUrl: "/messages?tab=whatsapp", read: false, createdAt: hoursAgo(2) } }),
     prisma.notification.create({ data: { organizationId: ORG_ID, type: "status_report_feedback", title: "Customer replied to a status report", message: "Lisa Martinez: \"Thanks for the video, that makes sense. Go ahead.\"", entityType: "service_record", entityId: serviceRecords[0].id, entityUrl: `/work-orders/${serviceRecords[0].id}`, read: true, createdAt: hoursAgo(96) } }),
     prisma.notification.create({ data: { organizationId: ORG_ID, type: "status_report_feedback", title: "Customer replied to a status report", message: "Lisa Martinez: \"Thanks for the video, that makes sense. Go ahead.\"", entityType: "service_record", entityId: serviceRecords[0].id, entityUrl: `/work-orders/${serviceRecords[0].id}`, read: true, createdAt: hoursAgo(96) } }),
   ]);
   ]);
   console.log(`  Created ${notifications.length} notifications`);
   console.log(`  Created ${notifications.length} notifications`);
@@ -2155,6 +2288,63 @@ async function seed() {
   ]);
   ]);
   console.log(`  Created ${laborPresets.length} labor presets`);
   console.log(`  Created ${laborPresets.length} labor presets`);
 
 
+  // -- Roles --
+  // The demo has one member, so the team page shows nothing about the
+  // permission model unless the roles themselves are there to open. Members
+  // are deliberately not seeded: each one needs a real User row, and inviting
+  // is blocked in demo mode anyway.
+  console.log("\nCreating roles...");
+  const roles = await Promise.all([
+    prisma.role.create({ data: { organizationId: ORG_ID, name: "Service advisor", permissions: { create: [
+      { action: "read", subject: "dashboard" },
+      { action: "read", subject: "vehicles" }, { action: "update", subject: "vehicles" },
+      { action: "create", subject: "customers" }, { action: "read", subject: "customers" }, { action: "update", subject: "customers" },
+      { action: "create", subject: "work_orders" }, { action: "read", subject: "work_orders" }, { action: "update", subject: "work_orders" },
+      { action: "create", subject: "quotes" }, { action: "read", subject: "quotes" }, { action: "update", subject: "quotes" },
+      { action: "read", subject: "work_board" }, { action: "read", subject: "inventory" }, { action: "read", subject: "tire_hotel" },
+    ] } } }),
+    prisma.role.create({ data: { organizationId: ORG_ID, name: "Technician", permissions: { create: [
+      { action: "read", subject: "dashboard" },
+      { action: "read", subject: "vehicles" },
+      { action: "read", subject: "customers" },
+      { action: "read", subject: "work_orders" }, { action: "update", subject: "work_orders" },
+      { action: "read", subject: "work_board" }, { action: "update", subject: "work_board" },
+      { action: "create", subject: "inspections" }, { action: "read", subject: "inspections" }, { action: "update", subject: "inspections" },
+      { action: "read", subject: "inventory" },
+      { action: "read", subject: "tire_hotel" }, { action: "update", subject: "tire_hotel" },
+    ] } } }),
+    prisma.role.create({ data: { organizationId: ORG_ID, name: "Bookkeeper", permissions: { create: [
+      { action: "read", subject: "dashboard" },
+      { action: "read", subject: "customers" },
+      { action: "read", subject: "work_orders" },
+      { action: "read", subject: "billing" }, { action: "update", subject: "billing" },
+      { action: "read", subject: "reports" },
+    ] } } }),
+  ]);
+  console.log(`  Created ${roles.length} roles`);
+
+  // -- Webhooks --
+  // Left inactive on purpose. The dispatcher and the delivery worker both skip
+  // an inactive webhook, so the page shows a real integration with real
+  // delivery history without the demo posting anything at a stranger's URL.
+  console.log("\nCreating webhooks...");
+  const webhooks = await Promise.all([
+    prisma.webhook.create({ data: { organizationId: ORG_ID, createdById: USER_ID, name: "Accounting sync", url: "https://hooks.example.com/torqvoice/accounting", secret: randomBytes(24).toString("hex"), events: JSON.stringify(["payment.create", "quote.status", "service.status"]), description: "Pushes every payment and quote decision into the bookkeeping ledger.", isActive: false, lastTriggeredAt: hoursAgo(5), lastSuccessAt: hoursAgo(5) } }),
+    prisma.webhook.create({ data: { organizationId: ORG_ID, createdById: USER_ID, name: "Fleet portal", url: "https://hooks.example.com/summit/work-orders", secret: randomBytes(24).toString("hex"), events: JSON.stringify(["service.status", "vehicle.update"]), description: "Tells Summit Construction's own system when one of their units is finished.", isActive: false, lastTriggeredAt: hoursAgo(31), lastSuccessAt: hoursAgo(31), failureCount: 1, lastFailureAt: hoursAgo(52) } }),
+  ]);
+  // Payloads match what dispatchWebhookEvent actually sends, so the delivery
+  // detail view shows the real envelope rather than an invented one.
+  const deliveryPayload = (event: string, entity: string, entityId: string, message: string, data: Record<string, unknown>, sentAt: Date) =>
+    JSON.stringify({ id: randomBytes(12).toString("hex"), event, createdAt: sentAt.toISOString(), organizationId: ORG_ID, entity, entityId, message, userId: USER_ID, data });
+  await Promise.all([
+    prisma.webhookDelivery.create({ data: { webhookId: webhooks[0].id, event: "payment.create", payload: deliveryPayload("payment.create", "payment", serviceRecords[12].id, "Payment recorded on the Sprinter turbo invoice", { amount: 2243.7, currency: "USD", method: "card" }, hoursAgo(5)), status: "success", statusCode: 200, attempt: 1, durationMs: 214, deliveredAt: hoursAgo(5), createdAt: hoursAgo(5) } }),
+    prisma.webhookDelivery.create({ data: { webhookId: webhooks[0].id, event: "quote.status", payload: deliveryPayload("quote.status", "quote", quotes[10].id, `Quote Q-${YEAR}-011 was accepted`, { status: "accepted", total: 3180 }, hoursAgo(30)), status: "success", statusCode: 200, attempt: 1, durationMs: 187, deliveredAt: hoursAgo(30), createdAt: hoursAgo(30) } }),
+    prisma.webhookDelivery.create({ data: { webhookId: webhooks[1].id, event: "service.status", payload: deliveryPayload("service.status", "service_record", serviceRecords[0].id, "Transmission flush completed", { status: "completed", licensePlate: "CO-4455" }, hoursAgo(31)), status: "success", statusCode: 202, attempt: 2, durationMs: 1340, deliveredAt: hoursAgo(31), createdAt: hoursAgo(31) } }),
+    // Five attempts, all timed out. This is the row that pushed failureCount up.
+    prisma.webhookDelivery.create({ data: { webhookId: webhooks[1].id, event: "service.status", payload: deliveryPayload("service.status", "service_record", serviceRecords[4].id, "DOT inspection completed", { status: "completed", licensePlate: "AZ-9901" }, hoursAgo(52)), status: "failed", statusCode: 504, errorMessage: "Gateway timeout after 10000ms", attempt: 5, maxAttempts: 5, durationMs: 10000, createdAt: hoursAgo(52) } }),
+  ]);
+  console.log(`  Created ${webhooks.length} webhooks`);
+
   // -- Report schedules --
   // -- Report schedules --
   console.log("\nCreating report schedules...");
   console.log("\nCreating report schedules...");
   const reportSchedules = await Promise.all([
   const reportSchedules = await Promise.all([
@@ -2669,10 +2859,13 @@ async function seed() {
   console.log(`  Findings:           ${findings.length}`);
   console.log(`  Findings:           ${findings.length}`);
   console.log(`  Inspections:        ${inspections.length} (${templates.length} templates)`);
   console.log(`  Inspections:        ${inspections.length} (${templates.length} templates)`);
   console.log(`  SMS Messages:       22`);
   console.log(`  SMS Messages:       22`);
+  console.log(`  WhatsApp Messages:  ${whatsapp.length}`);
   console.log(`  Telegram Messages:  ${telegram.length}`);
   console.log(`  Telegram Messages:  ${telegram.length}`);
   console.log(`  Scheduled Messages: ${scheduledMessages.length}`);
   console.log(`  Scheduled Messages: ${scheduledMessages.length}`);
   console.log(`  Notifications:      ${notifications.length}`);
   console.log(`  Notifications:      ${notifications.length}`);
   console.log(`  Labor Presets:      ${laborPresets.length}`);
   console.log(`  Labor Presets:      ${laborPresets.length}`);
+  console.log(`  Roles:              ${roles.length}`);
+  console.log(`  Webhooks:           ${webhooks.length} (inactive)`);
   console.log(`  Report Schedules:   ${reportSchedules.length}`);
   console.log(`  Report Schedules:   ${reportSchedules.length}`);
   console.log(`  Recurring Invoices: ${recurring.length}`);
   console.log(`  Recurring Invoices: ${recurring.length}`);
   console.log(`  Custom Fields:      ${customFields.length}`);
   console.log(`  Custom Fields:      ${customFields.length}`);

+ 71 - 0
src/__tests__/lib/demo-guard-coverage.test.ts

@@ -25,6 +25,27 @@ function actionFiles(dir: string): string[] {
   })
   })
 }
 }
 
 
+/**
+ * Every server-side `fetch` that can carry an org's own data or credentials to
+ * a host outside this box, and where it is stopped in demo mode. A new one
+ * belongs in a transport that refuses, and then in this list.
+ */
+// Quote style is biome's business and it differs across these files, so the
+// patterns accept either rather than breaking on the next reformat.
+const EGRESS_PATHS: Array<{ file: string; stoppedBy: RegExp }> = [
+  { file: 'src/lib/email.ts', stoppedBy: /assertOutboundAllowed\(['"]email['"]\)/ },
+  { file: 'src/lib/sms.ts', stoppedBy: /assertOutboundAllowed\(['"]sms['"]\)/ },
+  { file: 'src/lib/telegram.ts', stoppedBy: /assertOutboundAllowed\(['"]telegram['"]\)/ },
+  { file: 'src/lib/whatsapp/index.ts', stoppedBy: /assertOutboundAllowed\(['"]whatsapp['"]\)/ },
+  // Webhooks are the odd one out: delivery must not throw, so both the
+  // dispatcher and the wire check the flag and return instead.
+  { file: 'src/features/webhooks/Lib/dispatcher.ts', stoppedBy: /if \(isDemoMode\) return/ },
+  { file: 'src/features/webhooks/Lib/deliver.ts', stoppedBy: /if \(isDemoMode\)/ },
+  // Fetches whatever URL it is handed, which on a public demo is an open
+  // outbound proxy rather than a part lookup.
+  { file: 'src/app/api/protected/fetch-metadata/route.ts', stoppedBy: /if \(isDemoMode\)/ },
+]
+
 describe('demo mode', () => {
 describe('demo mode', () => {
   it('refuses every action that reaches a customer', () => {
   it('refuses every action that reaches a customer', () => {
     const unguarded: string[] = []
     const unguarded: string[] = []
@@ -40,4 +61,54 @@ describe('demo mode', () => {
 
 
     expect(unguarded).toEqual([])
     expect(unguarded).toEqual([])
   })
   })
+
+  it('stops every outbound transport before it reaches the network', () => {
+    const unstopped = EGRESS_PATHS.filter(
+      ({ file, stoppedBy }) => !stoppedBy.test(fs.readFileSync(file, 'utf-8'))
+    ).map(({ file }) => file)
+
+    expect(unstopped, `these can leave the box on the demo:\n${unstopped.join('\n')}`).toEqual([])
+  })
+
+  it('leaves no server-side fetch outside that list', () => {
+    // A `fetch` in a new lib/route is the shape every hole so far has had, so
+    // finding one that nobody has classified is worth failing over. Payment
+    // providers are exempt: their credentials cannot be set on the demo, so
+    // there is no configured client for anything to call.
+    const searchRoots = ['src/lib', 'src/app/api', 'src/features']
+    // The directory names are anchored to a path separator on both sides:
+    // unanchored, `hooks/` also matches `webhooks/` and quietly exempts the
+    // one tree in here that talks to a URL somebody else chose.
+    const exempt =
+      /__tests__|src[/\\]lib[/\\]payment-providers[/\\]|[/\\](Components|hooks)[/\\]|\.tsx$/
+
+    const files: string[] = []
+    const walk = (dir: string) => {
+      for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+        const full = path.join(dir, entry.name)
+        if (entry.isDirectory()) walk(full)
+        else if (entry.isFile() && full.endsWith('.ts') && !exempt.test(full)) files.push(full)
+      }
+    }
+    searchRoots.forEach(walk)
+
+    const known = new Set(EGRESS_PATHS.map((e) => e.file.split(path.posix.sep).join(path.sep)))
+    // Adapters are reached only through their transport, which already refuses.
+    const reachedViaTransport = /whatsapp[/\\]adapters[/\\]/
+    // Guarded by demoGuard() in the action that calls them, or by isDemoMode
+    // in the cron that does.
+    const guardedByCaller =
+      /aiSettingsActions\.ts$|validateLicense\.ts$|cron[/\\]check-licenses\.ts$/
+
+    const unclassified = files.filter((file) => {
+      if (known.has(file) || reachedViaTransport.test(file) || guardedByCaller.test(file))
+        return false
+      return /\bawait fetch\(|= fetch\(/.test(fs.readFileSync(file, 'utf-8'))
+    })
+
+    expect(
+      unclassified,
+      `these make a server-side request nobody has classified for demo mode:\n${unclassified.join('\n')}`
+    ).toEqual([])
+  })
 })
 })

+ 7 - 0
src/__tests__/lib/demo-guards.test.ts

@@ -79,6 +79,11 @@ describe('the settings back door', () => {
     'sms.webhookSecret',
     'sms.webhookSecret',
     'telegram.botToken',
     'telegram.botToken',
     'telegram.webhookSecret',
     'telegram.webhookSecret',
+    'whatsapp.cred.meta.accessToken',
+    'whatsapp.cred.meta.phoneNumberId',
+    'whatsapp.cred.twilio.authToken',
+    'whatsapp.provider',
+    'whatsapp.from',
     'email.smtp.pass',
     'email.smtp.pass',
     'email.smtp.host',
     'email.smtp.host',
     'email.resend.apiKey',
     'email.resend.apiKey',
@@ -100,6 +105,8 @@ describe('the settings back door', () => {
     'tireHotel.defaultSeasonalPrice',
     'tireHotel.defaultSeasonalPrice',
     'telegram.enabled',
     'telegram.enabled',
     'telegram.template.quoteReady',
     'telegram.template.quoteReady',
+    'whatsapp.enabled',
+    'whatsapp.tpl.meta.text.name',
     'sms.template.quoteReady',
     'sms.template.quoteReady',
     'email.enabled',
     'email.enabled',
     'email.fromName',
     'email.fromName',

+ 34 - 33
src/__tests__/lib/demo-outbound.test.ts

@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
 
 
 /**
 /**
  * The demo instance seeds customer-looking contact details and a queue of
  * The demo instance seeds customer-looking contact details and a queue of
@@ -6,50 +6,51 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
  * that keep any of that from reaching a real inbox or handset.
  * that keep any of that from reaching a real inbox or handset.
  */
  */
 
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
   db: {
     scheduledMessage: {
     scheduledMessage: {
       findMany: vi.fn(() => {
       findMany: vi.fn(() => {
-        throw new Error("the demo must not read the send queue at all");
+        throw new Error('the demo must not read the send queue at all')
       }),
       }),
     },
     },
   },
   },
-}));
+}))
 
 
-describe("demo mode blocks every outbound transport", () => {
+describe('demo mode blocks every outbound transport', () => {
   beforeEach(() => {
   beforeEach(() => {
-    vi.resetModules();
-    process.env.DEMO_MODE = "true";
-  });
+    vi.resetModules()
+    process.env.DEMO_MODE = 'true'
+  })
   afterEach(() => {
   afterEach(() => {
-    delete process.env.DEMO_MODE;
-  });
+    delete process.env.DEMO_MODE
+  })
 
 
-  it("refuses email, SMS and Telegram", async () => {
-    const { assertOutboundAllowed } = await import("@/lib/demo");
-    for (const channel of ["email", "sms", "telegram"] as const) {
-      expect(() => assertOutboundAllowed(channel)).toThrow(/disabled on the demo/);
+  it('refuses email, SMS, WhatsApp and Telegram', async () => {
+    const { assertOutboundAllowed } = await import('@/lib/demo')
+    for (const channel of ['email', 'sms', 'whatsapp', 'telegram'] as const) {
+      expect(() => assertOutboundAllowed(channel)).toThrow(/disabled on the demo/)
     }
     }
-  });
+  })
 
 
-  it("allows them when demo mode is off", async () => {
-    process.env.DEMO_MODE = "false";
-    vi.resetModules();
-    const { assertOutboundAllowed } = await import("@/lib/demo");
-    expect(() => assertOutboundAllowed("email")).not.toThrow();
-  });
+  it('allows them when demo mode is off', async () => {
+    process.env.DEMO_MODE = 'false'
+    vi.resetModules()
+    const { assertOutboundAllowed } = await import('@/lib/demo')
+    expect(() => assertOutboundAllowed('email')).not.toThrow()
+  })
 
 
-  it("unlocks the plan features so the messaging pages render", async () => {
-    const { getFeatures } = await import("@/lib/features");
-    const features = await getFeatures("any-org");
-    expect(features.sms).toBe(true);
-    expect(features.telegram).toBe(true);
+  it('unlocks the plan features so the messaging pages render', async () => {
+    const { getFeatures } = await import('@/lib/features')
+    const features = await getFeatures('any-org')
+    expect(features.sms).toBe(true)
+    expect(features.whatsapp).toBe(true)
+    expect(features.telegram).toBe(true)
     // Branding stays on: the demo is still advertising the product.
     // Branding stays on: the demo is still advertising the product.
-    expect(features.brandingRemoved).toBe(false);
-  });
+    expect(features.brandingRemoved).toBe(false)
+  })
 
 
-  it("stops the scheduled-message cron before it reads the queue", async () => {
-    const { processDueMessages } = await import("@/lib/cron/scheduled-messages");
-    await expect(processDueMessages()).resolves.toBe(0);
-  });
-});
+  it('stops the scheduled-message cron before it reads the queue', async () => {
+    const { processDueMessages } = await import('@/lib/cron/scheduled-messages')
+    await expect(processDueMessages()).resolves.toBe(0)
+  })
+})

+ 156 - 159
src/app/api/protected/fetch-metadata/route.ts

@@ -1,66 +1,81 @@
-import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
-import { headers } from "next/headers";
-import { checkWebhookUrl } from "@/features/webhooks/Lib/ssrf";
+import { NextRequest, NextResponse } from 'next/server'
+import { auth } from '@/lib/auth'
+import { headers } from 'next/headers'
+import { isDemoMode } from '@/lib/demo'
+import { checkWebhookUrl } from '@/features/webhooks/Lib/ssrf'
 
 
 // Cap the redirect chain we will follow (mirrors browser/undici defaults) so a
 // Cap the redirect chain we will follow (mirrors browser/undici defaults) so a
 // malicious or misconfigured target can't loop us indefinitely.
 // malicious or misconfigured target can't loop us indefinitely.
-const MAX_REDIRECTS = 20;
+const MAX_REDIRECTS = 20
 
 
 interface Metadata {
 interface Metadata {
-  name?: string;
-  description?: string;
-  partNumber?: string;
-  unitCost?: number;
-  supplier?: string;
-  category?: string;
-  imageUrl?: string;
+  name?: string
+  description?: string
+  partNumber?: string
+  unitCost?: number
+  supplier?: string
+  category?: string
+  imageUrl?: string
 }
 }
 
 
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
   const session = await auth.api.getSession({
   const session = await auth.api.getSession({
     headers: await headers(),
     headers: await headers(),
-  });
+  })
 
 
   if (!session?.user?.id) {
   if (!session?.user?.id) {
-    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
   }
   }
 
 
-  let url: string;
+  // This is a route that fetches whatever URL it is handed, and the demo's
+  // credentials are printed on the sign-in page. The SSRF guard keeps it off
+  // the internal network, but on a public instance it is still an open
+  // outbound proxy for anyone who wants one.
+  if (isDemoMode) {
+    return NextResponse.json(
+      {
+        error:
+          'Looking up a part from a URL is disabled on the demo. Install Torqvoice on your own server to use it.',
+      },
+      { status: 403 }
+    )
+  }
+
+  let url: string
   try {
   try {
-    const body = await request.json();
-    url = body.url;
-    if (!url || typeof url !== "string") {
-      return NextResponse.json({ error: "URL is required" }, { status: 400 });
+    const body = await request.json()
+    url = body.url
+    if (!url || typeof url !== 'string') {
+      return NextResponse.json({ error: 'URL is required' }, { status: 400 })
     }
     }
     // Validate URL format
     // Validate URL format
-    new URL(url);
+    new URL(url)
   } catch {
   } catch {
-    return NextResponse.json({ error: "Invalid request" }, { status: 400 });
+    return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
   }
   }
 
 
   try {
   try {
-    const controller = new AbortController();
-    const timeout = setTimeout(() => controller.abort(), 10000);
+    const controller = new AbortController()
+    const timeout = setTimeout(() => controller.abort(), 10000)
 
 
     const requestHeaders = {
     const requestHeaders = {
-      "User-Agent":
-        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+      'User-Agent':
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
       Accept:
       Accept:
-        "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
-      "Accept-Language": "en-US,en;q=0.9,no;q=0.8",
-      "Accept-Encoding": "gzip, deflate, br",
-      "Cache-Control": "no-cache",
-      Pragma: "no-cache",
-      "Sec-Ch-Ua": '"Chromium";v="131", "Not_A Brand";v="24"',
-      "Sec-Ch-Ua-Mobile": "?0",
-      "Sec-Ch-Ua-Platform": '"Windows"',
-      "Sec-Fetch-Dest": "document",
-      "Sec-Fetch-Mode": "navigate",
-      "Sec-Fetch-Site": "none",
-      "Sec-Fetch-User": "?1",
-      "Upgrade-Insecure-Requests": "1",
-    };
+        'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
+      'Accept-Language': 'en-US,en;q=0.9,no;q=0.8',
+      'Accept-Encoding': 'gzip, deflate, br',
+      'Cache-Control': 'no-cache',
+      Pragma: 'no-cache',
+      'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"',
+      'Sec-Ch-Ua-Mobile': '?0',
+      'Sec-Ch-Ua-Platform': '"Windows"',
+      'Sec-Fetch-Dest': 'document',
+      'Sec-Fetch-Mode': 'navigate',
+      'Sec-Fetch-Site': 'none',
+      'Sec-Fetch-User': '?1',
+      'Upgrade-Insecure-Requests': '1',
+    }
 
 
     // Follow redirects manually so the SSRF guard runs on EVERY hop. Plain
     // Follow redirects manually so the SSRF guard runs on EVERY hop. Plain
     // `redirect: "follow"` would let an allowed public URL bounce (via 3xx) into
     // `redirect: "follow"` would let an allowed public URL bounce (via 3xx) into
@@ -68,88 +83,79 @@ export async function POST(request: NextRequest) {
     // validated by checkWebhookUrl (blocks private/loopback/link-local/metadata
     // validated by checkWebhookUrl (blocks private/loopback/link-local/metadata
     // hosts and non-http(s) schemes, with DNS resolution) before we fetch them.
     // hosts and non-http(s) schemes, with DNS resolution) before we fetch them.
     // Strip fragment from URL (servers don't receive it).
     // Strip fragment from URL (servers don't receive it).
-    let currentUrl = url.split("#")[0];
-    let response: Response;
+    let currentUrl = url.split('#')[0]
+    let response: Response
     for (let redirects = 0; ; redirects++) {
     for (let redirects = 0; ; redirects++) {
-      const safety = await checkWebhookUrl(currentUrl);
+      const safety = await checkWebhookUrl(currentUrl)
       if (!safety.ok) {
       if (!safety.ok) {
-        clearTimeout(timeout);
-        return NextResponse.json(
-          { error: "This URL is not allowed." },
-          { status: 400 }
-        );
+        clearTimeout(timeout)
+        return NextResponse.json({ error: 'This URL is not allowed.' }, { status: 400 })
       }
       }
 
 
       response = await fetch(currentUrl, {
       response = await fetch(currentUrl, {
         signal: controller.signal,
         signal: controller.signal,
         headers: requestHeaders,
         headers: requestHeaders,
-        redirect: "manual",
-      });
+        redirect: 'manual',
+      })
 
 
       // Non-3xx → this is the final response (its body is read below).
       // Non-3xx → this is the final response (its body is read below).
-      if (response.status < 300 || response.status >= 400) break;
+      if (response.status < 300 || response.status >= 400) break
 
 
       // It's a redirect: capture the target, then release this intermediate
       // It's a redirect: capture the target, then release this intermediate
       // response body so undici can free the socket instead of holding it open
       // response body so undici can free the socket instead of holding it open
       // across the chain until GC.
       // across the chain until GC.
-      const location = response.headers.get("location");
-      await response.body?.cancel();
-      if (!location) break; // redirect without a target — treat as final
+      const location = response.headers.get('location')
+      await response.body?.cancel()
+      if (!location) break // redirect without a target — treat as final
 
 
       if (redirects >= MAX_REDIRECTS) {
       if (redirects >= MAX_REDIRECTS) {
-        clearTimeout(timeout);
-        return NextResponse.json(
-          { error: "Too many redirects" },
-          { status: 422 }
-        );
+        clearTimeout(timeout)
+        return NextResponse.json({ error: 'Too many redirects' }, { status: 422 })
       }
       }
       // Resolve relative redirects against the current URL; drop any fragment.
       // Resolve relative redirects against the current URL; drop any fragment.
-      currentUrl = new URL(location, currentUrl).toString().split("#")[0];
+      currentUrl = new URL(location, currentUrl).toString().split('#')[0]
     }
     }
 
 
-    clearTimeout(timeout);
+    clearTimeout(timeout)
 
 
     if (!response.ok) {
     if (!response.ok) {
       // Detect Cloudflare / bot-protection challenges
       // Detect Cloudflare / bot-protection challenges
-      const cfMitigated = response.headers.get("cf-mitigated");
-      const server = response.headers.get("server") || "";
-      if (cfMitigated === "challenge" || (response.status === 403 && server.includes("cloudflare"))) {
+      const cfMitigated = response.headers.get('cf-mitigated')
+      const server = response.headers.get('server') || ''
+      if (
+        cfMitigated === 'challenge' ||
+        (response.status === 403 && server.includes('cloudflare'))
+      ) {
         return NextResponse.json(
         return NextResponse.json(
-          { error: "This site has bot protection. Please fill in the fields manually." },
+          { error: 'This site has bot protection. Please fill in the fields manually.' },
           { status: 422 }
           { status: 422 }
-        );
+        )
       }
       }
       return NextResponse.json(
       return NextResponse.json(
         { error: `Failed to fetch URL (${response.status})` },
         { error: `Failed to fetch URL (${response.status})` },
         { status: 422 }
         { status: 422 }
-      );
+      )
     }
     }
 
 
-    const contentType = response.headers.get("content-type") || "";
-    if (!contentType.includes("text/html") && !contentType.includes("application/xhtml")) {
-      return NextResponse.json(
-        { error: "URL does not return HTML content" },
-        { status: 422 }
-      );
+    const contentType = response.headers.get('content-type') || ''
+    if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
+      return NextResponse.json({ error: 'URL does not return HTML content' }, { status: 422 })
     }
     }
 
 
-    const html = await response.text();
-    const metadata = parseMetadata(html, url);
+    const html = await response.text()
+    const metadata = parseMetadata(html, url)
 
 
-    return NextResponse.json(metadata);
+    return NextResponse.json(metadata)
   } catch (err) {
   } catch (err) {
-    if (err instanceof Error && err.name === "AbortError") {
-      return NextResponse.json({ error: "Request timed out" }, { status: 422 });
+    if (err instanceof Error && err.name === 'AbortError') {
+      return NextResponse.json({ error: 'Request timed out' }, { status: 422 })
     }
     }
-    return NextResponse.json(
-      { error: "Failed to fetch metadata" },
-      { status: 422 }
-    );
+    return NextResponse.json({ error: 'Failed to fetch metadata' }, { status: 422 })
   }
   }
 }
 }
 
 
 function parseMetadata(html: string, baseUrl: string): Metadata {
 function parseMetadata(html: string, baseUrl: string): Metadata {
-  const result: Metadata = {};
+  const result: Metadata = {}
 
 
   // Helper to extract content from meta tags
   // Helper to extract content from meta tags
   const getMeta = (attr: string, value: string): string | null => {
   const getMeta = (attr: string, value: string): string | null => {
@@ -157,136 +163,126 @@ function parseMetadata(html: string, baseUrl: string): Metadata {
     const patterns = [
     const patterns = [
       new RegExp(
       new RegExp(
         `<meta[^>]+${attr}=["']${escapeRegex(value)}["'][^>]+content=["']([^"']*)["']`,
         `<meta[^>]+${attr}=["']${escapeRegex(value)}["'][^>]+content=["']([^"']*)["']`,
-        "i"
+        'i'
       ),
       ),
       new RegExp(
       new RegExp(
         `<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${escapeRegex(value)}["']`,
         `<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${escapeRegex(value)}["']`,
-        "i"
+        'i'
       ),
       ),
-    ];
+    ]
     for (const pattern of patterns) {
     for (const pattern of patterns) {
-      const match = html.match(pattern);
-      if (match?.[1]) return decodeHtmlEntities(match[1].trim());
+      const match = html.match(pattern)
+      if (match?.[1]) return decodeHtmlEntities(match[1].trim())
     }
     }
-    return null;
-  };
+    return null
+  }
 
 
   // Name: og:title → <title>
   // Name: og:title → <title>
   result.name =
   result.name =
-    getMeta("property", "og:title") ||
-    getMeta("name", "title") ||
+    getMeta('property', 'og:title') ||
+    getMeta('name', 'title') ||
     html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ||
     html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ||
-    undefined;
+    undefined
   if (result.name) {
   if (result.name) {
-    result.name = decodeHtmlEntities(result.name);
+    result.name = decodeHtmlEntities(result.name)
   }
   }
 
 
   // Description: og:description → meta description
   // Description: og:description → meta description
   result.description =
   result.description =
-    getMeta("property", "og:description") ||
-    getMeta("name", "description") ||
-    undefined;
+    getMeta('property', 'og:description') || getMeta('name', 'description') || undefined
 
 
   // Supplier: og:site_name
   // Supplier: og:site_name
-  result.supplier = getMeta("property", "og:site_name") || undefined;
+  result.supplier = getMeta('property', 'og:site_name') || undefined
 
 
   // Price: product:price:amount → JSON-LD
   // Price: product:price:amount → JSON-LD
   const priceStr =
   const priceStr =
-    getMeta("property", "product:price:amount") ||
-    getMeta("property", "og:price:amount");
+    getMeta('property', 'product:price:amount') || getMeta('property', 'og:price:amount')
   if (priceStr) {
   if (priceStr) {
-    const price = parseFloat(priceStr);
+    const price = parseFloat(priceStr)
     if (!isNaN(price) && price > 0) {
     if (!isNaN(price) && price > 0) {
-      result.unitCost = price;
+      result.unitCost = price
     }
     }
   }
   }
 
 
   // Image: og:image
   // Image: og:image
-  const ogImage = getMeta("property", "og:image");
+  const ogImage = getMeta('property', 'og:image')
   if (ogImage) {
   if (ogImage) {
     try {
     try {
       // Resolve relative URLs against the fetched page
       // Resolve relative URLs against the fetched page
-      const resolved = new URL(ogImage, baseUrl).href;
-      result.imageUrl = resolved;
+      const resolved = new URL(ogImage, baseUrl).href
+      result.imageUrl = resolved
     } catch {
     } catch {
-      result.imageUrl = ogImage;
+      result.imageUrl = ogImage
     }
     }
   }
   }
 
 
   // Category: product:category meta tag
   // Category: product:category meta tag
   result.category =
   result.category =
-    getMeta("property", "product:category") ||
-    getMeta("property", "og:category") ||
-    undefined;
+    getMeta('property', 'product:category') || getMeta('property', 'og:category') || undefined
 
 
   // Try JSON-LD for price, sku, mpn, category
   // Try JSON-LD for price, sku, mpn, category
   const jsonLdMatches = html.matchAll(
   const jsonLdMatches = html.matchAll(
     /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
     /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
-  );
+  )
   for (const match of jsonLdMatches) {
   for (const match of jsonLdMatches) {
     try {
     try {
-      const data = JSON.parse(match[1]);
-      extractFromJsonLd(data, result);
+      const data = JSON.parse(match[1])
+      extractFromJsonLd(data, result)
     } catch {
     } catch {
       // Skip invalid JSON-LD
       // Skip invalid JSON-LD
     }
     }
   }
   }
 
 
-  return result;
+  return result
 }
 }
 
 
 function extractFromJsonLd(data: unknown, result: Metadata): void {
 function extractFromJsonLd(data: unknown, result: Metadata): void {
-  if (!data || typeof data !== "object") return;
+  if (!data || typeof data !== 'object') return
 
 
   // Handle arrays (multiple JSON-LD blocks)
   // Handle arrays (multiple JSON-LD blocks)
   if (Array.isArray(data)) {
   if (Array.isArray(data)) {
     for (const item of data) {
     for (const item of data) {
-      extractFromJsonLd(item, result);
+      extractFromJsonLd(item, result)
     }
     }
-    return;
+    return
   }
   }
 
 
-  const obj = data as Record<string, unknown>;
+  const obj = data as Record<string, unknown>
 
 
   // Check for Product type
   // Check for Product type
-  const type = obj["@type"];
-  const isProduct =
-    type === "Product" ||
-    (Array.isArray(type) && type.includes("Product"));
+  const type = obj['@type']
+  const isProduct = type === 'Product' || (Array.isArray(type) && type.includes('Product'))
 
 
   if (isProduct) {
   if (isProduct) {
     // Name from JSON-LD (only if not already set)
     // Name from JSON-LD (only if not already set)
-    if (!result.name && typeof obj.name === "string") {
-      result.name = obj.name;
+    if (!result.name && typeof obj.name === 'string') {
+      result.name = obj.name
     }
     }
 
 
     // Description from JSON-LD
     // Description from JSON-LD
-    if (!result.description && typeof obj.description === "string") {
-      result.description = obj.description;
+    if (!result.description && typeof obj.description === 'string') {
+      result.description = obj.description
     }
     }
 
 
     // SKU / MPN → partNumber
     // SKU / MPN → partNumber
     if (!result.partNumber) {
     if (!result.partNumber) {
-      if (typeof obj.sku === "string" && obj.sku) {
-        result.partNumber = obj.sku;
-      } else if (typeof obj.mpn === "string" && obj.mpn) {
-        result.partNumber = obj.mpn;
+      if (typeof obj.sku === 'string' && obj.sku) {
+        result.partNumber = obj.sku
+      } else if (typeof obj.mpn === 'string' && obj.mpn) {
+        result.partNumber = obj.mpn
       }
       }
     }
     }
 
 
     // Price from offers
     // Price from offers
     if (!result.unitCost && obj.offers) {
     if (!result.unitCost && obj.offers) {
-      const offers = Array.isArray(obj.offers) ? obj.offers : [obj.offers];
+      const offers = Array.isArray(obj.offers) ? obj.offers : [obj.offers]
       for (const offer of offers) {
       for (const offer of offers) {
-        if (offer && typeof offer === "object") {
-          const offerObj = offer as Record<string, unknown>;
-          const price =
-            offerObj.price !== undefined
-              ? parseFloat(String(offerObj.price))
-              : NaN;
+        if (offer && typeof offer === 'object') {
+          const offerObj = offer as Record<string, unknown>
+          const price = offerObj.price !== undefined ? parseFloat(String(offerObj.price)) : NaN
           if (!isNaN(price) && price > 0) {
           if (!isNaN(price) && price > 0) {
-            result.unitCost = price;
-            break;
+            result.unitCost = price
+            break
           }
           }
         }
         }
       }
       }
@@ -294,63 +290,64 @@ function extractFromJsonLd(data: unknown, result: Metadata): void {
 
 
     // Image from JSON-LD Product
     // Image from JSON-LD Product
     if (!result.imageUrl && obj.image) {
     if (!result.imageUrl && obj.image) {
-      if (typeof obj.image === "string") {
-        result.imageUrl = obj.image;
-      } else if (Array.isArray(obj.image) && typeof obj.image[0] === "string") {
-        result.imageUrl = obj.image[0];
+      if (typeof obj.image === 'string') {
+        result.imageUrl = obj.image
+      } else if (Array.isArray(obj.image) && typeof obj.image[0] === 'string') {
+        result.imageUrl = obj.image[0]
       }
       }
     }
     }
 
 
     // Category from JSON-LD Product
     // Category from JSON-LD Product
-    if (!result.category && typeof obj.category === "string" && obj.category) {
-      result.category = obj.category;
+    if (!result.category && typeof obj.category === 'string' && obj.category) {
+      result.category = obj.category
     }
     }
 
 
     // Brand as supplier fallback
     // Brand as supplier fallback
     if (!result.supplier && obj.brand) {
     if (!result.supplier && obj.brand) {
-      if (typeof obj.brand === "string") {
-        result.supplier = obj.brand;
+      if (typeof obj.brand === 'string') {
+        result.supplier = obj.brand
       } else if (
       } else if (
-        typeof obj.brand === "object" &&
+        typeof obj.brand === 'object' &&
         obj.brand !== null &&
         obj.brand !== null &&
-        typeof (obj.brand as Record<string, unknown>).name === "string"
+        typeof (obj.brand as Record<string, unknown>).name === 'string'
       ) {
       ) {
-        result.supplier = (obj.brand as Record<string, unknown>).name as string;
+        result.supplier = (obj.brand as Record<string, unknown>).name as string
       }
       }
     }
     }
   }
   }
 
 
   // BreadcrumbList → category fallback (use second-to-last or last meaningful breadcrumb)
   // BreadcrumbList → category fallback (use second-to-last or last meaningful breadcrumb)
   const isBreadcrumb =
   const isBreadcrumb =
-    type === "BreadcrumbList" ||
-    (Array.isArray(type) && type.includes("BreadcrumbList"));
+    type === 'BreadcrumbList' || (Array.isArray(type) && type.includes('BreadcrumbList'))
   if (isBreadcrumb && !result.category && Array.isArray(obj.itemListElement)) {
   if (isBreadcrumb && !result.category && Array.isArray(obj.itemListElement)) {
     const items = obj.itemListElement
     const items = obj.itemListElement
       .filter(
       .filter(
         (item: unknown) =>
         (item: unknown) =>
-          item && typeof item === "object" && typeof (item as Record<string, unknown>).name === "string"
+          item &&
+          typeof item === 'object' &&
+          typeof (item as Record<string, unknown>).name === 'string'
       )
       )
-      .map((item: unknown) => (item as Record<string, unknown>).name as string);
+      .map((item: unknown) => (item as Record<string, unknown>).name as string)
     // Pick the last meaningful breadcrumb (skip Home / first item, and the product name itself)
     // Pick the last meaningful breadcrumb (skip Home / first item, and the product name itself)
     if (items.length >= 3) {
     if (items.length >= 3) {
-      result.category = items[items.length - 2];
+      result.category = items[items.length - 2]
     } else if (items.length === 2) {
     } else if (items.length === 2) {
-      result.category = items[1];
+      result.category = items[1]
     }
     }
   }
   }
 }
 }
 
 
 function escapeRegex(str: string): string {
 function escapeRegex(str: string): string {
-  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
 }
 }
 
 
 function decodeHtmlEntities(str: string): string {
 function decodeHtmlEntities(str: string): string {
   return str
   return str
-    .replace(/&amp;/g, "&")
-    .replace(/&lt;/g, "<")
-    .replace(/&gt;/g, ">")
+    .replace(/&amp;/g, '&')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
     .replace(/&quot;/g, '"')
     .replace(/&quot;/g, '"')
     .replace(/&#39;/g, "'")
     .replace(/&#39;/g, "'")
     .replace(/&#x27;/g, "'")
     .replace(/&#x27;/g, "'")
-    .replace(/&#x2F;/g, "/");
+    .replace(/&#x2F;/g, '/')
 }
 }

+ 21 - 14
src/features/admin/Actions/testEmailConnection.ts

@@ -1,31 +1,38 @@
-"use server";
+'use server'
 
 
-import { withSuperAdmin } from "@/lib/with-super-admin";
-import { db } from "@/lib/db";
-import { sendMail, getFromAddress } from "@/lib/email";
-import { SYSTEM_SETTING_KEYS } from "../Schema/systemSettingsSchema";
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { sendMail, getFromAddress } from '@/lib/email'
+import { SYSTEM_SETTING_KEYS } from '../Schema/systemSettingsSchema'
 
 
 export async function testEmailConnection() {
 export async function testEmailConnection() {
   return withSuperAdmin(async (ctx) => {
   return withSuperAdmin(async (ctx) => {
+    // Unreachable today, since the demo owner is not a super admin and
+    // sendMail refuses anyway. Kept because every other action in this
+    // directory carries it, and the day one of those two facts changes is not
+    // the day to find out this one was the exception.
+    demoGuard()
+
     const user = await db.user.findUnique({
     const user = await db.user.findUnique({
       where: { id: ctx.userId },
       where: { id: ctx.userId },
       select: { email: true, name: true },
       select: { email: true, name: true },
-    });
+    })
 
 
     if (!user?.email) {
     if (!user?.email) {
-      throw new Error("Could not find your email address");
+      throw new Error('Could not find your email address')
     }
     }
 
 
     const providerSetting = await db.systemSetting.findUnique({
     const providerSetting = await db.systemSetting.findUnique({
       where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
       where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
-    });
-    const provider = providerSetting?.value || "smtp";
-    const from = await getFromAddress();
+    })
+    const provider = providerSetting?.value || 'smtp'
+    const from = await getFromAddress()
 
 
     await sendMail({
     await sendMail({
       from,
       from,
       to: user.email,
       to: user.email,
-      subject: "Email Test - Torqvoice",
+      subject: 'Email Test - Torqvoice',
       html: `
       html: `
         <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
         <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
           <h2>Email Configuration Test</h2>
           <h2>Email Configuration Test</h2>
@@ -39,8 +46,8 @@ export async function testEmailConnection() {
           </p>
           </p>
         </div>
         </div>
       `,
       `,
-    });
+    })
 
 
-    return { sentTo: user.email };
-  });
+    return { sentTo: user.email }
+  })
 }
 }

+ 15 - 9
src/features/admin/Actions/toggleSuperAdmin.ts

@@ -1,23 +1,29 @@
-"use server";
+'use server'
 
 
-import { withSuperAdmin } from "@/lib/with-super-admin";
-import { db } from "@/lib/db";
-import { toggleSuperAdminSchema } from "../Schema/adminSchema";
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { toggleSuperAdminSchema } from '../Schema/adminSchema'
 
 
 export async function toggleSuperAdmin(input: { userId: string; isSuperAdmin: boolean }) {
 export async function toggleSuperAdmin(input: { userId: string; isSuperAdmin: boolean }) {
   return withSuperAdmin(async (ctx) => {
   return withSuperAdmin(async (ctx) => {
-    const { userId, isSuperAdmin } = toggleSuperAdminSchema.parse(input);
+    // Granting platform-wide admin is the one thing in here worth refusing
+    // twice, so it refuses alongside deleteUser and deleteOrganization rather
+    // than resting on the demo owner never being a super admin.
+    demoGuard()
+
+    const { userId, isSuperAdmin } = toggleSuperAdminSchema.parse(input)
 
 
     if (userId === ctx.userId) {
     if (userId === ctx.userId) {
-      throw new Error("Cannot modify your own super admin status");
+      throw new Error('Cannot modify your own super admin status')
     }
     }
 
 
     const user = await db.user.update({
     const user = await db.user.update({
       where: { id: userId },
       where: { id: userId },
       data: { isSuperAdmin },
       data: { isSuperAdmin },
       select: { id: true, name: true, isSuperAdmin: true },
       select: { id: true, name: true, isSuperAdmin: true },
-    });
+    })
 
 
-    return user;
-  });
+    return user
+  })
 }
 }

+ 69 - 61
src/features/onboarding/Actions/createOnboardingOrg.ts

@@ -1,59 +1,70 @@
-"use server";
+'use server'
 
 
-import { headers, cookies } from "next/headers";
-import { getLocale, getTranslations } from "next-intl/server";
-import { auth } from "@/lib/auth";
-import { db } from "@/lib/db";
-import { logAudit } from "@/lib/audit";
-import { onboardingSchema } from "../Schema/onboardingSchema";
+import { headers, cookies } from 'next/headers'
+import { getLocale, getTranslations } from 'next-intl/server'
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
+import { logAudit } from '@/lib/audit'
+import { onboardingSchema } from '../Schema/onboardingSchema'
 import {
 import {
   installDefaultInspectionTemplates,
   installDefaultInspectionTemplates,
   installDefaultLaborPresets,
   installDefaultLaborPresets,
-} from "../Lib/onboardingDefaults";
-import { seedSampleData } from "../Lib/sampleData";
-import {
-  CHECKLIST_DISMISSED_KEY,
-  SAMPLE_DATA_IDS_KEY,
-} from "../Lib/onboardingKeys";
-import type { ActionResult } from "@/lib/with-auth";
+} from '../Lib/onboardingDefaults'
+import { seedSampleData } from '../Lib/sampleData'
+import { CHECKLIST_DISMISSED_KEY, SAMPLE_DATA_IDS_KEY } from '../Lib/onboardingKeys'
+import type { ActionResult } from '@/lib/with-auth'
 
 
 export async function createOnboardingOrg(
 export async function createOnboardingOrg(
-  input: unknown,
+  input: unknown
 ): Promise<ActionResult<{ organizationId: string }>> {
 ): Promise<ActionResult<{ organizationId: string }>> {
   try {
   try {
-    const session = await auth.api.getSession({ headers: await headers() });
+    const session = await auth.api.getSession({ headers: await headers() })
     if (!session?.user?.id) {
     if (!session?.user?.id) {
-      return { success: false, error: "Unauthorized" };
+      return { success: false, error: 'Unauthorized' }
+    }
+
+    // createOrganization carries the same refusal, but sign-up is open on the
+    // demo and this is the path a new account lands on, so it was the way
+    // around it. The seed purges foreign organizations on every reset, which
+    // is a worse answer than saying so: it looks like the workshop somebody
+    // set up simply vanished.
+    if (isDemoMode) {
+      return {
+        success: false,
+        error:
+          'Creating a workshop is disabled on the demo. Sign in with the demo account, or install Torqvoice on your own server.',
+      }
     }
     }
 
 
-    const data = onboardingSchema.parse(input);
+    const data = onboardingSchema.parse(input)
 
 
     // Guard against duplicate org creation
     // Guard against duplicate org creation
     const existingMembership = await db.organizationMember.findFirst({
     const existingMembership = await db.organizationMember.findFirst({
       where: { userId: session.user.id },
       where: { userId: session.user.id },
-    });
+    })
     if (existingMembership) {
     if (existingMembership) {
-      return { success: false, error: "You already belong to an organization" };
+      return { success: false, error: 'You already belong to an organization' }
     }
     }
 
 
     const org = await db.organization.create({
     const org = await db.organization.create({
       data: { name: data.workshopName },
       data: { name: data.workshopName },
-    });
+    })
 
 
     await db.organizationMember.create({
     await db.organizationMember.create({
       data: {
       data: {
         userId: session.user.id,
         userId: session.user.id,
         organizationId: org.id,
         organizationId: org.id,
-        role: "owner",
+        role: 'owner',
       },
       },
-    });
+    })
 
 
-    const cookieStore = await cookies();
-    cookieStore.set("active-org-id", org.id, {
-      path: "/",
+    const cookieStore = await cookies()
+    cookieStore.set('active-org-id', org.id, {
+      path: '/',
       httpOnly: true,
       httpOnly: true,
-      sameSite: "lax",
-    });
+      sameSite: 'lax',
+    })
 
 
     // First-run setup: a default inspection template, common labor presets
     // First-run setup: a default inspection template, common labor presets
     // and (optionally) removable sample data. All best-effort: the org and
     // and (optionally) removable sample data. All best-effort: the org and
@@ -61,21 +72,13 @@ export async function createOnboardingOrg(
     // never fails the action — the library sync tops templates up later
     // never fails the action — the library sync tops templates up later
     // anyway.
     // anyway.
     try {
     try {
-      const [locale, t] = await Promise.all([
-        getLocale(),
-        getTranslations("onboarding"),
-      ]);
+      const [locale, t] = await Promise.all([getLocale(), getTranslations('onboarding')])
 
 
-      const template = await installDefaultInspectionTemplates(org.id, locale);
-      await installDefaultLaborPresets(org.id, session.user.id, t);
+      const template = await installDefaultInspectionTemplates(org.id, locale)
+      await installDefaultLaborPresets(org.id, session.user.id, t)
 
 
       if (data.loadSampleData) {
       if (data.loadSampleData) {
-        const sampleIds = await seedSampleData(
-          org.id,
-          session.user.id,
-          t,
-          template
-        );
+        const sampleIds = await seedSampleData(org.id, session.user.id, t, template)
         await db.appSetting.create({
         await db.appSetting.create({
           data: {
           data: {
             organizationId: org.id,
             organizationId: org.id,
@@ -83,7 +86,7 @@ export async function createOnboardingOrg(
             value: JSON.stringify(sampleIds),
             value: JSON.stringify(sampleIds),
             userId: session.user.id,
             userId: session.user.id,
           },
           },
-        });
+        })
       }
       }
 
 
       // Written for every new org: its presence is what tells the dashboard
       // Written for every new org: its presence is what tells the dashboard
@@ -92,41 +95,46 @@ export async function createOnboardingOrg(
         data: {
         data: {
           organizationId: org.id,
           organizationId: org.id,
           key: CHECKLIST_DISMISSED_KEY,
           key: CHECKLIST_DISMISSED_KEY,
-          value: "false",
+          value: 'false',
           userId: session.user.id,
           userId: session.user.id,
         },
         },
-      });
+      })
     } catch (setupError) {
     } catch (setupError) {
       console.error(
       console.error(
-        "[createOnboardingOrg] First-run setup failed:",
+        '[createOnboardingOrg] First-run setup failed:',
         setupError instanceof Error ? setupError.message : setupError
         setupError instanceof Error ? setupError.message : setupError
-      );
+      )
     }
     }
 
 
     // Audit: log registration + org creation (first org = new user onboarding)
     // Audit: log registration + org creation (first org = new user onboarding)
-    const h = await headers();
-    const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() || h.get("x-real-ip") || null;
-    const userAgent = h.get("user-agent") || null;
-    const ctx = { userId: session.user.id, organizationId: org.id };
+    const h = await headers()
+    const ip = h.get('x-forwarded-for')?.split(',')[0]?.trim() || h.get('x-real-ip') || null
+    const userAgent = h.get('user-agent') || null
+    const ctx = { userId: session.user.id, organizationId: org.id }
     logAudit(ctx, {
     logAudit(ctx, {
-      action: "auth.register",
+      action: 'auth.register',
       message: `New user registered: ${session.user.email}`,
       message: `New user registered: ${session.user.email}`,
-      ip, userAgent,
-    }).catch(() => { /* best-effort */ });
+      ip,
+      userAgent,
+    }).catch(() => {
+      /* best-effort */
+    })
     logAudit(ctx, {
     logAudit(ctx, {
-      action: "organization.create",
-      entity: "Organization",
+      action: 'organization.create',
+      entity: 'Organization',
       entityId: org.id,
       entityId: org.id,
       message: `Created organization: ${data.workshopName}`,
       message: `Created organization: ${data.workshopName}`,
       metadata: { organizationName: data.workshopName },
       metadata: { organizationName: data.workshopName },
-      ip, userAgent,
-    }).catch(() => { /* best-effort */ });
+      ip,
+      userAgent,
+    }).catch(() => {
+      /* best-effort */
+    })
 
 
-    return { success: true, data: { organizationId: org.id } };
+    return { success: true, data: { organizationId: org.id } }
   } catch (error) {
   } catch (error) {
-    const message =
-      error instanceof Error ? error.message : "An unexpected error occurred";
-    console.error("[createOnboardingOrg] Error:", message);
-    return { success: false, error: message };
+    const message = error instanceof Error ? error.message : 'An unexpected error occurred'
+    console.error('[createOnboardingOrg] Error:', message)
+    return { success: false, error: message }
   }
   }
 }
 }

+ 96 - 95
src/features/report-schedule/Actions/reportScheduleActions.ts

@@ -1,46 +1,47 @@
-"use server";
-
-import { toSafeDate } from "@/lib/invoice-utils";
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { revalidatePath } from "next/cache";
+'use server'
+
+import { toSafeDate } from '@/lib/invoice-utils'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { demoGuard } from '@/lib/demo'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { revalidatePath } from 'next/cache'
 import {
 import {
   createReportScheduleSchema,
   createReportScheduleSchema,
   updateReportScheduleSchema,
   updateReportScheduleSchema,
-} from "../Schema/reportScheduleSchema";
+} from '../Schema/reportScheduleSchema'
 
 
 function calculateNextRunDate(from: Date, frequency: string): Date {
 function calculateNextRunDate(from: Date, frequency: string): Date {
-  const next = new Date(from);
+  const next = new Date(from)
   switch (frequency) {
   switch (frequency) {
-    case "daily":
-      next.setDate(next.getDate() + 1);
-      break;
-    case "weekly":
-      next.setDate(next.getDate() + 7);
-      break;
-    case "biweekly":
-      next.setDate(next.getDate() + 14);
-      break;
-    case "monthly":
-      next.setMonth(next.getMonth() + 1);
-      break;
-    case "bimonthly":
-      next.setMonth(next.getMonth() + 2);
-      break;
-    case "quarterly":
-      next.setMonth(next.getMonth() + 4);
-      break;
-    case "semiannually":
-      next.setMonth(next.getMonth() + 6);
-      break;
-    case "yearly":
-      next.setFullYear(next.getFullYear() + 1);
-      break;
+    case 'daily':
+      next.setDate(next.getDate() + 1)
+      break
+    case 'weekly':
+      next.setDate(next.getDate() + 7)
+      break
+    case 'biweekly':
+      next.setDate(next.getDate() + 14)
+      break
+    case 'monthly':
+      next.setMonth(next.getMonth() + 1)
+      break
+    case 'bimonthly':
+      next.setMonth(next.getMonth() + 2)
+      break
+    case 'quarterly':
+      next.setMonth(next.getMonth() + 4)
+      break
+    case 'semiannually':
+      next.setMonth(next.getMonth() + 6)
+      break
+    case 'yearly':
+      next.setFullYear(next.getFullYear() + 1)
+      break
   }
   }
   // Set to 8:00 AM
   // Set to 8:00 AM
-  next.setHours(8, 0, 0, 0);
-  return next;
+  next.setHours(8, 0, 0, 0)
+  return next
 }
 }
 
 
 export async function getReportSchedules() {
 export async function getReportSchedules() {
@@ -48,21 +49,19 @@ export async function getReportSchedules() {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const schedules = await db.reportSchedule.findMany({
       const schedules = await db.reportSchedule.findMany({
         where: { organizationId },
         where: { organizationId },
-        orderBy: { createdAt: "desc" },
-      });
+        orderBy: { createdAt: 'desc' },
+      })
 
 
       return schedules.map((s) => ({
       return schedules.map((s) => ({
         ...s,
         ...s,
         sections: JSON.parse(s.sections) as string[],
         sections: JSON.parse(s.sections) as string[],
         recipients: JSON.parse(s.recipients) as string[],
         recipients: JSON.parse(s.recipients) as string[],
-      }));
+      }))
     },
     },
     {
     {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.REPORTS },
-      ],
-    },
-  );
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }],
+    }
+  )
 }
 }
 
 
 export async function getOrgMembers() {
 export async function getOrgMembers() {
@@ -73,28 +72,26 @@ export async function getOrgMembers() {
         select: {
         select: {
           user: { select: { id: true, name: true, email: true } },
           user: { select: { id: true, name: true, email: true } },
         },
         },
-      });
-      return members.map((m) => m.user);
+      })
+      return members.map((m) => m.user)
     },
     },
     {
     {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.REPORTS },
-      ],
-    },
-  );
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }],
+    }
+  )
 }
 }
 
 
 export async function createReportSchedule(input: unknown) {
 export async function createReportSchedule(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId, userId }) => {
     async ({ organizationId, userId }) => {
-      const data = createReportScheduleSchema.parse(input);
-      const nextRunDate = calculateNextRunDate(new Date(), data.frequency);
+      const data = createReportScheduleSchema.parse(input)
+      const nextRunDate = calculateNextRunDate(new Date(), data.frequency)
 
 
       const schedule = await db.reportSchedule.create({
       const schedule = await db.reportSchedule.create({
         data: {
         data: {
-          name: data.name || "Scheduled Report",
+          name: data.name || 'Scheduled Report',
           frequency: data.frequency,
           frequency: data.frequency,
-          dateRange: data.dateRange || "last30d",
+          dateRange: data.dateRange || 'last30d',
           sections: JSON.stringify(data.sections),
           sections: JSON.stringify(data.sections),
           recipients: JSON.stringify(data.recipients),
           recipients: JSON.stringify(data.recipients),
           nextRunDate,
           nextRunDate,
@@ -102,10 +99,10 @@ export async function createReportSchedule(input: unknown) {
           organizationId,
           organizationId,
           createdById: userId,
           createdById: userId,
         },
         },
-      });
+      })
 
 
-      revalidatePath("/settings/report-schedule");
-      return schedule;
+      revalidatePath('/settings/report-schedule')
+      return schedule
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -114,41 +111,41 @@ export async function createReportSchedule(input: unknown) {
           subject: PermissionSubject.REPORTS,
           subject: PermissionSubject.REPORTS,
         },
         },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function updateReportSchedule(input: unknown) {
 export async function updateReportSchedule(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      const data = updateReportScheduleSchema.parse(input);
+      const data = updateReportScheduleSchema.parse(input)
 
 
       const existing = await db.reportSchedule.findFirst({
       const existing = await db.reportSchedule.findFirst({
         where: { id: data.id, organizationId },
         where: { id: data.id, organizationId },
-      });
-      if (!existing) throw new Error("Schedule not found");
+      })
+      if (!existing) throw new Error('Schedule not found')
 
 
-      const frequencyChanged = data.frequency !== existing.frequency;
+      const frequencyChanged = data.frequency !== existing.frequency
       const nextRunDate = frequencyChanged
       const nextRunDate = frequencyChanged
         ? calculateNextRunDate(new Date(), data.frequency)
         ? calculateNextRunDate(new Date(), data.frequency)
-        : existing.nextRunDate;
+        : existing.nextRunDate
 
 
       const schedule = await db.reportSchedule.update({
       const schedule = await db.reportSchedule.update({
         where: { id: data.id },
         where: { id: data.id },
         data: {
         data: {
-          name: data.name || "Scheduled Report",
+          name: data.name || 'Scheduled Report',
           frequency: data.frequency,
           frequency: data.frequency,
-          dateRange: data.dateRange || "last30d",
+          dateRange: data.dateRange || 'last30d',
           sections: JSON.stringify(data.sections),
           sections: JSON.stringify(data.sections),
           recipients: JSON.stringify(data.recipients),
           recipients: JSON.stringify(data.recipients),
           nextRunDate,
           nextRunDate,
           endDate: toSafeDate(data.endDate) ?? null,
           endDate: toSafeDate(data.endDate) ?? null,
           isActive: data.isActive ?? existing.isActive,
           isActive: data.isActive ?? existing.isActive,
         },
         },
-      });
+      })
 
 
-      revalidatePath("/settings/report-schedule");
-      return schedule;
+      revalidatePath('/settings/report-schedule')
+      return schedule
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -157,8 +154,8 @@ export async function updateReportSchedule(input: unknown) {
           subject: PermissionSubject.REPORTS,
           subject: PermissionSubject.REPORTS,
         },
         },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function deleteReportSchedule(id: string) {
 export async function deleteReportSchedule(id: string) {
@@ -166,13 +163,13 @@ export async function deleteReportSchedule(id: string) {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const existing = await db.reportSchedule.findFirst({
       const existing = await db.reportSchedule.findFirst({
         where: { id, organizationId },
         where: { id, organizationId },
-      });
-      if (!existing) throw new Error("Schedule not found");
+      })
+      if (!existing) throw new Error('Schedule not found')
 
 
-      await db.reportSchedule.delete({ where: { id } });
+      await db.reportSchedule.delete({ where: { id } })
 
 
-      revalidatePath("/settings/report-schedule");
-      return { deleted: true };
+      revalidatePath('/settings/report-schedule')
+      return { deleted: true }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -181,24 +178,28 @@ export async function deleteReportSchedule(id: string) {
           subject: PermissionSubject.REPORTS,
           subject: PermissionSubject.REPORTS,
         },
         },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function sendReportNow(id: string) {
 export async function sendReportNow(id: string) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
+      // processOneSchedule already returns early in demo mode, so without this
+      // the visitor gets `{ sent: true }` for a report that was never built and
+      // never left. Refusing out loud is the honest answer.
+      demoGuard()
       const schedule = await db.reportSchedule.findFirst({
       const schedule = await db.reportSchedule.findFirst({
         where: { id, organizationId },
         where: { id, organizationId },
-      });
-      if (!schedule) throw new Error("Schedule not found");
+      })
+      if (!schedule) throw new Error('Schedule not found')
 
 
       // Dynamically import and run the cron's processing logic for this single schedule
       // Dynamically import and run the cron's processing logic for this single schedule
-      const { processOneSchedule } = await import("@/lib/cron/report-schedules");
-      await processOneSchedule(schedule);
+      const { processOneSchedule } = await import('@/lib/cron/report-schedules')
+      await processOneSchedule(schedule)
 
 
-      revalidatePath("/settings/report-schedule");
-      return { sent: true };
+      revalidatePath('/settings/report-schedule')
+      return { sent: true }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -207,8 +208,8 @@ export async function sendReportNow(id: string) {
           subject: PermissionSubject.REPORTS,
           subject: PermissionSubject.REPORTS,
         },
         },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function toggleReportSchedule(id: string) {
 export async function toggleReportSchedule(id: string) {
@@ -216,21 +217,21 @@ export async function toggleReportSchedule(id: string) {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const existing = await db.reportSchedule.findFirst({
       const existing = await db.reportSchedule.findFirst({
         where: { id, organizationId },
         where: { id, organizationId },
-      });
-      if (!existing) throw new Error("Schedule not found");
+      })
+      if (!existing) throw new Error('Schedule not found')
 
 
-      const isActive = !existing.isActive;
+      const isActive = !existing.isActive
       const nextRunDate = isActive
       const nextRunDate = isActive
         ? calculateNextRunDate(new Date(), existing.frequency)
         ? calculateNextRunDate(new Date(), existing.frequency)
-        : existing.nextRunDate;
+        : existing.nextRunDate
 
 
       const schedule = await db.reportSchedule.update({
       const schedule = await db.reportSchedule.update({
         where: { id },
         where: { id },
         data: { isActive, nextRunDate },
         data: { isActive, nextRunDate },
-      });
+      })
 
 
-      revalidatePath("/settings/report-schedule");
-      return schedule;
+      revalidatePath('/settings/report-schedule')
+      return schedule
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -239,6 +240,6 @@ export async function toggleReportSchedule(id: string) {
           subject: PermissionSubject.REPORTS,
           subject: PermissionSubject.REPORTS,
         },
         },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }

+ 255 - 215
src/features/team/Actions/teamActions.ts

@@ -1,240 +1,280 @@
-"use server";
+'use server'
 
 
-import { cookies } from "next/headers";
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { createOrganizationSchema, inviteMemberSchema, updateMemberRoleSchema } from "../Schema/teamSchema";
-import { revalidatePath } from "next/cache";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { getFeatures, getMaxOrganizations, isCloudMode, FeatureGatedError } from "@/lib/features";
-import { demoGuard } from "@/lib/demo";
+import { cookies } from 'next/headers'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import {
+  createOrganizationSchema,
+  inviteMemberSchema,
+  updateMemberRoleSchema,
+} from '../Schema/teamSchema'
+import { revalidatePath } from 'next/cache'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { getFeatures, getMaxOrganizations, isCloudMode, FeatureGatedError } from '@/lib/features'
+import { demoGuard } from '@/lib/demo'
 
 
 export async function getOrganization() {
 export async function getOrganization() {
-  return withAuth(async ({ userId, organizationId }) => {
-    // Find org where user is a member
-    const membership = await db.organizationMember.findFirst({
-      where: { userId, organizationId },
-      include: {
-        organization: {
-          include: {
-            members: {
-              orderBy: { role: "asc" },
-              include: {
-                customRole: { select: { id: true, name: true } },
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      // Find org where user is a member
+      const membership = await db.organizationMember.findFirst({
+        where: { userId, organizationId },
+        include: {
+          organization: {
+            include: {
+              members: {
+                orderBy: { role: 'asc' },
+                include: {
+                  customRole: { select: { id: true, name: true } },
+                },
               },
               },
             },
             },
           },
           },
         },
         },
-      },
-    });
-
-    if (!membership) return null;
-
-    // Look up user details for each member
-    const memberUserIds = membership.organization.members.map((m) => m.userId);
-    const users = await db.user.findMany({
-      where: { id: { in: memberUserIds } },
-      select: { id: true, name: true, email: true },
-    });
-    const userMap = new Map(users.map((u) => [u.id, u]));
-
-    const membersWithUsers = membership.organization.members.map((m) => ({
-      id: m.id,
-      role: m.role,
-      roleId: m.roleId,
-      customRoleName: m.customRole?.name || null,
-      user: userMap.get(m.userId) || { id: m.userId, name: "Unknown", email: "" },
-    }));
-
-    return {
-      organization: {
-        id: membership.organization.id,
-        name: membership.organization.name,
-        members: membersWithUsers,
-      },
-      currentRole: membership.role,
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }] });
+      })
+
+      if (!membership) return null
+
+      // Look up user details for each member
+      const memberUserIds = membership.organization.members.map((m) => m.userId)
+      const users = await db.user.findMany({
+        where: { id: { in: memberUserIds } },
+        select: { id: true, name: true, email: true },
+      })
+      const userMap = new Map(users.map((u) => [u.id, u]))
+
+      const membersWithUsers = membership.organization.members.map((m) => ({
+        id: m.id,
+        role: m.role,
+        roleId: m.roleId,
+        customRoleName: m.customRole?.name || null,
+        user: userMap.get(m.userId) || { id: m.userId, name: 'Unknown', email: '' },
+      }))
+
+      return {
+        organization: {
+          id: membership.organization.id,
+          name: membership.organization.name,
+          members: membersWithUsers,
+        },
+        currentRole: membership.role,
+      }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
 }
 }
 
 
 export async function createOrganization(input: unknown) {
 export async function createOrganization(input: unknown) {
-  return withAuth(async ({ userId }) => {
-    // A visitor-created org survives the per-org demo reset and hijacks the
-    // shared demo user's active-org cookie for every later visitor.
-    demoGuard();
-    const data = createOrganizationSchema.parse(input);
-
-    if (isCloudMode()) {
-      const ownedCount = await db.organizationMember.count({
-        where: { userId, role: "owner" },
-      });
-      const maxOrgs = await getMaxOrganizations(userId);
-
-      if (ownedCount >= maxOrgs) {
-        throw new Error(
-          `You have reached the maximum number of organizations (${maxOrgs}) for your plan. Upgrade to create more.`,
-        );
+  return withAuth(
+    async ({ userId }) => {
+      // A visitor-created org survives the per-org demo reset and hijacks the
+      // shared demo user's active-org cookie for every later visitor.
+      demoGuard()
+      const data = createOrganizationSchema.parse(input)
+
+      if (isCloudMode()) {
+        const ownedCount = await db.organizationMember.count({
+          where: { userId, role: 'owner' },
+        })
+        const maxOrgs = await getMaxOrganizations(userId)
+
+        if (ownedCount >= maxOrgs) {
+          throw new Error(
+            `You have reached the maximum number of organizations (${maxOrgs}) for your plan. Upgrade to create more.`
+          )
+        }
       }
       }
-    }
 
 
-    const org = await db.$transaction(async (tx) => {
-      const created = await tx.organization.create({
-        data: { name: data.name },
-      });
+      const org = await db.$transaction(async (tx) => {
+        const created = await tx.organization.create({
+          data: { name: data.name },
+        })
 
 
-      await tx.organizationMember.create({
-        data: {
-          userId,
-          organizationId: created.id,
-          role: "owner",
-        },
-      });
-
-      return created;
-    });
-
-    // Auto-switch to the newly created organization
-    const cookieStore = await cookies();
-    cookieStore.set("active-org-id", org.id, {
-      httpOnly: true,
-      sameSite: "lax",
-      path: "/",
-      maxAge: 60 * 60 * 24 * 365,
-    });
-
-    revalidatePath("/settings/team");
-    return org;
-  }, {
-    requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    audit: ({ result }) => ({
-      action: "organization.create",
-      entity: "Organization",
-      entityId: result.id,
-      details: { key: "organization_create", params: { name: result.name } },
-      metadata: { organizationId: result.id },
-    }),
-  });
+        await tx.organizationMember.create({
+          data: {
+            userId,
+            organizationId: created.id,
+            role: 'owner',
+          },
+        })
+
+        return created
+      })
+
+      // Auto-switch to the newly created organization
+      const cookieStore = await cookies()
+      cookieStore.set('active-org-id', org.id, {
+        httpOnly: true,
+        sameSite: 'lax',
+        path: '/',
+        maxAge: 60 * 60 * 24 * 365,
+      })
+
+      revalidatePath('/settings/team')
+      return org
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'organization.create',
+        entity: 'Organization',
+        entityId: result.id,
+        details: { key: 'organization_create', params: { name: result.name } },
+        metadata: { organizationId: result.id },
+      }),
+    }
+  )
 }
 }
 
 
 export async function inviteMember(input: unknown) {
 export async function inviteMember(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const data = inviteMemberSchema.parse(input);
-
-    // Find caller's org and verify they are owner/admin
-    const membership = await db.organizationMember.findFirst({
-      where: { userId, organizationId },
-      include: { organization: true },
-    });
-
-    if (!membership) throw new Error("You don't belong to an organization");
-    if (membership.role === "member") throw new Error("Only owners and admins can invite members");
-
-    const features = await getFeatures(organizationId);
-    const memberCount = await db.organizationMember.count({ where: { organizationId } });
-    if (memberCount >= features.maxUsers) {
-      throw new FeatureGatedError("maxUsers", "Team member limit reached. Upgrade your plan to add more members.");
-    }
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      // This adds an existing account straight into the organization, with no
+      // invitation for them to accept. On the demo that hands anyone who knows a
+      // registered address a seat in the shared workshop. updateMemberRole and
+      // removeMember below already refuse for the same reason.
+      demoGuard()
+      const data = inviteMemberSchema.parse(input)
 
 
-    // Find user by email
-    const invitedUser = await db.user.findFirst({
-      where: { email: data.email },
-    });
-    if (!invitedUser) return { invited: false, userNotFound: true };
-
-    // Check if already a member of this org
-    const existingMembership = await db.organizationMember.findFirst({
-      where: { userId: invitedUser.id, organizationId },
-    });
-    if (existingMembership) {
-      throw new Error("This user is already a member");
-    }
+      // Find caller's org and verify they are owner/admin
+      const membership = await db.organizationMember.findFirst({
+        where: { userId, organizationId },
+        include: { organization: true },
+      })
+
+      if (!membership) throw new Error("You don't belong to an organization")
+      if (membership.role === 'member') throw new Error('Only owners and admins can invite members')
+
+      const features = await getFeatures(organizationId)
+      const memberCount = await db.organizationMember.count({ where: { organizationId } })
+      if (memberCount >= features.maxUsers) {
+        throw new FeatureGatedError(
+          'maxUsers',
+          'Team member limit reached. Upgrade your plan to add more members.'
+        )
+      }
+
+      // Find user by email
+      const invitedUser = await db.user.findFirst({
+        where: { email: data.email },
+      })
+      if (!invitedUser) return { invited: false, userNotFound: true }
+
+      // Check if already a member of this org
+      const existingMembership = await db.organizationMember.findFirst({
+        where: { userId: invitedUser.id, organizationId },
+      })
+      if (existingMembership) {
+        throw new Error('This user is already a member')
+      }
+
+      await db.organizationMember.create({
+        data: {
+          userId: invitedUser.id,
+          organizationId,
+          role: data.role,
+          roleId: data.roleId,
+        },
+      })
 
 
-    await db.organizationMember.create({
-      data: {
-        userId: invitedUser.id,
-        organizationId,
-        role: data.role,
-        roleId: data.roleId,
-      },
-    });
-
-    revalidatePath("/settings/team");
-    return { invited: true, email: data.email, role: data.role };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    audit: ({ result }) => result.invited ? ({
-      action: "team.invite",
-      entity: "OrganizationMember",
-      message: `Invited ${result.email} as ${result.role}`,
-      metadata: { email: result.email, role: result.role },
-    }) : null,
-  });
+      revalidatePath('/settings/team')
+      return { invited: true, email: data.email, role: data.role }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) =>
+        result.invited
+          ? {
+              action: 'team.invite',
+              entity: 'OrganizationMember',
+              message: `Invited ${result.email} as ${result.role}`,
+              metadata: { email: result.email, role: result.role },
+            }
+          : null,
+    }
+  )
 }
 }
 
 
 export async function updateMemberRole(input: unknown) {
 export async function updateMemberRole(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    demoGuard();
-    const data = updateMemberRoleSchema.parse(input);
-
-    const membership = await db.organizationMember.findFirst({
-      where: { userId, organizationId },
-    });
-    if (!membership) throw new Error("You don't belong to an organization");
-    if (membership.role !== "owner") throw new Error("Only the owner can change roles");
-
-    const target = await db.organizationMember.findFirst({
-      where: { id: data.memberId, organizationId },
-    });
-    if (!target) throw new Error("Member not found");
-    if (target.role === "owner") throw new Error("Cannot change the owner's role");
-
-    await db.organizationMember.update({
-      where: { id: data.memberId },
-      data: { role: data.role },
-    });
-
-    revalidatePath("/settings/team");
-    return { updated: true, memberId: data.memberId, role: data.role };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    audit: ({ result }) => ({
-      action: "team.updateRole",
-      entity: "OrganizationMember",
-      entityId: result.memberId,
-      details: { key: "team_updateRole", params: { role: result.role } },
-      metadata: { memberId: result.memberId, role: result.role },
-    }),
-  });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      demoGuard()
+      const data = updateMemberRoleSchema.parse(input)
+
+      const membership = await db.organizationMember.findFirst({
+        where: { userId, organizationId },
+      })
+      if (!membership) throw new Error("You don't belong to an organization")
+      if (membership.role !== 'owner') throw new Error('Only the owner can change roles')
+
+      const target = await db.organizationMember.findFirst({
+        where: { id: data.memberId, organizationId },
+      })
+      if (!target) throw new Error('Member not found')
+      if (target.role === 'owner') throw new Error("Cannot change the owner's role")
+
+      await db.organizationMember.update({
+        where: { id: data.memberId },
+        data: { role: data.role },
+      })
+
+      revalidatePath('/settings/team')
+      return { updated: true, memberId: data.memberId, role: data.role }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'team.updateRole',
+        entity: 'OrganizationMember',
+        entityId: result.memberId,
+        details: { key: 'team_updateRole', params: { role: result.role } },
+        metadata: { memberId: result.memberId, role: result.role },
+      }),
+    }
+  )
 }
 }
 
 
 export async function removeMember(memberId: string) {
 export async function removeMember(memberId: string) {
-  return withAuth(async ({ userId, organizationId }) => {
-    demoGuard();
-    const membership = await db.organizationMember.findFirst({
-      where: { userId, organizationId },
-    });
-    if (!membership) throw new Error("You don't belong to an organization");
-    if (membership.role === "member") throw new Error("Only owners and admins can remove members");
-
-    const target = await db.organizationMember.findFirst({
-      where: { id: memberId, organizationId },
-    });
-    if (!target) throw new Error("Member not found");
-    if (target.role === "owner") throw new Error("Cannot remove the owner");
-    if (target.userId === userId) throw new Error("Cannot remove yourself");
-
-    await db.organizationMember.delete({ where: { id: memberId } });
-
-    revalidatePath("/settings/team");
-    return { removed: true, memberId };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    audit: ({ result }) => ({
-      action: "team.removeMember",
-      entity: "OrganizationMember",
-      entityId: result.memberId,
-      details: { key: "team_removeMember", params: { id: result.memberId } },
-      metadata: { memberId: result.memberId },
-    }),
-  });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      demoGuard()
+      const membership = await db.organizationMember.findFirst({
+        where: { userId, organizationId },
+      })
+      if (!membership) throw new Error("You don't belong to an organization")
+      if (membership.role === 'member') throw new Error('Only owners and admins can remove members')
+
+      const target = await db.organizationMember.findFirst({
+        where: { id: memberId, organizationId },
+      })
+      if (!target) throw new Error('Member not found')
+      if (target.role === 'owner') throw new Error('Cannot remove the owner')
+      if (target.userId === userId) throw new Error('Cannot remove yourself')
+
+      await db.organizationMember.delete({ where: { id: memberId } })
+
+      revalidatePath('/settings/team')
+      return { removed: true, memberId }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'team.removeMember',
+        entity: 'OrganizationMember',
+        entityId: result.memberId,
+        details: { key: 'team_removeMember', params: { id: result.memberId } },
+        metadata: { memberId: result.memberId },
+      }),
+    }
+  )
 }
 }

+ 130 - 140
src/features/webhooks/Actions/webhookActions.ts

@@ -1,33 +1,29 @@
-"use server";
+'use server'
 
 
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { revalidatePath } from "next/cache";
-import {
-  createWebhookSchema,
-  updateWebhookSchema,
-  WEBHOOK_EVENTS,
-} from "../Schema/webhookSchema";
-import { generateWebhookSecret, signPayload } from "../Lib/sign";
-import { deliverOnce } from "../Lib/deliver";
-import { checkWebhookUrl } from "../Lib/ssrf";
-import { demoGuard } from "@/lib/demo";
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { revalidatePath } from 'next/cache'
+import { createWebhookSchema, updateWebhookSchema, WEBHOOK_EVENTS } from '../Schema/webhookSchema'
+import { generateWebhookSecret, signPayload } from '../Lib/sign'
+import { deliverOnce } from '../Lib/deliver'
+import { checkWebhookUrl } from '../Lib/ssrf'
+import { demoGuard } from '@/lib/demo'
 
 
 const SSRF_REASONS: Record<string, string> = {
 const SSRF_REASONS: Record<string, string> = {
-  invalid_url: "URL is not valid",
-  scheme_not_http: "Only http:// and https:// URLs are allowed",
-  missing_host: "URL is missing a host",
-  metadata_host: "Cloud metadata endpoints are not allowed",
-  loopback_host: "Loopback hostnames are not allowed",
-  private_ip: "Private IP addresses are not allowed",
-  resolves_private: "URL resolves to a private network address",
-  dns_resolution_failed: "Could not resolve URL host",
-  dns_no_records: "No DNS records found for URL host",
-};
+  invalid_url: 'URL is not valid',
+  scheme_not_http: 'Only http:// and https:// URLs are allowed',
+  missing_host: 'URL is missing a host',
+  metadata_host: 'Cloud metadata endpoints are not allowed',
+  loopback_host: 'Loopback hostnames are not allowed',
+  private_ip: 'Private IP addresses are not allowed',
+  resolves_private: 'URL resolves to a private network address',
+  dns_resolution_failed: 'Could not resolve URL host',
+  dns_no_records: 'No DNS records found for URL host',
+}
 
 
 function ssrfMessage(reason: string): string {
 function ssrfMessage(reason: string): string {
-  return SSRF_REASONS[reason] ?? `URL was rejected: ${reason}`;
+  return SSRF_REASONS[reason] ?? `URL was rejected: ${reason}`
 }
 }
 
 
 export async function getWebhooks() {
 export async function getWebhooks() {
@@ -35,11 +31,11 @@ export async function getWebhooks() {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const rows = await db.webhook.findMany({
       const rows = await db.webhook.findMany({
         where: { organizationId },
         where: { organizationId },
-        orderBy: { createdAt: "desc" },
+        orderBy: { createdAt: 'desc' },
         include: {
         include: {
           _count: { select: { deliveries: true } },
           _count: { select: { deliveries: true } },
         },
         },
-      });
+      })
       return rows.map((w) => ({
       return rows.map((w) => ({
         id: w.id,
         id: w.id,
         name: w.name,
         name: w.name,
@@ -54,14 +50,12 @@ export async function getWebhooks() {
         autoDisabled: !w.isActive && w.failureCount >= 20,
         autoDisabled: !w.isActive && w.failureCount >= 20,
         deliveryCount: w._count.deliveries,
         deliveryCount: w._count.deliveries,
         createdAt: w.createdAt,
         createdAt: w.createdAt,
-      }));
+      }))
     },
     },
     {
     {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
-      ],
-    },
-  );
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
 }
 }
 
 
 export async function createWebhook(input: unknown) {
 export async function createWebhook(input: unknown) {
@@ -69,11 +63,11 @@ export async function createWebhook(input: unknown) {
     async ({ organizationId, userId }) => {
     async ({ organizationId, userId }) => {
       // Otherwise the demo becomes an open outbound HTTP relay: SSRF blocks
       // Otherwise the demo becomes an open outbound HTTP relay: SSRF blocks
       // private ranges, but not arbitrary public URLs.
       // private ranges, but not arbitrary public URLs.
-      demoGuard();
-      const data = createWebhookSchema.parse(input);
-      const safety = await checkWebhookUrl(data.url);
-      if (!safety.ok) throw new Error(ssrfMessage(safety.reason));
-      const secret = generateWebhookSecret();
+      demoGuard()
+      const data = createWebhookSchema.parse(input)
+      const safety = await checkWebhookUrl(data.url)
+      if (!safety.ok) throw new Error(ssrfMessage(safety.reason))
+      const secret = generateWebhookSecret()
 
 
       const webhook = await db.webhook.create({
       const webhook = await db.webhook.create({
         data: {
         data: {
@@ -86,47 +80,47 @@ export async function createWebhook(input: unknown) {
           organizationId,
           organizationId,
           createdById: userId,
           createdById: userId,
         },
         },
-      });
+      })
 
 
-      revalidatePath("/settings/webhooks");
+      revalidatePath('/settings/webhooks')
       // Returned ONCE on creation; the secret never appears in subsequent reads.
       // Returned ONCE on creation; the secret never appears in subsequent reads.
       return {
       return {
         id: webhook.id,
         id: webhook.id,
         name: webhook.name,
         name: webhook.name,
         url: webhook.url,
         url: webhook.url,
         secret,
         secret,
-      };
+      }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],
       ],
       audit: ({ result }) => ({
       audit: ({ result }) => ({
-        action: "webhook.create",
-        entity: "webhook",
+        action: 'webhook.create',
+        entity: 'webhook',
         entityId: result.id,
         entityId: result.id,
-        details: { key: "webhook_create", params: { name: result.name } },
+        details: { key: 'webhook_create', params: { name: result.name } },
       }),
       }),
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function updateWebhook(input: unknown) {
 export async function updateWebhook(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      demoGuard();
-      const data = updateWebhookSchema.parse(input);
+      demoGuard()
+      const data = updateWebhookSchema.parse(input)
       const existing = await db.webhook.findFirst({
       const existing = await db.webhook.findFirst({
         where: { id: data.id, organizationId },
         where: { id: data.id, organizationId },
-      });
-      if (!existing) throw new Error("Webhook not found");
+      })
+      if (!existing) throw new Error('Webhook not found')
 
 
       if (data.url !== existing.url) {
       if (data.url !== existing.url) {
-        const safety = await checkWebhookUrl(data.url);
-        if (!safety.ok) throw new Error(ssrfMessage(safety.reason));
+        const safety = await checkWebhookUrl(data.url)
+        if (!safety.ok) throw new Error(ssrfMessage(safety.reason))
       }
       }
 
 
-      const reEnabling = data.isActive === true && existing.isActive === false;
+      const reEnabling = data.isActive === true && existing.isActive === false
 
 
       const updated = await db.webhook.update({
       const updated = await db.webhook.update({
         where: { id: data.id },
         where: { id: data.id },
@@ -138,48 +132,53 @@ export async function updateWebhook(input: unknown) {
           isActive: data.isActive ?? existing.isActive,
           isActive: data.isActive ?? existing.isActive,
           ...(reEnabling ? { failureCount: 0 } : {}),
           ...(reEnabling ? { failureCount: 0 } : {}),
         },
         },
-      });
+      })
 
 
-      revalidatePath("/settings/webhooks");
-      return { id: updated.id };
+      revalidatePath('/settings/webhooks')
+      return { id: updated.id }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],
       ],
       audit: ({ result }) => ({
       audit: ({ result }) => ({
-        action: "webhook.update",
-        entity: "webhook",
+        action: 'webhook.update',
+        entity: 'webhook',
         entityId: result.id,
         entityId: result.id,
       }),
       }),
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function toggleWebhook(id: string) {
 export async function toggleWebhook(id: string) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
+      // The demo ships two webhooks so the page has something to show, and
+      // they are inactive on purpose. Nothing would leave the box either way
+      // now that the dispatcher refuses, but a switch that flips and then
+      // never fires reads as a broken feature rather than a disabled one.
+      demoGuard()
       const existing = await db.webhook.findFirst({
       const existing = await db.webhook.findFirst({
         where: { id, organizationId },
         where: { id, organizationId },
-      });
-      if (!existing) throw new Error("Webhook not found");
-      const reEnabling = !existing.isActive;
+      })
+      if (!existing) throw new Error('Webhook not found')
+      const reEnabling = !existing.isActive
       const updated = await db.webhook.update({
       const updated = await db.webhook.update({
         where: { id },
         where: { id },
         data: {
         data: {
           isActive: reEnabling,
           isActive: reEnabling,
           ...(reEnabling ? { failureCount: 0 } : {}),
           ...(reEnabling ? { failureCount: 0 } : {}),
         },
         },
-      });
-      revalidatePath("/settings/webhooks");
-      return { id: updated.id, isActive: updated.isActive };
+      })
+      revalidatePath('/settings/webhooks')
+      return { id: updated.id, isActive: updated.isActive }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function deleteWebhook(id: string) {
 export async function deleteWebhook(id: string) {
@@ -187,23 +186,23 @@ export async function deleteWebhook(id: string) {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const existing = await db.webhook.findFirst({
       const existing = await db.webhook.findFirst({
         where: { id, organizationId },
         where: { id, organizationId },
-      });
-      if (!existing) throw new Error("Webhook not found");
-      await db.webhook.delete({ where: { id } });
-      revalidatePath("/settings/webhooks");
-      return { id };
+      })
+      if (!existing) throw new Error('Webhook not found')
+      await db.webhook.delete({ where: { id } })
+      revalidatePath('/settings/webhooks')
+      return { id }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],
       ],
       audit: ({ result }) => ({
       audit: ({ result }) => ({
-        action: "webhook.delete",
-        entity: "webhook",
+        action: 'webhook.delete',
+        entity: 'webhook',
         entityId: result.id,
         entityId: result.id,
       }),
       }),
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function rotateWebhookSecret(id: string) {
 export async function rotateWebhookSecret(id: string) {
@@ -211,67 +210,67 @@ export async function rotateWebhookSecret(id: string) {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const existing = await db.webhook.findFirst({
       const existing = await db.webhook.findFirst({
         where: { id, organizationId },
         where: { id, organizationId },
-      });
-      if (!existing) throw new Error("Webhook not found");
-      const secret = generateWebhookSecret();
+      })
+      if (!existing) throw new Error('Webhook not found')
+      const secret = generateWebhookSecret()
       await db.webhook.update({
       await db.webhook.update({
         where: { id },
         where: { id },
         data: { secret },
         data: { secret },
-      });
-      revalidatePath("/settings/webhooks");
-      return { id, secret };
+      })
+      revalidatePath('/settings/webhooks')
+      return { id, secret }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],
       ],
       audit: ({ result }) => ({
       audit: ({ result }) => ({
-        action: "webhook.rotateSecret",
-        entity: "webhook",
+        action: 'webhook.rotateSecret',
+        entity: 'webhook',
         entityId: result.id,
         entityId: result.id,
       }),
       }),
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function sendTestWebhook(id: string) {
 export async function sendTestWebhook(id: string) {
   return withAuth(
   return withAuth(
     async ({ organizationId, userId }) => {
     async ({ organizationId, userId }) => {
-      demoGuard();
+      demoGuard()
       const webhook = await db.webhook.findFirst({
       const webhook = await db.webhook.findFirst({
         where: { id, organizationId },
         where: { id, organizationId },
-      });
-      if (!webhook) throw new Error("Webhook not found");
+      })
+      if (!webhook) throw new Error('Webhook not found')
 
 
       const payload = JSON.stringify({
       const payload = JSON.stringify({
         id: `evt_test_${Date.now().toString(36)}`,
         id: `evt_test_${Date.now().toString(36)}`,
-        event: "ping.test",
+        event: 'ping.test',
         createdAt: new Date().toISOString(),
         createdAt: new Date().toISOString(),
         organizationId,
         organizationId,
         userId,
         userId,
         data: { test: true },
         data: { test: true },
-      });
+      })
 
 
       const delivery = await db.webhookDelivery.create({
       const delivery = await db.webhookDelivery.create({
         data: {
         data: {
           webhookId: webhook.id,
           webhookId: webhook.id,
-          event: "ping.test",
+          event: 'ping.test',
           payload,
           payload,
-          status: "pending",
+          status: 'pending',
         },
         },
         select: { id: true },
         select: { id: true },
-      });
+      })
 
 
-      await deliverOnce(delivery.id);
-      revalidatePath("/settings/webhooks");
-      return { deliveryId: delivery.id };
+      await deliverOnce(delivery.id)
+      revalidatePath('/settings/webhooks')
+      return { deliveryId: delivery.id }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function getWebhookDeliveries(webhookId: string, limit: number = 25) {
 export async function getWebhookDeliveries(webhookId: string, limit: number = 25) {
@@ -280,12 +279,12 @@ export async function getWebhookDeliveries(webhookId: string, limit: number = 25
       const webhook = await db.webhook.findFirst({
       const webhook = await db.webhook.findFirst({
         where: { id: webhookId, organizationId },
         where: { id: webhookId, organizationId },
         select: { id: true },
         select: { id: true },
-      });
-      if (!webhook) throw new Error("Webhook not found");
+      })
+      if (!webhook) throw new Error('Webhook not found')
 
 
       const deliveries = await db.webhookDelivery.findMany({
       const deliveries = await db.webhookDelivery.findMany({
         where: { webhookId },
         where: { webhookId },
-        orderBy: { createdAt: "desc" },
+        orderBy: { createdAt: 'desc' },
         take: Math.min(Math.max(limit, 1), 100),
         take: Math.min(Math.max(limit, 1), 100),
         select: {
         select: {
           id: true,
           id: true,
@@ -300,53 +299,46 @@ export async function getWebhookDeliveries(webhookId: string, limit: number = 25
           deliveredAt: true,
           deliveredAt: true,
           nextRetryAt: true,
           nextRetryAt: true,
         },
         },
-      });
-      return deliveries;
+      })
+      return deliveries
     },
     },
     {
     {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
-      ],
-    },
-  );
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
 }
 }
 
 
 export async function retryWebhookDelivery(deliveryId: string) {
 export async function retryWebhookDelivery(deliveryId: string) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      demoGuard();
+      demoGuard()
       const delivery = await db.webhookDelivery.findFirst({
       const delivery = await db.webhookDelivery.findFirst({
         where: { id: deliveryId, webhook: { organizationId } },
         where: { id: deliveryId, webhook: { organizationId } },
         select: { id: true, status: true },
         select: { id: true, status: true },
-      });
-      if (!delivery) throw new Error("Delivery not found");
+      })
+      if (!delivery) throw new Error('Delivery not found')
 
 
       // Reset to pending so deliverOnce will pick it up
       // Reset to pending so deliverOnce will pick it up
       await db.webhookDelivery.update({
       await db.webhookDelivery.update({
         where: { id: deliveryId },
         where: { id: deliveryId },
-        data: { status: "pending", nextRetryAt: null },
-      });
-      await deliverOnce(deliveryId);
-      revalidatePath("/settings/webhooks");
-      return { id: deliveryId };
+        data: { status: 'pending', nextRetryAt: null },
+      })
+      await deliverOnce(deliveryId)
+      revalidatePath('/settings/webhooks')
+      return { id: deliveryId }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function getAvailableEvents() {
 export async function getAvailableEvents() {
-  return withAuth(
-    async () => ({ events: [...WEBHOOK_EVENTS] }),
-    {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
-      ],
-    },
-  );
+  return withAuth(async () => ({ events: [...WEBHOOK_EVENTS] }), {
+    requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+  })
 }
 }
 
 
 export async function getDeliveryPayload(deliveryId: string) {
 export async function getDeliveryPayload(deliveryId: string) {
@@ -368,26 +360,24 @@ export async function getDeliveryPayload(deliveryId: string) {
           createdAt: true,
           createdAt: true,
           deliveredAt: true,
           deliveredAt: true,
         },
         },
-      });
-      if (!delivery) throw new Error("Delivery not found");
-      return delivery;
+      })
+      if (!delivery) throw new Error('Delivery not found')
+      return delivery
     },
     },
     {
     {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
-      ],
-    },
-  );
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
 }
 }
 
 
 function safeParseEvents(raw: string): string[] {
 function safeParseEvents(raw: string): string[] {
   try {
   try {
-    const v = JSON.parse(raw);
-    return Array.isArray(v) ? v : [];
+    const v = JSON.parse(raw)
+    return Array.isArray(v) ? v : []
   } catch {
   } catch {
-    return [];
+    return []
   }
   }
 }
 }
 
 
 // Re-export for use in unit tests / route handlers
 // Re-export for use in unit tests / route handlers
-export { signPayload };
+export { signPayload }

+ 102 - 89
src/features/webhooks/Lib/deliver.ts

@@ -1,21 +1,22 @@
-import { db } from "@/lib/db";
-import { signPayload } from "./sign";
-import { checkWebhookUrl } from "./ssrf";
-
-const DELIVERY_TIMEOUT_MS = 10_000;
-const RESPONSE_TRUNCATE_BYTES = 4096;
-const MAX_PAYLOAD_BYTES = 256 * 1024;
-const USER_AGENT = "Torqvoice-Webhooks/1.0";
-const AUTO_DISABLE_THRESHOLD = 20;
-const STUCK_INFLIGHT_MS = 5 * 60 * 1000;
+import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
+import { signPayload } from './sign'
+import { checkWebhookUrl } from './ssrf'
+
+const DELIVERY_TIMEOUT_MS = 10_000
+const RESPONSE_TRUNCATE_BYTES = 4096
+const MAX_PAYLOAD_BYTES = 256 * 1024
+const USER_AGENT = 'Torqvoice-Webhooks/1.0'
+const AUTO_DISABLE_THRESHOLD = 20
+const STUCK_INFLIGHT_MS = 5 * 60 * 1000
 
 
 /**
 /**
  * attempt 1 → ~1m, 2 → ~5m, 3 → ~30m, 4 → ~2h, 5 → ~6h.
  * attempt 1 → ~1m, 2 → ~5m, 3 → ~30m, 4 → ~2h, 5 → ~6h.
  */
  */
 function nextBackoff(attempt: number): Date {
 function nextBackoff(attempt: number): Date {
-  const base = [60, 300, 1800, 7200, 21_600][Math.min(attempt - 1, 4)] ?? 21_600;
-  const jitter = Math.floor(Math.random() * Math.min(base * 0.2, 60));
-  return new Date(Date.now() + (base + jitter) * 1000);
+  const base = [60, 300, 1800, 7200, 21_600][Math.min(attempt - 1, 4)] ?? 21_600
+  const jitter = Math.floor(Math.random() * Math.min(base * 0.2, 60))
+  return new Date(Date.now() + (base + jitter) * 1000)
 }
 }
 
 
 /**
 /**
@@ -24,10 +25,10 @@ function nextBackoff(attempt: number): Date {
  */
  */
 async function claimDelivery(deliveryId: string): Promise<boolean> {
 async function claimDelivery(deliveryId: string): Promise<boolean> {
   const r = await db.webhookDelivery.updateMany({
   const r = await db.webhookDelivery.updateMany({
-    where: { id: deliveryId, status: { in: ["pending", "retrying"] } },
-    data: { status: "inflight" },
-  });
-  return r.count === 1;
+    where: { id: deliveryId, status: { in: ['pending', 'retrying'] } },
+    data: { status: 'inflight' },
+  })
+  return r.count === 1
 }
 }
 
 
 /**
 /**
@@ -36,12 +37,12 @@ async function claimDelivery(deliveryId: string): Promise<boolean> {
  * cron tick will pick it up.
  * cron tick will pick it up.
  */
  */
 export async function recoverStuckDeliveries(): Promise<number> {
 export async function recoverStuckDeliveries(): Promise<number> {
-  const cutoff = new Date(Date.now() - STUCK_INFLIGHT_MS);
+  const cutoff = new Date(Date.now() - STUCK_INFLIGHT_MS)
   const r = await db.webhookDelivery.updateMany({
   const r = await db.webhookDelivery.updateMany({
-    where: { status: "inflight", updatedAt: { lt: cutoff } },
-    data: { status: "retrying", nextRetryAt: new Date() },
-  });
-  return r.count;
+    where: { status: 'inflight', updatedAt: { lt: cutoff } },
+    data: { status: 'retrying', nextRetryAt: new Date() },
+  })
+  return r.count
 }
 }
 
 
 /**
 /**
@@ -49,40 +50,52 @@ export async function recoverStuckDeliveries(): Promise<number> {
  * row in place with the outcome. Never throws.
  * row in place with the outcome. Never throws.
  */
  */
 export async function deliverOnce(deliveryId: string): Promise<void> {
 export async function deliverOnce(deliveryId: string): Promise<void> {
-  const claimed = await claimDelivery(deliveryId);
-  if (!claimed) return;
+  const claimed = await claimDelivery(deliveryId)
+  if (!claimed) return
 
 
   const delivery = await db.webhookDelivery.findUnique({
   const delivery = await db.webhookDelivery.findUnique({
     where: { id: deliveryId },
     where: { id: deliveryId },
     include: { webhook: true },
     include: { webhook: true },
-  });
-  if (!delivery || !delivery.webhook) return;
+  })
+  if (!delivery || !delivery.webhook) return
 
 
   if (!delivery.webhook.isActive) {
   if (!delivery.webhook.isActive) {
     await db.webhookDelivery.update({
     await db.webhookDelivery.update({
       where: { id: deliveryId },
       where: { id: deliveryId },
-      data: { status: "failed", errorMessage: "webhook is disabled" },
-    });
-    return;
+      data: { status: 'failed', errorMessage: 'webhook is disabled' },
+    })
+    return
+  }
+
+  // The dispatcher stops at the source on the demo, but the retry cron picks
+  // rows straight out of the table, so nothing reaches this without a second
+  // check here. Marked failed rather than thrown: this function never throws,
+  // and its callers treat a return as "handled".
+  if (isDemoMode) {
+    await db.webhookDelivery.update({
+      where: { id: deliveryId },
+      data: { status: 'failed', errorMessage: 'webhook delivery is disabled on the demo' },
+    })
+    return
   }
   }
 
 
-  const attempt = delivery.attempt + 1;
-  const startedAt = Date.now();
-  const body = delivery.payload;
+  const attempt = delivery.attempt + 1
+  const startedAt = Date.now()
+  const body = delivery.payload
 
 
-  if (Buffer.byteLength(body, "utf8") > MAX_PAYLOAD_BYTES) {
+  if (Buffer.byteLength(body, 'utf8') > MAX_PAYLOAD_BYTES) {
     await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
     await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
       ok: false,
       ok: false,
       statusCode: null,
       statusCode: null,
       responseBody: null,
       responseBody: null,
-      errorMessage: "payload exceeds maximum allowed size",
+      errorMessage: 'payload exceeds maximum allowed size',
       durationMs: 0,
       durationMs: 0,
       forceFinal: true,
       forceFinal: true,
-    });
-    return;
+    })
+    return
   }
   }
 
 
-  const safety = await checkWebhookUrl(delivery.webhook.url);
+  const safety = await checkWebhookUrl(delivery.webhook.url)
   if (!safety.ok) {
   if (!safety.ok) {
     await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
     await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
       ok: false,
       ok: false,
@@ -91,51 +104,51 @@ export async function deliverOnce(deliveryId: string): Promise<void> {
       errorMessage: `target rejected: ${safety.reason}`,
       errorMessage: `target rejected: ${safety.reason}`,
       durationMs: 0,
       durationMs: 0,
       forceFinal: true,
       forceFinal: true,
-    });
-    return;
+    })
+    return
   }
   }
 
 
-  const signature = signPayload(delivery.webhook.secret, body);
-  let statusCode: number | null = null;
-  let responseBody: string | null = null;
-  let errorMessage: string | null = null;
-  let ok = false;
+  const signature = signPayload(delivery.webhook.secret, body)
+  let statusCode: number | null = null
+  let responseBody: string | null = null
+  let errorMessage: string | null = null
+  let ok = false
 
 
-  const controller = new AbortController();
-  const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS);
+  const controller = new AbortController()
+  const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
 
 
   try {
   try {
     const res = await fetch(delivery.webhook.url, {
     const res = await fetch(delivery.webhook.url, {
-      method: "POST",
+      method: 'POST',
       signal: controller.signal,
       signal: controller.signal,
-      redirect: "manual",
+      redirect: 'manual',
       headers: {
       headers: {
-        "content-type": "application/json",
-        "user-agent": USER_AGENT,
-        "x-torqvoice-event": delivery.event,
-        "x-torqvoice-delivery": delivery.id,
-        "x-torqvoice-signature": signature,
-        "x-torqvoice-attempt": String(attempt),
+        'content-type': 'application/json',
+        'user-agent': USER_AGENT,
+        'x-torqvoice-event': delivery.event,
+        'x-torqvoice-delivery': delivery.id,
+        'x-torqvoice-signature': signature,
+        'x-torqvoice-attempt': String(attempt),
       },
       },
       body,
       body,
-    });
-    statusCode = res.status;
+    })
+    statusCode = res.status
     if (statusCode >= 300 && statusCode < 400) {
     if (statusCode >= 300 && statusCode < 400) {
-      errorMessage = "redirects are not followed for security";
-      ok = false;
+      errorMessage = 'redirects are not followed for security'
+      ok = false
     } else {
     } else {
-      const text = await res.text().catch(() => "");
-      responseBody = text.slice(0, RESPONSE_TRUNCATE_BYTES);
-      ok = res.ok;
+      const text = await res.text().catch(() => '')
+      responseBody = text.slice(0, RESPONSE_TRUNCATE_BYTES)
+      ok = res.ok
     }
     }
   } catch (err) {
   } catch (err) {
-    if (err instanceof Error && err.name === "AbortError") {
-      errorMessage = `timeout after ${DELIVERY_TIMEOUT_MS}ms`;
+    if (err instanceof Error && err.name === 'AbortError') {
+      errorMessage = `timeout after ${DELIVERY_TIMEOUT_MS}ms`
     } else {
     } else {
-      errorMessage = err instanceof Error ? err.message : String(err);
+      errorMessage = err instanceof Error ? err.message : String(err)
     }
     }
   } finally {
   } finally {
-    clearTimeout(timeout);
+    clearTimeout(timeout)
   }
   }
 
 
   await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
   await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
@@ -144,7 +157,7 @@ export async function deliverOnce(deliveryId: string): Promise<void> {
     responseBody,
     responseBody,
     errorMessage,
     errorMessage,
     durationMs: Date.now() - startedAt,
     durationMs: Date.now() - startedAt,
-  });
+  })
 }
 }
 
 
 async function finalize(
 async function finalize(
@@ -153,17 +166,17 @@ async function finalize(
   attempt: number,
   attempt: number,
   maxAttempts: number,
   maxAttempts: number,
   outcome: {
   outcome: {
-    ok: boolean;
-    statusCode: number | null;
-    responseBody: string | null;
-    errorMessage: string | null;
-    durationMs: number;
-    forceFinal?: boolean;
-  },
+    ok: boolean
+    statusCode: number | null
+    responseBody: string | null
+    errorMessage: string | null
+    durationMs: number
+    forceFinal?: boolean
+  }
 ): Promise<void> {
 ): Promise<void> {
-  const finalAttempt = outcome.forceFinal || attempt >= maxAttempts;
-  const status = outcome.ok ? "success" : finalAttempt ? "failed" : "retrying";
-  const nextRetryAt = outcome.ok || finalAttempt ? null : nextBackoff(attempt);
+  const finalAttempt = outcome.forceFinal || attempt >= maxAttempts
+  const status = outcome.ok ? 'success' : finalAttempt ? 'failed' : 'retrying'
+  const nextRetryAt = outcome.ok || finalAttempt ? null : nextBackoff(attempt)
 
 
   await db.webhookDelivery.update({
   await db.webhookDelivery.update({
     where: { id: deliveryId },
     where: { id: deliveryId },
@@ -177,7 +190,7 @@ async function finalize(
       nextRetryAt,
       nextRetryAt,
       deliveredAt: outcome.ok ? new Date() : undefined,
       deliveredAt: outcome.ok ? new Date() : undefined,
     },
     },
-  });
+  })
 
 
   if (outcome.ok) {
   if (outcome.ok) {
     await db.webhook.update({
     await db.webhook.update({
@@ -187,16 +200,16 @@ async function finalize(
         lastSuccessAt: new Date(),
         lastSuccessAt: new Date(),
         failureCount: 0,
         failureCount: 0,
       },
       },
-    });
-    return;
+    })
+    return
   }
   }
 
 
   if (!finalAttempt) {
   if (!finalAttempt) {
     await db.webhook.update({
     await db.webhook.update({
       where: { id: webhookId },
       where: { id: webhookId },
       data: { lastTriggeredAt: new Date() },
       data: { lastTriggeredAt: new Date() },
-    });
-    return;
+    })
+    return
   }
   }
 
 
   // Permanent failure — bump counter and auto-disable past the threshold so
   // Permanent failure — bump counter and auto-disable past the threshold so
@@ -209,29 +222,29 @@ async function finalize(
       failureCount: { increment: 1 },
       failureCount: { increment: 1 },
     },
     },
     select: { failureCount: true, isActive: true },
     select: { failureCount: true, isActive: true },
-  });
+  })
 
 
   if (updated.isActive && updated.failureCount >= AUTO_DISABLE_THRESHOLD) {
   if (updated.isActive && updated.failureCount >= AUTO_DISABLE_THRESHOLD) {
     await db.webhook.update({
     await db.webhook.update({
       where: { id: webhookId },
       where: { id: webhookId },
       data: { isActive: false },
       data: { isActive: false },
-    });
+    })
   }
   }
 }
 }
 
 
 export async function processDueDeliveries(limit: number = 100): Promise<number> {
 export async function processDueDeliveries(limit: number = 100): Promise<number> {
   const due = await db.webhookDelivery.findMany({
   const due = await db.webhookDelivery.findMany({
     where: {
     where: {
-      status: { in: ["pending", "retrying"] },
+      status: { in: ['pending', 'retrying'] },
       OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
       OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
     },
     },
-    orderBy: { createdAt: "asc" },
+    orderBy: { createdAt: 'asc' },
     take: limit,
     take: limit,
     select: { id: true },
     select: { id: true },
-  });
+  })
 
 
-  await Promise.allSettled(due.map((d) => deliverOnce(d.id)));
-  return due.length;
+  await Promise.allSettled(due.map((d) => deliverOnce(d.id)))
+  return due.length
 }
 }
 
 
-export const __TEST__ = { nextBackoff, AUTO_DISABLE_THRESHOLD, MAX_PAYLOAD_BYTES };
+export const __TEST__ = { nextBackoff, AUTO_DISABLE_THRESHOLD, MAX_PAYLOAD_BYTES }

+ 44 - 37
src/features/webhooks/Lib/dispatcher.ts

@@ -1,19 +1,20 @@
-import { db } from "@/lib/db";
-import { WEBHOOK_EVENTS } from "../Schema/webhookSchema";
-import { deliverOnce } from "./deliver";
+import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
+import { WEBHOOK_EVENTS } from '../Schema/webhookSchema'
+import { deliverOnce } from './deliver'
 
 
-const KNOWN = new Set<string>([...WEBHOOK_EVENTS, "*"]);
+const KNOWN = new Set<string>([...WEBHOOK_EVENTS, '*'])
 
 
 export type DispatchInput = {
 export type DispatchInput = {
-  event: string;
-  organizationId: string;
-  entity?: string | null;
-  entityId?: string | null;
-  message?: string | null;
+  event: string
+  organizationId: string
+  entity?: string | null
+  entityId?: string | null
+  message?: string | null
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  data?: Record<string, any> | null;
-  userId?: string | null;
-};
+  data?: Record<string, any> | null
+  userId?: string | null
+}
 
 
 /**
 /**
  * Dispatch an event to all webhooks in the org subscribed to it. Creates one
  * Dispatch an event to all webhooks in the org subscribed to it. Creates one
@@ -22,33 +23,39 @@ export type DispatchInput = {
  *
  *
  * No-op for unknown events so we don't generate deliveries for noise like
  * No-op for unknown events so we don't generate deliveries for noise like
  * `auth.permissionDenied` or future audit-only actions.
  * `auth.permissionDenied` or future audit-only actions.
+ *
+ * Also a no-op on the demo. Every CRUD action a visitor can reach calls this,
+ * so without it one saved customer is enough to make the demo box POST at a
+ * URL somebody else chose. Stopping here rather than at the wire also keeps
+ * the delivery log from filling with rows that could never be sent.
  */
  */
 export async function dispatchWebhookEvent(input: DispatchInput): Promise<void> {
 export async function dispatchWebhookEvent(input: DispatchInput): Promise<void> {
-  if (!input.event || !input.organizationId) return;
-  if (!KNOWN.has(input.event)) return;
+  if (isDemoMode) return
+  if (!input.event || !input.organizationId) return
+  if (!KNOWN.has(input.event)) return
 
 
-  let webhooks: { id: string; events: string }[] = [];
+  let webhooks: { id: string; events: string }[] = []
   try {
   try {
     webhooks = await db.webhook.findMany({
     webhooks = await db.webhook.findMany({
       where: { organizationId: input.organizationId, isActive: true },
       where: { organizationId: input.organizationId, isActive: true },
       select: { id: true, events: true },
       select: { id: true, events: true },
-    });
+    })
   } catch (err) {
   } catch (err) {
-    console.error("[webhooks] failed to load subscribers:", err);
-    return;
+    console.error('[webhooks] failed to load subscribers:', err)
+    return
   }
   }
-  if (webhooks.length === 0) return;
+  if (webhooks.length === 0) return
 
 
   const matching = webhooks.filter((w) => {
   const matching = webhooks.filter((w) => {
-    let subscribed: string[] = [];
+    let subscribed: string[] = []
     try {
     try {
-      subscribed = JSON.parse(w.events) as string[];
+      subscribed = JSON.parse(w.events) as string[]
     } catch {
     } catch {
-      return false;
+      return false
     }
     }
-    return subscribed.includes("*") || subscribed.includes(input.event);
-  });
-  if (matching.length === 0) return;
+    return subscribed.includes('*') || subscribed.includes(input.event)
+  })
+  if (matching.length === 0) return
 
 
   const payload = JSON.stringify({
   const payload = JSON.stringify({
     id: cryptoRandomId(),
     id: cryptoRandomId(),
@@ -60,7 +67,7 @@ export async function dispatchWebhookEvent(input: DispatchInput): Promise<void>
     message: input.message ?? null,
     message: input.message ?? null,
     userId: input.userId ?? null,
     userId: input.userId ?? null,
     data: input.data ?? null,
     data: input.data ?? null,
-  });
+  })
 
 
   const created = await Promise.all(
   const created = await Promise.all(
     matching.map((w) =>
     matching.map((w) =>
@@ -70,28 +77,28 @@ export async function dispatchWebhookEvent(input: DispatchInput): Promise<void>
             webhookId: w.id,
             webhookId: w.id,
             event: input.event,
             event: input.event,
             payload,
             payload,
-            status: "pending",
+            status: 'pending',
           },
           },
           select: { id: true },
           select: { id: true },
         })
         })
         .catch((err) => {
         .catch((err) => {
-          console.error("[webhooks] failed to enqueue delivery:", err);
-          return null;
-        }),
-    ),
-  );
+          console.error('[webhooks] failed to enqueue delivery:', err)
+          return null
+        })
+    )
+  )
 
 
   // Fire-and-forget initial delivery. Failures are picked up by the retry cron.
   // Fire-and-forget initial delivery. Failures are picked up by the retry cron.
   for (const row of created) {
   for (const row of created) {
-    if (!row) continue;
+    if (!row) continue
     setImmediate(() => {
     setImmediate(() => {
       deliverOnce(row.id).catch((err) => {
       deliverOnce(row.id).catch((err) => {
-        console.error("[webhooks] delivery failed:", err);
-      });
-    });
+        console.error('[webhooks] delivery failed:', err)
+      })
+    })
   }
   }
 }
 }
 
 
 function cryptoRandomId(): string {
 function cryptoRandomId(): string {
-  return `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
+  return `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
 }
 }

+ 19 - 11
src/lib/demo.ts

@@ -10,11 +10,11 @@
  * the app. The reset cron (every 3 hours) reverts their changes.
  * the app. The reset cron (every 3 hours) reverts their changes.
  */
  */
 
 
-export const isDemoMode = process.env.DEMO_MODE === "true";
+export const isDemoMode = process.env.DEMO_MODE === 'true'
 
 
 /** Credentials the sign-in page auto-fills. The seed script provisions this user. */
 /** Credentials the sign-in page auto-fills. The seed script provisions this user. */
-export const DEMO_USER_EMAIL = process.env.DEMO_USER_EMAIL || "demo@torqvoice.com";
-export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || "demo";
+export const DEMO_USER_EMAIL = process.env.DEMO_USER_EMAIL || 'demo@torqvoice.com'
+export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || 'demo'
 
 
 /**
 /**
  * Throws inside a server action when demo mode is active. `withAuth`
  * Throws inside a server action when demo mode is active. `withAuth`
@@ -23,12 +23,14 @@ export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || "demo";
  */
  */
 export function demoGuard(): void {
 export function demoGuard(): void {
   if (isDemoMode) {
   if (isDemoMode) {
-    throw new Error("This action is disabled on the demo. Install Torqvoice on your own server to use it.");
+    throw new Error(
+      'This action is disabled on the demo. Install Torqvoice on your own server to use it.'
+    )
   }
   }
 }
 }
 
 
 /**
 /**
- * Hard stop on anything that would leave the box: email, SMS, Telegram.
+ * Hard stop on anything that would leave the box: email, SMS, WhatsApp, Telegram.
  *
  *
  * `demoGuard()` only covers server actions, so the background crons —
  * `demoGuard()` only covers server actions, so the background crons —
  * scheduled messages, reminder alerts, report schedules, low-stock digests —
  * scheduled messages, reminder alerts, report schedules, low-stock digests —
@@ -37,11 +39,11 @@ export function demoGuard(): void {
  * to go through. Seed data carries customer-looking addresses, so a demo reset
  * to go through. Seed data carries customer-looking addresses, so a demo reset
  * is enough to queue mail at real inboxes without it.
  * is enough to queue mail at real inboxes without it.
  */
  */
-export function assertOutboundAllowed(channel: "email" | "sms" | "telegram"): void {
+export function assertOutboundAllowed(channel: 'email' | 'sms' | 'whatsapp' | 'telegram'): void {
   if (isDemoMode) {
   if (isDemoMode) {
     throw new Error(
     throw new Error(
-      `Outbound ${channel} is disabled on the demo. Install Torqvoice on your own server to send for real.`,
-    );
+      `Outbound ${channel} is disabled on the demo. Install Torqvoice on your own server to send for real.`
+    )
   }
   }
 }
 }
 
 
@@ -62,13 +64,19 @@ const DEMO_BLOCKED_SETTING_KEY_PATTERNS: RegExp[] = [
   /^sms\.(twilio|vonage|telnyx)\./,
   /^sms\.(twilio|vonage|telnyx)\./,
   /^sms\.(provider|phoneNumber|webhookSecret)$/,
   /^sms\.(provider|phoneNumber|webhookSecret)$/,
   /^telegram\.(botToken|webhookSecret)$/,
   /^telegram\.(botToken|webhookSecret)$/,
+  // WhatsApp namespaces credentials by provider rather than enumerating them,
+  // so this matches the namespace and any adapter added later is covered
+  // without another entry here. The template keys stay open, since an approved
+  // template name is not a secret and is worth playing with.
+  /^whatsapp\.cred\./,
+  /^whatsapp\.(provider|from)$/,
   /^email\.(smtp|resend|sendgrid|mailgun|postmark|ses)\./,
   /^email\.(smtp|resend|sendgrid|mailgun|postmark|ses)\./,
   /^email\.provider$/,
   /^email\.provider$/,
   /^ai\.apiKey$/,
   /^ai\.apiKey$/,
-];
+]
 
 
 export function isDemoBlockedSettingKey(key: string): boolean {
 export function isDemoBlockedSettingKey(key: string): boolean {
-  return DEMO_BLOCKED_SETTING_KEY_PATTERNS.some((p) => p.test(key));
+  return DEMO_BLOCKED_SETTING_KEY_PATTERNS.some((p) => p.test(key))
 }
 }
 
 
 /**
 /**
@@ -77,6 +85,6 @@ export function isDemoBlockedSettingKey(key: string): boolean {
  */
  */
 export function demoGuardSettingKey(key: string): void {
 export function demoGuardSettingKey(key: string): void {
   if (isDemoMode && isDemoBlockedSettingKey(key)) {
   if (isDemoMode && isDemoBlockedSettingKey(key)) {
-    throw new Error("This setting can't be changed on the demo.");
+    throw new Error("This setting can't be changed on the demo.")
   }
   }
 }
 }

+ 7 - 0
src/lib/whatsapp/index.ts

@@ -2,6 +2,7 @@ import 'server-only'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getPhoneLookupVariants, normalizePortalPhone } from '@/lib/portal-phone'
 import { getPhoneLookupVariants, normalizePortalPhone } from '@/lib/portal-phone'
+import { assertOutboundAllowed } from '@/lib/demo'
 import {
 import {
   ORG_WHATSAPP_KEYS,
   ORG_WHATSAPP_KEYS,
   WHATSAPP_WEBHOOK_TOKEN_FIELD,
   WHATSAPP_WEBHOOK_TOKEN_FIELD,
@@ -343,6 +344,12 @@ export async function sendOrgWhatsapp(
   organizationId: string,
   organizationId: string,
   options: SendWhatsappOptions
   options: SendWhatsappOptions
 ): Promise<SendWhatsappResult> {
 ): Promise<SendWhatsappResult> {
+  // The send actions carry demoGuard(), but the scheduled-message cron reaches
+  // this directly and would have gone straight past it. The demo ships a
+  // configured WhatsApp workshop, so the seed alone is enough to queue a
+  // message at a stranger's phone without this.
+  assertOutboundAllowed('whatsapp')
+
   const config = await getWhatsappConfig(organizationId)
   const config = await getWhatsappConfig(organizationId)
   if (!config) {
   if (!config) {
     throw new Error('WhatsApp is not configured. Set it up in Settings → WhatsApp.')
     throw new Error('WhatsApp is not configured. Set it up in Settings → WhatsApp.')