chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
1432
lib/admin-crud.ts
Normal file
1432
lib/admin-crud.ts
Normal file
File diff suppressed because it is too large
Load Diff
50
lib/audit.ts
Normal file
50
lib/audit.ts
Normal 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
200
lib/auth-tokens.ts
Normal 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
437
lib/auth.ts
Normal 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;
|
||||
}
|
||||
685
lib/campaign-dispatch-service.ts
Normal file
685
lib/campaign-dispatch-service.ts
Normal 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
79
lib/campaign-utils.ts
Normal 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
413
lib/demo-data.ts
Normal 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
225
lib/i18n.ts
Normal 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: "Don’t 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
1001
lib/inbox-ops.ts
Normal file
File diff suppressed because it is too large
Load Diff
52
lib/job-alerts.ts
Normal file
52
lib/job-alerts.ts
Normal 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
71
lib/mock-data.ts
Normal 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
154
lib/notification.ts
Normal 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
71
lib/permissions.ts
Normal 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
8
lib/platform-data.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export {
|
||||
getDashboardData,
|
||||
getInboxData,
|
||||
getContactsData,
|
||||
getTeamData,
|
||||
getPlatformSummary,
|
||||
getTenantsData
|
||||
} from "./demo-data";
|
||||
11
lib/prisma.ts
Normal file
11
lib/prisma.ts
Normal 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
94
lib/rate-limit.ts
Normal 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
186
lib/whatsapp-provider.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user