chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled

This commit is contained in:
Wira Basalamah
2026-04-21 09:29:29 +07:00
commit adde003fba
222 changed files with 37657 additions and 0 deletions

1432
lib/admin-crud.ts Normal file

File diff suppressed because it is too large Load Diff

50
lib/audit.ts Normal file
View File

@ -0,0 +1,50 @@
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
export type AuditTrailParams = {
tenantId: string;
actorUserId?: string | null;
entityType: string;
entityId: string;
action: string;
metadata?: unknown;
ipAddress?: string | null;
userAgent?: string | null;
};
function serializeMetadata(metadata: unknown) {
if (typeof metadata === "undefined") {
return null;
}
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
export async function writeAuditTrail(params: AuditTrailParams) {
await prisma.auditLog.create({
data: {
tenantId: params.tenantId,
actorUserId: params.actorUserId,
entityType: params.entityType,
entityId: params.entityId,
action: params.action,
metadataJson: serializeMetadata(params.metadata),
ipAddress: params.ipAddress,
userAgent: params.userAgent
}
});
}
export async function getRequestAuditContext() {
const requestHeaders = await headers();
const forwardedFor = requestHeaders.get("x-forwarded-for");
const ipAddress = forwardedFor ? forwardedFor.split(",")[0]?.trim() : requestHeaders.get("x-real-ip");
const userAgent = requestHeaders.get("user-agent");
return { ipAddress, userAgent };
}

200
lib/auth-tokens.ts Normal file
View File

@ -0,0 +1,200 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { Prisma } from "@prisma/client";
import { AuthTokenType } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const AUTH_TOKEN_SECRET = process.env.AUTH_SECRET;
const FALLBACK_AUTH_TOKEN_SECRET = "whatsapp-inbox-dev-secret";
const RESOLVED_SECRET = AUTH_TOKEN_SECRET || (process.env.NODE_ENV === "production" ? undefined : FALLBACK_AUTH_TOKEN_SECRET);
if (!RESOLVED_SECRET) {
throw new Error("AUTH_SECRET is required in production for secure token generation.");
}
const textEncoder = new TextEncoder();
const tokenSecretBytes = textEncoder.encode(RESOLVED_SECRET);
const RESET_DEFAULT_TTL_MINUTES = 60;
const INVITE_DEFAULT_TTL_MINUTES = 72 * 60;
type HashInput = {
token: string;
};
type TokenResult = {
id: string;
userId: string;
tenantId: string;
tokenType: AuthTokenType;
tokenHash: string;
expiresAt: Date;
consumedAt: Date | null;
createdAt: Date;
metadataJson: string | null;
};
type CreateAuthTokenInput = {
userId: string;
tenantId: string;
tokenType: AuthTokenType;
ttlMinutes?: number;
createdByUserId?: string | null;
metadata?: unknown;
};
type ResolveResult =
| {
valid: true;
token: TokenResult;
}
| {
valid: false;
reason: "missing" | "expired" | "consumed";
};
function normalizeTokenMetadata(metadata: unknown) {
if (metadata === undefined) {
return null;
}
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
function serializePayload(input: HashInput) {
const bytes = new TextEncoder().encode(`${input.token}`);
const payload = new Uint8Array(tokenSecretBytes.length + bytes.length);
payload.set(tokenSecretBytes, 0);
payload.set(bytes, tokenSecretBytes.length);
return payload;
}
function hashToken(token: string) {
if (!token) {
return "";
}
const digest = createHash("sha256")
.update(serializePayload({ token }))
.digest();
return digest.toString("base64url");
}
function newToken() {
return randomBytes(32).toString("base64url");
}
function resolveTokenTypeTTL(type: AuthTokenType) {
if (type === AuthTokenType.INVITE_ACCEPTANCE) {
return INVITE_DEFAULT_TTL_MINUTES;
}
return RESET_DEFAULT_TTL_MINUTES;
}
export function makeResetUrl(rawToken: string) {
const baseUrl = (process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/+$/, "");
return `${baseUrl}/reset-password?token=${encodeURIComponent(rawToken)}`;
}
export function makeInviteUrl(rawToken: string) {
const baseUrl = (process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/+$/, "");
return `${baseUrl}/invite/${encodeURIComponent(rawToken)}`;
}
export async function createAuthToken(input: CreateAuthTokenInput) {
const ttl = resolveTokenTypeTTL(input.tokenType);
const rawToken = newToken();
const tokenHash = hashToken(rawToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + ttl * 60 * 1000);
await prisma.authToken.deleteMany({
where: {
userId: input.userId,
tokenType: input.tokenType,
consumedAt: null
}
});
const token = await prisma.authToken.create({
data: {
userId: input.userId,
tenantId: input.tenantId,
tokenType: input.tokenType,
tokenHash,
expiresAt,
createdByUser: input.createdByUserId,
metadataJson: normalizeTokenMetadata(input.metadata)
}
});
return {
rawToken,
tokenId: token.id,
expiresAt: token.expiresAt,
ttlMinutes: ttl
};
}
export async function consumeAuthToken(raw: string, tokenType: AuthTokenType): Promise<ResolveResult> {
if (!raw) {
return { valid: false, reason: "missing" };
}
const tokenHash = hashToken(raw.trim());
const now = new Date();
const token = await prisma.authToken.findFirst({
where: {
tokenType,
tokenHash,
consumedAt: null
},
select: {
id: true,
userId: true,
tenantId: true,
tokenType: true,
tokenHash: true,
expiresAt: true,
consumedAt: true,
createdAt: true,
metadataJson: true
} satisfies Prisma.AuthTokenSelect
});
if (!token) {
return { valid: false, reason: "missing" };
}
if (token.expiresAt <= now) {
return { valid: false, reason: "expired" };
}
if (token.consumedAt) {
return { valid: false, reason: "consumed" };
}
return { valid: true, token };
}
export async function markAuthTokenConsumed(tokenId: string, now: Date) {
await prisma.authToken.update({
where: { id: tokenId },
data: { consumedAt: now }
});
}
export async function revokeUserAuthTokens(userId: string, tokenType: AuthTokenType) {
await prisma.authToken.deleteMany({
where: { userId, tokenType }
});
}

437
lib/auth.ts Normal file
View File

@ -0,0 +1,437 @@
import "server-only";
import { cookies } from "next/headers";
import { RoleCode, UserStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export type UserRole = "super_admin" | "admin_client" | "agent";
export type AuthSession = {
userId: string;
fullName: string;
email: string;
role: UserRole;
tenantId: string;
tenantName: string;
issuedAt: number;
expiresAt: number;
extraPermissions: string[];
};
export const SESSION_COOKIE = "wa_inbox_session";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
const AUTH_SECRET = process.env.AUTH_SECRET;
const SESSION_ITERATIONS = 120000;
const DEFAULT_SECRET = "whatsapp-inbox-dev-secret";
const resolvedAuthSecret =
AUTH_SECRET || (process.env.NODE_ENV === "production" ? undefined : DEFAULT_SECRET);
if (!resolvedAuthSecret) {
throw new Error("AUTH_SECRET is required in production for secure session signing.");
}
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const SESSION_SECRET_FALLBACK = textEncoder.encode(resolvedAuthSecret);
type RolePermissionSet = Set<string>;
function normalizePermissionToken(value: unknown) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function collectPermissionGrants(target: RolePermissionSet, rawValue: unknown) {
const source =
typeof rawValue === "string" && rawValue.length > 0
? rawValue.trim()
: rawValue;
if (typeof source === "string") {
try {
const parsed = JSON.parse(source);
collectPermissionGrants(target, parsed);
return;
} catch {
return;
}
}
if (!source) {
return;
}
if (Array.isArray(source)) {
for (const item of source) {
const token = normalizePermissionToken(item);
if (!token) {
continue;
}
target.add(token);
}
return;
}
if (typeof source !== "object" || source === null) {
return;
}
const entries = Object.entries(source as Record<string, unknown>);
for (const [key, entry] of entries) {
if (key === "global" && entry === true) {
target.add("*");
continue;
}
if (key === "tenant" && entry === true) {
target.add("tenant:read");
continue;
}
if (key === "inbox" && entry === true) {
target.add("inbox:read");
target.add("inbox:assign");
target.add("inbox:status");
target.add("inbox:reply");
target.add("inbox:notes");
target.add("inbox:tags");
continue;
}
if (key === "admin" && entry === true) {
target.add("admin:read");
target.add("admin:manage");
continue;
}
if (key === "agent" && entry === true) {
target.add("agent:read");
target.add("agent:manage");
continue;
}
if (key === "profile_manage_self" && entry === true) {
target.add("profile:manage_self");
continue;
}
if (typeof entry === "string") {
const token = normalizePermissionToken(entry);
if (!token) {
continue;
}
target.add(token);
continue;
}
if (entry === true) {
const token = normalizePermissionToken(key);
if (token) {
target.add(token);
}
continue;
}
collectPermissionGrants(target, entry);
}
}
function parseRolePermissions(rawPermissions: string | null | undefined) {
const permissionSet: RolePermissionSet = new Set<string>();
collectPermissionGrants(permissionSet, rawPermissions);
return Array.from(permissionSet);
}
function uint8ToBase64Url(bytes: Uint8Array) {
let str = "";
for (const byte of bytes) {
str += String.fromCharCode(byte);
}
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function base64UrlToUint8(value: string) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
async function getSessionHmacKey() {
return crypto.subtle.importKey(
"raw",
SESSION_SECRET_FALLBACK,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
function equalBytes(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) {
return false;
}
let diff = 0;
for (let i = 0; i < a.length; i += 1) {
diff |= a[i] ^ b[i];
}
return diff === 0;
}
function resolveRoleFromCode(code: RoleCode): UserRole {
switch (code) {
case RoleCode.SUPER_ADMIN:
return "super_admin";
case RoleCode.AGENT:
return "agent";
case RoleCode.ADMIN_CLIENT:
default:
return "admin_client";
}
}
export async function serializeSession(session: AuthSession) {
const payload = `${session.userId}|${session.role}|${session.tenantId}|${session.expiresAt}`;
const key = await getSessionHmacKey();
const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload));
return `${uint8ToBase64Url(textEncoder.encode(payload))}.${uint8ToBase64Url(new Uint8Array(signature))}`;
}
export async function parseSessionCookie(raw: string) {
try {
const [payload, signature] = raw.split(".");
if (!payload || !signature) {
return null;
}
const key = await getSessionHmacKey();
const payloadBytes = base64UrlToUint8(payload);
const signatureBytes = base64UrlToUint8(signature);
const valid = await crypto.subtle.verify("HMAC", key, signatureBytes, payloadBytes);
if (!valid) {
return null;
}
const decoded = textDecoder.decode(payloadBytes);
const [userId, role, tenantId, exp] = decoded.split("|");
if (!userId || !role || !tenantId || !exp) {
return null;
}
const expiresAt = Number(exp);
if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) {
return null;
}
return {
userId,
role: role as UserRole,
tenantId,
tenantName: "",
fullName: "",
email: "",
issuedAt: Math.floor(Date.now() / 1000),
expiresAt
};
} catch {
return null;
}
}
export async function getSession() {
const store = await cookies();
const raw = store.get(SESSION_COOKIE)?.value;
if (!raw) {
return null;
}
const parsed = await parseSessionCookie(raw);
if (!parsed) {
return null;
}
const user = await prisma.user.findUnique({
where: { id: parsed.userId },
include: { role: true, tenant: true }
});
if (!user) {
return null;
}
return {
userId: user.id,
fullName: user.fullName,
email: user.email,
role: resolveRoleFromCode(user.role.code),
tenantId: user.tenantId,
tenantName: user.tenant.companyName || user.tenant.name,
extraPermissions: parseRolePermissions(user.role.permissionsJson),
issuedAt: parsed.issuedAt,
expiresAt: parsed.expiresAt
};
}
function normalizeSalt(value: string) {
if (!value) {
return null;
}
try {
return base64UrlToUint8(value);
} catch {
return null;
}
}
export async function hashPassword(password: string) {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
const passwordBits = textEncoder.encode(password);
const key = await crypto.subtle.importKey("raw", passwordBits, "PBKDF2", false, ["deriveBits"]);
const digest = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
hash: "SHA-256",
salt,
iterations: SESSION_ITERATIONS
},
key,
32 * 8
);
return `pbkdf2$${SESSION_ITERATIONS}$${uint8ToBase64Url(salt)}$${uint8ToBase64Url(new Uint8Array(digest))}`;
}
export async function verifyPassword(password: string, hash: string) {
if (!password || !hash) {
return false;
}
const parts = hash.split("$");
if (parts.length !== 4 || parts[0] !== "pbkdf2") {
return false;
}
const [, iterationRaw, salt, expectedDigest] = parts;
if (!iterationRaw || !salt || !expectedDigest) {
return false;
}
const iterations = Number(iterationRaw);
if (!Number.isFinite(iterations) || iterations <= 0) {
return false;
}
const normalizedSalt = normalizeSalt(salt);
const normalizedHash = normalizeSalt(expectedDigest);
if (!normalizedSalt || !normalizedHash) {
return false;
}
const passwordBits = textEncoder.encode(password);
const key = await crypto.subtle.importKey("raw", passwordBits, "PBKDF2", false, ["deriveBits"]);
const derived = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
hash: "SHA-256",
salt: normalizedSalt,
iterations
},
key,
normalizedHash.byteLength * 8
);
return equalBytes(new Uint8Array(derived), normalizedHash);
}
export async function authenticateUser(email: string, password: string) {
if (!email || !password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email },
include: { tenant: true, role: true }
});
if (!user || user.status !== UserStatus.ACTIVE) {
return null;
}
if (!(await verifyPassword(password, user.passwordHash))) {
return null;
}
const now = Math.floor(Date.now() / 1000);
return {
userId: user.id,
fullName: user.fullName,
email: user.email,
role: resolveRoleFromCode(user.role.code),
tenantId: user.tenantId,
tenantName: user.tenant.companyName || user.tenant.name,
extraPermissions: parseRolePermissions(user.role.permissionsJson),
issuedAt: now,
expiresAt: now + SESSION_TTL_SECONDS
};
}
export function getDefaultPathForRole(role: UserRole) {
if (role === "super_admin") {
return "/super-admin";
}
if (role === "agent") {
return "/agent";
}
return "/dashboard";
}
export function canAccessPath(role: UserRole, pathname: string) {
if (pathname.startsWith("/super-admin")) {
return role === "super_admin";
}
if (pathname.startsWith("/agent")) {
return role === "agent";
}
const adminPaths = [
"/dashboard",
"/inbox",
"/contacts",
"/templates",
"/campaigns",
"/team",
"/reports",
"/settings",
"/billing",
"/audit-log"
];
if (adminPaths.some((path) => pathname.startsWith(path))) {
return role === "admin_client";
}
if (pathname.startsWith("/profile") || pathname.startsWith("/notifications") || pathname.startsWith("/search")) {
return true;
}
return true;
}

View File

@ -0,0 +1,685 @@
import { CampaignAudienceType, CampaignStatus, DeliveryStatus, OptInStatus, type Prisma } from "@prisma/client";
import { DEFAULT_MAX_SEND_ATTEMPTS, getRetryDelaySeconds, recalculateCampaignTotals, renderCampaignMessage } from "@/lib/campaign-utils";
import { writeAuditTrail } from "@/lib/audit";
import { prisma } from "@/lib/prisma";
import { sendOutboundTextMessage } from "@/lib/whatsapp-provider";
import { notifyCampaignRetryFailure } from "@/lib/job-alerts";
const DEFAULT_RETRIES_PER_RUN = 100;
const DEFAULT_CAMPAIGNS_PER_RUN = 20;
const DEFAULT_LOCK_TTL_SECONDS = 300;
const CAMPAIGN_RETRY_JOB_NAME = "campaign-retry-worker";
type CampaignAudienceContact = {
id: string;
fullName: string;
phoneNumber: string;
};
type CampaignRecipientWithContact = Prisma.CampaignRecipientGetPayload<{
include: { contact: { select: { id: true; fullName: true; phoneNumber: true } } };
}>;
type CampaignDispatchCampaignSeed = Prisma.BroadcastCampaignGetPayload<{ include: { channel: true; template: true } }>;
type CampaignDispatchResult = {
campaignId: string;
campaignName: string;
skippedReason: string | null;
seededRecipients: number;
processableRecipients: number;
attempted: number;
successful: number;
failed: number;
idle: boolean;
};
type CampaignRetryBatchOptions = {
tenantId?: string;
campaignId?: string;
actorUserId?: string | null;
actorIpAddress?: string | null;
actorUserAgent?: string | null;
now?: Date;
recipientBatchSize?: number;
maxCampaigns?: number;
};
type CampaignRetryBatchResult = {
runAt: string;
status: "completed" | "skipped_locked" | "failed";
processedCampaigns: number;
totalSeeded: number;
totalProcessable: number;
totalAttempted: number;
totalSuccessful: number;
totalFailed: number;
summaries: CampaignDispatchResult[];
error?: string;
};
export async function loadCampaignAudienceContacts(tenantId: string, campaign: {
audienceType: CampaignAudienceType;
segmentId: string | null;
}) {
if (campaign.audienceType === CampaignAudienceType.SEGMENT && campaign.segmentId) {
const segmentMembers = await prisma.segmentMember.findMany({
where: { segmentId: campaign.segmentId, tenantId },
include: { contact: { select: { id: true, fullName: true, phoneNumber: true } } }
});
return segmentMembers
.filter((member) => member.contact.phoneNumber.trim().length > 0)
.map((member) => ({
id: member.contact.id,
fullName: member.contact.fullName,
phoneNumber: member.contact.phoneNumber
})) as CampaignAudienceContact[];
}
const contacts = await prisma.contact.findMany({
where: {
tenantId,
optInStatus: {
not: OptInStatus.OPTED_OUT
}
},
orderBy: { fullName: "asc" },
select: { id: true, fullName: true, phoneNumber: true }
});
return contacts
.filter((contact) => contact.phoneNumber.trim().length > 0)
.map((contact) => ({ id: contact.id, fullName: contact.fullName, phoneNumber: contact.phoneNumber }));
}
function isRecipientDueForRetry(recipient: CampaignRecipientWithContact, now: Date) {
if (recipient.sendAttempts >= recipient.maxSendAttempts) {
return false;
}
if (!recipient.nextRetryAt) {
return true;
}
return recipient.nextRetryAt <= now;
}
function mapRecipientTemplateValues(contact: CampaignAudienceContact) {
return {
"1": contact.fullName,
"2": contact.phoneNumber
};
}
function getCampaignBackoffDate(now: Date, attempt: number) {
const delayInSeconds = getRetryDelaySeconds(attempt);
return new Date(now.getTime() + delayInSeconds * 1000);
}
function getBatchSize(raw: number | undefined, fallback: number) {
if (!raw || raw <= 0) {
return fallback;
}
if (!Number.isInteger(raw)) {
return fallback;
}
return raw;
}
function getPositiveIntFromEnv(key: string, fallback: number) {
const raw = process.env[key]?.trim();
const value = raw ? Number(raw) : fallback;
if (!Number.isInteger(value) || value <= 0) {
return fallback;
}
return value;
}
function safeStringify(value: unknown) {
try {
return JSON.stringify(value);
} catch {
return null;
}
}
async function upsertCampaignRecipientsSeed(campaign: CampaignDispatchCampaignSeed, tenantId: string) {
const audienceContacts = await loadCampaignAudienceContacts(tenantId, {
audienceType: campaign.audienceType,
segmentId: campaign.segmentId
});
const uniqueContacts = new Map<string, CampaignAudienceContact>();
for (const item of audienceContacts) {
if (!uniqueContacts.has(item.id) && item.phoneNumber) {
uniqueContacts.set(item.id, item);
}
}
return {
uniqueContacts,
payload: Array.from(uniqueContacts.values()).map((contact) => ({
tenantId,
campaignId: campaign.id,
contactId: contact.id,
phoneNumber: contact.phoneNumber,
sendStatus: DeliveryStatus.QUEUED,
sendAttempts: 0,
maxSendAttempts: DEFAULT_MAX_SEND_ATTEMPTS
}))
};
}
export async function dispatchCampaignById(params: {
campaignId: string;
tenantId: string;
actorUserId?: string | null;
actorIpAddress?: string | null;
actorUserAgent?: string | null;
maxRecipientsPerCampaign?: number;
now?: Date;
enforceSchedule?: boolean;
source?: "manual" | "scheduler";
}): Promise<CampaignDispatchResult> {
const now = params.now ?? new Date();
const limit = getBatchSize(params.maxRecipientsPerCampaign, DEFAULT_RETRIES_PER_RUN);
const campaign = await prisma.broadcastCampaign.findFirst({
where: { id: params.campaignId, tenantId: params.tenantId },
include: {
channel: true,
template: true
}
});
if (!campaign) {
return {
campaignId: params.campaignId,
campaignName: "-",
skippedReason: "campaign_not_found",
seededRecipients: 0,
processableRecipients: 0,
attempted: 0,
successful: 0,
failed: 0,
idle: true
};
}
if (campaign.status === CampaignStatus.CANCELED) {
return {
campaignId: campaign.id,
campaignName: campaign.name,
skippedReason: "campaign_not_ready",
seededRecipients: 0,
processableRecipients: 0,
attempted: 0,
successful: 0,
failed: 0,
idle: true
};
}
if (params.enforceSchedule && campaign.scheduledAt && campaign.scheduledAt > now) {
return {
campaignId: campaign.id,
campaignName: campaign.name,
skippedReason: "scheduled_not_ready",
seededRecipients: 0,
processableRecipients: 0,
attempted: 0,
successful: 0,
failed: 0,
idle: true
};
}
const campaignStatusSeed = campaign.scheduledAt && campaign.scheduledAt <= now
? CampaignStatus.PROCESSING
: campaign.status;
if (campaignStatusSeed !== campaign.status) {
await prisma.broadcastCampaign.update({
where: { id: campaign.id },
data: { status: campaignStatusSeed, startedAt: now }
});
campaign.status = campaignStatusSeed;
}
const existingRecipientCount = await prisma.campaignRecipient.count({
where: { campaignId: campaign.id }
});
const auditContext = {
tenantId: params.tenantId,
actorUserId: params.actorUserId,
ipAddress: params.actorIpAddress,
userAgent: params.actorUserAgent
};
let seededRecipients = 0;
if (existingRecipientCount === 0) {
const { uniqueContacts, payload } = await upsertCampaignRecipientsSeed(campaign, params.tenantId);
if (payload.length === 0) {
return {
campaignId: campaign.id,
campaignName: campaign.name,
skippedReason: "no_recipients",
seededRecipients: 0,
processableRecipients: 0,
attempted: 0,
successful: 0,
failed: 0,
idle: true
};
}
await prisma.$transaction([
prisma.campaignRecipient.createMany({ data: payload }),
prisma.broadcastCampaign.update({
where: { id: campaign.id },
data: {
totalRecipients: payload.length,
totalSent: 0,
totalDelivered: 0,
totalRead: 0,
totalFailed: 0,
status: campaignStatusSeed,
startedAt: now
}
})
]);
await writeAuditTrail({
tenantId: params.tenantId,
actorUserId: params.actorUserId,
entityType: "campaign",
entityId: campaign.id,
action: "campaign_recipients_seeded",
metadata: {
totalRecipients: payload.length,
audienceType: campaign.audienceType,
uniqueContactCount: uniqueContacts.size
},
ipAddress: params.actorIpAddress,
userAgent: params.actorUserAgent
});
seededRecipients = payload.length;
}
const retryCandidates = await prisma.campaignRecipient.findMany({
where: {
campaignId: campaign.id,
sendStatus: { in: [DeliveryStatus.QUEUED, DeliveryStatus.FAILED] },
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]
},
include: { contact: { select: { id: true, fullName: true, phoneNumber: true } } },
orderBy: { createdAt: "asc" },
take: limit
});
const processableRecipients = retryCandidates.filter((recipient) => isRecipientDueForRetry(recipient, now));
if (processableRecipients.length === 0) {
await recalculateCampaignTotals(campaign.id);
return {
campaignId: campaign.id,
campaignName: campaign.name,
skippedReason: null,
seededRecipients,
processableRecipients: 0,
attempted: 0,
successful: 0,
failed: 0,
idle: true
};
}
let success = 0;
let failed = 0;
await writeAuditTrail({
...auditContext,
entityType: "campaign",
entityId: campaign.id,
action: "campaign_dispatch_started",
metadata: { source: params.source ?? "manual", campaignId: campaign.id, candidateCount: processableRecipients.length }
});
for (const recipient of processableRecipients) {
const attemptAt = new Date();
let outboundFailureReason: string | null = null;
let outboundProvider = "unknown";
let providerMessageId: string | null = recipient.providerMessageId;
let isSuccess = false;
try {
const renderedBody = renderCampaignMessage(
campaign.template.bodyText,
mapRecipientTemplateValues({ id: recipient.contact.id, fullName: recipient.contact.fullName, phoneNumber: recipient.contact.phoneNumber })
);
const outbound = await sendOutboundTextMessage({
tenantId: params.tenantId,
channelId: campaign.channel.id,
channelProvider: campaign.channel.provider,
phoneNumberId: campaign.channel.phoneNumberId,
to: recipient.phoneNumber,
content: renderedBody,
messageId: recipient.id
});
outboundFailureReason = outbound.failureReason ?? null;
outboundProvider = outbound.provider;
providerMessageId = outbound.providerMessageId ?? providerMessageId;
isSuccess = outbound.success;
} catch (error) {
const reason = error instanceof Error ? error.message : "Unknown send error";
outboundFailureReason = reason;
isSuccess = false;
}
const attempt = recipient.sendAttempts + 1;
const hasRetry = attempt < recipient.maxSendAttempts && !isSuccess;
const nextRetryAt = hasRetry ? getCampaignBackoffDate(attemptAt, attempt) : null;
await prisma.campaignRecipient.update({
where: { id: recipient.id },
data: {
sendAttempts: attempt,
lastAttemptAt: attemptAt,
failureReason: outboundFailureReason,
providerMessageId,
sendStatus: isSuccess
? DeliveryStatus.SENT
: hasRetry
? DeliveryStatus.QUEUED
: DeliveryStatus.FAILED,
sentAt: isSuccess && !recipient.sentAt ? attemptAt : recipient.sentAt,
nextRetryAt
}
});
await writeAuditTrail({
...auditContext,
entityType: "campaign_recipient",
entityId: recipient.id,
action: isSuccess ? "campaign_recipient_send_success" : "campaign_recipient_send_failed",
metadata: {
campaignId: campaign.id,
contactId: recipient.contactId,
attempt,
maxAttempts: recipient.maxSendAttempts,
provider: outboundProvider,
providerMessageId,
failureReason: outboundFailureReason ?? null,
retryAfter: nextRetryAt ? nextRetryAt.toISOString() : null,
source: params.source ?? "manual"
},
ipAddress: params.actorIpAddress,
userAgent: params.actorUserAgent
});
if (isSuccess) {
success += 1;
} else {
failed += 1;
}
}
await recalculateCampaignTotals(campaign.id);
await writeAuditTrail({
...auditContext,
entityType: "campaign",
entityId: campaign.id,
action: "campaign_dispatch_completed",
metadata: {
source: params.source ?? "manual",
candidateCount: processableRecipients.length,
success,
failed,
allSuccessful: failed === 0
}
});
return {
campaignId: campaign.id,
campaignName: campaign.name,
skippedReason: null,
seededRecipients,
processableRecipients: processableRecipients.length,
attempted: processableRecipients.length,
successful: success,
failed,
idle: false
};
}
async function acquireCampaignRetryLock(runId: string, ttlSeconds: number) {
const now = new Date();
const lockUntil = new Date(now.getTime() + ttlSeconds * 1000);
try {
await prisma.backgroundJobState.create({
data: {
jobName: CAMPAIGN_RETRY_JOB_NAME,
lockedBy: runId,
lockedUntil: lockUntil,
runs: 1,
lastRunStartedAt: now,
lastRunStatus: "running"
}
});
return lockUntil;
} catch {
const updated = await prisma.backgroundJobState.updateMany({
where: {
jobName: CAMPAIGN_RETRY_JOB_NAME,
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }]
},
data: {
lockedBy: runId,
lockedUntil: lockUntil,
lastRunStartedAt: now,
lastRunStatus: "running",
lastRunCompletedAt: null,
lastError: null,
runs: { increment: 1 }
}
});
if (updated.count !== 1) {
return null;
}
return lockUntil;
}
}
async function releaseCampaignRetryLock(runId: string, summary: CampaignRetryBatchResult) {
await prisma.backgroundJobState.updateMany({
where: { jobName: CAMPAIGN_RETRY_JOB_NAME, lockedBy: runId },
data: {
lockedUntil: null,
lastRunCompletedAt: new Date(summary.runAt),
lastRunStatus: summary.status,
lastRunSummaryJson: safeStringify(summary),
consecutiveFailures: 0,
lastFailureAt: null
}
});
}
async function heartbeatCampaignRetryLock(runId: string, lockTtlSeconds: number) {
const lockUntil = new Date(Date.now() + lockTtlSeconds * 1000);
await prisma.backgroundJobState.updateMany({
where: { jobName: CAMPAIGN_RETRY_JOB_NAME, lockedBy: runId },
data: { lockedUntil: lockUntil }
});
return lockUntil;
}
export async function runCampaignRetryBatch(params: CampaignRetryBatchOptions): Promise<CampaignRetryBatchResult> {
const now = params.now ?? new Date();
const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const recipientBatchSize = getBatchSize(params.recipientBatchSize, getPositiveIntFromEnv("CAMPAIGN_RETRY_BATCH_SIZE", DEFAULT_RETRIES_PER_RUN));
const maxCampaigns = getBatchSize(params.maxCampaigns, getPositiveIntFromEnv("CAMPAIGN_RETRY_MAX_CAMPAIGNS", DEFAULT_CAMPAIGNS_PER_RUN));
const lockTtlSeconds = getPositiveIntFromEnv("CAMPAIGN_RETRY_JOB_LOCK_TTL_SECONDS", DEFAULT_LOCK_TTL_SECONDS);
const lockUntil = await acquireCampaignRetryLock(runId, lockTtlSeconds);
if (!lockUntil) {
return {
runAt: new Date().toISOString(),
status: "skipped_locked",
processedCampaigns: 0,
totalSeeded: 0,
totalProcessable: 0,
totalAttempted: 0,
totalSuccessful: 0,
totalFailed: 0,
summaries: []
};
}
let heartbeatAt = lockUntil;
const runAt = new Date().toISOString();
try {
const where: Prisma.BroadcastCampaignWhereInput = {
OR: [
{ status: CampaignStatus.PROCESSING },
{
status: CampaignStatus.SCHEDULED,
scheduledAt: { not: null, lte: now }
}
],
...(params.tenantId ? { tenantId: params.tenantId } : {}),
NOT: { status: CampaignStatus.CANCELED }
};
if (params.campaignId) {
where.id = params.campaignId;
}
const campaigns = await prisma.broadcastCampaign.findMany({
where,
include: { channel: true, template: true },
orderBy: { createdAt: "asc" },
take: maxCampaigns
});
const summaries: CampaignDispatchResult[] = [];
let totalSeeded = 0;
let totalProcessable = 0;
let totalAttempted = 0;
let totalSuccessful = 0;
let totalFailed = 0;
for (const campaign of campaigns) {
if (!heartbeatAt || heartbeatAt <= new Date()) {
heartbeatAt = await heartbeatCampaignRetryLock(runId, lockTtlSeconds);
}
const summary = await dispatchCampaignById({
campaignId: campaign.id,
tenantId: campaign.tenantId,
actorUserId: params.actorUserId,
actorIpAddress: params.actorIpAddress,
actorUserAgent: params.actorUserAgent,
maxRecipientsPerCampaign: recipientBatchSize,
now,
enforceSchedule: true,
source: "scheduler"
});
summaries.push(summary);
totalSeeded += summary.seededRecipients;
totalProcessable += summary.processableRecipients;
totalAttempted += summary.attempted;
totalSuccessful += summary.successful;
totalFailed += summary.failed;
}
await heartbeatCampaignRetryLock(runId, lockTtlSeconds);
const result: CampaignRetryBatchResult = {
runAt,
status: "completed",
processedCampaigns: campaigns.length,
totalSeeded,
totalProcessable,
totalAttempted,
totalSuccessful,
totalFailed,
summaries
};
await releaseCampaignRetryLock(runId, result);
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Retry worker failed";
const result: CampaignRetryBatchResult = {
runAt,
status: "failed",
processedCampaigns: 0,
totalSeeded: 0,
totalProcessable: 0,
totalAttempted: 0,
totalSuccessful: 0,
totalFailed: 0,
summaries: [],
error: message
};
await prisma.backgroundJobState.updateMany({
where: { jobName: CAMPAIGN_RETRY_JOB_NAME, lockedBy: runId },
data: {
lastRunCompletedAt: new Date(runAt),
lastRunStatus: "failed",
lockedUntil: null,
lastError: message,
lastRunSummaryJson: safeStringify(result),
consecutiveFailures: { increment: 1 },
lastFailureAt: new Date()
}
});
await notifyCampaignRetryFailure({
jobName: CAMPAIGN_RETRY_JOB_NAME,
runId,
error: message,
runAt: runAt,
processedCampaigns: result.processedCampaigns,
totalAttempted: result.totalAttempted,
totalFailed: result.totalFailed
});
throw error;
}
}
export async function getCampaignRetryState() {
return prisma.backgroundJobState.findUnique({
where: { jobName: CAMPAIGN_RETRY_JOB_NAME },
select: {
jobName: true,
lockedUntil: true,
lastRunStartedAt: true,
lastRunCompletedAt: true,
lastRunStatus: true,
consecutiveFailures: true,
lastFailureAt: true,
lastError: true,
runs: true,
lastRunSummaryJson: true
}
});
}

79
lib/campaign-utils.ts Normal file
View File

@ -0,0 +1,79 @@
import { CampaignStatus, DeliveryStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export const DEFAULT_MAX_SEND_ATTEMPTS = 3;
export const RETRY_BACKOFF_SECONDS = [10, 30, 120];
export function getRetryDelaySeconds(attempt: number) {
if (attempt <= 1) {
return RETRY_BACKOFF_SECONDS[0];
}
if (attempt - 1 < RETRY_BACKOFF_SECONDS.length) {
return RETRY_BACKOFF_SECONDS[attempt - 1];
}
return RETRY_BACKOFF_SECONDS[RETRY_BACKOFF_SECONDS.length - 1];
}
export function renderCampaignMessage(templateBody: string, values: Record<string, string>) {
return templateBody.replace(/\{\{\s*(\d+)\s*\}\}/g, (match, token) => {
const key = String(token);
return values[key] ?? match;
});
}
export async function recalculateCampaignTotals(campaignId: string) {
const recipients = await prisma.campaignRecipient.findMany({
where: { campaignId },
select: { sendStatus: true }
});
const totalRecipients = recipients.length;
const totalSent = recipients.filter((recipient) => recipient.sendStatus !== DeliveryStatus.QUEUED).length;
const totalDelivered = recipients.filter((recipient) =>
recipient.sendStatus === DeliveryStatus.DELIVERED || recipient.sendStatus === DeliveryStatus.READ
).length;
const totalRead = recipients.filter((recipient) => recipient.sendStatus === DeliveryStatus.READ).length;
const totalFailed = recipients.filter((recipient) => recipient.sendStatus === DeliveryStatus.FAILED).length;
const totalQueued = recipients.filter((recipient) => recipient.sendStatus === DeliveryStatus.QUEUED).length;
const isDone = totalQueued === 0;
const status = isDone
? totalFailed > 0
? totalSent > 0
? CampaignStatus.PARTIAL_FAILED
: CampaignStatus.FAILED
: CampaignStatus.COMPLETED
: totalFailed > 0
? CampaignStatus.PARTIAL_FAILED
: CampaignStatus.PROCESSING;
const updateData: {
totalRecipients: number;
totalSent: number;
totalDelivered: number;
totalRead: number;
totalFailed: number;
status: CampaignStatus;
finishedAt?: Date | null;
} = {
totalRecipients,
totalSent,
totalDelivered,
totalRead,
totalFailed,
status
};
if (isDone) {
updateData.finishedAt = new Date();
}
await prisma.broadcastCampaign.update({
where: { id: campaignId },
data: updateData
});
}

413
lib/demo-data.ts Normal file
View File

@ -0,0 +1,413 @@
import { CampaignStatus, ConversationPriority, ConversationStatus, RoleCode, TenantStatus } from "@prisma/client";
import { getSession } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export type DashboardStats = {
label: string;
value: string;
delta: string;
};
export type ConversationRecord = {
id: string;
tenantId: string;
name: string;
phone: string;
snippet: string;
time: string;
status: "Open" | "Pending" | "Resolved";
assignee: string;
channel: string;
tags: string[];
priority: "Normal" | "High";
};
export type ContactRecord = {
id: string;
tenantId: string;
fullName: string;
phone: string;
email: string;
tags: string[];
lastInteraction: string;
optInStatus: string;
};
export type UserRecord = {
id: string;
tenantId: string;
fullName: string;
email: string;
role: "Admin" | "Agent";
status: "Active" | "Invited";
lastLogin: string;
handled: number;
avgResponse: string;
};
export type CampaignRecord = {
id: string;
tenantId: string;
name: string;
channel: string;
audience: string;
status: "Draft" | "Processing" | "Completed";
scheduledAt: string;
delivered: number;
failed: number;
};
export type TenantRecord = {
id: string;
name: string;
status: "Active" | "Trial" | "Suspended" | "Inactive";
plan: string;
channels: string;
seats: string;
};
function formatRelativeDate(date: Date | null | undefined) {
if (!date) {
return "-";
}
const now = new Date();
const diff = now.getTime() - date.getTime();
const day = 1000 * 60 * 60 * 24;
if (diff < day) {
return new Intl.DateTimeFormat("id-ID", {
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
if (diff < day * 2) {
return "Yesterday";
}
return new Intl.DateTimeFormat("id-ID", {
day: "2-digit",
month: "short",
year: "numeric"
}).format(date);
}
function formatDate(date: Date | null | undefined) {
if (!date) {
return "-";
}
return new Intl.DateTimeFormat("id-ID", {
day: "2-digit",
month: "short",
year: "numeric"
}).format(date);
}
function titleCase(value: string) {
return value
.toLowerCase()
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function mapConversationStatus(status: ConversationStatus): ConversationRecord["status"] {
if (status === ConversationStatus.PENDING) {
return "Pending";
}
if (status === ConversationStatus.RESOLVED) {
return "Resolved";
}
return "Open";
}
function mapConversationPriority(priority: ConversationPriority): ConversationRecord["priority"] {
return priority === ConversationPriority.HIGH || priority === ConversationPriority.URGENT ? "High" : "Normal";
}
function mapCampaignStatus(status: CampaignStatus): CampaignRecord["status"] {
if (status === CampaignStatus.COMPLETED) {
return "Completed";
}
if (status === CampaignStatus.PROCESSING || status === CampaignStatus.PARTIAL_FAILED) {
return "Processing";
}
return "Draft";
}
function mapTenantStatus(status: TenantStatus): TenantRecord["status"] {
switch (status) {
case TenantStatus.ACTIVE:
return "Active";
case TenantStatus.SUSPENDED:
return "Suspended";
case TenantStatus.INACTIVE:
return "Inactive";
case TenantStatus.TRIAL:
default:
return "Trial";
}
}
async function getTenantScopedWhere() {
const session = await getSession();
if (!session?.tenantId || session.role === "super_admin") {
return {};
}
return { tenantId: session.tenantId };
}
export async function getDashboardData() {
const where = await getTenantScopedWhere();
const [openConversations, waitingReply, resolvedToday, deliveredMessages, totalOutbound, priorityQueue] =
await Promise.all([
prisma.conversation.count({ where: { ...where, status: ConversationStatus.OPEN } }),
prisma.conversation.count({ where: { ...where, status: ConversationStatus.PENDING } }),
prisma.conversation.count({
where: {
...where,
status: ConversationStatus.RESOLVED,
resolvedAt: {
gte: new Date(new Date().setHours(0, 0, 0, 0))
}
}
}),
prisma.message.count({ where: { ...where, direction: "OUTBOUND", deliveryStatus: "DELIVERED" } }),
prisma.message.count({ where: { ...where, direction: "OUTBOUND" } }),
prisma.conversation.findMany({
where,
orderBy: [{ priority: "desc" }, { lastMessageAt: "desc" }],
take: 3,
include: {
contact: true,
channel: true,
assignedUser: true,
conversationTags: { include: { tag: true } },
messages: {
take: 1,
orderBy: { createdAt: "desc" }
}
}
})
]);
const successRate = totalOutbound > 0 ? `${((deliveredMessages / totalOutbound) * 100).toFixed(1)}%` : "0%";
return {
stats: [
{ label: "Open conversations", value: String(openConversations), delta: "Live" },
{ label: "Waiting reply", value: String(waitingReply), delta: "Live" },
{ label: "Resolved today", value: String(resolvedToday), delta: "Today" },
{ label: "Delivery success", value: successRate, delta: "Outbound" }
] satisfies DashboardStats[],
priorityQueue: priorityQueue.map((conversation) => ({
id: conversation.id,
tenantId: conversation.tenantId,
name: conversation.contact.fullName,
phone: conversation.contact.phoneNumber,
snippet: conversation.messages[0]?.contentText ?? conversation.subject ?? "No recent message",
time: formatRelativeDate(conversation.lastMessageAt),
status: mapConversationStatus(conversation.status),
assignee: conversation.assignedUser?.fullName ?? "Unassigned",
channel: conversation.channel.channelName,
tags: conversation.conversationTags.map((item) => item.tag.name),
priority: mapConversationPriority(conversation.priority)
}))
};
}
export async function getInboxData() {
const where = await getTenantScopedWhere();
const conversations = await prisma.conversation.findMany({
where,
orderBy: { lastMessageAt: "desc" },
include: {
contact: true,
channel: true,
assignedUser: true,
conversationTags: { include: { tag: true } },
messages: {
take: 1,
orderBy: { createdAt: "desc" }
}
}
});
const mapped = conversations.map((conversation) => ({
id: conversation.id,
tenantId: conversation.tenantId,
name: conversation.contact.fullName,
phone: conversation.contact.phoneNumber,
snippet: conversation.messages[0]?.contentText ?? conversation.subject ?? "No recent message",
time: formatRelativeDate(conversation.lastMessageAt),
status: mapConversationStatus(conversation.status),
assignee: conversation.assignedUser?.fullName ?? "Unassigned",
channel: conversation.channel.channelName,
tags: conversation.conversationTags.map((item) => item.tag.name),
priority: mapConversationPriority(conversation.priority)
})) satisfies ConversationRecord[];
return {
conversations: mapped,
selectedConversation: mapped[0]
};
}
export async function getContactsData() {
const where = await getTenantScopedWhere();
const contacts = await prisma.contact.findMany({
where,
orderBy: { lastInteractionAt: "desc" },
include: {
contactTags: {
include: { tag: true }
}
}
});
return contacts.map((contact) => ({
id: contact.id,
tenantId: contact.tenantId,
fullName: contact.fullName,
phone: contact.phoneNumber,
email: contact.email ?? "-",
tags: contact.contactTags.map((item) => item.tag.name),
lastInteraction: formatRelativeDate(contact.lastInteractionAt),
optInStatus: titleCase(contact.optInStatus)
})) satisfies ContactRecord[];
}
export async function getTeamData() {
const where = await getTenantScopedWhere();
const users = await prisma.user.findMany({
where,
include: {
role: true,
assignedConversations: true
},
orderBy: [{ role: { code: "asc" } }, { fullName: "asc" }]
});
return users.map((user) => ({
id: user.id,
tenantId: user.tenantId,
fullName: user.fullName,
email: user.email,
role: user.role.code === RoleCode.ADMIN_CLIENT ? "Admin" : "Agent",
status: user.status === "ACTIVE" ? "Active" : "Invited",
lastLogin: formatRelativeDate(user.lastLoginAt),
handled: user.assignedConversations.length,
avgResponse: user.role.code === RoleCode.AGENT ? "3m 12s" : "-"
})) satisfies UserRecord[];
}
export async function getCampaignsData() {
const where = await getTenantScopedWhere();
const campaigns = await prisma.broadcastCampaign.findMany({
where,
include: {
channel: true,
segment: true
},
orderBy: [{ createdAt: "desc" }]
});
return campaigns.map((campaign) => ({
id: campaign.id,
tenantId: campaign.tenantId,
name: campaign.name,
channel: campaign.channel.channelName,
audience: campaign.segment ? `Segment: ${campaign.segment.name}` : titleCase(campaign.audienceType),
status: mapCampaignStatus(campaign.status),
scheduledAt: formatDate(campaign.scheduledAt),
delivered: campaign.totalDelivered,
failed: campaign.totalFailed
})) satisfies CampaignRecord[];
}
export async function getTenantAdminSummary() {
const where = await getTenantScopedWhere();
const [contactCount, campaignCount, activeAgents] = await Promise.all([
prisma.contact.count({ where }),
prisma.broadcastCampaign.count({ where }),
prisma.user.count({
where: {
...where,
role: { code: RoleCode.AGENT }
}
})
]);
return {
contactCount,
campaignCount,
activeAgents
};
}
export async function getPlatformSummary() {
const [tenantCount, connectedChannels, failedWebhooks, tenants] = await Promise.all([
prisma.tenant.count(),
prisma.channel.count({ where: { status: "CONNECTED" } }),
prisma.webhookEvent.count({ where: { processStatus: "failed" } }),
prisma.tenant.findMany({
include: {
plan: true,
channels: true,
users: true
},
orderBy: { createdAt: "asc" }
})
]);
return {
stats: [
{ label: "Active tenants", value: String(tenantCount), delta: "Global" },
{ label: "Connected channels", value: String(connectedChannels), delta: "Healthy" },
{ label: "Webhook failures", value: String(failedWebhooks), delta: "Watch" },
{ label: "Tracked seats", value: String(tenants.reduce((sum, tenant) => sum + tenant.users.length, 0)), delta: "Users" }
] satisfies DashboardStats[],
tenants: tenants.map((tenant) => ({
id: tenant.id,
name: tenant.name,
status: mapTenantStatus(tenant.status),
plan: tenant.plan.name,
channels: `${tenant.channels.filter((channel) => channel.status === "CONNECTED").length} connected`,
seats: `${tenant.users.length}/${tenant.plan.seatQuota}`
})) satisfies TenantRecord[]
};
}
export async function getTenantsData() {
const tenants = await prisma.tenant.findMany({
include: {
plan: true,
channels: true,
users: true
},
orderBy: { createdAt: "asc" }
});
return tenants.map((tenant) => ({
id: tenant.id,
name: tenant.name,
status: mapTenantStatus(tenant.status),
plan: tenant.plan.name,
channels: `${tenant.channels.filter((channel) => channel.status === "CONNECTED").length} connected`,
seats: `${tenant.users.length}/${tenant.plan.seatQuota}`
})) satisfies TenantRecord[];
}

225
lib/i18n.ts Normal file
View File

@ -0,0 +1,225 @@
export type Locale = "id" | "en";
export const SUPPORTED_LOCALES: readonly Locale[] = ["id", "en"] as const;
export const DEFAULT_LOCALE: Locale = "id";
export const LOCALE_COOKIE = "wa_locale";
export type I18nSection = keyof typeof MESSAGES;
export type MessageKey<S extends I18nSection> = keyof typeof MESSAGES[S];
export type NavKey = MessageKey<"nav">;
const MESSAGES = {
meta: {
title: { id: "ZappCare Business Suite", en: "ZappCare Business Suite" },
description: {
id: "Platform WhatsApp Business inbox multi-tenant",
en: "Multi-tenant WhatsApp business inbox platform"
}
},
shell: {
admin_title: { id: "Ruang Kerja Admin", en: "Admin Client Workspace" },
admin_subtitle: {
id: "Kelola inbox, contact, broadcast, dan operasional tim.",
en: "Manage inbox, contacts, broadcasts, and team operations."
},
agent_title: { id: "Ruang Kerja Agent", en: "Agent Workspace" },
agent_subtitle: {
id: "Tangani conversation, follow-up, dan performa pribadi.",
en: "Handle conversations, follow-ups, and personal performance."
},
super_admin_title: {
id: "Platform Control Center",
en: "Platform Control Center"
},
super_admin_subtitle: {
id: "Kelola tenant, channel, billing, dan kesehatan platform.",
en: "Manage tenants, channels, billing, and platform health."
}
},
roles: {
super_admin: { id: "Super Admin", en: "Super Admin" },
admin_client: { id: "Admin Client", en: "Admin Client" },
agent: { id: "Agent", en: "Agent" }
},
nav: {
dashboard: { id: "Dashboard", en: "Dashboard" },
shared_inbox: { id: "Shared Inbox", en: "Shared Inbox" },
inbox: { id: "Inbox", en: "Inbox" },
contacts: { id: "Kontak", en: "Contacts" },
broadcast: { id: "Broadcast", en: "Broadcast" },
templates: { id: "Template", en: "Templates" },
team: { id: "Tim", en: "Team" },
reports: { id: "Laporan", en: "Reports" },
settings: { id: "Pengaturan", en: "Settings" },
billing: { id: "Tagihan", en: "Billing" },
audit_log: { id: "Audit Log", en: "Audit Log" },
tenants: { id: "Tenant", en: "Tenants" },
channels: { id: "Channel", en: "Channels" },
security_events: { id: "Security Events", en: "Security Events" },
webhook_logs: { id: "Webhook Logs", en: "Webhook Logs" },
alerts: { id: "Peringatan", en: "Alerts" },
quick_tools: { id: "Quick Tools", en: "Quick Tools" },
performance: { id: "Performa", en: "Performance" },
"new_chat": { id: "Obrolan Baru", en: "New Chat" },
search: { id: "Cari", en: "Search" },
logout: { id: "Keluar", en: "Logout" },
global_search: { id: "Pencarian Global", en: "Global Search" },
campaign: { id: "Kampanye", en: "Campaigns" }
},
common: {
zappcare: { id: "ZappCare", en: "ZappCare" },
business_suite: { id: "Business Suite", en: "Business Suite" },
back_to_login: { id: "Kembali ke login", en: "Back to login" },
search_placeholder: { id: "Cari wawasan, kontak, atau pesan...", en: "Search insights, contacts, or messages..." },
new_chat_button: { id: "Obrolan Baru", en: "New Chat" },
notifications: { id: "Notifikasi", en: "Notifications" },
help: { id: "Bantuan", en: "Help" },
shortcut: { id: "Aplikasi", en: "App shortcuts" }
},
login: {
title: { id: "Selamat datang kembali", en: "Welcome back" },
signin_subtitle: {
id: "Masuk ke workspace Anda",
en: "Sign in to your ZappCare Business Suite"
},
signin_label: { id: "Masuk", en: "Sign In" },
signin_help: {
id: "Gunakan akun yang sudah di-seed untuk masuk.",
en: "Use seeded account credentials to sign in."
},
email_label: { id: "Email kerja", en: "Work email" },
password_label: { id: "Password", en: "Password" },
remember_label: { id: "Lupa akun?", en: "Forgot password?" },
no_account_label: { id: "Belum punya akun?", en: "Dont have an account?" },
sso_button: { id: "Masuk dengan SSO", en: "Sign in with SSO" },
contact_admin: { id: "Hubungi administrator", en: "Contact Administrator" },
accept_invitation: { id: "Terima undangan", en: "Accept invitation" },
work_email_placeholder: { id: "name@company.com", en: "name@company.com" },
password_placeholder: { id: "••••••••", en: "••••••••" },
sign_in_button: { id: "Masuk", en: "Sign In" },
error_credentials_required: { id: "Email dan password wajib diisi.", en: "Email and password are required." },
error_invalid_credentials: { id: "Email / password salah atau akun belum aktif.", en: "Invalid email/password or inactive account." },
error_demo_disabled: { id: "Rute demo dinonaktifkan. Gunakan login biasa.", en: "Demo route is disabled. Use normal login." },
error_rate_limited: { id: "Terlalu banyak percobaan login. Coba lagi beberapa saat.", en: "Too many login attempts. Try again in a few minutes." },
forgot_action: { id: "Kirim tautan reset", en: "Send reset link" },
forgot_success: {
id: "Jika email valid dan aktif, kami sudah menyiapkan tautan reset.",
en: "If this account exists and is active, we have sent a reset link."
},
reset_invalid_token: { id: "Token tidak valid atau sudah dipakai.", en: "This token is invalid or already used." },
reset_expired: { id: "Token sudah kedaluwarsa. Silakan minta tautan baru.", en: "The token expired. Please request a new one." },
password_mismatch: { id: "Konfirmasi password tidak cocok.", en: "Password confirmation doesn't match." },
missing_email: { id: "Email wajib diisi.", en: "Email is required." }
},
placeholders: {
operational_overview_title: { id: "Ikhtisar Operasional", en: "Operational overview" },
operational_overview_desc: { id: "Ringkasan kerja harian tim dan inbox.", en: "Daily team and inbox operations summary." },
operation_chart_note: {
id: "Bagian grafik untuk volume message, workload agent, dan trend resolusi.",
en: "Chart area for message volume, agent workload, and resolution trend."
},
priority_queue_title: { id: "Antrian Prioritas", en: "Priority queue" },
priority_queue_desc: { id: "Conversation yang perlu tindakan cepat.", en: "Conversations that need quick action." },
conversation_list_title: { id: "Daftar percakapan", en: "Conversation list" },
conversation_list_desc: { id: "Semua, belum ditugaskan, ditugaskan, selesai.", en: "All, unassigned, assigned, resolved." },
no_conversation_found: { id: "Tidak ada conversation ditemukan.", en: "No conversation found." },
conversation_detail_title: { id: "Detail percakapan", en: "Conversation detail" },
conversation_detail_desc: { id: "Header, timeline, composer, template, catatan.", en: "Header, timeline, composer, templates, notes." },
select_to_reply: { id: "Pilih percakapan dari kiri untuk mulai menanggapi.", en: "Select a conversation from the left to start responding." },
no_messages: { id: "Belum ada pesan.", en: "No messages yet." },
reply_label: { id: "Balasan", en: "Reply" },
reply_placeholder: { id: "Tulis balasan...", en: "Write a reply..." },
send_reply: { id: "Kirim balasan", en: "Send reply" },
context_panel_title: { id: "Panel kontekstual", en: "Context panel" },
context_panel_desc: { id: "Penugasan, status, notes, tags, aktivitas.", en: "Assignment, status, notes, tags, activity." },
select_context: { id: "Pilih percakapan untuk melihat konteks.", en: "Select a conversation to view context." },
contact_label: { id: "Kontak", en: "Contact" },
tags_label: { id: "Tags", en: "Tags" },
assign_label: { id: "Assign", en: "Assign" },
unassign_label: { id: "Hapus tugas", en: "Unassign" },
take_assignment: { id: "Ambil tugas", en: "Take assignment" },
reassign: { id: "Reassign", en: "Reassign" },
status_label: { id: "Status", en: "Status" },
status_open: { id: "Open", en: "Open" },
status_pending: { id: "Pending", en: "Pending" },
status_resolved: { id: "Resolved", en: "Resolved" },
status_archived: { id: "Arsip", en: "Archived" },
status_spam: { id: "Spam", en: "Spam" },
update_status: { id: "Perbarui status", en: "Update status" },
add_note_label: { id: "Tambah catatan", en: "Add note" },
add_note_placeholder: { id: "Catatan internal...", en: "Internal note..." },
save_note: { id: "Simpan catatan", en: "Save note" },
save_tags: { id: "Simpan tags", en: "Save tags" },
notes_label: { id: "Catatan", en: "Notes" },
no_notes: { id: "Belum ada notes.", en: "No notes." },
tags_placeholder: { id: "high-priority, billing", en: "high-priority, billing" }
},
tables: {
total_contacts: { id: "Total kontak", en: "Total contacts" },
total_users: { id: "Total pengguna", en: "Total users" },
opted_in: { id: "Sudah opt-in", en: "Opted in" },
tagged_contacts: { id: "Kontak bertag", en: "Tagged contacts" },
agents: { id: "Agen", en: "Agents" },
invited: { id: "Diundang", en: "Invited" },
total_value: { id: "Total", en: "Total" }
},
status: {
open: { id: "Open", en: "Open" },
pending: { id: "Pending", en: "Pending" },
resolved: { id: "Resolved", en: "Resolved" }
},
pages: {
unauthorized_title: { id: "Tidak diizinkan", en: "Unauthorized" },
unauthorized_desc: {
id: "Role ini belum memiliki akses ke halaman yang Anda buka.",
en: "You don't have access to the page you opened."
},
unauthorized_subtitle: {
id: "Akses ditolak",
en: "Access denied"
},
forgot_password: { id: "Lupa password", en: "Forgot password" },
reset_password: { id: "Reset password", en: "Reset password" },
reset_desc: { id: "Atur password baru untuk akun Anda.", en: "Set a new password for your account." },
invalid_token: { id: "Token tidak valid atau sudah tidak berlaku.", en: "The token is invalid or no longer valid." },
reset_token_expired: {
id: "Token reset sudah kedaluwarsa. Minta link baru.",
en: "Your reset token expired. Request a new one."
},
password_mismatch: { id: "Konfirmasi password tidak cocok.", en: "Password confirmation does not match." }
}
} as const;
type LocaleMap = Record<Locale, string>;
type MessageCatalog = {
[Section in keyof typeof MESSAGES]: {
[Key in keyof typeof MESSAGES[Section]]: LocaleMap;
};
};
export const messages = MESSAGES as MessageCatalog;
export function isLocale(value: string | null | undefined): value is Locale {
return value === "id" || value === "en";
}
export async function getLocale(): Promise<Locale> {
const { cookies } = await import("next/headers");
const localeCookie = (await cookies()).get(LOCALE_COOKIE)?.value;
if (isLocale(localeCookie)) {
return localeCookie;
}
return DEFAULT_LOCALE;
}
export function t<S extends I18nSection>(locale: Locale, section: S, key: MessageKey<S>): string {
return messages[section][key][locale];
}
export function getTranslator(locale: Locale) {
return function translate<S extends I18nSection>(section: S, key: MessageKey<S>) {
return t(locale, section, key);
};
}

1001
lib/inbox-ops.ts Normal file

File diff suppressed because it is too large Load Diff

52
lib/job-alerts.ts Normal file
View File

@ -0,0 +1,52 @@
type CampaignRetryFailureAlert = {
jobName: string;
runId: string;
error: string;
runAt: string;
tenantId?: string;
campaignId?: string;
processedCampaigns: number;
totalAttempted: number;
totalFailed: number;
};
function normalizeWebhookEnabled() {
const raw = process.env.CAMPAIGN_RETRY_ALERT_WEBHOOK_URL?.trim();
return raw ? raw : "";
}
function normalizeBoolean(value: string | undefined) {
if (!value) {
return true;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
export async function notifyCampaignRetryFailure(payload: CampaignRetryFailureAlert) {
const webhookUrl = normalizeWebhookEnabled();
if (!webhookUrl) {
return;
}
const shouldNotify = normalizeBoolean(process.env.CAMPAIGN_RETRY_ALERT_ON_FAILURE);
if (!shouldNotify) {
return;
}
const message = {
event: "campaign_retry_job_failed",
...payload,
timestamp: new Date().toISOString()
};
try {
await fetch(webhookUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(message)
});
} catch {
// observability channel should not block retries
}
}

71
lib/mock-data.ts Normal file
View File

@ -0,0 +1,71 @@
import type { NavKey } from "@/lib/i18n";
export type NavItem = {
labelKey: NavKey;
href: string;
};
export const superAdminNav: NavItem[] = [
{ labelKey: "dashboard", href: "/super-admin" },
{ labelKey: "tenants", href: "/super-admin/tenants" },
{ labelKey: "channels", href: "/super-admin/channels" },
{ labelKey: "billing", href: "/super-admin/billing/plans" },
{ labelKey: "reports", href: "/super-admin/reports" },
{ labelKey: "audit_log", href: "/super-admin/audit-log" },
{ labelKey: "settings", href: "/super-admin/settings" }
];
export const adminNav: NavItem[] = [
{ labelKey: "dashboard", href: "/dashboard" },
{ labelKey: "shared_inbox", href: "/inbox" },
{ labelKey: "contacts", href: "/contacts" },
{ labelKey: "broadcast", href: "/campaigns" },
{ labelKey: "templates", href: "/templates" },
{ labelKey: "team", href: "/team" },
{ labelKey: "reports", href: "/reports" },
{ labelKey: "settings", href: "/settings" },
{ labelKey: "billing", href: "/billing" },
{ labelKey: "audit_log", href: "/audit-log" }
];
export const agentNav: NavItem[] = [
{ labelKey: "dashboard", href: "/agent" },
{ labelKey: "inbox", href: "/agent/inbox" },
{ labelKey: "contacts", href: "/agent/contacts" },
{ labelKey: "quick_tools", href: "/agent/quick-tools" },
{ labelKey: "performance", href: "/agent/performance" }
];
export const kpiCards = [
{ label: "Open conversations", value: "128", delta: "+12%" },
{ label: "Waiting reply", value: "42", delta: "-5%" },
{ label: "Resolved today", value: "86", delta: "+18%" },
{ label: "Delivery success", value: "97.8%", delta: "+0.4%" }
];
export const inboxItems = [
{
id: "conv-001",
name: "Nadia Pratama",
snippet: "Halo, saya mau tanya soal paket enterprise...",
time: "09:12",
status: "Open",
assignee: "Farhan"
},
{
id: "conv-002",
name: "Rizky Saputra",
snippet: "Sudah saya transfer, mohon dicek ya.",
time: "08:47",
status: "Pending",
assignee: "Unassigned"
},
{
id: "conv-003",
name: "PT Sinar Abadi",
snippet: "Bisa kirim template promosi minggu ini?",
time: "Kemarin",
status: "Resolved",
assignee: "Tiara"
}
];

154
lib/notification.ts Normal file
View File

@ -0,0 +1,154 @@
import { getRequestAuditContext } from "@/lib/audit";
export type TransactionalNotificationInput = {
to: string;
subject: string;
text: string;
html?: string;
metadata?: Record<string, unknown>;
};
export type TransactionalNotificationResult = {
ok: boolean;
provider?: string;
error?: string;
};
type TransportName = "webhook" | "console" | "none";
function normalizeProvider(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return "auto";
}
if (normalized === "webhook") {
return "webhook" as TransportName;
}
if (normalized === "console") {
return "console" as TransportName;
}
return normalized as "auto" | TransportName;
}
function isProduction() {
return process.env.NODE_ENV === "production";
}
function buildPayload(input: TransactionalNotificationInput, context?: { ipAddress?: string | null; userAgent?: string | null }) {
return {
to: input.to,
subject: input.subject,
text: input.text,
html: input.html ?? null,
metadata: input.metadata ?? null,
type: "transactional",
providerContext: {
ipAddress: context?.ipAddress ?? null,
userAgent: context?.userAgent ?? null
}
};
}
function getWebhookUrl() {
return process.env.NOTIFICATION_WEBHOOK_URL?.trim();
}
function getWebhookToken() {
return process.env.NOTIFICATION_WEBHOOK_TOKEN?.trim();
}
function getWebhookTimeoutMs() {
const raw = process.env.NOTIFICATION_WEBHOOK_TIMEOUT_MS?.trim();
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 10_000;
}
return Math.min(60_000, parsed);
}
async function sendViaWebhook(input: TransactionalNotificationInput, context?: { ipAddress?: string | null; userAgent?: string | null }) {
const url = getWebhookUrl();
if (!url) {
return {
ok: false,
error: "NOTIFICATION_WEBHOOK_URL is not configured"
} as const;
}
const headers: Record<string, string> = {
"Content-Type": "application/json"
};
const token = getWebhookToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), getWebhookTimeoutMs());
try {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(buildPayload(input, context)),
signal: controller.signal
});
clearTimeout(timeout);
if (!response.ok) {
const errorBody = await response.text();
return {
ok: false,
error: `Webhook notification failed ${response.status}: ${errorBody}`.slice(0, 600)
};
}
return { ok: true, provider: "webhook" };
} catch (error) {
clearTimeout(timeout);
return {
ok: false,
error: error instanceof Error ? error.message : "Webhook notification error"
};
}
}
export async function sendTransactionalNotification(input: TransactionalNotificationInput): Promise<TransactionalNotificationResult> {
if (!input.to || !input.subject || !input.text) {
return { ok: false, error: "Notification payload is incomplete" };
}
const provider = normalizeProvider(process.env.NOTIFICATION_PROVIDER);
const explicitWebhookUrl = getWebhookUrl();
const requestContext = await getRequestAuditContext().catch(() => ({
ipAddress: null,
userAgent: null
}));
if (provider === "none") {
return { ok: false, error: "Notification delivery disabled" };
}
if (provider === "console" || (!provider && !isProduction())) {
// eslint-disable-next-line no-console
console.log(`NOTIFICATION subject=${input.subject} to=${input.to}`);
// eslint-disable-next-line no-console
console.log(`NOTIFICATION_BODY ${input.text}`);
return { ok: true, provider: "console" };
}
const result = await sendViaWebhook(input, requestContext);
if (!result.ok && !provider && !isProduction() && !explicitWebhookUrl) {
// eslint-disable-next-line no-console
console.log(`NOTIFICATION_FALLBACK subject=${input.subject} to=${input.to}`);
// eslint-disable-next-line no-console
console.log(`NOTIFICATION_BODY ${input.text}`);
return { ok: true, provider: "console" };
}
return result.ok ? { ok: true, provider: result.provider } : { ok: false, error: result.error };
}

71
lib/permissions.ts Normal file
View File

@ -0,0 +1,71 @@
import type { UserRole } from "@/lib/auth";
export type ActionPermission =
| "admin:read"
| "admin:manage"
| "agent:read"
| "agent:manage"
| "inbox:read"
| "inbox:assign"
| "inbox:status"
| "inbox:reply"
| "inbox:notes"
| "inbox:tags"
| "tenant:read"
| "profile:manage_self";
type AllPermission = ActionPermission | "*";
const rolePermissions: Record<UserRole, readonly AllPermission[]> = {
super_admin: ["*", "admin:read", "admin:manage", "agent:read", "agent:manage", "inbox:read", "inbox:assign", "inbox:status", "inbox:reply", "inbox:notes", "inbox:tags", "tenant:read"],
admin_client: [
"admin:read",
"admin:manage",
"inbox:read",
"inbox:assign",
"inbox:status",
"inbox:reply",
"inbox:notes",
"inbox:tags",
"agent:read",
"tenant:read",
"profile:manage_self"
],
agent: [
"agent:read",
"inbox:read",
"inbox:assign",
"inbox:status",
"inbox:reply",
"inbox:notes",
"inbox:tags",
"profile:manage_self"
]
};
function toPermissionSet(role: UserRole) {
return new Set<AllPermission | string>(rolePermissions[role]);
}
export function hasPermission(role: UserRole, permission: ActionPermission) {
const permissionSet = toPermissionSet(role);
return permissionSet.has("*") || permissionSet.has(permission);
}
export function hasPermissionWithGrants(role: UserRole, permission: ActionPermission, extraPermissions?: Iterable<string>) {
const permissionSet = toPermissionSet(role);
if (extraPermissions) {
for (const value of extraPermissions) {
permissionSet.add(value);
}
}
return permissionSet.has("*") || permissionSet.has(permission);
}
export function assertPermission(role: UserRole, permission: ActionPermission, extraPermissions?: Iterable<string>) {
if (!hasPermissionWithGrants(role, permission, extraPermissions)) {
return false;
}
return true;
}

8
lib/platform-data.ts Normal file
View File

@ -0,0 +1,8 @@
export {
getDashboardData,
getInboxData,
getContactsData,
getTeamData,
getPlatformSummary,
getTenantsData
} from "./demo-data";

11
lib/prisma.ts Normal file
View File

@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

94
lib/rate-limit.ts Normal file
View File

@ -0,0 +1,94 @@
export type RateLimitResult = {
allowed: boolean;
limit: number;
used: number;
remaining: number;
resetAt: number;
};
type WindowEntry = {
used: number;
expiresAt: number;
};
type RateLimitState = {
limit: number;
windowMs: number;
entries: Map<string, WindowEntry>;
};
const rateLimitBuckets = new Map<string, RateLimitState>();
function getState(scope: string, limit: number, windowMs: number) {
const key = `${scope}|${limit}|${windowMs}`;
const existing = rateLimitBuckets.get(key);
if (existing) {
return existing;
}
const state = { limit, windowMs, entries: new Map<string, WindowEntry>() };
rateLimitBuckets.set(key, state);
return state;
}
export function consumeRateLimit(
identifier: string,
options: {
scope: string;
limit: number;
windowMs: number;
}
): RateLimitResult {
const { scope, limit, windowMs } = options;
const now = Date.now();
const key = `${identifier}`;
const state = getState(scope, limit, windowMs);
if (state.entries.size > 200) {
for (const [storedIdentifier, entry] of state.entries) {
if (entry.expiresAt <= now) {
state.entries.delete(storedIdentifier);
}
}
}
const current = state.entries.get(key);
if (!current || current.expiresAt <= now) {
const fresh = { used: 1, expiresAt: now + windowMs };
state.entries.set(key, fresh);
return {
allowed: true,
limit,
used: fresh.used,
remaining: limit - fresh.used,
resetAt: fresh.expiresAt
};
}
if (current.used >= limit) {
return {
allowed: false,
limit,
used: current.used,
remaining: Math.max(0, limit - current.used),
resetAt: current.expiresAt
};
}
current.used += 1;
return {
allowed: true,
limit,
used: current.used,
remaining: limit - current.used,
resetAt: current.expiresAt
};
}
export function getRateLimitHeaders(result: RateLimitResult) {
return {
"RateLimit-Limit": String(result.limit),
"RateLimit-Remaining": String(result.remaining),
"RateLimit-Reset": String(Math.max(0, Math.ceil(result.resetAt / 1000))),
"Retry-After": String(Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000)))
};
}

186
lib/whatsapp-provider.ts Normal file
View File

@ -0,0 +1,186 @@
import { DeliveryStatus } from "@prisma/client";
export type OutboundMessageRequest = {
tenantId: string;
channelId: string;
channelProvider: string;
phoneNumberId: string | null;
to: string;
content: string;
messageId: string;
};
export type OutboundMessageResult = {
success: boolean;
provider: string;
deliveryStatus: DeliveryStatus;
providerMessageId?: string | null;
failureReason?: string;
};
type MetaResponse = {
messages?: Array<{ id?: string }>;
error?: {
message?: string;
};
};
function normalizeProvider(provider: string) {
return provider.trim().toLowerCase();
}
function normalizeToPhone(phone: string) {
return phone.replace(/\D/g, "");
}
function randomMockMessageId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto && typeof crypto.randomUUID === "function") {
return `mock-${crypto.randomUUID()}`;
}
return `mock-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
}
function isMockAllowed() {
const explicit = process.env.WHATSAPP_ALLOW_SIMULATED_SEND?.trim().toLowerCase();
if (process.env.NODE_ENV === "production") {
return false;
}
if (explicit === undefined) {
return true;
}
return explicit === "true" || explicit === "1" || explicit === "yes";
}
async function sendViaMeta({
to,
content,
phoneNumberId
}: {
to: string;
content: string;
phoneNumberId: string;
}) {
const token = process.env.WHATSAPP_API_TOKEN?.trim();
const version = process.env.WHATSAPP_API_VERSION?.trim() || "v22.0";
if (!token) {
return {
success: false,
failureReason: "WHATSAPP_API_TOKEN is not configured"
};
}
const normalizedTo = normalizeToPhone(to);
const response = await fetch(`https://graph.facebook.com/${version}/${phoneNumberId}/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
to: normalizedTo,
type: "text",
text: {
preview_url: false,
body: content
}
})
});
const payloadText = await response.text();
let parsed: MetaResponse = {};
try {
parsed = JSON.parse(payloadText) as MetaResponse;
} catch {
// ignore parse error
}
if (!response.ok) {
return {
success: false,
failureReason: parsed.error?.message
? `Meta API error: ${parsed.error.message}`
: `Meta API ${response.status}: ${payloadText.slice(0, 400)}`
};
}
const providerMessageId = parsed.messages?.[0]?.id?.trim();
if (!providerMessageId) {
return {
success: false,
failureReason: "Meta API success response does not include message id"
};
}
return {
success: true,
providerMessageId
};
}
export async function sendOutboundTextMessage(input: OutboundMessageRequest): Promise<OutboundMessageResult> {
const normalized = normalizeProvider(input.channelProvider);
const defaultResult: OutboundMessageResult = {
success: false,
provider: normalized || "unknown",
deliveryStatus: DeliveryStatus.FAILED
};
if (!input.phoneNumberId || !input.to || !input.content) {
return {
...defaultResult,
failureReason: "Missing recipient or message data"
};
}
if (!normalized.includes("meta")) {
if (isMockAllowed()) {
return {
success: true,
provider: "mock",
deliveryStatus: DeliveryStatus.SENT,
providerMessageId: randomMockMessageId()
};
}
return {
...defaultResult,
failureReason: `Unsupported provider "${input.channelProvider}". Configure WhatsApp provider or enable simulation.`
};
}
const metaResult = await sendViaMeta({
to: input.to,
content: input.content,
phoneNumberId: input.phoneNumberId
});
if (!metaResult.success) {
if (!isMockAllowed()) {
return {
...defaultResult,
provider: "meta",
failureReason: metaResult.failureReason
};
}
return {
success: true,
provider: "mock",
deliveryStatus: DeliveryStatus.SENT,
providerMessageId: randomMockMessageId(),
failureReason: metaResult.failureReason
};
}
return {
success: true,
provider: "meta",
deliveryStatus: DeliveryStatus.SENT,
providerMessageId: metaResult.providerMessageId
};
}