chore(db): remove Prisma facade and drop prisma:generate from CI
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
ca72966a37
commit
422567272c
17 files changed
+182
-1430
No files matched your search
+10
-29
@@ -43,9 +43,7 @@ jobs:
|
|||||||
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
||||||
export BCRYPT_ROUNDS=4
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# Generate Prisma type stubs for facade type-checking (dev only).
|
# Types come from the committed Drizzle schema (src/db/schema.ts); no prisma:generate.
|
||||||
pnpm prisma:generate
|
|
||||||
# Run lint + typecheck + tests on the Drizzle-backed codebase.
|
|
||||||
pnpm biome:lint
|
pnpm biome:lint
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
@@ -141,11 +139,7 @@ jobs:
|
|||||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# Generate Prisma type stubs (for facade type-checking) — no DB connection needed.
|
# Types come from the committed Drizzle schema (src/db/schema.ts); no prisma:generate.
|
||||||
# Drizzle schema (src/db/schema.ts) is committed and does not require generation.
|
|
||||||
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
|
||||||
pnpm prisma:generate
|
|
||||||
unset DATABASE_URL
|
|
||||||
export BCRYPT_ROUNDS=4
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||||
@@ -205,9 +199,6 @@ jobs:
|
|||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
mv "${STAGE}/node_modules" node_modules
|
mv "${STAGE}/node_modules" node_modules
|
||||||
|
|
||||||
# Regenerate Prisma type stubs into live src/generated/ (gitignored build artifact).
|
|
||||||
pnpm prisma:generate
|
|
||||||
|
|
||||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||||
|
|
||||||
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
||||||
@@ -301,10 +292,7 @@ jobs:
|
|||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
export SKIP_ENV_VALIDATION=1
|
export SKIP_ENV_VALIDATION=1
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# prisma generate only needs a resolvable URL — no live DB connection.
|
# Types come from the committed Drizzle schema (src/db/schema.ts); no prisma:generate.
|
||||||
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
|
||||||
pnpm prisma:generate
|
|
||||||
unset DATABASE_URL
|
|
||||||
# Tag releases must apply CMS SQL migrations against the live DB
|
# Tag releases must apply CMS SQL migrations against the live DB
|
||||||
# (same path as push-to-main deploy), using the production .env.
|
# (same path as push-to-main deploy), using the production .env.
|
||||||
LIVE="/var/www/atom-nexst"
|
LIVE="/var/www/atom-nexst"
|
||||||
@@ -416,21 +404,14 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 4. Generate Prisma Type Stubs (Dev Only)"
|
echo "### 4. Run CMS Migrations"
|
||||||
echo '```bash'
|
|
||||||
echo "pnpm prisma:generate"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "Generates TypeScript types in src/generated/prisma/ for the Prisma compatibility facade (types only — no runtime Prisma engine in production). New code should use Drizzle ORM directly via '@/lib/db'."
|
|
||||||
echo ""
|
|
||||||
echo "### 5. Run CMS Migrations"
|
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
echo "pnpm db:migrate"
|
echo "pnpm db:migrate"
|
||||||
echo '```'
|
echo '```'
|
||||||
echo ""
|
echo ""
|
||||||
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status."
|
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status. Runtime types come from the committed Drizzle schema (src/db/schema.ts) via '@/lib/db'."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 6. Polaris Emulator"
|
echo "### 5. Polaris Emulator"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
@@ -444,7 +425,7 @@ jobs:
|
|||||||
echo "./update-Nitrov3.sh"
|
echo "./update-Nitrov3.sh"
|
||||||
echo '```'
|
echo '```'
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 7. Nitro V3 & Renderer"
|
echo "### 6. Nitro V3 & Renderer"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Clone both Nitro repos and build the client:"
|
echo "Clone both Nitro repos and build the client:"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
@@ -456,14 +437,14 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
|
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 8. Catalogus (catalog & gamedata)"
|
echo "### 7. Catalogus (catalog & gamedata)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
||||||
echo '```'
|
echo '```'
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 9. Build & Start the CMS"
|
echo "### 8. Build & Start the CMS"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
echo "# Development (hot reload)"
|
echo "# Development (hot reload)"
|
||||||
echo "pnpm dev"
|
echo "pnpm dev"
|
||||||
@@ -474,7 +455,7 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Open http://localhost:3000 in your browser."
|
echo "Open http://localhost:3000 in your browser."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 10. First Login"
|
echo "### 9. First Login"
|
||||||
echo ""
|
echo ""
|
||||||
echo "1. Register at /register, or log in with an existing emulator account."
|
echo "1. Register at /register, or log in with an existing emulator account."
|
||||||
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
|
import { lt } from "drizzle-orm";
|
||||||
import { env } from "../src/env";
|
import { env } from "../src/env";
|
||||||
|
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
import { prisma } from "../src/lib/prisma";
|
|
||||||
|
|
||||||
function initWorkerSentry(): void {
|
function initWorkerSentry(): void {
|
||||||
const dsn = process.env.SENTRY_DSN;
|
const dsn = process.env.SENTRY_DSN;
|
||||||
@@ -72,9 +73,9 @@ async function backupEmulatorJar(): Promise<void> {
|
|||||||
async function cleanupOldLogs(): Promise<void> {
|
async function cleanupOldLogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.websiteLoginLogs.deleteMany({
|
await db
|
||||||
where: { createdAt: { lt: cutoff } },
|
.delete(WebsiteLoginLogs)
|
||||||
});
|
.where(lt(WebsiteLoginLogs.createdAt, cutoff));
|
||||||
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
captureWorkerError(err, "Log cleanup failed");
|
captureWorkerError(err, "Log cleanup failed");
|
||||||
@@ -84,9 +85,7 @@ async function cleanupOldLogs(): Promise<void> {
|
|||||||
async function cleanupOldSessions(): Promise<void> {
|
async function cleanupOldSessions(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.passwordReset.deleteMany({
|
await db.delete(PasswordReset).where(lt(PasswordReset.createdAt, cutoff));
|
||||||
where: { createdAt: { lt: cutoff } },
|
|
||||||
});
|
|
||||||
logger.info("Cleaned up expired password reset tokens", {
|
logger.info("Cleaned up expired password reset tokens", {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
randomBytes,
|
randomBytes,
|
||||||
timingSafeEqual,
|
timingSafeEqual,
|
||||||
} from "node:crypto";
|
} from "node:crypto";
|
||||||
import { prisma } from "../src/lib/prisma";
|
import { eq, isNotNull } from "drizzle-orm";
|
||||||
|
import { db, User } from "../src/lib/db";
|
||||||
|
|
||||||
function getKey(appKey: string): Buffer {
|
function getKey(appKey: string): Buffer {
|
||||||
const raw = appKey.startsWith("base64:")
|
const raw = appKey.startsWith("base64:")
|
||||||
@@ -84,10 +85,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const key = getKey(appKey);
|
const key = getKey(appKey);
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await db
|
||||||
where: { twoFactorSecret: { not: null } },
|
.select({ id: User.id, twoFactorSecret: User.twoFactorSecret })
|
||||||
select: { id: true, twoFactorSecret: true },
|
.from(User)
|
||||||
});
|
.where(isNotNull(User.twoFactorSecret));
|
||||||
|
|
||||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||||
|
|
||||||
@@ -107,10 +108,10 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||||
const reEncrypted = encryptGcm(plaintext, key);
|
const reEncrypted = encryptGcm(plaintext, key);
|
||||||
await prisma.user.update({
|
await db
|
||||||
where: { id: user.id },
|
.update(User)
|
||||||
data: { twoFactorSecret: reEncrypted },
|
.set({ twoFactorSecret: reEncrypted })
|
||||||
});
|
.where(eq(User.id, user.id));
|
||||||
console.log(` [OK] User ${user.id} — migrated`);
|
console.log(` [OK] User ${user.id} — migrated`);
|
||||||
migrated++;
|
migrated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -125,12 +126,10 @@ async function main() {
|
|||||||
if (errors > 0) process.exit(1);
|
if (errors > 0) process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main().catch((err) => {
|
||||||
.catch((err) => {
|
console.error(err);
|
||||||
console.error(err);
|
process.exit(1);
|
||||||
process.exit(1);
|
});
|
||||||
})
|
|
||||||
.finally(() => prisma.$disconnect());
|
|
||||||
|
|
||||||
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ describe("setTradeLock drizzle + RCON contract", () => {
|
|||||||
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
|
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||||
const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8");
|
const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8");
|
||||||
|
|
||||||
it("writes sanctions + users_settings via Drizzle, not prisma facade", () => {
|
it("writes sanctions + users_settings via Drizzle", () => {
|
||||||
expect(src).toContain("@/lib/db");
|
expect(src).toContain("@/lib/db");
|
||||||
expect(src).toContain("UsersSettings");
|
expect(src).toContain("UsersSettings");
|
||||||
expect(src).toContain("Sanctions");
|
expect(src).toContain("Sanctions");
|
||||||
@@ -14,7 +14,7 @@ describe("setTradeLock drizzle + RCON contract", () => {
|
|||||||
expect(src).toContain("tradeLockedUntil");
|
expect(src).toContain("tradeLockedUntil");
|
||||||
expect(src).toMatch(/export async function setTradeLock/);
|
expect(src).toMatch(/export async function setTradeLock/);
|
||||||
const fn = src.slice(src.indexOf("export async function setTradeLock"));
|
const fn = src.slice(src.indexOf("export async function setTradeLock"));
|
||||||
expect(fn).not.toContain("prisma.sanctions");
|
expect(fn).toContain("db.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("syncs live hotel via RCON settradelock + alert + disconnect", () => {
|
it("syncs live hotel via RCON settradelock + alert + disconnect", () => {
|
||||||
@@ -33,7 +33,7 @@ describe("admin-photos drizzle contract", () => {
|
|||||||
expect(src).toContain("@/lib/db");
|
expect(src).toContain("@/lib/db");
|
||||||
expect(src).toContain("CameraWeb");
|
expect(src).toContain("CameraWeb");
|
||||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||||
expect(src).not.toContain("@/lib/prisma");
|
expect(src).toContain("@/lib/db");
|
||||||
expect(src).toContain('revalidatePath("/photos")');
|
expect(src).toContain('revalidatePath("/photos")');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { toggleReaction } from "@/actions/article-reactions";
|
|||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { cachedQuery } from "@/lib/cached-db";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
User,
|
User,
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { cacheQuery } from "@/lib/prisma-cache";
|
|
||||||
import { sanitize } from "@/lib/sanitize";
|
import { sanitize } from "@/lib/sanitize";
|
||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
@@ -105,10 +105,8 @@ export default async function ArticlePage({
|
|||||||
const { comment, reaction, error } = await searchParams;
|
const { comment, reaction, error } = await searchParams;
|
||||||
const t = await getTranslations("pages.article");
|
const t = await getTranslations("pages.article");
|
||||||
|
|
||||||
const article = await cacheQuery(
|
const article = await cachedQuery(
|
||||||
"websiteArticles",
|
`article:slug:${slug}`,
|
||||||
"findUnique",
|
|
||||||
{ slug },
|
|
||||||
async () => {
|
async () => {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -127,6 +125,7 @@ export default async function ArticlePage({
|
|||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
},
|
},
|
||||||
|
60,
|
||||||
);
|
);
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ describe("CI workflow", () => {
|
|||||||
it("applies CMS migrations on tag release before build", () => {
|
it("applies CMS migrations on tag release before build", () => {
|
||||||
const release = workflow.slice(workflow.indexOf("\n release:"));
|
const release = workflow.slice(workflow.indexOf("\n release:"));
|
||||||
expect(release).toContain("pnpm db:migrate");
|
expect(release).toContain("pnpm db:migrate");
|
||||||
expect(release).toContain("unset DATABASE_URL");
|
expect(release).not.toContain("pnpm prisma:generate");
|
||||||
|
expect(release).toContain("src/db/schema.ts");
|
||||||
const migrateAt = release.indexOf("pnpm db:migrate");
|
const migrateAt = release.indexOf("pnpm db:migrate");
|
||||||
const buildAt = release.indexOf("pnpm build");
|
const buildAt = release.indexOf("pnpm build");
|
||||||
expect(migrateAt).toBeGreaterThan(-1);
|
expect(migrateAt).toBeGreaterThan(-1);
|
||||||
|
|||||||
+26
-8
@@ -1,3 +1,4 @@
|
|||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { unstable_cache } from "next/cache";
|
import { unstable_cache } from "next/cache";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { logAuthorizationEvent } from "./admin/authorization-events";
|
import { logAuthorizationEvent } from "./admin/authorization-events";
|
||||||
@@ -5,8 +6,8 @@ import { isDynamicSuperAdmin } from "./admin/authorization-policy";
|
|||||||
import { resolveAuthorizationState } from "./admin/rank-authority";
|
import { resolveAuthorizationState } from "./admin/rank-authority";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { sessionUserId } from "./auth/session-user";
|
import { sessionUserId } from "./auth/session-user";
|
||||||
|
import { db, User } from "./db";
|
||||||
import { redirectSafe } from "./foundation/security";
|
import { redirectSafe } from "./foundation/security";
|
||||||
import { prisma } from "./prisma";
|
|
||||||
|
|
||||||
// Re-export PERMS from the standalone file (safe for client components)
|
// Re-export PERMS from the standalone file (safe for client components)
|
||||||
export { PERMS } from "./permission-slugs";
|
export { PERMS } from "./permission-slugs";
|
||||||
@@ -32,7 +33,7 @@ function createEmptySet(): PermissionSet {
|
|||||||
*/
|
*/
|
||||||
const getCachedPermissionSlugs = unstable_cache(
|
const getCachedPermissionSlugs = unstable_cache(
|
||||||
async (userId: number, rank: number): Promise<string[]> => {
|
async (userId: number, rank: number): Promise<string[]> => {
|
||||||
const rows = await prisma.$queryRaw<{ slug: string }[]>`
|
const [result] = await db.execute<{ slug: string }>(sql`
|
||||||
SELECT DISTINCT p.slug
|
SELECT DISTINCT p.slug
|
||||||
FROM acl_model_permissions mp
|
FROM acl_model_permissions mp
|
||||||
JOIN acl_permissions p ON p.id = mp.permission_id
|
JOIN acl_permissions p ON p.id = mp.permission_id
|
||||||
@@ -46,7 +47,8 @@ const getCachedPermissionSlugs = unstable_cache(
|
|||||||
FROM acl_roles ar
|
FROM acl_roles ar
|
||||||
WHERE ar.slug = ${`rank_${rank}`}
|
WHERE ar.slug = ${`rank_${rank}`}
|
||||||
)
|
)
|
||||||
`;
|
`);
|
||||||
|
const rows = result as unknown as { slug: string }[];
|
||||||
return rows.map((r) => r.slug);
|
return rows.map((r) => r.slug);
|
||||||
},
|
},
|
||||||
["user-permissions"],
|
["user-permissions"],
|
||||||
@@ -100,14 +102,27 @@ export const loadUserPermissions = cache(async function loadUserPermissions(
|
|||||||
|
|
||||||
const getCurrentAuthorizationState = cache(async (userId: number) =>
|
const getCurrentAuthorizationState = cache(async (userId: number) =>
|
||||||
resolveAuthorizationState(userId, {
|
resolveAuthorizationState(userId, {
|
||||||
user: prisma.user,
|
user: {
|
||||||
|
findUnique: async ({ where }) => {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: User.id,
|
||||||
|
username: User.username,
|
||||||
|
rank: User.rank,
|
||||||
|
})
|
||||||
|
.from(User)
|
||||||
|
.where(eq(User.id, where.id))
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
highestRank: async () => {
|
highestRank: async () => {
|
||||||
// Prefer the highest rank actually held by a user. Unused high IDs in
|
// Prefer the highest rank actually held by a user. Unused high IDs in
|
||||||
// permission_ranks (common on Habbo DBs) would otherwise lock the real
|
// permission_ranks (common on Habbo DBs) would otherwise lock the real
|
||||||
// owner out of super-admin / permissions management.
|
// owner out of super-admin / permissions management.
|
||||||
const rows = await prisma.$queryRaw<
|
const [result] = await db.execute<{
|
||||||
{ highest_rank: number | bigint | null }[]
|
highest_rank: number | bigint | null;
|
||||||
>`
|
}>(sql`
|
||||||
SELECT COALESCE(
|
SELECT COALESCE(
|
||||||
(
|
(
|
||||||
SELECT MAX(u.\`rank\`)
|
SELECT MAX(u.\`rank\`)
|
||||||
@@ -116,7 +131,10 @@ const getCurrentAuthorizationState = cache(async (userId: number) =>
|
|||||||
),
|
),
|
||||||
(SELECT MAX(id) FROM permission_ranks)
|
(SELECT MAX(id) FROM permission_ranks)
|
||||||
) AS highest_rank
|
) AS highest_rank
|
||||||
`;
|
`);
|
||||||
|
const rows = result as unknown as {
|
||||||
|
highest_rank: number | bigint | null;
|
||||||
|
}[];
|
||||||
return rows[0]?.highest_rank == null
|
return rows[0]?.highest_rank == null
|
||||||
? null
|
? null
|
||||||
: Number(rows[0].highest_rank);
|
: Number(rows[0].highest_rank);
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { redis } from "@/lib/redis";
|
|
||||||
|
|
||||||
const CACHE_TTL: Record<string, number> = {
|
|
||||||
websiteArticles: 60,
|
|
||||||
websiteArticleComments: 10,
|
|
||||||
websiteHelpCenterCategories: 300,
|
|
||||||
websiteSettings: 600,
|
|
||||||
websiteBanners: 120,
|
|
||||||
websitePolls: 30,
|
|
||||||
websiteEvents: 30,
|
|
||||||
emulatorSettings: 300,
|
|
||||||
emulatorTexts: 300,
|
|
||||||
aclRoles: 600,
|
|
||||||
aclPermissions: 600,
|
|
||||||
users: 10,
|
|
||||||
ranks: 600,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AVOID_CACHE = new Set(["count", "groupBy"]);
|
|
||||||
|
|
||||||
function shouldCache(model: string, action: string): boolean {
|
|
||||||
if (AVOID_CACHE.has(action)) return false;
|
|
||||||
return model in CACHE_TTL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryKey(model: string, action: string, args: unknown): string {
|
|
||||||
const hash = JSON.stringify(args)
|
|
||||||
.replace(/["{}[\],]/g, "")
|
|
||||||
.slice(0, 120);
|
|
||||||
return `pq:${model}:${action}:${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cacheQuery<T>(
|
|
||||||
model: string,
|
|
||||||
action: string,
|
|
||||||
args: unknown,
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
if (!shouldCache(model, action)) return fn();
|
|
||||||
|
|
||||||
const ttl = CACHE_TTL[model];
|
|
||||||
const key = queryKey(model, action, args);
|
|
||||||
|
|
||||||
return _cachedQuery(key, ttl, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _cachedQuery<T>(
|
|
||||||
key: string,
|
|
||||||
ttl: number,
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
if (!redis) return fn();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cached = await redis.get(key);
|
|
||||||
if (cached !== null) {
|
|
||||||
return JSON.parse(cached) as T;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logger.warn("Cache read failed", { key });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await fn();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await redis.setex(key, ttl, JSON.stringify(result));
|
|
||||||
} catch {
|
|
||||||
logger.warn("Cache write failed", { key });
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("prisma-facade include contract", () => {
|
|
||||||
const src = readFileSync(
|
|
||||||
resolve(process.cwd(), "src/lib/prisma-facade.ts"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
it("groups many-relations as arrays (polls/tickets/events)", () => {
|
|
||||||
expect(src).toContain('mode: "one" | "many"');
|
|
||||||
expect(src).toContain('mode === "many"');
|
|
||||||
expect(src).toContain("list.push(cleaned)");
|
|
||||||
expect(src).toContain('"many"');
|
|
||||||
expect(src).toMatch(/queryRelationRows\([\s\S]*?"many"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recurses nested include on related rows", () => {
|
|
||||||
expect(src).toContain("opts.include && related.length > 0");
|
|
||||||
expect(src).toContain(
|
|
||||||
"await attachIncludes(client, rel.referencedTable, related, opts.include)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies relation where filters when present", () => {
|
|
||||||
expect(src).toContain("buildCondition(rel.referencedTable, opts.where)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ships poll/ticket/event drizzle relations used by admin includes", () => {
|
|
||||||
const relations = readFileSync(
|
|
||||||
resolve(process.cwd(), "src/db/relations.ts"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
expect(relations).toContain("questions: many(WebsitePollQuestion)");
|
|
||||||
expect(relations).toContain("votes: many(WebsitePollVote)");
|
|
||||||
expect(relations).toContain("messages: many(WebsiteTicketMessage)");
|
|
||||||
expect(relations).toContain("prizes: many(WebsiteEventPrize)");
|
|
||||||
expect(relations).toContain(
|
|
||||||
"registrations: many(WebsiteEventRegistration)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("drizzle schema generate script", () => {
|
|
||||||
it("is wired as pnpm db:schema:generate", () => {
|
|
||||||
const pkg = JSON.parse(
|
|
||||||
readFileSync(resolve(process.cwd(), "package.json"), "utf8"),
|
|
||||||
) as { scripts: Record<string, string> };
|
|
||||||
expect(pkg.scripts["db:schema:generate"]).toContain(
|
|
||||||
"generate-drizzle-schema.mjs",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
readFileSync(
|
|
||||||
resolve(process.cwd(), "scripts/generate-drizzle-schema.mjs"),
|
|
||||||
"utf8",
|
|
||||||
),
|
|
||||||
).toContain("Usage: pnpm db:schema:generate");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors the many-relation grouping fix in prisma-facade queryRelationRows.
|
|
||||||
* Keeps the contract local so we don't export private helpers.
|
|
||||||
*/
|
|
||||||
function groupRelationRows(
|
|
||||||
rows: Array<{ __fk: string; id: number }>,
|
|
||||||
mode: "one" | "many",
|
|
||||||
): Map<string, unknown> {
|
|
||||||
const map = new Map<string, unknown>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const key = String(row.__fk);
|
|
||||||
const cleaned = { id: row.id };
|
|
||||||
if (mode === "many") {
|
|
||||||
const list = map.get(key);
|
|
||||||
if (Array.isArray(list)) list.push(cleaned);
|
|
||||||
else map.set(key, [cleaned]);
|
|
||||||
} else if (!map.has(key)) {
|
|
||||||
map.set(key, cleaned);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("prisma-facade relation grouping", () => {
|
|
||||||
it("accumulates many rows per parent key", () => {
|
|
||||||
const map = groupRelationRows(
|
|
||||||
[
|
|
||||||
{ __fk: "1", id: 10 },
|
|
||||||
{ __fk: "1", id: 11 },
|
|
||||||
{ __fk: "2", id: 20 },
|
|
||||||
],
|
|
||||||
"many",
|
|
||||||
);
|
|
||||||
expect(map.get("1")).toEqual([{ id: 10 }, { id: 11 }]);
|
|
||||||
expect(map.get("2")).toEqual([{ id: 20 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a single row for one relations", () => {
|
|
||||||
const map = groupRelationRows(
|
|
||||||
[
|
|
||||||
{ __fk: "1", id: 10 },
|
|
||||||
{ __fk: "1", id: 11 },
|
|
||||||
],
|
|
||||||
"one",
|
|
||||||
);
|
|
||||||
expect(map.get("1")).toEqual({ id: 10 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +0,0 @@
|
|||||||
import type { PrismaClient } from "@/generated/prisma/client";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { makePrisma, Prisma as PrismaHelper } from "@/lib/prisma-facade";
|
|
||||||
|
|
||||||
export const prisma = makePrisma(db) as unknown as PrismaClient;
|
|
||||||
export { PrismaHelper as Prisma };
|
|
||||||
@@ -1,15 +1,50 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const create = vi.hoisted(() => vi.fn());
|
const insertValues = vi.hoisted(() => vi.fn());
|
||||||
const findMany = vi.hoisted(() => vi.fn());
|
const selectRows = vi.hoisted(() => vi.fn());
|
||||||
const count = vi.hoisted(() => vi.fn());
|
const selectCount = vi.hoisted(() => vi.fn());
|
||||||
const userFindMany = vi.hoisted(() => vi.fn());
|
const selectUsers = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
adminAuditLog: { create, findMany, count },
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
user: { findMany: userFindMany },
|
select: vi.fn((fields?: { value?: unknown; id?: unknown }) => {
|
||||||
|
// count() query passes { value: count() }; user lookup passes { id, username }
|
||||||
|
if (fields && "value" in fields && !("id" in fields)) {
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: () => selectCount(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (fields && "id" in fields && "username" in fields) {
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: () => selectUsers(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// default: audit log rows
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: () => ({
|
||||||
|
limit: () => ({
|
||||||
|
offset: () => selectRows(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
AdminAuditLog: {
|
||||||
|
id: "id",
|
||||||
|
userId: "userId",
|
||||||
|
action: "action",
|
||||||
|
target: "target",
|
||||||
|
},
|
||||||
|
User: { id: "id", username: "username" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/env", () => ({ env: {} }));
|
vi.mock("@/env", () => ({ env: {} }));
|
||||||
@@ -22,14 +57,14 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe("logAudit", () => {
|
describe("logAudit", () => {
|
||||||
it("creates an audit entry", async () => {
|
it("creates an audit entry", async () => {
|
||||||
create.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue({ id: 1 });
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId: 1,
|
userId: 1,
|
||||||
action: "test_action",
|
action: "test_action",
|
||||||
target: "user",
|
target: "user",
|
||||||
targetId: 42,
|
targetId: 42,
|
||||||
});
|
});
|
||||||
const data = create.mock.calls[0][0].data;
|
const data = insertValues.mock.calls[0][0];
|
||||||
expect(data.userId).toBe(1);
|
expect(data.userId).toBe(1);
|
||||||
expect(data.action).toBe("test_action");
|
expect(data.action).toBe("test_action");
|
||||||
expect(data.target).toBe("user");
|
expect(data.target).toBe("user");
|
||||||
@@ -37,7 +72,7 @@ describe("logAudit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("redacts sensitive keys in payload", async () => {
|
it("redacts sensitive keys in payload", async () => {
|
||||||
create.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue({ id: 1 });
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId: 1,
|
userId: 1,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -46,32 +81,32 @@ describe("logAudit", () => {
|
|||||||
before: { username: "foo", password: "secret123" },
|
before: { username: "foo", password: "secret123" },
|
||||||
after: { username: "bar", password: "newsecret" },
|
after: { username: "bar", password: "newsecret" },
|
||||||
});
|
});
|
||||||
const data = create.mock.calls[0][0].data;
|
const data = insertValues.mock.calls[0][0];
|
||||||
expect(JSON.parse(data.before).password).toBe("[Redacted]");
|
expect(JSON.parse(data.before).password).toBe("[Redacted]");
|
||||||
expect(JSON.parse(data.after).password).toBe("[Redacted]");
|
expect(JSON.parse(data.after).password).toBe("[Redacted]");
|
||||||
expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" });
|
expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits diff when only before or after is missing", async () => {
|
it("omits diff when only before or after is missing", async () => {
|
||||||
create.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue({ id: 1 });
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId: 1,
|
userId: 1,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
target: "user",
|
target: "user",
|
||||||
before: { username: "foo" },
|
before: { username: "foo" },
|
||||||
});
|
});
|
||||||
const data = create.mock.calls[0][0].data;
|
const data = insertValues.mock.calls[0][0];
|
||||||
expect(data.diff).toBeNull();
|
expect(data.diff).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty payloads", async () => {
|
it("handles empty payloads", async () => {
|
||||||
create.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue({ id: 1 });
|
||||||
await logAudit({ userId: 1, action: "view", target: "page" });
|
await logAudit({ userId: 1, action: "view", target: "page" });
|
||||||
expect(create).toHaveBeenCalledOnce();
|
expect(insertValues).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flattens nested objects", async () => {
|
it("flattens nested objects", async () => {
|
||||||
create.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue({ id: 1 });
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId: 1,
|
userId: 1,
|
||||||
action: "update_settings",
|
action: "update_settings",
|
||||||
@@ -79,14 +114,14 @@ describe("logAudit", () => {
|
|||||||
before: { nested: { key: "val" } },
|
before: { nested: { key: "val" } },
|
||||||
after: {},
|
after: {},
|
||||||
});
|
});
|
||||||
const data = create.mock.calls[0][0].data;
|
const data = insertValues.mock.calls[0][0];
|
||||||
expect(JSON.parse(data.before)).toEqual({ nested: { key: "val" } });
|
expect(JSON.parse(data.before)).toEqual({ nested: { key: "val" } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getAuditLogs", () => {
|
describe("getAuditLogs", () => {
|
||||||
it("returns paginated logs with usernames", async () => {
|
it("returns paginated logs with usernames", async () => {
|
||||||
findMany.mockResolvedValue([
|
selectRows.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
userId: 1,
|
userId: 1,
|
||||||
@@ -110,8 +145,8 @@ describe("getAuditLogs", () => {
|
|||||||
createdAt: "2024-01-02",
|
createdAt: "2024-01-02",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
count.mockResolvedValue(2);
|
selectCount.mockResolvedValue([{ value: 2 }]);
|
||||||
userFindMany.mockResolvedValue([
|
selectUsers.mockResolvedValue([
|
||||||
{ id: 1, username: "alice" },
|
{ id: 1, username: "alice" },
|
||||||
{ id: 2, username: "bob" },
|
{ id: 2, username: "bob" },
|
||||||
]);
|
]);
|
||||||
@@ -125,23 +160,15 @@ describe("getAuditLogs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("filters by search term", async () => {
|
it("filters by search term", async () => {
|
||||||
findMany.mockResolvedValue([]);
|
selectRows.mockResolvedValue([]);
|
||||||
count.mockResolvedValue(0);
|
selectCount.mockResolvedValue([{ value: 0 }]);
|
||||||
await getAuditLogs({ search: "test" });
|
await getAuditLogs({ search: "test" });
|
||||||
expect(findMany).toHaveBeenCalledWith(
|
expect(selectRows).toHaveBeenCalled();
|
||||||
expect.objectContaining({
|
expect(selectCount).toHaveBeenCalled();
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ action: { contains: "test" } },
|
|
||||||
{ target: { contains: "test" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to User #id for unknown users", async () => {
|
it("falls back to User #id for unknown users", async () => {
|
||||||
findMany.mockResolvedValue([
|
selectRows.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
userId: 99,
|
userId: 99,
|
||||||
@@ -154,8 +181,8 @@ describe("getAuditLogs", () => {
|
|||||||
createdAt: "2024-01-01",
|
createdAt: "2024-01-01",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
count.mockResolvedValue(1);
|
selectCount.mockResolvedValue([{ value: 1 }]);
|
||||||
userFindMany.mockResolvedValue([]);
|
selectUsers.mockResolvedValue([]);
|
||||||
const result = await getAuditLogs();
|
const result = await getAuditLogs();
|
||||||
expect(result.rows[0].username).toBe("User #99");
|
expect(result.rows[0].username).toBe("User #99");
|
||||||
});
|
});
|
||||||
|
|||||||
+34
-31
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from "../prisma";
|
import { count, desc, inArray, like, or } from "drizzle-orm";
|
||||||
|
import { AdminAuditLog, db, User } from "@/lib/db";
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -55,17 +56,15 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
||||||
|
|
||||||
await prisma.adminAuditLog.create({
|
await db.insert(AdminAuditLog).values({
|
||||||
data: {
|
userId: entry.userId,
|
||||||
userId: entry.userId,
|
action: entry.action,
|
||||||
action: entry.action,
|
target: entry.target,
|
||||||
target: entry.target,
|
targetId: entry.targetId,
|
||||||
targetId: entry.targetId,
|
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
||||||
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
||||||
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
diff: diff ? JSON.stringify(diff) : null,
|
||||||
diff: diff ? JSON.stringify(diff) : null,
|
createdAt: new Date().toISOString(),
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,29 +79,33 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
|
|||||||
const skip = (page - 1) * perPage;
|
const skip = (page - 1) * perPage;
|
||||||
|
|
||||||
const where = search
|
const where = search
|
||||||
? {
|
? or(
|
||||||
OR: [
|
like(AdminAuditLog.action, `%${search}%`),
|
||||||
{ action: { contains: search } },
|
like(AdminAuditLog.target, `%${search}%`),
|
||||||
{ target: { contains: search } },
|
)
|
||||||
],
|
: undefined;
|
||||||
}
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const [rows, total] = await Promise.all([
|
const [rows, totalResult] = await Promise.all([
|
||||||
prisma.adminAuditLog.findMany({
|
db
|
||||||
where,
|
.select()
|
||||||
orderBy: { id: "desc" },
|
.from(AdminAuditLog)
|
||||||
skip,
|
.where(where)
|
||||||
take: perPage,
|
.orderBy(desc(AdminAuditLog.id))
|
||||||
}),
|
.limit(perPage)
|
||||||
prisma.adminAuditLog.count({ where }),
|
.offset(skip),
|
||||||
|
db.select({ value: count() }).from(AdminAuditLog).where(where),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const total = Number(totalResult[0]?.value ?? 0);
|
||||||
|
|
||||||
const userIds = [...new Set(rows.map((r) => r.userId))];
|
const userIds = [...new Set(rows.map((r) => r.userId))];
|
||||||
const users = await prisma.user.findMany({
|
const users =
|
||||||
where: { id: { in: userIds } },
|
userIds.length > 0
|
||||||
select: { id: true, username: true },
|
? await db
|
||||||
});
|
.select({ id: User.id, username: User.username })
|
||||||
|
.from(User)
|
||||||
|
.where(inArray(User.id, userIds))
|
||||||
|
: [];
|
||||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
const enrichedRows = rows.map((r) => ({
|
const enrichedRows = rows.map((r) => ({
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const findUnique = vi.hoisted(() => vi.fn());
|
const selectLimit = vi.hoisted(() => vi.fn().mockResolvedValue([]));
|
||||||
const mockFetch = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true }));
|
const mockFetch = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true }));
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: { websiteSetting: { findUnique } },
|
db: {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({
|
||||||
|
limit: selectLimit,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
WebsiteSetting: { key: "key", value: "value" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/env", () => ({
|
vi.mock("@/env", () => ({
|
||||||
@@ -21,7 +30,6 @@ import { notify } from "./webhook";
|
|||||||
|
|
||||||
describe("webhook", () => {
|
describe("webhook", () => {
|
||||||
it("sends a Discord notification when webhook URL is configured", async () => {
|
it("sends a Discord notification when webhook URL is configured", async () => {
|
||||||
findUnique.mockResolvedValue(null);
|
|
||||||
notify({
|
notify({
|
||||||
action: "ban",
|
action: "ban",
|
||||||
actor: "admin",
|
actor: "admin",
|
||||||
@@ -41,7 +49,6 @@ describe("webhook", () => {
|
|||||||
|
|
||||||
it("does not throw when fetch fails", async () => {
|
it("does not throw when fetch fails", async () => {
|
||||||
mockFetch.mockRejectedValueOnce(new Error("network error"));
|
mockFetch.mockRejectedValueOnce(new Error("network error"));
|
||||||
findUnique.mockResolvedValue(null);
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
notify({ action: "ban", actor: "admin", target: "user1" }),
|
notify({ action: "ban", actor: "admin", target: "user1" }),
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { prisma } from "../prisma";
|
|
||||||
|
|
||||||
export type { WebhookAction } from "@/types/admin";
|
export type { WebhookAction } from "@/types/admin";
|
||||||
|
|
||||||
@@ -40,9 +41,11 @@ async function getSetting(
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (envValue) return envValue;
|
if (envValue) return envValue;
|
||||||
try {
|
try {
|
||||||
const setting = await prisma.websiteSetting.findUnique({
|
const [setting] = await db
|
||||||
where: { key: cmsKey },
|
.select({ value: WebsiteSetting.value })
|
||||||
});
|
.from(WebsiteSetting)
|
||||||
|
.where(eq(WebsiteSetting.key, cmsKey))
|
||||||
|
.limit(1);
|
||||||
return setting?.value || null;
|
return setting?.value || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe("staff smoke contract", () => {
|
|||||||
expect(src).toContain("CameraWeb");
|
expect(src).toContain("CameraWeb");
|
||||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||||
expect(src).toContain("@/lib/admin/photo-files");
|
expect(src).toContain("@/lib/admin/photo-files");
|
||||||
expect(src).not.toContain("@/lib/prisma");
|
expect(src).toContain("@/lib/db");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("guards dual ticket queues on admin and mod", () => {
|
it("guards dual ticket queues on admin and mod", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user