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: `

Anda diundang masuk ke tenant ${tenantId}.

Aktifkan akun Anda di sini: ${inviteUrl}

`, 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: `

Anda diundang menjadi admin untuk tenant ${adminInvite.tenantName}.

Gunakan tautan berikut untuk mengaktifkan akun: ${inviteUrl}

` }); 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}`); }