cpns / packages /api /src /routers /admin.ts
pramudya-yb
Rename project from Labas to Pram
2b62b0c
Raw
History Blame Contribute Delete
17.5 kB
import { z } from "zod";
import { eq, desc, sql, count, ilike, or } from "drizzle-orm";
import { adminProcedure, protectedProcedure, router } from "../index";
import * as schema from "@pram/db";
import { db } from "@pram/db";
import { paginationSchema, paginateDefaults } from "../lib/pagination";
import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
import { getUserCredit, getLastRefillAt, getPoolUsage, getConfig, setConfig, autoRefillIfEligible } from "../lib/credit";
import { env } from "@pram/env/server";
function audit(adminUserId: string, action: string, targetUserId: string | null, details?: Record<string, unknown>) {
return db.insert(schema.adminAuditLog).values({
adminUserId,
action,
targetUserId,
details,
});
}
export const adminRouter = router({
isAdmin: protectedProcedure.query(async ({ ctx }) => {
const [u] = await db.select({ role: schema.user.role }).from(schema.user).where(eq(schema.user.id, ctx.session.user.id)).limit(1);
return { isAdmin: u?.role === "admin" };
}),
getMyCredit: protectedProcedure.query(async ({ ctx }) => {
let credit = await getUserCredit(ctx.session.user.id);
// Proactively grant first free-credit refill so new users see balance before clicking Generate.
if (credit.tokenBalance <= 0) {
const refill = await autoRefillIfEligible(ctx.session.user.id);
if (refill.refilled && refill.newBalance !== undefined) {
credit = { ...credit, tokenBalance: refill.newBalance };
}
}
const lastRefill = await getLastRefillAt(ctx.session.user.id);
const cooldownMs = 7 * 24 * 60 * 60 * 1000;
const lastRefillTime = lastRefill?.createdAt ? new Date(lastRefill.createdAt).getTime() : 0;
const cooldownEnd = lastRefillTime + cooldownMs;
const cooldownRemaining = Math.max(0, Math.ceil((cooldownEnd - Date.now()) / (24 * 60 * 60 * 1000)));
const enabledVal = await getConfig("free_credits_enabled", () => String(env.FREE_CREDITS_ENABLED));
return {
tokenBalance: credit.tokenBalance,
lifetimeTokensUsed: credit.lifetimeTokensUsed,
freeCreditsEnabled: enabledVal === "true",
lastRefillAt: lastRefill?.createdAt ?? null,
cooldownDays: 7,
cooldownRemaining,
};
}),
// ── User Management ───────────────────────────────────────
listUsers: adminProcedure
.input(
z
.object({
search: z.string().optional(),
...paginationSchema.shape,
})
.optional(),
)
.query(async ({ input }) => {
const { limit, offset } = paginateDefaults(input);
const where = input?.search
? or(
ilike(schema.user.name, `%${input.search}%`),
ilike(schema.user.email, `%${input.search}%`),
)
: undefined;
const [total] = await db
.select({ count: count() })
.from(schema.user)
.where(where);
const users = await db
.select({
id: schema.user.id,
name: schema.user.name,
email: schema.user.email,
role: schema.user.role,
suspended: schema.user.suspended,
emailVerified: schema.user.emailVerified,
createdAt: schema.user.createdAt,
})
.from(schema.user)
.where(where)
.orderBy(desc(schema.user.createdAt))
.limit(limit)
.offset(offset);
return { users, total: Number(total?.count ?? 0) };
}),
getUserDetail: adminProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ input }) => {
const [u] = await db.select().from(schema.user).where(eq(schema.user.id, input.userId)).limit(1);
if (!u) throwNotFound("User");
const [credit] = await db
.select()
.from(schema.userCredit)
.where(eq(schema.userCredit.userId, input.userId))
.limit(1);
return { user: u, credit: credit ?? null };
}),
suspendUser: adminProcedure
.input(z.object({ userId: z.string(), suspended: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (input.userId === ctx.session.user.id) throwForbidden("Cannot suspend yourself");
const [u] = await db
.update(schema.user)
.set({ suspended: input.suspended })
.where(eq(schema.user.id, input.userId))
.returning();
if (!u) throwNotFound("User");
await audit(ctx.session.user.id, input.suspended ? "suspend_user" : "unsuspend_user", input.userId);
return u;
}),
setUserRole: adminProcedure
.input(z.object({ userId: z.string(), role: z.enum(["user", "admin"]) }))
.mutation(async ({ ctx, input }) => {
if (input.userId === ctx.session.user.id) throwForbidden("Cannot change your own role");
const [u] = await db
.update(schema.user)
.set({ role: input.role })
.where(eq(schema.user.id, input.userId))
.returning();
if (!u) throwNotFound("User");
await audit(ctx.session.user.id, "change_role", input.userId, { newRole: input.role });
return u;
}),
// ── Credit Management ─────────────────────────────────────
getCreditBalance: adminProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ input }) => {
const [credit] = await db
.select()
.from(schema.userCredit)
.where(eq(schema.userCredit.userId, input.userId))
.limit(1);
return credit ?? { userId: input.userId, tokenBalance: 0, lifetimeTokensUsed: 0 };
}),
adjustCredit: adminProcedure
.input(
z.object({
userId: z.string(),
amount: z.number().min(-1000000).max(1000000),
description: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const [user] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.id, input.userId)).limit(1);
if (!user) throwNotFound("User");
await db
.insert(schema.userCredit)
.values({ userId: input.userId, tokenBalance: 0 })
.onConflictDoNothing();
const [updated] = await db
.update(schema.userCredit)
.set({
tokenBalance: sql`token_balance + ${input.amount}`,
})
.where(eq(schema.userCredit.userId, input.userId))
.returning();
if (!updated) throwBadRequest("Failed to adjust credit");
await db.insert(schema.creditTransaction).values({
userId: input.userId,
amount: input.amount,
type: "admin_adjust",
description: input.description ?? null,
});
await audit(ctx.session.user.id, "adjust_credit", input.userId, {
amount: input.amount,
newBalance: updated.tokenBalance,
});
return { newBalance: updated.tokenBalance };
}),
getCreditHistory: adminProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ input }) => {
const transactions = await db
.select()
.from(schema.creditTransaction)
.where(eq(schema.creditTransaction.userId, input.userId))
.orderBy(desc(schema.creditTransaction.createdAt))
.limit(50);
return { transactions };
}),
// ── Featured Management ──────────────────────────────────
toggleFeaturedPackage: adminProcedure
.input(z.object({ packageId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [pkg] = await db
.select({ id: schema.testPackage.id, isFeatured: schema.testPackage.isFeatured })
.from(schema.testPackage)
.where(eq(schema.testPackage.id, input.packageId))
.limit(1);
if (!pkg) throwNotFound("Package");
const newValue = !pkg.isFeatured;
const [updated] = await db
.update(schema.testPackage)
.set({ isFeatured: newValue })
.where(eq(schema.testPackage.id, input.packageId))
.returning();
await audit(ctx.session.user.id, newValue ? "feature_package" : "unfeature_package", null, {
packageId: input.packageId,
});
return updated;
}),
toggleFeaturedQuestion: adminProcedure
.input(z.object({ questionId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [q] = await db
.select({ id: schema.question.id, isFeatured: schema.question.isFeatured })
.from(schema.question)
.where(eq(schema.question.id, input.questionId))
.limit(1);
if (!q) throwNotFound("Question");
const newValue = !q.isFeatured;
const [updated] = await db
.update(schema.question)
.set({ isFeatured: newValue })
.where(eq(schema.question.id, input.questionId))
.returning();
await audit(ctx.session.user.id, newValue ? "feature_question" : "unfeature_question", null, {
questionId: input.questionId,
});
return updated;
}),
listFeatured: protectedProcedure.query(async () => {
const packages = await db
.select({
id: schema.testPackage.id,
title: schema.testPackage.title,
description: schema.testPackage.description,
examTypeId: schema.testPackage.examTypeId,
creatorUserId: schema.testPackage.creatorUserId,
isPublic: schema.testPackage.isPublic,
isFeatured: schema.testPackage.isFeatured,
totalQuestions: schema.testPackage.totalQuestions,
totalSections: schema.testPackage.totalSections,
estimatedDurationMin: schema.testPackage.estimatedDurationMin,
usageCount: schema.testPackage.usageCount,
avgRating: schema.testPackage.avgRating,
createdAt: schema.testPackage.createdAt,
updatedAt: schema.testPackage.updatedAt,
examTypeName: schema.examType.name,
})
.from(schema.testPackage)
.leftJoin(schema.examType, eq(schema.testPackage.examTypeId, schema.examType.id))
.where(eq(schema.testPackage.isFeatured, true))
.orderBy(desc(schema.testPackage.updatedAt));
const questions = await db
.select()
.from(schema.question)
.where(eq(schema.question.isFeatured, true))
.orderBy(desc(schema.question.updatedAt));
return { packages, questions };
}),
searchContent: adminProcedure
.input(
z.object({
search: z.string(),
type: z.enum(["packages", "questions"]),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ input }) => {
if (input.type === "packages") {
const where = or(
ilike(schema.testPackage.title, `%${input.search}%`),
ilike(schema.testPackage.description ?? sql`''`, `%${input.search}%`),
);
const [total] = await db.select({ count: count() }).from(schema.testPackage).where(where);
const items = await db
.select()
.from(schema.testPackage)
.where(where)
.orderBy(desc(schema.testPackage.updatedAt))
.limit(input.limit)
.offset(input.offset);
return { items, total: Number(total?.count ?? 0) };
}
const where = or(
ilike(schema.question.questionText, `%${input.search}%`),
ilike(schema.question.passageText, `%${input.search}%`),
);
const [total] = await db.select({ count: count() }).from(schema.question).where(where);
const items = await db
.select()
.from(schema.question)
.where(where)
.orderBy(desc(schema.question.updatedAt))
.limit(input.limit)
.offset(input.offset);
return { items, total: Number(total?.count ?? 0) };
}),
// ── Generation Jobs ──────────────────────────────────────
listAllJobs: adminProcedure
.input(
z
.object({
status: z.string().optional(),
...paginationSchema.shape,
})
.optional(),
)
.query(async ({ input }) => {
const { limit, offset } = paginateDefaults(input);
const where = input?.status
? eq(schema.generationJob.status, input.status)
: undefined;
const [total] = await db
.select({ count: count() })
.from(schema.generationJob)
.where(where);
const jobs = await db
.select({
id: schema.generationJob.id,
userId: schema.generationJob.userId,
status: schema.generationJob.status,
mode: schema.generationJob.mode,
examTypeId: schema.generationJob.examTypeId,
sectionTypeId: schema.generationJob.sectionTypeId,
questionCount: schema.generationJob.questionCount,
progress: schema.generationJob.progress,
tokensUsed: schema.generationJob.tokensUsed,
errorMessage: schema.generationJob.errorMessage,
createdAt: schema.generationJob.createdAt,
completedAt: schema.generationJob.completedAt,
})
.from(schema.generationJob)
.where(where)
.orderBy(desc(schema.generationJob.createdAt))
.limit(limit)
.offset(offset);
return { jobs, total: Number(total?.count ?? 0) };
}),
cancelAnyJob: adminProcedure
.input(z.object({ jobId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [job] = await db
.select()
.from(schema.generationJob)
.where(eq(schema.generationJob.id, input.jobId))
.limit(1);
if (!job) throwNotFound("Job");
if (job.status !== "pending" && job.status !== "running") {
throwBadRequest("Only pending or running jobs can be cancelled");
}
const [updated] = await db
.update(schema.generationJob)
.set({ status: "cancelled", completedAt: new Date() })
.where(eq(schema.generationJob.id, input.jobId))
.returning();
await audit(ctx.session.user.id, "cancel_job", job.userId, { jobId: input.jobId });
return updated;
}),
// ── Content Moderation ───────────────────────────────────
listLatestQuestions: adminProcedure
.input(
z
.object({
...paginationSchema.shape,
})
.optional(),
)
.query(async ({ input }) => {
const { limit, offset } = paginateDefaults(input);
const questions = await db
.select()
.from(schema.question)
.orderBy(desc(schema.question.createdAt))
.limit(limit)
.offset(offset);
return { questions };
}),
togglePublicAny: adminProcedure
.input(z.object({ questionId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [q] = await db
.select({ id: schema.question.id, isPublic: schema.question.isPublic, creatorUserId: schema.question.creatorUserId })
.from(schema.question)
.where(eq(schema.question.id, input.questionId))
.limit(1);
if (!q) throwNotFound("Question");
const newValue = !q.isPublic;
const [updated] = await db
.update(schema.question)
.set({ isPublic: newValue })
.where(eq(schema.question.id, input.questionId))
.returning();
await audit(ctx.session.user.id, newValue ? "publish_question" : "unpublish_question", q.creatorUserId, {
questionId: input.questionId,
});
return updated;
}),
// ── Dashboard Stats ──────────────────────────────────────
dashboardStats: adminProcedure.query(async () => {
const [userCount] = await db.select({ count: count() }).from(schema.user);
const [questionCount] = await db.select({ count: count() }).from(schema.question);
const [packageCount] = await db.select({ count: count() }).from(schema.testPackage);
const [jobCount] = await db.select({ count: count() }).from(schema.generationJob);
const pool = await getPoolUsage();
const enabledVal = await getConfig("free_credits_enabled", () => String(env.FREE_CREDITS_ENABLED));
return {
totalUsers: Number(userCount?.count ?? 0),
totalQuestions: Number(questionCount?.count ?? 0),
totalPackages: Number(packageCount?.count ?? 0),
totalJobs: Number(jobCount?.count ?? 0),
poolUsed: pool.totalDistributed,
poolMax: pool.maxPool,
poolRemaining: pool.remaining,
freeCreditsEnabled: enabledVal === "true",
};
}),
getPlatformConfig: adminProcedure.query(async () => {
const [enabled, maxPool] = await Promise.all([
getConfig("free_credits_enabled", () => String(env.FREE_CREDITS_ENABLED)),
getConfig("free_credits_max_pool", () => String(env.FREE_CREDITS_MAX_POOL)),
]);
return {
freeCreditsEnabled: enabled === "true",
freeCreditsMaxPool: parseInt(maxPool, 10) || env.FREE_CREDITS_MAX_POOL,
};
}),
setPlatformConfig: adminProcedure
.input(z.object({ key: z.string(), value: z.string() }))
.mutation(async ({ ctx, input }) => {
await setConfig(input.key, input.value);
await audit(ctx.session.user.id, "set_config", null, { key: input.key, value: input.value });
return { ok: true };
}),
});