admin roadmap filter & search, user management, reopen rejected, system health

This commit is contained in:
2026-05-18 19:45:14 +07:00
parent c52b12daad
commit 6e02f2f0d7
36 changed files with 2013 additions and 339 deletions
+5
View File
@@ -10,3 +10,8 @@ export function isAdminEmail(email: string | null | undefined): boolean {
if (!email) return false;
return adminEmails().includes(email.toLowerCase());
}
/** Daftar email admin yang aman di-expose ke UI admin (dropdown filter). */
export function listAdminEmails(): string[] {
return adminEmails();
}
+18
View File
@@ -0,0 +1,18 @@
import { userRepo } from "@/server/repositories/user.repo";
/**
* Pastikan user session aktif (tidak suspended) sebelum lanjut aksi mutatif.
* Dipanggil di awal server action yang menulis ke DB (joinTrip, createTrip,
* createReview, dll). Lookup fresh dari DB — jangan trust JWT karena suspend
* bisa di-trigger sejak token dibuat.
*
* Throw Error dengan pesan user-friendly kalau suspended.
*/
export async function requireActiveUser(userId: string): Promise<void> {
const suspended = await userRepo.isSuspended(userId);
if (suspended) {
throw new Error(
"Akun kamu sedang ditangguhkan. Hubungi support@setrip.id untuk klarifikasi."
);
}
}
+16
View File
@@ -63,6 +63,22 @@ export const authOptions: AuthOptions = {
strategy: "jwt",
},
callbacks: {
async signIn({ user }) {
// Block suspended user dari sign-in (Credentials + OAuth).
// Email-based lookup karena `user.id` belum tentu ada untuk first-time
// OAuth sign-in sebelum adapter persist.
const email = user.email;
if (!email) return true;
const existing = await prisma.user.findUnique({
where: { email },
select: { suspended: true },
});
if (existing?.suspended) {
// NextAuth menerjemahkan return false jadi error "AccessDenied".
return false;
}
return true;
},
async jwt({ token, user, trigger }) {
if (user) {
token.id = user.id;
+67
View File
@@ -0,0 +1,67 @@
import { prisma } from "@/lib/prisma";
/**
* Wrapper untuk cron route handler — otomatis log start/finish/error ke
* `CronRun`. Idempotent terhadap kegagalan: kalau row gagal dibuat (DB down),
* fn tetap jalan dan kegagalannya hanya hilang log.
*
* Pemakaian:
* ```ts
* return runCron("auto-complete-trips", async () => {
* const result = await tripService.autoCompletePastTrips();
* return { completed: result.count, ids: result.ids };
* });
* ```
*
* Caller bertanggung jawab untuk mengembalikan NextResponse — `runCron`
* cuma menjalankan fn dan log; return value fn dipassthrough sebagai `payload`.
*/
export async function runCron<T>(
jobName: string,
fn: () => Promise<T>
): Promise<{ ok: true; payload: T } | { ok: false; error: string }> {
let runId: string | null = null;
try {
const row = await prisma.cronRun.create({
data: { jobName, status: "RUNNING" },
select: { id: true },
});
runId = row.id;
} catch (err) {
console.error(`[cron-runner] gagal create row ${jobName}`, err);
// Lanjut tanpa log — jangan blok cron karena DB log gagal.
}
try {
const payload = await fn();
if (runId) {
await prisma.cronRun
.update({
where: { id: runId },
data: {
status: "SUCCESS",
finishedAt: new Date(),
payload: payload as unknown as object,
},
})
.catch((e) => console.error(`[cron-runner] gagal update SUCCESS`, e));
}
return { ok: true, payload };
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown cron failure";
if (runId) {
await prisma.cronRun
.update({
where: { id: runId },
data: {
status: "FAILED",
finishedAt: new Date(),
errorMessage: message,
},
})
.catch((e) => console.error(`[cron-runner] gagal update FAILED`, e));
}
return { ok: false, error: message };
}
}