Files
whatsapp-inbox-platform/lib/admin-crud.ts
wirabasalamah f48c87e36d
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Fix tenant creation page error handling and logging
2026-04-21 20:02:38 +07:00

1459 lines
38 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").trim();
const companyName = (formValue(formData, "companyName") || name).trim();
const slug = formValue(formData, "slug").trim().toLowerCase();
const timezone = formValue(formData, "timezone").trim();
const planId = formValue(formData, "planId");
const adminFullName = (formValue(formData, "adminFullName") || `Admin ${name}`).trim();
const adminEmail = formValue(formData, "adminEmail").trim() || undefined;
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;
};
let tenantCreation: {
tenant: {
id: string;
name: string;
slug: string;
planId: string;
timezone: string;
status: TenantStatus;
createdAt: Date;
updatedAt: Date;
companyName: string;
};
adminInvite: AdminInvitePayload | null;
};
try {
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
};
});
} catch (error) {
console.error("[createTenant] transaction failed", {
actorUserId: session.userId,
tenantName: name,
tenantSlug: slug,
planId,
error: error instanceof Error ? error.message : String(error)
});
redirect("/super-admin/tenants/new?error=tenant_creation_failed");
}
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}`);
}