1433 lines
37 KiB
TypeScript
1433 lines
37 KiB
TypeScript
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
|
|
import {
|
|
RoleCode,
|
|
OptInStatus,
|
|
TemplateApprovalStatus,
|
|
CampaignStatus,
|
|
CampaignAudienceType,
|
|
CampaignType,
|
|
AuthTokenType,
|
|
UserStatus,
|
|
TenantStatus,
|
|
ChannelStatus
|
|
} from "@prisma/client";
|
|
|
|
import { getSession, hashPassword, verifyPassword } from "@/lib/auth";
|
|
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
|
import { createAuthToken } from "@/lib/auth-tokens";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { assertPermission, type ActionPermission } from "@/lib/permissions";
|
|
import { dispatchCampaignById } from "@/lib/campaign-dispatch-service";
|
|
import { sendTransactionalNotification } from "@/lib/notification";
|
|
import { makeInviteUrl } from "@/lib/auth-tokens";
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
function formValue(formData: FormData, name: string, fallback = "") {
|
|
const value = formData.get(name);
|
|
return typeof value === "string" ? value.trim() : fallback;
|
|
}
|
|
|
|
function parseSegmentRules(raw: string) {
|
|
if (!raw) {
|
|
return {
|
|
description: null as string | null,
|
|
rulesJson: null as string | null
|
|
};
|
|
}
|
|
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return {
|
|
description: null as string | null,
|
|
rulesJson: null as string | null
|
|
};
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
return {
|
|
description: null as string | null,
|
|
rulesJson: JSON.stringify(parsed)
|
|
};
|
|
} catch {
|
|
return {
|
|
description: trimmed,
|
|
rulesJson: null
|
|
};
|
|
}
|
|
}
|
|
|
|
function isValidTenantStatus(value: string) {
|
|
return Object.prototype.hasOwnProperty.call(TenantStatus, value);
|
|
}
|
|
|
|
function isValidChannelStatus(value: string) {
|
|
return Object.prototype.hasOwnProperty.call(ChannelStatus, value);
|
|
}
|
|
|
|
async function requireSuperAdminSession() {
|
|
const session = await getSession();
|
|
|
|
if (!session) {
|
|
redirect("/login");
|
|
}
|
|
|
|
if (session.role !== "super_admin") {
|
|
await writePermissionDeniedAudit({
|
|
session,
|
|
permission: "admin:manage",
|
|
action: "super_admin_access"
|
|
});
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
async function writePermissionDeniedAudit(data: {
|
|
session: { tenantId: string; userId: string };
|
|
permission: ActionPermission | "super_admin";
|
|
action: string;
|
|
}) {
|
|
const requestContext = await getRequestAuditContext();
|
|
await writeAuditTrail({
|
|
tenantId: data.session.tenantId,
|
|
actorUserId: data.session.userId,
|
|
entityType: "user",
|
|
entityId: data.session.userId,
|
|
action: "permission_denied",
|
|
metadata: {
|
|
action: data.action,
|
|
permission: data.permission
|
|
},
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
});
|
|
}
|
|
|
|
async function requireAdminManageSession() {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
redirect("/login");
|
|
}
|
|
|
|
if (!assertPermission(session.role, "admin:manage", session.extraPermissions)) {
|
|
await writePermissionDeniedAudit({
|
|
session,
|
|
permission: "admin:manage",
|
|
action: "admin_manage_required"
|
|
});
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
async function requireProfileManageSession() {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
redirect("/login");
|
|
}
|
|
|
|
if (
|
|
!assertPermission(session.role, "admin:manage", session.extraPermissions) &&
|
|
!assertPermission(session.role, "agent:manage", session.extraPermissions) &&
|
|
!assertPermission(session.role, "profile:manage_self", session.extraPermissions)
|
|
) {
|
|
await writePermissionDeniedAudit({
|
|
session,
|
|
permission: "profile:manage_self",
|
|
action: "profile_manage_required"
|
|
});
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
type AuditActorSession = {
|
|
tenantId: string;
|
|
userId: string;
|
|
};
|
|
|
|
async function getAuditContext(session: AuditActorSession) {
|
|
const requestContext = await getRequestAuditContext();
|
|
return {
|
|
tenantId: session.tenantId,
|
|
actorUserId: session.userId,
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
};
|
|
}
|
|
|
|
export async function createContact(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const fullName = formValue(formData, "fullName");
|
|
const phoneNumber = formValue(formData, "phoneNumber");
|
|
const email = formValue(formData, "email") || null;
|
|
const countryCode = formValue(formData, "countryCode") || null;
|
|
const channelId = formValue(formData, "channelId");
|
|
const optInStatus = (formValue(formData, "optInStatus") as OptInStatus) || OptInStatus.UNKNOWN;
|
|
const tagsRaw = formValue(formData, "tags");
|
|
|
|
if (!fullName || !phoneNumber) {
|
|
redirect("/contacts?error=missing_fields");
|
|
}
|
|
|
|
if (channelId) {
|
|
const channel = await prisma.channel.findFirst({
|
|
where: { id: channelId, tenantId }
|
|
});
|
|
if (!channel) {
|
|
redirect("/contacts/new?error=invalid_channel");
|
|
}
|
|
}
|
|
|
|
const contact = await prisma.contact.create({
|
|
data: {
|
|
tenantId,
|
|
fullName,
|
|
phoneNumber,
|
|
email: email || null,
|
|
countryCode: countryCode || null,
|
|
optInStatus,
|
|
channelId: channelId || null,
|
|
lastInteractionAt: new Date()
|
|
}
|
|
});
|
|
|
|
const tags = tagsRaw
|
|
.split(",")
|
|
.map((tag) => tag.trim())
|
|
.filter(Boolean);
|
|
const dedupedTags = [...new Set(tags)];
|
|
|
|
for (const name of dedupedTags) {
|
|
const tag = await prisma.tag.upsert({
|
|
where: {
|
|
tenantId_name: {
|
|
tenantId,
|
|
name
|
|
}
|
|
},
|
|
create: {
|
|
tenantId,
|
|
name
|
|
},
|
|
update: {}
|
|
});
|
|
|
|
await prisma.contactTag.create({
|
|
data: {
|
|
tenantId,
|
|
contactId: contact.id,
|
|
tagId: tag.id
|
|
}
|
|
});
|
|
}
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "contact",
|
|
entityId: contact.id,
|
|
action: "create_contact",
|
|
metadata: {
|
|
fullName,
|
|
phoneNumber,
|
|
email,
|
|
countryCode,
|
|
optInStatus,
|
|
channelId: channelId || null,
|
|
tags: dedupedTags
|
|
}
|
|
});
|
|
|
|
revalidatePath("/contacts");
|
|
redirect("/contacts");
|
|
}
|
|
|
|
export async function updateContact(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const id = formValue(formData, "contactId");
|
|
const fullName = formValue(formData, "fullName");
|
|
const phoneNumber = formValue(formData, "phoneNumber");
|
|
const email = formValue(formData, "email") || null;
|
|
const countryCode = formValue(formData, "countryCode") || null;
|
|
const channelId = formValue(formData, "channelId");
|
|
const optInStatus = (formValue(formData, "optInStatus") as OptInStatus) || OptInStatus.UNKNOWN;
|
|
const tagsRaw = formValue(formData, "tags");
|
|
|
|
if (!id || !fullName || !phoneNumber) {
|
|
redirect("/contacts?error=missing_fields");
|
|
}
|
|
|
|
const contact = await prisma.contact.findFirst({
|
|
where: { id, tenantId }
|
|
});
|
|
|
|
if (!contact) {
|
|
redirect("/contacts?error=contact_not_found");
|
|
}
|
|
|
|
if (channelId) {
|
|
const channel = await prisma.channel.findFirst({
|
|
where: { id: channelId, tenantId }
|
|
});
|
|
if (!channel) {
|
|
redirect(`/contacts/${id}/edit?error=invalid_channel`);
|
|
}
|
|
}
|
|
|
|
await prisma.contact.update({
|
|
where: { id },
|
|
data: {
|
|
fullName,
|
|
phoneNumber,
|
|
email,
|
|
countryCode,
|
|
optInStatus,
|
|
channelId: channelId || null
|
|
}
|
|
});
|
|
|
|
await prisma.contactTag.deleteMany({
|
|
where: { contactId: id }
|
|
});
|
|
|
|
const tags = tagsRaw
|
|
.split(",")
|
|
.map((tag) => tag.trim())
|
|
.filter(Boolean);
|
|
const dedupedTags = [...new Set(tags)];
|
|
|
|
for (const name of dedupedTags) {
|
|
const tag = await prisma.tag.upsert({
|
|
where: {
|
|
tenantId_name: {
|
|
tenantId,
|
|
name
|
|
}
|
|
},
|
|
create: { tenantId, name },
|
|
update: {}
|
|
});
|
|
|
|
await prisma.contactTag.create({
|
|
data: {
|
|
tenantId,
|
|
contactId: id,
|
|
tagId: tag.id
|
|
}
|
|
});
|
|
}
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "contact",
|
|
entityId: id,
|
|
action: "update_contact",
|
|
metadata: {
|
|
fullName,
|
|
phoneNumber,
|
|
email,
|
|
countryCode,
|
|
optInStatus,
|
|
channelId: channelId || null,
|
|
tags: dedupedTags,
|
|
previousData: {
|
|
previousFullName: contact.fullName,
|
|
previousPhoneNumber: contact.phoneNumber,
|
|
previousEmail: contact.email,
|
|
previousCountryCode: contact.countryCode,
|
|
previousOptInStatus: contact.optInStatus,
|
|
previousChannelId: contact.channelId
|
|
}
|
|
}
|
|
});
|
|
|
|
revalidatePath("/contacts");
|
|
revalidatePath(`/contacts/${id}`);
|
|
redirect(`/contacts/${id}`);
|
|
}
|
|
|
|
export async function deleteContact(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const id = formValue(formData, "contactId");
|
|
if (!id) {
|
|
redirect("/contacts?error=missing_contact_id");
|
|
}
|
|
|
|
const contact = await prisma.contact.findFirst({
|
|
where: { id, tenantId }
|
|
});
|
|
if (!contact) {
|
|
redirect("/contacts?error=contact_not_found");
|
|
}
|
|
|
|
const conversationCount = await prisma.conversation.count({
|
|
where: { contactId: id }
|
|
});
|
|
|
|
if (conversationCount > 0) {
|
|
redirect("/contacts?error=contact_has_conversations");
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.contactTag.deleteMany({ where: { contactId: id, tenantId } }),
|
|
prisma.contact.delete({ where: { id } })
|
|
]);
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "contact",
|
|
entityId: id,
|
|
action: "delete_contact",
|
|
metadata: {
|
|
fullName: contact.fullName,
|
|
phoneNumber: contact.phoneNumber,
|
|
hasConversations: conversationCount > 0
|
|
}
|
|
});
|
|
|
|
revalidatePath("/contacts");
|
|
redirect("/contacts");
|
|
}
|
|
|
|
export async function createTeamUser(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const fullName = formValue(formData, "fullName");
|
|
const email = formValue(formData, "email");
|
|
const roleId = formValue(formData, "roleId");
|
|
const status = (formValue(formData, "status") as UserStatus) || UserStatus.INVITED;
|
|
const password = formValue(formData, "password");
|
|
|
|
if (!fullName || !email || !roleId) {
|
|
redirect("/team/new?error=missing_fields");
|
|
}
|
|
|
|
if (status === UserStatus.ACTIVE && !password) {
|
|
redirect("/team/new?error=missing_fields");
|
|
}
|
|
|
|
const role = await prisma.role.findFirst({
|
|
where: { id: roleId, tenantId, code: { in: [RoleCode.ADMIN_CLIENT, RoleCode.AGENT] } }
|
|
});
|
|
if (!role) {
|
|
redirect("/team/new?error=invalid_role");
|
|
}
|
|
|
|
const initialPassword = password || randomBytes(20).toString("hex");
|
|
|
|
const createdUser = await prisma.user.create({
|
|
data: {
|
|
tenantId,
|
|
fullName,
|
|
email,
|
|
roleId,
|
|
status,
|
|
passwordHash: await hashPassword(initialPassword)
|
|
}
|
|
});
|
|
|
|
if (status === UserStatus.INVITED) {
|
|
const invite = await createAuthToken({
|
|
userId: createdUser.id,
|
|
tenantId,
|
|
tokenType: AuthTokenType.INVITE_ACCEPTANCE,
|
|
createdByUserId: session.userId,
|
|
metadata: { source: "team_invitation" }
|
|
});
|
|
const inviteUrl = makeInviteUrl(invite.rawToken);
|
|
|
|
const inviteNotification = await sendTransactionalNotification({
|
|
to: email,
|
|
subject: `Undangan akun: ${fullName}`,
|
|
text: `Anda diundang masuk ke tenant ${session.tenantId}. Klik tautan ini untuk mengaktifkan akun Anda: ${inviteUrl}`,
|
|
html: `<p>Anda diundang masuk ke tenant <b>${tenantId}</b>.</p><p>Aktifkan akun Anda di sini: <a href="${inviteUrl}">${inviteUrl}</a></p>`,
|
|
metadata: { tenantId, type: "team_invite" }
|
|
});
|
|
|
|
if (!inviteNotification.ok && process.env.NODE_ENV !== "production") {
|
|
console.error(`Invite notification failed for ${email}: ${inviteNotification.error ?? "unknown"}`);
|
|
}
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "user",
|
|
entityId: createdUser.id,
|
|
action: "team_user_invite_sent",
|
|
metadata: {
|
|
email,
|
|
fullName,
|
|
inviteStatus: inviteNotification.ok ? "queued" : "failed",
|
|
inviteError: inviteNotification.ok ? null : inviteNotification.error ?? null,
|
|
source: "team_user"
|
|
}
|
|
});
|
|
}
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "user",
|
|
entityId: createdUser.id,
|
|
action: "create_team_user",
|
|
metadata: {
|
|
fullName,
|
|
email,
|
|
roleId,
|
|
status,
|
|
roleCode: role.code
|
|
}
|
|
});
|
|
|
|
revalidatePath("/team");
|
|
redirect("/team");
|
|
}
|
|
|
|
export async function updateTeamUser(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const id = formValue(formData, "userId");
|
|
const fullName = formValue(formData, "fullName");
|
|
const email = formValue(formData, "email");
|
|
const roleId = formValue(formData, "roleId");
|
|
const status = (formValue(formData, "status") as UserStatus) || UserStatus.INVITED;
|
|
const password = formValue(formData, "password");
|
|
|
|
if (!id || !fullName || !email || !roleId) {
|
|
redirect("/team?error=missing_fields");
|
|
}
|
|
|
|
const user = await prisma.user.findFirst({
|
|
where: { id, tenantId },
|
|
include: { role: true }
|
|
});
|
|
if (!user) {
|
|
redirect("/team?error=user_not_found");
|
|
}
|
|
|
|
const role = await prisma.role.findFirst({
|
|
where: { id: roleId, tenantId, code: { in: [RoleCode.ADMIN_CLIENT, RoleCode.AGENT] } }
|
|
});
|
|
if (!role) {
|
|
redirect(`/team/${id}/edit?error=invalid_role`);
|
|
}
|
|
|
|
const updateData: {
|
|
fullName: string;
|
|
email: string;
|
|
roleId: string;
|
|
status: UserStatus;
|
|
passwordHash?: string;
|
|
} = {
|
|
fullName,
|
|
email,
|
|
roleId,
|
|
status
|
|
};
|
|
|
|
if (password) {
|
|
updateData.passwordHash = await hashPassword(password);
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: updateData
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "user",
|
|
entityId: id,
|
|
action: "update_team_user",
|
|
metadata: {
|
|
fullName,
|
|
email,
|
|
roleId,
|
|
status,
|
|
passwordChanged: Boolean(password)
|
|
}
|
|
});
|
|
|
|
revalidatePath("/team");
|
|
revalidatePath(`/team/${id}`);
|
|
redirect(`/team/${id}`);
|
|
}
|
|
|
|
export async function deleteTeamUser(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const id = formValue(formData, "userId");
|
|
if (!id) {
|
|
redirect("/team?error=missing_user_id");
|
|
}
|
|
|
|
if (id === session.userId) {
|
|
redirect("/team?error=self_delete_not_allowed");
|
|
}
|
|
|
|
const user = await prisma.user.findFirst({
|
|
where: { id, tenantId },
|
|
include: { role: true }
|
|
});
|
|
if (!user) {
|
|
redirect("/team?error=user_not_found");
|
|
}
|
|
|
|
const campaignCount = await prisma.broadcastCampaign.count({
|
|
where: { createdByUserId: id }
|
|
});
|
|
if (campaignCount > 0) {
|
|
redirect("/team?error=user_has_campaigns");
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.conversation.updateMany({ where: { assignedUserId: id }, data: { assignedUserId: null } }),
|
|
prisma.conversationNote.deleteMany({ where: { userId: id } }),
|
|
prisma.conversationActivity.deleteMany({ where: { actorUserId: id } }),
|
|
prisma.message.updateMany({ where: { sentByUserId: id }, data: { sentByUserId: null } }),
|
|
prisma.user.delete({ where: { id } })
|
|
]);
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "user",
|
|
entityId: id,
|
|
action: "delete_team_user",
|
|
metadata: {
|
|
deletedEmail: user.email,
|
|
deletedName: user.fullName,
|
|
roleCode: user.role.code
|
|
}
|
|
});
|
|
|
|
revalidatePath("/team");
|
|
redirect("/team");
|
|
}
|
|
|
|
export async function createTemplate(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const name = formValue(formData, "name");
|
|
const category = formValue(formData, "category");
|
|
const languageCode = formValue(formData, "languageCode");
|
|
const bodyText = formValue(formData, "bodyText");
|
|
const channelId = formValue(formData, "channelId");
|
|
const templateType = formValue(formData, "templateType") || "text";
|
|
|
|
if (!name || !category || !languageCode || !bodyText || !channelId) {
|
|
redirect("/templates/new?error=missing_fields");
|
|
}
|
|
|
|
const channel = await prisma.channel.findFirst({
|
|
where: { id: channelId, tenantId }
|
|
});
|
|
if (!channel) {
|
|
redirect("/templates/new?error=invalid_channel");
|
|
}
|
|
|
|
const template = await prisma.messageTemplate.create({
|
|
data: {
|
|
tenantId,
|
|
channelId,
|
|
name,
|
|
category,
|
|
languageCode,
|
|
templateType,
|
|
bodyText,
|
|
approvalStatus: TemplateApprovalStatus.DRAFT
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "message_template",
|
|
entityId: template.id,
|
|
action: "create_template",
|
|
metadata: {
|
|
name,
|
|
category,
|
|
languageCode,
|
|
templateType,
|
|
channelId
|
|
}
|
|
});
|
|
|
|
revalidatePath("/templates");
|
|
redirect("/templates");
|
|
}
|
|
|
|
export async function updateTemplate(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const id = formValue(formData, "templateId");
|
|
const name = formValue(formData, "name");
|
|
const category = formValue(formData, "category");
|
|
const languageCode = formValue(formData, "languageCode");
|
|
const bodyText = formValue(formData, "bodyText");
|
|
const channelId = formValue(formData, "channelId");
|
|
|
|
if (!id || !name || !category || !languageCode || !bodyText || !channelId) {
|
|
redirect("/templates?error=missing_fields");
|
|
}
|
|
|
|
const template = await prisma.messageTemplate.findFirst({
|
|
where: { id, tenantId }
|
|
});
|
|
if (!template) {
|
|
redirect("/templates?error=template_not_found");
|
|
}
|
|
|
|
await prisma.messageTemplate.update({
|
|
where: { id },
|
|
data: {
|
|
name,
|
|
category,
|
|
languageCode,
|
|
bodyText,
|
|
channelId
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "message_template",
|
|
entityId: id,
|
|
action: "update_template",
|
|
metadata: {
|
|
name,
|
|
category,
|
|
languageCode,
|
|
bodyText,
|
|
channelId
|
|
}
|
|
});
|
|
|
|
revalidatePath("/templates");
|
|
revalidatePath(`/templates/${id}`);
|
|
redirect(`/templates/${id}`);
|
|
}
|
|
|
|
export async function deleteTemplate(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const id = formValue(formData, "templateId");
|
|
if (!id) {
|
|
redirect("/templates?error=missing_template_id");
|
|
}
|
|
|
|
const template = await prisma.messageTemplate.findFirst({
|
|
where: { id, tenantId }
|
|
});
|
|
if (!template) {
|
|
redirect("/templates?error=template_not_found");
|
|
}
|
|
|
|
const recipientCount = await prisma.broadcastCampaign.count({
|
|
where: { templateId: id }
|
|
});
|
|
if (recipientCount > 0) {
|
|
redirect("/templates?error=template_in_use");
|
|
}
|
|
|
|
await prisma.messageTemplate.delete({
|
|
where: { id }
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "message_template",
|
|
entityId: id,
|
|
action: "delete_template",
|
|
metadata: {
|
|
reason: "manual_delete"
|
|
}
|
|
});
|
|
|
|
revalidatePath("/templates");
|
|
redirect("/templates");
|
|
}
|
|
|
|
export async function dispatchCampaign(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
const campaignId = formValue(formData, "campaignId");
|
|
if (!campaignId) {
|
|
redirect("/campaigns?error=missing_campaign_id");
|
|
}
|
|
|
|
const { ipAddress, userAgent } = await getRequestAuditContext();
|
|
|
|
const summary = await dispatchCampaignById({
|
|
campaignId,
|
|
tenantId,
|
|
actorUserId: session.userId,
|
|
actorIpAddress: ipAddress,
|
|
actorUserAgent: userAgent,
|
|
source: "manual"
|
|
});
|
|
|
|
if (summary.skippedReason === "campaign_not_found") {
|
|
redirect("/campaigns?error=campaign_not_found");
|
|
}
|
|
|
|
revalidatePath("/campaigns");
|
|
revalidatePath(`/campaigns/${campaignId}`);
|
|
revalidatePath(`/campaigns/${campaignId}/recipients`);
|
|
|
|
if (summary.skippedReason === "campaign_not_ready") {
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "campaign",
|
|
entityId: campaignId,
|
|
action: "campaign_dispatch_blocked",
|
|
metadata: {
|
|
reason: summary.skippedReason,
|
|
attempt: "manual"
|
|
}
|
|
});
|
|
redirect("/campaigns?error=campaign_not_ready");
|
|
}
|
|
|
|
if (summary.skippedReason === "no_recipients") {
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "campaign",
|
|
entityId: campaignId,
|
|
action: "campaign_dispatch_blocked",
|
|
metadata: {
|
|
reason: summary.skippedReason,
|
|
attempt: "manual"
|
|
}
|
|
});
|
|
redirect("/campaigns?error=no_recipients");
|
|
}
|
|
|
|
if (summary.idle) {
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "campaign",
|
|
entityId: campaignId,
|
|
action: "campaign_dispatch_executed_noop",
|
|
metadata: {
|
|
reason: "idle",
|
|
attempt: "manual",
|
|
seededRecipients: summary.seededRecipients,
|
|
processableRecipients: summary.processableRecipients
|
|
}
|
|
});
|
|
redirect(`/campaigns/${campaignId}?dispatch=idle`);
|
|
}
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "campaign",
|
|
entityId: campaignId,
|
|
action: "campaign_dispatch_executed",
|
|
metadata: {
|
|
attempt: "manual",
|
|
seededRecipients: summary.seededRecipients,
|
|
processableRecipients: summary.processableRecipients,
|
|
attempted: summary.attempted,
|
|
successful: summary.successful,
|
|
failed: summary.failed
|
|
}
|
|
});
|
|
|
|
redirect(`/campaigns/${campaignId}?dispatch=done`);
|
|
}
|
|
|
|
export async function createCampaign(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const name = formValue(formData, "name");
|
|
const channelId = formValue(formData, "channelId");
|
|
const templateId = formValue(formData, "templateId");
|
|
const audienceType = formValue(formData, "audienceType") as CampaignAudienceType;
|
|
const segmentId = formValue(formData, "segmentId");
|
|
const scheduledAtRaw = formValue(formData, "scheduledAt");
|
|
const scheduledAt = scheduledAtRaw ? new Date(scheduledAtRaw) : null;
|
|
|
|
if (!name || !channelId || !templateId || !audienceType) {
|
|
redirect("/campaigns/new?error=missing_fields");
|
|
}
|
|
|
|
const channel = await prisma.channel.findFirst({
|
|
where: { id: channelId, tenantId }
|
|
});
|
|
if (!channel) {
|
|
redirect("/campaigns/new?error=invalid_channel");
|
|
}
|
|
|
|
const template = await prisma.messageTemplate.findFirst({
|
|
where: { id: templateId, tenantId }
|
|
});
|
|
if (!template) {
|
|
redirect("/campaigns/new?error=invalid_template");
|
|
}
|
|
|
|
let targetSegmentId = segmentId || null;
|
|
if (audienceType === CampaignAudienceType.SEGMENT) {
|
|
const segment = await prisma.contactSegment.findFirst({
|
|
where: { id: segmentId, tenantId }
|
|
});
|
|
if (!segment) {
|
|
redirect("/campaigns/new?error=invalid_segment");
|
|
}
|
|
targetSegmentId = segment.id;
|
|
} else {
|
|
targetSegmentId = null;
|
|
}
|
|
|
|
const campaign = await prisma.broadcastCampaign.create({
|
|
data: {
|
|
tenantId,
|
|
channelId,
|
|
templateId,
|
|
createdByUserId: session.userId,
|
|
name,
|
|
audienceType,
|
|
segmentId: targetSegmentId,
|
|
scheduledAt: scheduledAtRaw ? scheduledAt : null,
|
|
status: scheduledAtRaw ? CampaignStatus.SCHEDULED : CampaignStatus.DRAFT,
|
|
campaignType: audienceType === CampaignAudienceType.SEGMENT ? CampaignType.BROADCAST : CampaignType.BULK_FOLLOWUP
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "campaign",
|
|
entityId: campaign.id,
|
|
action: "create_campaign",
|
|
metadata: {
|
|
name,
|
|
channelId,
|
|
templateId,
|
|
audienceType,
|
|
segmentId: targetSegmentId,
|
|
scheduledAt: scheduledAt?.toISOString() ?? null,
|
|
status: campaign.status
|
|
}
|
|
});
|
|
|
|
revalidatePath("/campaigns");
|
|
redirect(`/campaigns/${campaign.id}`);
|
|
}
|
|
|
|
export async function deleteCampaign(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const id = formValue(formData, "campaignId");
|
|
if (!id) {
|
|
redirect("/campaigns?error=missing_campaign_id");
|
|
}
|
|
|
|
const campaign = await prisma.broadcastCampaign.findFirst({
|
|
where: { id, tenantId }
|
|
});
|
|
if (!campaign) {
|
|
redirect("/campaigns?error=campaign_not_found");
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.campaignRecipient.deleteMany({ where: { campaignId: id } }),
|
|
prisma.broadcastCampaign.delete({ where: { id } })
|
|
]);
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "campaign",
|
|
entityId: id,
|
|
action: "delete_campaign",
|
|
metadata: {
|
|
campaignName: campaign.name
|
|
}
|
|
});
|
|
|
|
revalidatePath("/campaigns");
|
|
redirect("/campaigns");
|
|
}
|
|
|
|
export async function updateMyProfile(formData: FormData) {
|
|
const session = await requireProfileManageSession();
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const fullName = formValue(formData, "fullName");
|
|
const avatarUrl = formValue(formData, "avatarUrl") || null;
|
|
|
|
if (!fullName) {
|
|
redirect("/profile/edit?error=missing_fullname");
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id: session.userId },
|
|
data: {
|
|
fullName,
|
|
avatarUrl: avatarUrl || null
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "user",
|
|
entityId: session.userId,
|
|
action: "update_profile",
|
|
metadata: {
|
|
fullName,
|
|
avatarUrl: avatarUrl || null
|
|
}
|
|
});
|
|
|
|
revalidatePath("/profile");
|
|
revalidatePath("/profile/edit");
|
|
redirect("/profile");
|
|
}
|
|
|
|
export async function updateTenantProfile(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const companyName = formValue(formData, "companyName");
|
|
const timezone = formValue(formData, "timezone");
|
|
const slug = formValue(formData, "slug");
|
|
|
|
if (!companyName || !timezone || !slug) {
|
|
redirect("/settings/profile?error=missing_fields");
|
|
}
|
|
|
|
const tenantId = session.tenantId;
|
|
const existing = await prisma.tenant.findUnique({
|
|
where: { slug }
|
|
});
|
|
|
|
if (existing && existing.id !== tenantId) {
|
|
redirect("/settings/profile?error=tenant_slug_taken");
|
|
}
|
|
|
|
await prisma.tenant.update({
|
|
where: { id: tenantId },
|
|
data: {
|
|
name: companyName,
|
|
companyName,
|
|
slug,
|
|
timezone
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "tenant",
|
|
entityId: tenantId,
|
|
action: "update_tenant_profile",
|
|
metadata: {
|
|
companyName,
|
|
slug,
|
|
timezone
|
|
}
|
|
});
|
|
|
|
revalidatePath("/settings/profile");
|
|
revalidatePath("/profile");
|
|
redirect("/settings/profile?success=updated");
|
|
}
|
|
|
|
export async function changePassword(formData: FormData) {
|
|
const session = await requireProfileManageSession();
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const currentPassword = formValue(formData, "currentPassword");
|
|
const newPassword = formValue(formData, "newPassword");
|
|
const confirmPassword = formValue(formData, "confirmPassword");
|
|
|
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
|
redirect("/profile/change-password?error=missing_fields");
|
|
}
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
redirect("/profile/change-password?error=password_mismatch");
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: session.userId },
|
|
select: { passwordHash: true }
|
|
});
|
|
|
|
if (!user) {
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
const isCurrentValid = await verifyPassword(currentPassword, user.passwordHash);
|
|
if (!isCurrentValid) {
|
|
redirect("/profile/change-password?error=wrong_current_password");
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id: session.userId },
|
|
data: {
|
|
passwordHash: await hashPassword(newPassword)
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "user",
|
|
entityId: session.userId,
|
|
action: "change_password",
|
|
metadata: {
|
|
success: true
|
|
}
|
|
});
|
|
|
|
redirect("/profile/change-password?success=updated");
|
|
}
|
|
|
|
export async function createContactSegment(formData: FormData) {
|
|
const session = await requireAdminManageSession();
|
|
const tenantId = session.tenantId;
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const name = formValue(formData, "name");
|
|
const rulesRaw = formValue(formData, "rules");
|
|
|
|
if (!name) {
|
|
redirect("/contacts/segments/new?error=missing_fields");
|
|
}
|
|
|
|
const parsedRules = parseSegmentRules(rulesRaw);
|
|
|
|
const segment = await prisma.contactSegment.create({
|
|
data: {
|
|
tenantId,
|
|
name,
|
|
description: parsedRules.description,
|
|
rulesJson: parsedRules.rulesJson
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "contact_segment",
|
|
entityId: segment.id,
|
|
action: "create_contact_segment",
|
|
metadata: {
|
|
name,
|
|
hasRules: Boolean(parsedRules.rulesJson)
|
|
}
|
|
});
|
|
|
|
revalidatePath("/contacts/segments");
|
|
redirect("/contacts/segments");
|
|
}
|
|
|
|
export async function createTenant(formData: FormData) {
|
|
const session = await requireSuperAdminSession();
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const name = formValue(formData, "name");
|
|
const companyName = formValue(formData, "companyName") || name;
|
|
const slug = formValue(formData, "slug").toLowerCase();
|
|
const timezone = formValue(formData, "timezone");
|
|
const planId = formValue(formData, "planId");
|
|
const adminFullName = formValue(formData, "adminFullName") || `Admin ${name}`;
|
|
const adminEmail = formValue(formData, "adminEmail");
|
|
const adminPassword = formValue(formData, "adminPassword");
|
|
|
|
if (!name || !slug || !timezone || !planId) {
|
|
redirect("/super-admin/tenants/new?error=missing_fields");
|
|
}
|
|
|
|
const [plan, existingTenant, existingAdmin] = await Promise.all([
|
|
prisma.subscriptionPlan.findUnique({ where: { id: planId } }),
|
|
prisma.tenant.findUnique({ where: { slug } }),
|
|
adminEmail ? prisma.user.findUnique({ where: { email: adminEmail } }) : Promise.resolve(null)
|
|
]);
|
|
|
|
if (!plan) {
|
|
redirect("/super-admin/tenants/new?error=invalid_plan");
|
|
}
|
|
|
|
if (existingTenant) {
|
|
redirect("/super-admin/tenants/new?error=slug_exists");
|
|
}
|
|
|
|
if (adminEmail && existingAdmin) {
|
|
redirect("/super-admin/tenants/new?error=admin_email_exists");
|
|
}
|
|
|
|
type AdminInvitePayload = {
|
|
adminId: string;
|
|
tenantId: string;
|
|
tenantName: string;
|
|
adminEmail: string;
|
|
};
|
|
|
|
const tenantCreation = await prisma.$transaction(async (tx) => {
|
|
const createdTenant = await tx.tenant.create({
|
|
data: {
|
|
name,
|
|
companyName: companyName || name,
|
|
slug,
|
|
timezone,
|
|
status: TenantStatus.ACTIVE,
|
|
planId
|
|
}
|
|
});
|
|
|
|
if (adminEmail) {
|
|
const shouldInviteAdmin = !adminPassword;
|
|
const adminPasswordRaw = shouldInviteAdmin
|
|
? randomBytes(20).toString("hex")
|
|
: adminPassword;
|
|
const adminStatus = shouldInviteAdmin ? UserStatus.INVITED : UserStatus.ACTIVE;
|
|
const adminPasswordHash = await hashPassword(adminPasswordRaw);
|
|
const adminRole = await tx.role.create({
|
|
data: {
|
|
tenantId: createdTenant.id,
|
|
name: `${companyName} Admin`,
|
|
code: RoleCode.ADMIN_CLIENT,
|
|
permissionsJson: JSON.stringify({ tenant: true })
|
|
}
|
|
});
|
|
|
|
const createdAdmin = await tx.user.create({
|
|
data: {
|
|
tenantId: createdTenant.id,
|
|
fullName: adminFullName || `${companyName} Admin`,
|
|
email: adminEmail,
|
|
roleId: adminRole.id,
|
|
status: adminStatus,
|
|
passwordHash: adminPasswordHash
|
|
}
|
|
});
|
|
|
|
if (shouldInviteAdmin) {
|
|
return {
|
|
tenant: createdTenant,
|
|
adminInvite: {
|
|
adminId: createdAdmin.id,
|
|
tenantId: createdTenant.id,
|
|
tenantName: name,
|
|
adminEmail
|
|
} satisfies AdminInvitePayload
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
tenant: createdTenant,
|
|
adminInvite: null
|
|
};
|
|
});
|
|
|
|
const tenant = tenantCreation.tenant;
|
|
const adminInvite = tenantCreation.adminInvite;
|
|
|
|
if (adminInvite) {
|
|
const adminInviteToken = await createAuthToken({
|
|
userId: adminInvite.adminId,
|
|
tenantId: adminInvite.tenantId,
|
|
tokenType: AuthTokenType.INVITE_ACCEPTANCE,
|
|
createdByUserId: session.userId,
|
|
metadata: { source: "tenant_admin_invitation", tenantId: adminInvite.tenantId }
|
|
});
|
|
|
|
const inviteUrl = makeInviteUrl(adminInviteToken.rawToken);
|
|
const adminInviteResult = await sendTransactionalNotification({
|
|
to: adminInvite.adminEmail,
|
|
subject: `Setup admin tenant ${adminInvite.tenantName}`,
|
|
text: `Anda diundang menjadi admin untuk tenant ${adminInvite.tenantName}. Gunakan tautan ini untuk mengatur kata sandi: ${inviteUrl}`,
|
|
html: `<p>Anda diundang menjadi admin untuk tenant <b>${adminInvite.tenantName}</b>.</p><p>Gunakan tautan berikut untuk mengaktifkan akun: <a href="${inviteUrl}">${inviteUrl}</a></p>`
|
|
});
|
|
|
|
if (!adminInviteResult.ok) {
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "user",
|
|
entityId: adminInvite.adminId,
|
|
action: "tenant_admin_invite_delivery_failed",
|
|
metadata: {
|
|
email: adminInvite.adminEmail,
|
|
tenantId: adminInvite.tenantId,
|
|
reason: adminInviteResult.error ?? "delivery_error",
|
|
source: "tenant_admin"
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
revalidatePath("/super-admin/tenants");
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "tenant",
|
|
entityId: tenant.id,
|
|
action: "create_tenant",
|
|
metadata: {
|
|
name,
|
|
companyName,
|
|
slug,
|
|
timezone,
|
|
planId
|
|
}
|
|
});
|
|
|
|
redirect(`/super-admin/tenants/${tenant.id}`);
|
|
}
|
|
|
|
export async function updateTenant(formData: FormData) {
|
|
const session = await requireSuperAdminSession();
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const tenantId = formValue(formData, "tenantId");
|
|
const name = formValue(formData, "name");
|
|
const companyName = formValue(formData, "companyName");
|
|
const slug = formValue(formData, "slug");
|
|
const timezone = formValue(formData, "timezone");
|
|
const planId = formValue(formData, "planId");
|
|
const statusValue = formValue(formData, "status");
|
|
|
|
if (!tenantId || !name || !companyName || !slug || !timezone || !planId || !statusValue) {
|
|
redirect(`/super-admin/tenants/${tenantId || ""}?error=missing_fields`);
|
|
}
|
|
|
|
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, include: { plan: true } });
|
|
if (!tenant) {
|
|
redirect("/super-admin/tenants?error=tenant_not_found");
|
|
}
|
|
|
|
const slugConflict = await prisma.tenant.findFirst({
|
|
where: {
|
|
slug,
|
|
NOT: { id: tenantId }
|
|
}
|
|
});
|
|
if (slugConflict) {
|
|
redirect(`/super-admin/tenants/${tenantId}?error=slug_exists`);
|
|
}
|
|
|
|
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: planId } });
|
|
if (!plan) {
|
|
redirect(`/super-admin/tenants/${tenantId}?error=invalid_plan`);
|
|
}
|
|
|
|
const normalizedStatus = isValidTenantStatus(statusValue) ? (statusValue as TenantStatus) : tenant.status;
|
|
|
|
await prisma.tenant.update({
|
|
where: { id: tenantId },
|
|
data: {
|
|
name,
|
|
companyName,
|
|
slug,
|
|
timezone,
|
|
status: normalizedStatus,
|
|
planId
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "tenant",
|
|
entityId: tenantId,
|
|
action: "update_tenant",
|
|
metadata: {
|
|
name,
|
|
companyName,
|
|
slug,
|
|
timezone,
|
|
status: normalizedStatus,
|
|
planId
|
|
}
|
|
});
|
|
|
|
revalidatePath(`/super-admin/tenants/${tenantId}`);
|
|
revalidatePath("/super-admin/tenants");
|
|
redirect(`/super-admin/tenants/${tenantId}`);
|
|
}
|
|
|
|
export async function connectTenantChannel(formData: FormData) {
|
|
const session = await requireSuperAdminSession();
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const tenantId = formValue(formData, "tenantId");
|
|
const channelName = formValue(formData, "channelName");
|
|
const provider = formValue(formData, "provider");
|
|
const wabaId = formValue(formData, "wabaId");
|
|
const phoneNumberId = formValue(formData, "phoneNumberId");
|
|
const displayPhoneNumber = formValue(formData, "displayPhoneNumber");
|
|
const statusValue = formValue(formData, "status");
|
|
|
|
if (!tenantId || !provider || !channelName || !wabaId || !phoneNumberId || !displayPhoneNumber) {
|
|
redirect(`/super-admin/tenants/${tenantId || ""}/channels/new?error=missing_fields`);
|
|
}
|
|
|
|
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
|
|
if (!tenant) {
|
|
redirect("/super-admin/tenants?error=tenant_not_found");
|
|
}
|
|
|
|
const status = isValidChannelStatus(statusValue) ? (statusValue as ChannelStatus) : ChannelStatus.PENDING;
|
|
|
|
const channel = await prisma.channel.create({
|
|
data: {
|
|
tenantId,
|
|
channelName,
|
|
provider,
|
|
wabaId,
|
|
phoneNumberId,
|
|
displayPhoneNumber,
|
|
status,
|
|
webhookStatus: "pending"
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "channel",
|
|
entityId: channel.id,
|
|
action: "connect_tenant_channel",
|
|
metadata: {
|
|
tenantId,
|
|
channelName,
|
|
provider,
|
|
wabaId,
|
|
phoneNumberId,
|
|
displayPhoneNumber,
|
|
status
|
|
}
|
|
});
|
|
|
|
revalidatePath("/super-admin/channels");
|
|
revalidatePath(`/super-admin/tenants/${tenantId}`);
|
|
redirect(`/super-admin/channels/${channel.id}`);
|
|
}
|