"use server" import { headers } from "next/headers" import { redirect } from "next/navigation" import { revalidatePath } from "next/cache" import { eq } from "drizzle-orm" import { z } from "zod" import { getAdminSession } from "@/lib/session" import { logAdminAction } from "@/lib/admin/audit" import { setMaintenanceMode } from "@/lib/settings" import { setAiProvider, type AiProvider } from "@/lib/ai/provider" import { auth } from "@/lib/auth" import { db } from "@/lib/db" import { profiles, user as userTable } from "@/lib/db/schema" import { executeAccountDeletion } from "@/lib/gdpr/delete" import { changePlanInStripe, cancelSubscription, resumeSubscription, refundCharge, } from "@/lib/admin/billing" import type { Plan } from "@/types" // ── gate ──────────────────────────────────────────────────────────────────── // Every server action re-verifies the caller is an admin. NEVER skip — these // mutate any user's data and bypass user_id scoping. async function guard() { const a = await getAdminSession() if (!a) throw new Error("Forbidden") return a } const planSchema = z.enum(["starter", "pro", "landlord", "lifetime"]) // ── change plan ───────────────────────────────────────────────────────────── // TWO DISTINCT OPERATIONS, deliberately not merged: // // "comp" — grant the entitlement in our database only. Stripe is untouched, // so the user is billed exactly as before. This is the right choice // for a free upgrade, a support gesture, or a user with no // subscription at all. // "stripe" — actually move their Stripe subscription (prorated), then mirror // it locally. This CHARGES OR CREDITS REAL MONEY. // // Before this split, the only behaviour was "comp" while the UI called it // "change plan" — so an admin granting Pro left the customer on their old // Stripe subscription, silently desyncing entitlement from billing. export async function changeUserPlan( userId: string, plan: string, mode: "comp" | "stripe" = "comp", interval: "month" | "year" = "month" ) { const a = await guard() const nextPlan = planSchema.parse(plan) const existing = await db.query.profiles.findFirst({ where: eq(profiles.id, userId) }) const oldPlan = existing?.plan ?? null if (mode === "stripe") { const result = await changePlanInStripe(userId, nextPlan as Plan, interval) if (!result.ok) return { ok: false as const, error: result.error } await logAdminAction({ adminId: a.user.id, action: "plan_change_stripe", targetUserId: userId, metadata: { from: oldPlan, to: nextPlan, interval, detail: result.detail }, }) revalidatePath(`/admin/users/${userId}`) return { ok: true as const, detail: result.detail } } // Comp: entitlement only. Recorded as such so the audit trail distinguishes a // deliberate free grant from a paid upgrade. await db.update(profiles).set({ plan: nextPlan }).where(eq(profiles.id, userId)) await logAdminAction({ adminId: a.user.id, action: "plan_change", targetUserId: userId, metadata: { from: oldPlan, to: nextPlan, mode: "comp", billingUnchanged: true }, }) revalidatePath(`/admin/users/${userId}`) return { ok: true as const, detail: `Plan set to ${nextPlan} as a comp. Stripe billing was NOT changed.`, } } // ── subscription lifecycle ────────────────────────────────────────────────── export async function cancelUserSubscription(userId: string, immediate = false) { const a = await guard() const result = await cancelSubscription(userId, immediate) if (!result.ok) return { ok: false as const, error: result.error } await logAdminAction({ adminId: a.user.id, action: "cancel_subscription", targetUserId: userId, metadata: { immediate, detail: result.detail }, }) revalidatePath(`/admin/users/${userId}`) return { ok: true as const, detail: result.detail } } export async function resumeUserSubscription(userId: string) { const a = await guard() const result = await resumeSubscription(userId) if (!result.ok) return { ok: false as const, error: result.error } await logAdminAction({ adminId: a.user.id, action: "resume_subscription", targetUserId: userId, metadata: { detail: result.detail }, }) revalidatePath(`/admin/users/${userId}`) return { ok: true as const, detail: result.detail } } // ── refunds ───────────────────────────────────────────────────────────────── // `amountCents` omitted refunds everything still outstanding on the charge. The // charge is verified to belong to this user inside refundCharge(). export async function refundUserCharge( userId: string, chargeId: string, amountCents?: number, reason?: "duplicate" | "fraudulent" | "requested_by_customer" ) { const a = await guard() const result = await refundCharge(userId, chargeId, amountCents, reason) if (!result.ok) return { ok: false as const, error: result.error } await logAdminAction({ adminId: a.user.id, action: "refund", targetUserId: userId, metadata: { chargeId, refundId: result.data.refundId, amountCents: result.data.amount, reason: reason ?? null, }, }) revalidatePath(`/admin/users/${userId}`) return { ok: true as const, detail: result.detail } } // ── admin role management ─────────────────────────────────────────────────── // Promotes/demotes via the Better Auth admin plugin, which writes `user.role`. // This replaces the previous situation where the ONLY way to create an admin was // editing ADMIN_USER_IDS in env and redeploying. // // Self-demotion is blocked: an admin removing their own last access would need a // redeploy to undo, and ADMIN_USER_IDS remains the break-glass path. export async function setUserRole(userId: string, role: "admin" | "user") { const a = await guard() if (userId === a.user.id) { throw new Error("You cannot change your own role. Ask another admin.") } await auth.api.setRole({ body: { userId, role }, headers: await headers(), }) await logAdminAction({ adminId: a.user.id, action: "set_role", targetUserId: userId, metadata: { role }, }) revalidatePath(`/admin/users/${userId}`) return { ok: true as const, detail: role === "admin" ? "User promoted to admin." : "Admin access revoked." } } // ── ban ───────────────────────────────────────────────────────────────────── export async function banUser(userId: string, reason?: string) { const a = await guard() if (userId === a.user.id) throw new Error("You cannot ban yourself") await auth.api.banUser({ body: { userId, banReason: reason || "Banned by admin" }, headers: await headers(), }) await logAdminAction({ adminId: a.user.id, action: "ban", targetUserId: userId, metadata: { reason: reason || "Banned by admin" }, }) revalidatePath(`/admin/users/${userId}`) return { ok: true } } // ── unban ─────────────────────────────────────────────────────────────────── export async function unbanUser(userId: string) { const a = await guard() await auth.api.unbanUser({ body: { userId }, headers: await headers(), }) await logAdminAction({ adminId: a.user.id, action: "unban", targetUserId: userId, }) revalidatePath(`/admin/users/${userId}`) return { ok: true } } // ── impersonate ───────────────────────────────────────────────────────────── export async function impersonateUser(userId: string) { const a = await guard() if (userId === a.user.id) throw new Error("You cannot impersonate yourself") await auth.api.impersonateUser({ body: { userId }, headers: await headers(), }) await logAdminAction({ adminId: a.user.id, action: "impersonate", targetUserId: userId, }) redirect("/dashboard") } // ── delete ────────────────────────────────────────────────────────────────── export async function deleteUser(userId: string) { const a = await guard() if (userId === a.user.id) throw new Error("You cannot delete yourself") // Full GDPR-grade purge: cancels Stripe billing, deletes stored files, and // removes the user row (FK cascade erases the whole portfolio + sessions). const outcome = await executeAccountDeletion(userId) await logAdminAction({ adminId: a.user.id, action: "delete_user", targetUserId: userId, metadata: { ...outcome }, }) redirect("/admin/users") } // ── mark email verified ───────────────────────────────────────────────────── export async function markEmailVerified(userId: string) { const a = await guard() await db.update(userTable).set({ emailVerified: true }).where(eq(userTable.id, userId)) await logAdminAction({ adminId: a.user.id, action: "mark_email_verified", targetUserId: userId, metadata: { markedVerified: true }, }) revalidatePath(`/admin/users/${userId}`) return { ok: true } } // ── site maintenance mode ───────────────────────────────────────────────────── // Toggles the site-wide maintenance flag (persisted in app_settings). When on, // the marketing site and dashboard show a maintenance page to everyone except // admins. Revalidates the whole app so the change takes effect immediately. export async function setSiteMaintenance(enabled: boolean, message?: string) { const a = await guard() const trimmed = message?.trim() || null await setMaintenanceMode({ enabled: Boolean(enabled), message: trimmed }) await logAdminAction({ adminId: a.user.id, action: "maintenance_mode", metadata: { enabled: Boolean(enabled), message: trimmed }, }) revalidatePath("/", "layout") return { ok: true } } // ── AI provider ─────────────────────────────────────────────────────────────── // Chooses which LLM provider powers all AI features (OpenAI or Anthropic/Claude), // persisted in app_settings. Applies immediately to every AI route. export async function setAiProviderAction(provider: string) { const a = await guard() if (provider !== "openai" && provider !== "anthropic") throw new Error("Invalid AI provider") await setAiProvider(provider as AiProvider) await logAdminAction({ adminId: a.user.id, action: "ai_provider", metadata: { provider }, }) revalidatePath("/admin/system") return { ok: true } }