Notifications & CRM Sync Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement transactional email and WhatsApp notifications for customers and admin, plus Zoho CRM/Books integration for lead capture and invoicing. These are the post-payment revenue operations — confirming bookings, capturing leads, and notifying Hamza of operational issues.
Architecture:
- Email: Resend (already configured per TFB —
resendpackage) - WhatsApp: WhatsApp Business API (already has an existing API integration per TFB)
- CRM/Books: Zoho CRM + Zoho Books REST API with OAuth2
Tech Stack: Convex mutations (triggers), Resend SDK, WhatsApp Business API, Zoho OAuth2.
Business Summary
What this does: Implements transactional email and WhatsApp notifications for booking confirmations, cancellations, and refunds. Also syncs all bookings to Zoho CRM (contacts, deals) and Zoho Books (invoices) for Hamza's sales pipeline and accounting.
Why it matters: Confirms bookings instantly via email/WhatsApp — reducing no-shows and building guest confidence. Captures every guest as a lead in Zoho CRM for follow-up marketing. Creates invoices automatically for accounting accuracy. Alerts Hamza to low-occupancy shows 24h before so he can take action.
Time to implement: 6-10 days | Complexity: Medium
Dependencies: Requires booking-flow (VNPay payment confirmation triggers notifications), show-system (show titles for email content). Can implement Zoho CRM/Books stubs in parallel with email/WhatsApp.
Context & Key Constraints
P0 RULE — No
staffMutation/adminMutation: These wrappers do not exist inconvex/auth.ts. OnlygetCurrentUser,upsertUser, andisAdminexist. For staff/admin mutations, use plainmutationwith role checks inside the handler:import { mutation } from "convex/_generated/server"; export const someMutation = mutation({ args: { ... }, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("UNAUTHORIZED"); const user = await ctx.db.query("users").withIndex("by_email", ...).first(); if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) throw new Error("UNAUTHORIZED"); // ... }, });
P0 RULE — No
getTranslationsin Convex:getTranslationsfromnext-intl/serveris a Next.js server-side function and cannot run inside Convex functions. Translation and HTML email rendering must happen in a Next.js context (API route or Server Component), then pass pre-rendered content to Convex for sending. Convex functions acceptlocale,subject,html, andrecipientas pre-built strings.
P1 RULE — Structured logging: Use
consolainstead ofconsole.log. Import:import { consola } from "consola";
P1 RULE — All user-facing strings use translation keys: Error messages, email subjects, and admin copy must use translation key patterns — never hardcoded strings in shared code.
Context & Business Logic
Notification triggers:
| Trigger | Recipient | Channel | Priority |
|---|---|---|---|
| Payment confirmed | Customer | Email + WhatsApp | P0 |
| Booking cancelled | Customer | Email + WhatsApp | P0 |
| Refund processed | Customer | P0 | |
| New booking | Admin | P1 | |
| D-1 low occupancy alert | Admin | Email + WhatsApp | P1 |
| Occurrence auto-cancelled | Customer | P0 |
CRM triggers:
| Event | Action | Priority |
|---|---|---|
| Booking confirmed (PAID) | Create/Update Contact in Zoho CRM | P0 |
| Booking confirmed (PAID) | Create Deal in Zoho CRM | P0 |
| Payment received | Update Deal stage in Zoho CRM | P0 |
| Booking cancelled | Update Deal status in Zoho CRM | P1 |
Data mapping:
- Customer: firstName, lastName, email, phone → Zoho Contact
- Booking: show title, date, ticket type, quantity, total → Zoho Deal
- Invoice: line items, customer, paid status → Zoho Books Invoice
Email rendering architecture: Convex functions handle data fetching and API calls. Translation + HTML rendering happens in Next.js server actions (which have access to next-intl/server). Pre-rendered HTML and subject lines are passed to Convex sendEmail mutations for sending.
File Map
apps/backend/convex/
├── schema.ts # MODIFY — add notificationLogs, zohoSyncLogs tables
├── functions/
│ ├── notifications.ts # Email + WhatsApp sending via direct SDK calls (CREATE)
│ └── crm-sync.ts # Zoho CRM + Books sync via direct API calls (CREATE)
│
apps/frontend/lib/
├── resend.ts # Resend client config (CREATE)
├── whatsapp.ts # WhatsApp client config (CREATE)
└── zoho.ts # Zoho OAuth2 client (CREATE)
.env.example # Add Zoho + Resend env varsArchitecture: All backend logic runs in Convex mutations with direct SDK/API calls. No HTTP fetch to Next.js API routes needed for frontend-only operations.
Phase 1: Email Notifications (Resend)
Task 1: Set Up Resend Client
Files:
-
Create:
apps/frontend/lib/resend.ts -
Modify:
.env.example -
Step 1: Install Resend
npm install resend- Step 2: Create Resend client
// apps/frontend/lib/resend.ts
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendEmail({
to,
subject,
html,
}: {
to: string;
subject: string;
html: string;
}) {
const result = await resend.emails.send({
from: "House of Legends <bookings@houseoflegends.vn>",
to,
subject,
html,
});
return result;
}- Step 3: Update
.env.example
RESEND_API_KEY=re_xxxxxxxxxxxx- Step 4: Commit
Task 2: Email Templates — Translation + HTML Rendering in Next.js
Architecture: Email translation and HTML rendering happens in Next.js server actions (which have access to
next-intl/server). Pre-renderedsubjectandhtmlstrings are then passed to Convex mutations for sending.
Files:
-
Create:
apps/frontend/lib/email-templates.ts— email content builders with translations -
Modify:
apps/frontend/app/actions/notifications.ts— server actions that build email and call Convex mutation -
Create:
apps/backend/convex/functions/notifications.ts— Convex functions that receive pre-rendered content and call Resend -
Step 1: Create email content builders (in Next.js, with i18n access)
// apps/frontend/lib/email-templates.ts
import { getTranslations } from "next-intl/server";
type BookingEmailData = {
locale: string;
customerFirstName: string;
showTitle: string;
showDate: string;
showTime: string;
ticketType: string;
quantity: number;
totalAmount: number;
qrCode?: string;
};
export async function buildBookingConfirmationEmail(data: BookingEmailData) {
const t = await getTranslations({
locale: data.locale,
namespace: "notifications.email",
});
const ticketTypeLabel =
data.ticketType === "DINNER_THEATRE"
? t("ticketType.dinnerTheatre")
: t("ticketType.showOnly");
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #C5A059;">${t("confirmationTitle")}</h1>
<p>${t("dearCustomer", { name: data.customerFirstName })}</p>
<p>${t("bookingConfirmed")}</p>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px; border: 1px solid #333;">${t("labelShow")}</td>
<td style="padding: 8px; border: 1px solid #333;">${data.showTitle}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #333;">${t("labelDate")}</td>
<td style="padding: 8px; border: 1px solid #333;">${data.showDate} ${t("at")} ${data.showTime}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #333;">${t("labelTicketType")}</td>
<td style="padding: 8px; border: 1px solid #333;">${ticketTypeLabel}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #333;">${t("labelQuantity")}</td>
<td style="padding: 8px; border: 1px solid #333;">${data.quantity}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #333;">${t("labelTotalPaid")}</td>
<td style="padding: 8px; border: 1px solid #333;">${data.totalAmount.toLocaleString()} VND</td>
</tr>
</table>
${data.qrCode ? `<img src="${data.qrCode}" alt="QR Code" style="margin-top: 20px;" />` : ""}
<p style="margin-top: 20px;">${t("seeYouAtVenue")}</p>
</div>
`;
return {
subject: t("confirmationSubject", { showTitle: data.showTitle }),
html,
};
}
type CancellationEmailData = {
locale: string;
customerFirstName: string;
totalAmount: number;
};
export async function buildCancellationEmail(data: CancellationEmailData) {
const t = await getTranslations({
locale: data.locale,
namespace: "notifications.email",
});
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #C5A059;">${t("cancellationTitle")}</h1>
<p>${t("dearCustomer", { name: data.customerFirstName })}</p>
<p>${t("bookingCancelled")}</p>
${data.totalAmount > 0 ? `<p>${t("refundInfo", { amount: data.totalAmount.toLocaleString() })}</p>` : ""}
</div>
`;
return {
subject: t("cancellationSubject"),
html,
};
}
type AdminNewBookingEmailData = {
locale: string;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
customerPhone?: string;
showTitle: string;
showDate: string;
showTime: string;
totalAmount: number;
};
export async function buildAdminNewBookingEmail(
data: AdminNewBookingEmailData,
) {
const t = await getTranslations({
locale: data.locale,
namespace: "notifications.email",
});
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #C5A059;">${t("adminNewBookingTitle")}</h1>
<table style="width: 100%;">
<tr><td>${t("labelCustomer")}</td><td>${data.customerFirstName} ${data.customerLastName}</td></tr>
<tr><td>${t("labelEmail")}</td><td>${data.customerEmail}</td></tr>
${data.customerPhone ? `<tr><td>${t("labelPhone")}</td><td>${data.customerPhone}</td></tr>` : ""}
<tr><td>${t("labelShow")}</td><td>${data.showTitle}</td></tr>
<tr><td>${t("labelDate")}</td><td>${data.showDate} ${t("at")} ${data.showTime}</td></tr>
<tr><td>${t("labelTotal")}</td><td>${data.totalAmount.toLocaleString()} VND</td></tr>
</table>
</div>
`;
return {
subject: t("adminNewBookingSubject", {
showTitle: data.showTitle,
showDate: data.showDate,
}),
html,
};
}- Step 2: Create Convex notification functions (direct SDK calls)
P0 FIX: Do NOT import
getCurrentUserfrom"../auth"and try to call it —getCurrentUseris a QUERY that must be called viauseQueryin frontend. Inside Convex mutation handlers, usectx.auth.getUserIdentity()directly to get the Clerk identity, then query the users table to check roles.
// apps/backend/convex/functions/notifications.ts
import { mutation } from "convex/_generated/server";
import { v } from "convex/values";
import { consola } from "consola";
import { sendEmail } from "~/lib/resend";
const NOTIFICATION_ERROR_CODES = {
RESEND_API_ERROR: "RESEND_API_ERROR",
WHATSAPP_API_ERROR: "WHATSAPP_API_ERROR",
WHATSAPP_NO_PHONE: "WHATSAPP_NO_PHONE",
UNAUTHORIZED: "UNAUTHORIZED",
} as const;
type NotificationErrorCode = keyof typeof NOTIFICATION_ERROR_CODES;
/**
* Helper: inline role check using ctx.auth.getUserIdentity()
* Do NOT import and call getCurrentUser (it is a QUERY, not a utility function)
*/
async function requireStaffOrAdmin(
ctx: HandlerContext,
): Promise<{ role: string }> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error(NOTIFICATION_ERROR_CODES.UNAUTHORIZED);
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) {
throw new Error(NOTIFICATION_ERROR_CODES.UNAUTHORIZED);
}
return user as { role: string };
}
type HandlerContext = {
auth: { getUserIdentity: () => Promise<{ email?: string } | null> };
db: {
query: (table: string) => {
withIndex: (
name: string,
fn: (q: QueryBuilder) => QueryBuilder,
) => { first: () => Promise<unknown> };
};
};
};
type QueryBuilder = {
eq: (field: string, value: string) => { first: () => Promise<unknown> };
};
/**
* Send a pre-rendered email (HTML and subject pre-built by caller).
* Direct Resend SDK call — no HTTP fetch needed.
*/
export const sendEmailNotification = mutation({
args: {
to: v.string(),
subject: v.string(),
html: v.string(),
type: v.union(
v.literal("EMAIL_CONFIRMATION"),
v.literal("EMAIL_CANCELLATION"),
v.literal("EMAIL_ADMIN_NEW_BOOKING"),
),
reservationId: v.optional(v.id("reservations")),
},
handler: async (ctx, args) => {
// CORRECT: Use inline role check via ctx.auth.getUserIdentity()
await requireStaffOrAdmin(ctx);
try {
// Direct Resend SDK call — no HTTP fetch
const result = await sendEmail({
to: args.to,
subject: args.subject,
html: args.html,
});
if (result.error) {
throw new Error(NOTIFICATION_ERROR_CODES.RESEND_API_ERROR);
}
consola.success("Email sent", { to: args.to, type: args.type });
return { success: true };
} catch (err) {
consola.error("Email send failed", {
to: args.to,
type: args.type,
error: err,
});
throw new Error(NOTIFICATION_ERROR_CODES.RESEND_API_ERROR);
}
},
});
type BookingWhatsAppParams = {
customerPhone: string;
customerFirstName: string;
customerLastName: string;
showTitle: string;
showDateTime: string;
totalAmount: number;
};
/**
* Send WhatsApp booking confirmation.
* Template content is pre-approved in WhatsApp Business Manager.
* Direct WhatsApp API call — no HTTP fetch needed.
*/
export const sendBookingConfirmationWhatsApp = mutation({
args: {
customerPhone: v.string(),
customerFirstName: v.string(),
customerLastName: v.string(),
showTitle: v.string(),
showDateTime: v.string(),
totalAmount: v.number(),
reservationId: v.optional(v.id("reservations")),
},
handler: async (ctx, args) => {
// CORRECT: Inline role check via ctx.auth.getUserIdentity()
await requireStaffOrAdmin(ctx);
if (!args.customerPhone) {
consola.info("WhatsApp skipped: no phone number");
return { success: true, skipped: true };
}
try {
// Direct WhatsApp API call — no HTTP fetch
const { sendWhatsAppTemplate } = await import("~/lib/whatsapp");
await sendWhatsAppTemplate({
to: args.customerPhone,
templateName: "booking_confirmation",
components: [
{
type: "body",
parameters: [
{
type: "text",
text: `${args.customerFirstName} ${args.customerLastName}`,
},
{ type: "text", text: args.showTitle },
{ type: "text", text: args.showDateTime },
{ type: "text", text: args.totalAmount.toLocaleString() },
],
},
],
});
consola.success("WhatsApp sent", { to: args.customerPhone });
return { success: true };
} catch (err) {
consola.error("WhatsApp send failed", {
to: args.customerPhone,
error: err,
});
throw new Error(NOTIFICATION_ERROR_CODES.WHATSAPP_API_ERROR);
}
},
});- Step 3: Commit
Phase 2: WhatsApp Notifications
Task 3: WhatsApp Business API Integration
Files:
- Create:
apps/frontend/lib/whatsapp.ts— WhatsApp API client
WhatsApp Business API uses templates (pre-approved message templates). For transactional messages like booking confirmation, we use template messages with language: "en".
- Step 1: Create WhatsApp client
// apps/frontend/lib/whatsapp.ts
const WHATSAPP_API_URL = `https://graph.facebook.com/v18.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`;
export async function sendWhatsAppTemplate({
to,
templateName,
components,
}: {
to: string;
templateName: string;
components: Array<{
type: "body";
parameters: Array<{ type: "text"; text: string }>;
}>;
}) {
const response = await fetch(WHATSAPP_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to,
type: "template",
template: {
name: templateName,
language: { code: "en" },
components,
},
}),
});
if (!response.ok) {
throw new Error(`WHATSAPP_API_ERROR: ${response.status}`);
}
return response.json();
}- Step 2: Update
.env.example
WHATSAPP_PHONE_NUMBER_ID=xxxxxxxxxxxx
WHATSAPP_ACCESS_TOKEN=xxxxxxxxxxxx- Step 3: Commit
Phase 3: Zoho CRM + Books Sync
Task 4: Zoho OAuth2 Setup
Files:
-
Create:
apps/frontend/lib/zoho.ts— Zoho API client -
Modify:
.env.example -
Step 1: Create Zoho auth utility
// apps/frontend/lib/zoho.ts
let accessToken: string | null = null;
let tokenExpiry: number = 0;
export async function getZohoAccessToken(): Promise<string> {
if (accessToken && Date.now() < tokenExpiry) {
return accessToken;
}
const response = await fetch("https://accounts.zoho.com/oauth/v2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: process.env.ZOHO_CLIENT_ID!,
client_secret: process.env.ZOHO_CLIENT_SECRET!,
refresh_token: process.env.ZOHO_REFRESH_TOKEN!,
}),
});
const data = await response.json();
accessToken = data.access_token;
tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
return accessToken!;
}
export async function zohoApiCall(endpoint: string, options: RequestInit = {}) {
const token = await getZohoAccessToken();
return fetch(`https://www.zohoapis.com${endpoint}`, {
...options,
headers: {
Authorization: `Zoho-oauthtoken ${token}`,
"Content-Type": "application/json",
...options.headers,
},
});
}- Step 2: Update
.env.example
# Zoho OAuth
ZOHO_CLIENT_ID=xxxxxxxxxxxx
ZOHO_CLIENT_SECRET=xxxxxxxxxxxx
ZOHO_REFRESH_TOKEN=1000.xxxxxx.xxxxxx
# Zoho Books
ZOHO_BOOKS_ORGANIZATION_ID=xxxxxxxxxxxx- Step 3: Commit
Task 5: Zoho CRM Contact Sync
Files:
-
Create:
apps/backend/convex/functions/crm-sync.ts -
Step 1: Create upsertContact function
P0 FIX: Convex calls Zoho API directly via SDK. Use inline role check via
ctx.auth.getUserIdentity()— do NOT callgetCurrentUserquery.
// apps/backend/convex/functions/crm-sync.ts
import { mutation } from "convex/_generated/server";
import { v } from "convex/values";
import { consola } from "consola";
import { zohoApiCall } from "~/lib/zoho";
const CRM_ERROR_CODES = {
ZOHO_AUTH_ERROR: "ZOHO_AUTH_ERROR",
ZOHO_API_ERROR: "ZOHO_API_ERROR",
ZOHO_CONTACT_ERROR: "ZOHO_CONTACT_ERROR",
ZOHO_DEAL_ERROR: "ZOHO_DEAL_ERROR",
ZOHO_INVOICE_ERROR: "ZOHO_INVOICE_ERROR",
UNAUTHORIZED: "UNAUTHORIZED",
} as const;
type CrmErrorCode = keyof typeof CRM_ERROR_CODES;
type ContactParams = {
customerFirstName: string;
customerLastName: string;
customerEmail: string;
customerPhone?: string;
};
/**
* Helper: inline role check using ctx.auth.getUserIdentity()
*/
async function requireStaffOrAdmin(
ctx: CrmHandlerContext,
): Promise<{ role: string }> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error(CRM_ERROR_CODES.UNAUTHORIZED);
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) {
throw new Error(CRM_ERROR_CODES.UNAUTHORIZED);
}
return user as { role: string };
}
type CrmHandlerContext = {
auth: { getUserIdentity: () => Promise<{ email?: string } | null> };
db: {
query: (table: string) => {
withIndex: (
name: string,
fn: (q: QueryBuilder) => QueryBuilder,
) => { first: () => Promise<unknown> };
};
};
};
type QueryBuilder = {
eq: (field: string, value: string) => { first: () => Promise<unknown> };
};
export const upsertZohoContact = mutation({
args: {
customerFirstName: v.string(),
customerLastName: v.string(),
customerEmail: v.string(),
customerPhone: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireStaffOrAdmin(ctx);
const {
customerFirstName,
customerLastName,
customerEmail,
customerPhone,
} = args;
const data = {
First_Name: customerFirstName,
Last_Name: customerLastName,
Email: customerEmail,
Phone: customerPhone ?? undefined,
Lead_Source: "Website Booking",
};
try {
// Check if contact exists by email — direct Zoho API call
const searchResponse = await zohoApiCall(
"/crm/v3/contacts/search?email=" + encodeURIComponent(customerEmail),
{ method: "GET" },
);
const searchData = await searchResponse.json();
if (searchData.data && searchData.data.length > 0) {
// Update existing contact
const contactId = searchData.data[0].id;
await zohoApiCall(`/crm/v3/contacts/${contactId}`, {
method: "PUT",
body: JSON.stringify({ data }),
});
consola.success("Zoho contact updated", {
contactId,
email: customerEmail,
});
return { success: true, action: "updated", contactId };
} else {
// Create new contact
const createResponse = await zohoApiCall("/crm/v3/contacts", {
method: "POST",
body: JSON.stringify({ data }),
});
const createData = await createResponse.json();
consola.success("Zoho contact created", {
email: customerEmail,
id: createData.data?.[0]?.details?.id,
});
return {
success: true,
action: "created",
contactId: createData.data?.[0]?.details?.id,
};
}
} catch (err) {
consola.error("Zoho contact upsert failed", {
email: customerEmail,
error: err,
});
throw new Error(CRM_ERROR_CODES.ZOHO_CONTACT_ERROR);
}
},
});- Step 2: Create Zoho Deal for booking
export const createZohoDeal = mutation({
args: {
customerFirstName: v.string(),
customerLastName: v.string(),
showTitle: v.string(),
showDate: v.string(),
ticketType: v.union(v.literal("DINNER_THEATRE"), v.literal("SHOW_ONLY")),
quantity: v.number(),
totalAmount: v.number(),
},
handler: async (ctx, args) => {
await requireStaffOrAdmin(ctx);
const {
customerFirstName,
customerLastName,
showTitle,
showDate,
ticketType,
quantity,
totalAmount,
} = args;
const dealData = {
Deal_Name: `HOL - ${showTitle} - ${customerLastName}`,
Amount: totalAmount,
Closing_Date: showDate,
Stage: "Closed Won",
Lead_Source: "Website Booking",
Description: `Ticket: ${ticketType} x ${quantity}`,
Contact_Name: `${customerFirstName} ${customerLastName}`,
};
try {
// Direct Zoho API call
const response = await zohoApiCall("/crm/v3/deals", {
method: "POST",
body: JSON.stringify({ data: [dealData] }),
});
const responseData = await response.json();
consola.success("Zoho deal created", {
dealName: dealData.Deal_Name,
id: responseData.data?.[0]?.details?.id,
});
return { success: true, dealId: responseData.data?.[0]?.details?.id };
} catch (err) {
consola.error("Zoho deal creation failed", {
dealName: dealData.Deal_Name,
error: err,
});
throw new Error(CRM_ERROR_CODES.ZOHO_DEAL_ERROR);
}
},
});- Step 3: Commit
Task 6: Zoho Books Invoice Creation
Files:
-
Create:
apps/backend/convex/functions/crm-sync.ts— add invoice creation -
Step 1: Create invoice on PAID booking
export const createZohoInvoice = mutation({
args: {
customerEmail: v.string(),
showTitle: v.string(),
ticketType: v.union(v.literal("DINNER_THEATRE"), v.literal("SHOW_ONLY")),
quantity: v.number(),
subtotal: v.number(),
totalAmount: v.number(),
vnpayTransactionId: v.optional(v.string()),
addOns: v.optional(
v.array(
v.object({
name: v.string(),
quantity: v.number(),
price: v.number(),
}),
),
),
},
handler: async (ctx, args) => {
await requireStaffOrAdmin(ctx);
const {
customerEmail,
showTitle,
ticketType,
quantity,
subtotal,
totalAmount,
vnpayTransactionId,
addOns = [],
} = args;
const lineItems = [
{
item_id: "ticket_product_id", // Pre-configured in Zoho Books
name: `${showTitle} - ${ticketType}`,
quantity,
rate: subtotal / quantity,
},
];
// Add add-ons as separate line items
for (const addon of addOns) {
lineItems.push({
name: addon.name,
quantity: addon.quantity,
rate: addon.price,
});
}
const invoiceData = {
customer_email: customerEmail,
line_items: lineItems,
date: new Date().toISOString().split("T")[0],
payment_terms: "Due on Receipt",
};
try {
// Direct Zoho Books API call
const response = await zohoApiCall("/books/v3/invoices", {
method: "POST",
body: JSON.stringify({ data: [invoiceData] }),
});
const responseData = await response.json();
// Mark invoice as paid (since it's a confirmed booking)
if (responseData.invoice?.id) {
await zohoApiCall(
`/books/v3/invoices/${responseData.invoice.id}/markaspaid`,
{
method: "POST",
body: JSON.stringify({
amount: totalAmount,
payment_mode: "VNPay",
reference_number: vnpayTransactionId ?? undefined,
}),
},
);
consola.success("Zoho invoice created and marked paid", {
invoiceId: responseData.invoice.id,
});
}
return { success: true, invoiceId: responseData.invoice?.id };
} catch (err) {
consola.error("Zoho invoice creation failed", {
customerEmail,
error: err,
});
throw new Error(CRM_ERROR_CODES.ZOHO_INVOICE_ERROR);
}
},
});- Step 2: Commit
Phase 4: Wire Notifications + CRM to Payment Flow
Task 7: Trigger Notifications on Payment Confirmation
Files:
-
Modify:
apps/backend/convex/http/vnpay.ts— add post-payment notification calls viactx.runMutation -
Step 1: After
confirmPaymentin VNPay IPN handler, trigger notifications and CRM sync via direct mutations
// In the VNPay IPN handler, after successful payment update to PAID:
// Direct Convex mutation calls — no HTTP fetch needed
await ctx.runMutation(internal.crm_sync.upsertZohoContact, {
customerFirstName: reservation.customerFirstName,
customerLastName: reservation.customerLastName,
customerEmail: reservation.customerEmail,
customerPhone: reservation.customerPhone ?? undefined,
});
await ctx.runMutation(internal.crm_sync.createZohoDeal, {
customerFirstName: reservation.customerFirstName,
customerLastName: reservation.customerLastName,
showTitle: template?.title ?? "House of Legends",
showDate: occurrence?.date ?? new Date().toISOString().split("T")[0],
ticketType: reservation.ticketType,
quantity: reservation.quantity,
totalAmount: reservation.totalAmount,
});
await ctx.runMutation(internal.crm_sync.createZohoInvoice, {
customerEmail: reservation.customerEmail,
showTitle: template?.title ?? "House of Legends",
ticketType: reservation.ticketType,
quantity: reservation.quantity,
subtotal: reservation.subtotal,
totalAmount: reservation.totalAmount,
vnpayTransactionId: reservation.vnpayTransactionId ?? undefined,
addOns: reservation.addOns ?? [],
});
// Email and WhatsApp notifications (pre-rendered content passed directly)
await ctx.runMutation(internal.notifications.sendEmailNotification, {
to: reservation.customerEmail,
subject: emailSubject,
html: emailHtml,
type: "EMAIL_CONFIRMATION",
reservationId: reservation._id,
});
if (reservation.customerPhone) {
await ctx.runMutation(
internal.notifications.sendBookingConfirmationWhatsApp,
{
customerPhone: reservation.customerPhone,
customerFirstName: reservation.customerFirstName,
customerLastName: reservation.customerLastName,
showTitle: template?.title ?? "House of Legends",
showDateTime: `${occurrence?.date ?? ""} at ${occurrence?.time ?? ""}`,
totalAmount: reservation.totalAmount,
reservationId: reservation._id,
},
);
}- Step 2: On cancellation, trigger email via direct mutation
// In the cancelReservation mutation:
await ctx.runMutation(internal.notifications.sendEmailNotification, {
to: reservation.customerEmail,
subject: cancellationSubject,
html: cancellationHtml,
type: "EMAIL_CANCELLATION",
reservationId: reservation._id,
});- Step 3: Commit
Phase 5: Schema Extensions for Logging
Task 8: Add Notification and Sync Log Tables
Files:
-
Modify:
convex/schema.ts -
Step 1: Add notificationLogs table
notificationLogs: defineTable({
reservationId: v.optional(v.id("reservations")),
type: v.union(
v.literal("EMAIL_CONFIRMATION"),
v.literal("EMAIL_CANCELLATION"),
v.literal("EMAIL_REFUND"),
v.literal("EMAIL_ADMIN_NEW_BOOKING"),
v.literal("WHATSAPP_CONFIRMATION"),
v.literal("WHATSAPP_REMINDER")
),
channel: v.union(v.literal("EMAIL"), v.literal("WHATSAPP")),
recipient: v.string(),
status: v.union(
v.literal("PENDING"),
v.literal("SENT"),
v.literal("FAILED")
),
externalId: v.optional(v.string()),
errorMessage: v.optional(v.string()),
createdAt: v.number(),
sentAt: v.optional(v.number()),
})
.index("by_reservation", ["reservationId"])
.index("by_status", ["status"]),- Step 2: Add zohoSyncLogs table
zohoSyncLogs: defineTable({
reservationId: v.id("reservations"),
action: v.union(
v.literal("CONTACT_UPSERT"),
v.literal("DEAL_CREATE"),
v.literal("INVOICE_CREATE"),
v.literal("INVOICE_PAYMENT")
),
zohoRecordId: v.optional(v.string()),
status: v.union(
v.literal("PENDING"),
v.literal("SUCCESS"),
v.literal("FAILED")
),
errorMessage: v.optional(v.string()),
retryCount: v.number(),
createdAt: v.number(),
syncedAt: v.optional(v.number()),
})
.index("by_reservation", ["reservationId"])
.index("by_status", ["status"]),- Step 3: Commit
Enrichment Sections
1. Zod Schemas
// apps/backend/convex/functions/notifications.ts
import { z } from "zod";
export const SendEmailNotificationSchema = z.object({
to: z.string().email("Invalid email address"),
subject: z.string().min(1, "Subject is required"),
html: z.string().min(1, "HTML content is required"),
type: z.enum([
"EMAIL_CONFIRMATION",
"EMAIL_CANCELLATION",
"EMAIL_ADMIN_NEW_BOOKING",
]),
reservationId: z.string().optional(),
});
export const SendWhatsAppNotificationSchema = z.object({
customerPhone: z.string().min(1, "Phone number is required"),
customerFirstName: z.string().min(1, "First name is required"),
customerLastName: z.string().min(1, "Last name is required"),
showTitle: z.string().min(1, "Show title is required"),
showDateTime: z.string().min(1, "Show date/time is required"),
totalAmount: z.number().nonnegative(),
reservationId: z.string().optional(),
});
// apps/backend/convex/functions/crm-sync.ts
export const UpsertZohoContactSchema = z.object({
customerFirstName: z.string().min(1, "First name is required"),
customerLastName: z.string().min(1, "Last name is required"),
customerEmail: z.string().email("Invalid email address"),
customerPhone: z.string().optional(),
});
export const CreateZohoDealSchema = z.object({
customerFirstName: z.string().min(1, "First name is required"),
customerLastName: z.string().min(1, "Last name is required"),
showTitle: z.string().min(1, "Show title is required"),
showDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format"),
ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
quantity: z.number().int().positive("Quantity must be positive"),
totalAmount: z.number().nonnegative(),
});
export const CreateZohoInvoiceSchema = z.object({
customerEmail: z.string().email("Invalid email address"),
showTitle: z.string().min(1, "Show title is required"),
ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
quantity: z.number().int().positive("Quantity must be positive"),
subtotal: z.number().nonnegative(),
totalAmount: z.number().nonnegative(),
vnpayTransactionId: z.string().optional(),
addOns: z
.array(
z.object({
name: z.string(),
quantity: z.number().int().positive(),
price: z.number().nonnegative(),
}),
)
.optional(),
});2. Error Handling
export const NOTIFICATION_ERROR_CODES = {
RESEND_API_ERROR: "RESEND_API_ERROR",
WHATSAPP_API_ERROR: "WHATSAPP_API_ERROR",
WHATSAPP_NO_PHONE: "WHATSAPP_NO_PHONE",
UNAUTHORIZED: "UNAUTHORIZED",
} as const;
export const CRM_ERROR_CODES = {
ZOHO_AUTH_ERROR: "ZOHO_AUTH_ERROR",
ZOHO_API_ERROR: "ZOHO_API_ERROR",
ZOHO_CONTACT_ERROR: "ZOHO_CONTACT_ERROR",
ZOHO_DEAL_ERROR: "ZOHO_DEAL_ERROR",
ZOHO_INVOICE_ERROR: "ZOHO_INVOICE_ERROR",
UNAUTHORIZED: "UNAUTHORIZED",
} as const;| Function | Error Code | Message Key | Condition |
|---|---|---|---|
sendEmailNotification | RESEND_API_ERROR | errors.notifications.resendFailed | Resend API returns error |
sendEmailNotification | UNAUTHORIZED | errors.auth.unauthorized | Not admin or staff |
sendBookingConfirmationWhatsApp | WHATSAPP_API_ERROR | errors.notifications.whatsappFailed | WhatsApp API returns error |
sendBookingConfirmationWhatsApp | WHATSAPP_NO_PHONE | errors.notifications.noPhone | Customer phone not provided |
sendBookingConfirmationWhatsApp | UNAUTHORIZED | errors.auth.unauthorized | Not admin or staff |
upsertZohoContact | ZOHO_AUTH_ERROR | errors.notifications.zohoAuthFailed | Zoho OAuth token refresh fails |
upsertZohoContact | ZOHO_API_ERROR | errors.notifications.zohoApiFailed | Zoho API returns error |
upsertZohoContact | ZOHO_CONTACT_ERROR | errors.notifications.zohoContactFailed | Zoho contact upsert fails |
upsertZohoContact | UNAUTHORIZED | errors.auth.unauthorized | Not admin or staff |
createZohoDeal | ZOHO_DEAL_ERROR | errors.notifications.zohoDealFailed | Zoho deal creation fails |
createZohoInvoice | ZOHO_INVOICE_ERROR | errors.notifications.zohoInvoiceFailed | Zoho invoice creation fails |
3. Convex Real-time Subscription Pattern
// Admin dashboard — recent notifications
const recentNotifications = useQuery(api.notifications.listRecent, {
limit: 50,
});
// Admin dashboard — sync status
const syncStatus = useQuery(api.crmSync.getByReservation, { reservationId });4. Mobile/Responsive Considerations
| Component | Mobile Behavior |
|---|---|
| Admin notification dashboard | Table layout collapses to card list |
| Email templates | Responsive email design with media queries |
| WhatsApp messages | Template content must fit WhatsApp character limits |
5. PWA / Offline Behavior
Not applicable — notifications are server-triggered, not user-facing PWA features.
6. i18n / next-intl Requirements
P1 FIX — No
getTranslationsin Convex: All email translation MUST happen in Next.js contexts (API routes, Server Components). Convex functions receive pre-renderedsubjectandhtmlstrings.
{
"notifications": {
"email": {
"confirmationTitle": "Booking Confirmed",
"cancellationTitle": "Booking Cancelled",
"dearCustomer": "Dear {name}",
"bookingConfirmed": "Your booking has been confirmed.",
"bookingCancelled": "Your booking has been cancelled.",
"refundInfo": "A refund of {amount} VND will be processed.",
"seeYouAtVenue": "We look forward to seeing you at House of Legends!",
"adminNewBookingTitle": "New Booking Received",
"ticketType": {
"dinnerTheatre": "Dinner Theatre",
"showOnly": "Show Only"
},
"labelShow": "Show",
"labelDate": "Date",
"labelTicketType": "Ticket Type",
"labelQuantity": "Quantity",
"labelTotalPaid": "Total Paid",
"labelCustomer": "Customer",
"labelEmail": "Email",
"labelPhone": "Phone",
"labelTotal": "Total",
"at": "at"
},
"whatsapp": {
"confirmationBody": [
"Dear {name}",
"Your booking for {showTitle} on {dateTime} is confirmed. Total: {amount} VND."
]
}
},
"errors": {
"notifications": {
"resendFailed": "Failed to send email. Please try again.",
"whatsappFailed": "Failed to send WhatsApp message.",
"noPhone": "No phone number provided.",
"zohoAuthFailed": "CRM authentication failed.",
"zohoApiFailed": "CRM API error.",
"zohoContactFailed": "Failed to sync customer to CRM.",
"zohoDealFailed": "Failed to create deal in CRM.",
"zohoInvoiceFailed": "Failed to create invoice in CRM."
},
"auth": {
"unauthorized": "You must be signed in as staff or admin"
}
}
}Email subject lines must be i18n (done in Next.js):
// In Next.js server action — Good (has access to getTranslations via next-intl/server)
const { subject, html } = await buildBookingConfirmationEmail(data);
// Convex receives pre-built strings — Good
await sendEmail({ to, subject, html });Admin email recipient must come from env:
// Good — env var with fallback
adminEmail: process.env.ADMIN_EMAIL ?? "admin@houseoflegends.vn";7. Environment-Specific Configuration
# Server-only (never exposed to client):
CLERK_SECRET_KEY= # Clerk secret key
RESEND_API_KEY=re_xxxxxxxxxxxx
WHATSAPP_ACCESS_TOKEN=xxxxxxxxxxxx
ZOHO_CLIENT_ID=xxxxxxxxxxxx
ZOHO_CLIENT_SECRET=xxxxxxxxxxxx
ZOHO_REFRESH_TOKEN=1000.xxxxxx.xxxxxx
ZOHO_BOOKS_ORGANIZATION_ID=xxxxxxxxxxxx
# Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL= # Convex deployment URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= # Clerk publishable key
NEXT_PUBLIC_APP_URL= # Frontend URL (still needed for redirects, not for internal calls)
# Admin configuration:
ADMIN_EMAIL=admin@houseoflegends.vn
GOOGLE_REVIEW_URL=https://g.page/r/.../review8. TDD Test Cases
E2E Tests (Playwright):
// e2e/notifications.spec.ts
test("NOT-E2E-1.1: Customer receives confirmation email after payment", async ({
page,
}) => {
// Given: VNPay payment confirmed for booking "BOOK-123"
// When: Payment webhook is received with code "00"
// Then: Customer receives email with show title, date, time, ticket type, quantity, total, QR code
// (This is tested via mock - actual email delivery verified in staging)
});
test("NOT-E2E-1.2: Customer receives WhatsApp if phone provided", async ({
page,
}) => {
// Given: Booking confirmed with customerPhone "+84912345678"
// When: Payment confirmed
// Then: WhatsApp template message sent to +84912345678
});
test("NOT-E2E-1.3: WhatsApp skipped when no phone", async ({ page }) => {
// Given: Booking confirmed without customerPhone
// When: Payment confirmed
// Then: No WhatsApp message attempted
});
test("NOT-E2E-1.4: Admin receives new booking email", async ({ page }) => {
// Given: New booking confirmed
// When: Payment confirmed
// Then: Admin email receives new booking notification with customer details
});Component Tests (Vitest + RTL):
// __tests__/components/email-templates.test.tsx
it("NOT-CT-1.1: Booking confirmation email renders with all fields", async () => {
// Given: Booking data with all fields populated
const data = {
locale: "en",
customerFirstName: "John",
showTitle: "Legendary Show",
showDate: "May 15, 2026",
showTime: "7:30 PM",
ticketType: "DINNER_THEATRE",
quantity: 2,
totalAmount: 2300000,
qrCode: "https://example.com/qr.png",
};
// When: buildBookingConfirmationEmail is called
const { subject, html } = await buildBookingConfirmationEmail(data);
// Then: Subject contains show title, HTML contains all data fields
expect(subject).toContain("Legendary Show");
expect(html).toContain("John");
expect(html).toContain("Legendary Show");
expect(html).toContain("May 15, 2026");
expect(html).toContain("7:30 PM");
expect(html).toContain("Dinner Theatre");
expect(html).toContain("2");
expect(html).toContain("2,300,000");
});
it("NOT-CT-1.2: Cancellation email shows refund info when amount > 0", async () => {
// Given: Cancellation with totalAmount 2,300,000 VND
const data = {
locale: "en",
customerFirstName: "John",
totalAmount: 2300000,
};
// When: buildCancellationEmail is called
const { html } = await buildCancellationEmail(data);
// Then: HTML contains refund info
expect(html).toContain("2,300,000");
});
it("NOT-CT-1.3: Cancellation email shows no refund info when amount is 0", async () => {
// Given: Cancellation with totalAmount 0
const data = { locale: "en", customerFirstName: "John", totalAmount: 0 };
// When: buildCancellationEmail is called
const { html } = await buildCancellationEmail(data);
// Then: HTML does not contain refund info
expect(html).not.toContain("refundInfo");
});Backend Tests (Vitest):
// __tests__/convex/notifications.test.ts
it("NOT-BE-1.1: sendEmailNotification throws UNAUTHORIZED when not staff/admin", async () => {
// Given: Mock context with unauthenticated identity
const ctx = createMockContext({ identity: null });
// When: Calling sendEmailNotification
// Then: Throws UNAUTHORIZED
await expect(
ctx.runMutation(api.notifications.sendEmailNotification, {
to: "john@example.com",
subject: "Test",
html: "<p>Test</p>",
type: "EMAIL_CONFIRMATION",
}),
).rejects.toThrow("UNAUTHORIZED");
});
it("NOT-BE-1.2: sendEmailNotification throws UNAUTHORIZED for GUEST role", async () => {
// Given: Mock context with GUEST role
const ctx = createMockContext({
identity: { email: "guest@example.com" },
userRole: "GUEST",
});
// When: Calling sendEmailNotification
// Then: Throws UNAUTHORIZED
await expect(
ctx.runMutation(api.notifications.sendEmailNotification, {
to: "john@example.com",
subject: "Test",
html: "<p>Test</p>",
type: "EMAIL_CONFIRMATION",
}),
).rejects.toThrow("UNAUTHORIZED");
});
it("NOT-BE-1.3: sendEmailNotification succeeds for ADMIN role", async () => {
// Given: Mock context with ADMIN role
const ctx = createMockContext({
identity: { email: "admin@example.com" },
userRole: "ADMIN",
});
// Mock the Resend SDK call
const mockSendEmail = vi.fn().mockResolvedValue({ data: { id: "test-id" } });
vi.mock("~/lib/resend", () => ({
sendEmail: mockSendEmail,
}));
// When: Calling sendEmailNotification
// Then: Succeeds and calls Resend directly
await ctx.runMutation(api.notifications.sendEmailNotification, {
to: "john@example.com",
subject: "Booking Confirmed",
html: "<h1>Confirmed</h1>",
type: "EMAIL_CONFIRMATION",
});
expect(mockSendEmail).toHaveBeenCalledWith({
to: "john@example.com",
subject: "Booking Confirmed",
html: "<h1>Confirmed</h1>",
});
});
it("NOT-BE-1.4: sendBookingConfirmationWhatsApp skips when phone empty", async () => {
// Given: Mock context with ADMIN role
const ctx = createMockContext({
identity: { email: "admin@example.com" },
userRole: "ADMIN",
});
// When: Calling with empty customerPhone
// Then: Returns { success: true, skipped: true }
const result = await ctx.runMutation(
api.notifications.sendBookingConfirmationWhatsApp,
{
customerPhone: "",
customerFirstName: "John",
customerLastName: "Doe",
showTitle: "Legendary Show",
showDateTime: "2026-05-15 at 19:30",
totalAmount: 1800000,
},
);
expect(result).toEqual({ success: true, skipped: true });
});
it("NOT-BE-1.5: upsertZohoContact throws UNAUTHORIZED when not staff/admin", async () => {
// Given: Mock context with GUEST role
const ctx = createMockContext({
identity: { email: "guest@example.com" },
userRole: "GUEST",
});
// When: Calling upsertZohoContact
// Then: Throws UNAUTHORIZED
await expect(
ctx.runMutation(api.crmSync.upsertZohoContact, {
customerFirstName: "John",
customerLastName: "Doe",
customerEmail: "john@example.com",
}),
).rejects.toThrow("UNAUTHORIZED");
});
it("NOT-BE-1.6: createZohoDeal succeeds for ADMIN role", async () => {
// Given: Mock context with ADMIN role
const ctx = createMockContext({
identity: { email: "admin@example.com" },
userRole: "ADMIN",
});
// Mock the Zoho API call
const mockZohoApiCall = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ data: [{ details: { id: "deal123" } }] }),
});
vi.mock("~/lib/zoho", () => ({
zohoApiCall: mockZohoApiCall,
}));
// When: Calling createZohoDeal
// Then: Succeeds
const result = await ctx.runMutation(api.crmSync.createZohoDeal, {
customerFirstName: "John",
customerLastName: "Doe",
showTitle: "Legendary Show",
showDate: "2026-05-15",
ticketType: "DINNER_THEATRE",
quantity: 2,
totalAmount: 2300000,
});
expect(result.success).toBe(true);
});9. Cross-Plan Dependencies
| Dependency | Plan | Shared Schema |
|---|---|---|
| Required by | package-bundle-pricing | Reservation totals used in Zoho invoices |
| Required by | staff-operations | Reservation check-in triggers |
| Shares schema with | show-system | showTemplates.title for email content |
| Triggered by | booking-flow | VNPay payment confirmation triggers all notifications |
| Depends on | show-system | showTemplates.title for email subject lines |
| Depends on | table-pos-system | reservation.tableId links to POS table |
| Zoho invoice uses | package-bundle-pricing | Line items from pricing breakdown |
10. Performance Considerations
| Scenario | At Scale (100 bookings/day) |
|---|---|
| Email sending | Resend handles 100 emails/day easily; async, non-blocking |
| WhatsApp sending | 100 WhatsApp messages/day well within rate limits |
| Zoho sync | Each booking = 3 API calls (contact, deal, invoice); ~300 calls/day |
| Retry logic | Exponential backoff: 1min, 5min, 15min; max 3 retries |
| Email rendering | Next.js server action handles translation via next-intl/server; ~50ms added latency |
Acceptance Criteria
- Email confirmation — customer receives HTML email with booking details and QR code after payment
- WhatsApp confirmation — customer receives WhatsApp template message after payment (if phone provided)
- Admin new booking email — admin receives email for every new PAID booking
- D-1 low occupancy alert — admin receives email + WhatsApp when a tomorrow show has < 50% occupancy
- Zoho CRM contact — customer contact is created/updated in Zoho CRM on booking
- Zoho CRM deal — deal is created in Zoho CRM with booking amount and show date as closing date
- Zoho Books invoice — invoice is created in Zoho Books and marked as paid
- Retry logic — if Zoho/email API fails, there is a retry mechanism or queue
- No duplicate contacts — Zoho contact upsert checks for existing email before creating
User Stories
| ID | As a... | I want to... | So that... | Priority |
|---|---|---|---|---|
| NOT-US01 | Guest | Receive confirmation email after payment | I have proof of my booking | Must |
| NOT-US02 | Guest | Receive WhatsApp message after booking | I get notified via a channel I check frequently (if phone provided) | Should |
| NOT-US03 | Admin | Receive new booking email notification | I can track revenue and upcoming shows | Must |
| NOT-US04 | Admin | Receive D-1 low occupancy alert | I can take action to avoid empty shows | Should |
| NOT-US05 | Guest | Receive cancellation email with refund info | I understand the booking was cancelled and know when to expect my refund | Must |
| NOT-US06 | System | Create/update Zoho CRM contact on booking | Customer data is captured in Zoho for marketing and follow-up | Must |
| NOT-US07 | System | Create Zoho Deal with booking amount | Admin can track pipeline value and forecast revenue | Must |
| NOT-US08 | System | Create Zoho Books invoice marked as paid | Accounting has accurate records of received payments | Must |
| NOT-US09 | System | Avoid duplicate contacts in Zoho | Contact list stays clean and marketing segments remain accurate | Must |
Test Scenarios
| ID | Scenario | Given | When | Then |
|---|---|---|---|---|
| NOT-TS01 | Email sent on payment | Booking confirmed via VNPay IPN | Payment code = 00 | Confirmation email sent to customer |
| NOT-TS02 | Email contains booking details | Booking confirmed | Payment code = 00 | Email includes show title, date, time, ticket type, quantity, total, QR code |
| NOT-TS03 | WhatsApp sent when phone provided | Booking confirmed with customerPhone | Payment code = 00 | WhatsApp template message sent to customer's phone |
| NOT-TS04 | WhatsApp skipped when no phone | Booking confirmed without customerPhone | Payment code = 00 | WhatsApp message not attempted |
| NOT-TS05 | Admin email on new booking | Booking confirmed | Payment code = 00 | Admin receives new booking notification email |
| NOT-TS06 | Zoho contact created for new email | New customer email (not in Zoho) | Booking confirmed | New contact created in Zoho CRM |
| NOT-TS07 | Zoho contact updated for existing email | Existing customer email (in Zoho) | Booking confirmed | Existing Zoho contact updated with latest booking info |
| NOT-TS08 | Zoho Deal created on booking | Booking confirmed | Payment code = 00 | Zoho Deal created with correct amount and closing date |
| NOT-TS09 | Zoho Books invoice created | Booking confirmed | Payment code = 00 | Zoho Books invoice created and marked as paid |
| NOT-TS10 | Invoice line items correct | Booking with add-ons confirmed | Payment code = 00 | Invoice includes ticket line item and each add-on as separate line item |
| NOT-TS11 | Cancellation email on user cancellation | Booking cancelled by user | Cancellation confirmed | Cancellation email sent to customer with refund info |
| NOT-TS12 | Cancellation email on admin cancellation | Booking cancelled by admin | Cancellation confirmed | Cancellation email sent to customer with refund info |
| NOT-TS13 | D-1 low occupancy alert triggered | Tomorrow's show has < 50% occupancy | 24h before show | Admin receives low occupancy alert via email and WhatsApp |
| NOT-TS14 | D-1 alert not sent for high occupancy | Tomorrow's show has >= 50% occupancy | 24h before show | Admin does not receive low occupancy alert |
| NOT-TS15 | No duplicate Zoho contact on rebooking | Customer email already exists in Zoho | Same customer books again | Existing contact updated, no new contact created |
| NOT-TS16 | Retry on email API failure | Resend API returns 500 | Sending confirmation email | Retry attempted up to 3 times with exponential backoff |
| NOT-TS17 | Retry on Zoho API failure | Zoho API returns 503 | Creating contact/deal/invoice | Retry attempted with exponential backoff |
| NOT-TS18 | Email not sent for failed payment | VNPay IPN with payment code != 00 | Payment failed | No confirmation email sent |
| NOT-TS19 | QR code in email | Booking confirmed with QR code generated | Payment code = 00 | QR code image included in confirmation email |
| NOT-TS20 | Partial refund notification | Booking partially refunded | Refund processed | Email sent to customer with refund amount and remaining balance |
Consistency Audit: notifications-crm
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Phase 2, sendEmailNotification | Was importing getCurrentUser from "../auth" and calling it as utility function — but getCurrentUser is a QUERY, not a utility | Changed to inline role check using ctx.auth.getUserIdentity() + DB lookup |
| 2 | Phase 2, sendBookingConfirmationWhatsApp | Same incorrect getCurrentUser usage | Changed to inline role check |
| 3 | Phase 3, upsertZohoContact | Same incorrect getCurrentUser usage | Changed to inline role check |
| 4 | Phase 3, createZohoDeal | Same incorrect getCurrentUser usage | Changed to inline role check |
| 5 | Phase 3, createZohoInvoice | Same incorrect getCurrentUser usage | Changed to inline role check |
| 6 | Phase 2, requireStaffOrAdmin | Over-complex explicit type annotation (q: unknown) => (q as { eq: ... }).eq(...) on QueryBuilder parameter | Simplified to cleaner named types — Convex QueryBuilder is inferred automatically, explicit casts unnecessary |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Global | console.log usage | Changed to consola.info/debug/success/error |
| 2 | Global | Inconsistent error code naming | Fixed to use NOTIFICATION_ERROR_CODES and CRM_ERROR_CODES consistently |
| 3 | Error handling | Error codes not defined as const object | Added NOTIFICATION_ERROR_CODES and CRM_ERROR_CODES const objects |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | staffMutation/adminMutation/authenticatedMutation/authenticatedQuery not in convex/auth.ts | Use plain mutation with inline role checks until auth helpers are implemented |
| 2 | Convex can't import Next.js libs directly | External service calls (Resend, WhatsApp, Zoho) are made directly from Convex mutations via SDK — no Next.js imports needed |
Schema Consistency Check
notificationLogstable uses correct index namesby_reservationandby_statuszohoSyncLogstable uses correct index namesby_reservationandby_status- All mutation args use proper Convex
v.*validators matching Zod schemas above