refactor(db): migrate staff and app actions from Prisma facade to Drizzle

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-31 21:35:05 +02:00
1 parent 9854719cfd
commit ed9c23c702
85 files changed
+2612 -1832

No files matched your search

+22 -16
View File
@@ -1,12 +1,20 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
const create = vi.hoisted(() => vi.fn());
const selectFrom = vi.hoisted(() => vi.fn());
const insertValues = vi.hoisted(() => vi.fn());
const getBool = vi.hoisted(() => vi.fn());
const get = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({
prisma: { websiteIpBlacklist: { findMany, create } },
vi.mock("@/lib/db", () => ({
db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
insert: () => ({
values: (data: unknown) => insertValues(data),
}),
},
WebsiteIpBlacklist: { ipAddress: "WebsiteIpBlacklist.ipAddress" },
}));
vi.mock("@/lib/services/alert", () => ({
@@ -26,23 +34,23 @@ describe("isIpBlacklisted", () => {
expect(await isIpBlacklisted("127.0.0.1")).toBe(false);
expect(await isIpBlacklisted("192.168.1.1")).toBe(false);
expect(await isIpBlacklisted("10.0.0.1")).toBe(false);
expect(findMany).not.toHaveBeenCalled();
expect(selectFrom).not.toHaveBeenCalled();
});
it("loads, caches, and correctly checks multiple IPs", async () => {
findMany.mockResolvedValue([{ ipAddress: "1.2.3.4" }]);
selectFrom.mockResolvedValue([{ ipAddress: "1.2.3.4" }]);
expect(await isIpBlacklisted("1.2.3.4")).toBe(true);
expect(await isIpBlacklisted("1.2.3.4")).toBe(true);
expect(await isIpBlacklisted("5.6.7.8")).toBe(false);
expect(findMany).toHaveBeenCalledTimes(1);
expect(selectFrom).toHaveBeenCalledTimes(1);
findMany.mockResolvedValue([]);
selectFrom.mockResolvedValue([]);
expect(await isIpBlacklisted("1.2.3.4")).toBe(true);
});
it("handles DB error gracefully (uses stale cache)", async () => {
findMany.mockRejectedValue(new Error("DB error"));
selectFrom.mockRejectedValue(new Error("DB error"));
expect(await isIpBlacklisted("1.2.3.4")).toBe(true);
expect(await isIpBlacklisted("9.9.9.9")).toBe(false);
});
@@ -52,7 +60,7 @@ describe("recordRequest", () => {
beforeEach(() => {
getBool.mockReset();
get.mockReset();
create.mockReset();
insertValues.mockReset();
});
it("ignores private IPs", async () => {
@@ -72,17 +80,15 @@ describe("recordRequest", () => {
if (_key === "abuse_guard_threshold") return "3";
return fallback;
});
create.mockResolvedValue({});
insertValues.mockResolvedValue({});
await recordRequest("1.2.3.4");
await recordRequest("1.2.3.4");
expect(create).not.toHaveBeenCalled();
expect(insertValues).not.toHaveBeenCalled();
await recordRequest("1.2.3.4");
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ ipAddress: "1.2.3.4" }),
}),
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ ipAddress: "1.2.3.4" }),
);
});
});
+8 -6
View File
@@ -1,4 +1,4 @@
import { prisma } from "@/lib/prisma";
import { db, WebsiteIpBlacklist } from "@/lib/db";
import { ddosDetected } from "@/lib/services/alert";
import { siteSettings } from "@/lib/services/site-settings";
@@ -54,9 +54,9 @@ export async function isIpBlacklisted(ip: string): Promise<boolean> {
const now = Date.now();
if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
try {
const rows = await prisma.websiteIpBlacklist.findMany({
select: { ipAddress: true },
});
const rows = await db
.select({ ipAddress: WebsiteIpBlacklist.ipAddress })
.from(WebsiteIpBlacklist);
blacklist = new Set(rows.map((r) => r.ipAddress));
blacklistLoadedAt = now;
} catch {
@@ -91,8 +91,10 @@ export async function recordRequest(ip: string): Promise<void> {
recentlyBlocked.add(ip);
setTimeout(() => recentlyBlocked.delete(ip), 60_000);
try {
await prisma.websiteIpBlacklist.create({
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
await db.insert(WebsiteIpBlacklist).values({
ipAddress: ip,
createdAt: new Date(),
updatedAt: new Date(),
});
blacklistLoadedAt = 0;
await ddosDetected(ip, b.count);
+15 -10
View File
@@ -1,9 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
const selectFrom = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({
prisma: { websiteWordfilter: { findMany } },
vi.mock("@/lib/db", () => ({
db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
},
WebsiteWordfilter: { word: "WebsiteWordfilter.word" },
}));
vi.mock("@/env", () => ({
@@ -15,19 +20,19 @@ vi.mock("@/env", () => ({
import { isAllowed, moderateOrThrow, reloadWordFilter } from "./moderation";
beforeEach(() => {
findMany.mockReset();
selectFrom.mockReset();
reloadWordFilter();
});
describe("moderation", () => {
it("allows clean text", async () => {
findMany.mockResolvedValue([{ word: "badword" }]);
selectFrom.mockResolvedValue([{ word: "badword" }]);
const result = await isAllowed("hello world");
expect(result).toEqual({ ok: true });
});
it("blocks text containing a filtered word", async () => {
findMany.mockResolvedValue([{ word: "badword" }]);
selectFrom.mockResolvedValue([{ word: "badword" }]);
const result = await isAllowed("this contains badword here");
expect(result).toEqual({
ok: false,
@@ -41,21 +46,21 @@ describe("moderation", () => {
});
it("caches the word list and respects TTL", async () => {
findMany.mockResolvedValue([{ word: "first" }]);
selectFrom.mockResolvedValue([{ word: "first" }]);
await isAllowed("test");
await isAllowed("test");
expect(findMany).toHaveBeenCalledTimes(1);
expect(selectFrom).toHaveBeenCalledTimes(1);
});
it("moderateOrThrow throws on blocked content", async () => {
findMany.mockResolvedValue([{ word: "bad" }]);
selectFrom.mockResolvedValue([{ word: "bad" }]);
await expect(moderateOrThrow("this is bad")).rejects.toThrow(
"Blocked by word filter",
);
});
it("moderateOrThrow resolves on clean content", async () => {
findMany.mockResolvedValue([{ word: "bad" }]);
selectFrom.mockResolvedValue([{ word: "bad" }]);
await expect(moderateOrThrow("clean text")).resolves.toBeUndefined();
});
});
+5 -5
View File
@@ -1,11 +1,11 @@
import { env } from "@/env";
import { prisma } from "@/lib/prisma";
import { db, WebsiteWordfilter } from "@/lib/db";
// AtomCMS-faithful content moderation, used by user-generated-content actions
// (article comments, mottos, registration, etc.) before they touch the DB.
//
// Two layers, evaluated in order:
// 1. website_wordfilter — the CMS-owned blocklist (prisma.websiteWordfilter),
// 1. website_wordfilter — the CMS-owned blocklist (WebsiteWordfilter),
// mirroring AtomCMS's word filter. Loaded once and cached in-process like
// site-settings, so the common path never hits the DB.
// 2. OpenAI Moderations — only when OPENAI_API_KEY is set. A flagged result
@@ -40,9 +40,9 @@ async function loadWordFilter(): Promise<string[]> {
now - wordFilterLoadedAt > WORD_FILTER_TTL_MS
) {
try {
const rows = await prisma.websiteWordfilter.findMany({
select: { word: true },
});
const rows = await db
.select({ word: WebsiteWordfilter.word })
.from(WebsiteWordfilter);
wordFilterCache = rows
.map((r) => r.word.trim().toLowerCase())
.filter((w) => w.length > 0);
+28
View File
@@ -1,3 +1,5 @@
import { eq, sql } from "drizzle-orm";
import { db, User, UsersCurrency } from "@/lib/db";
import type { RconClient } from "@/lib/services/rcon";
export type CurrencyName = "credits" | "duckets" | "diamonds" | "points";
@@ -25,6 +27,32 @@ export interface CurrencyDb {
};
}
/** Drizzle-backed CurrencyDb used by shop / voucher / referral (and callers). */
export const currencyDb: CurrencyDb = {
user: {
async update({ where, data }) {
await db
.update(User)
.set({
credits: sql`${User.credits} + ${data.credits.increment}`,
})
.where(eq(User.id, where.id));
},
},
usersCurrency: {
async upsert({ update, create }) {
await db
.insert(UsersCurrency)
.values(create)
.onDuplicateKeyUpdate({
set: {
amount: sql`${UsersCurrency.amount} + ${update.amount.increment}`,
},
});
},
},
};
export interface SendCurrencyDeps {
rcon: Pick<
RconClient,
+20 -10
View File
@@ -1,31 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
vi.mock("@/lib/prisma", () => ({
prisma: { websiteSetting: { findMany } },
const { selectFrom } = vi.hoisted(() => ({ selectFrom: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
insert: () => ({
values: () => ({
onDuplicateKeyUpdate: () => Promise.resolve(),
}),
}),
},
WebsiteSetting: { key: "WebsiteSetting.key", value: "WebsiteSetting.value" },
}));
import { siteSettings } from "./site-settings";
beforeEach(async () => {
findMany.mockReset();
selectFrom.mockReset();
await siteSettings.reload();
});
describe("siteSettings", () => {
it("returns a value by key", async () => {
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
selectFrom.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
expect(await siteSettings.get("hotel_name")).toBe("AtomHotel");
});
it("returns the fallback when key is missing", async () => {
findMany.mockResolvedValue([]);
selectFrom.mockResolvedValue([]);
expect(await siteSettings.get("missing", "fallback")).toBe("fallback");
expect(await siteSettings.get("missing")).toBeNull();
});
it("coerces '1'/'0' string booleans", async () => {
findMany.mockResolvedValue([
selectFrom.mockResolvedValue([
{ key: "maintenance_enabled", value: "1" },
{ key: "radio_enabled", value: "0" },
]);
@@ -35,12 +45,12 @@ describe("siteSettings", () => {
});
it("fetches once and caches until reload", async () => {
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
selectFrom.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
await siteSettings.get("hotel_name");
await siteSettings.getBool("hotel_name");
expect(findMany).toHaveBeenCalledTimes(1);
expect(selectFrom).toHaveBeenCalledTimes(1);
await siteSettings.reload();
await siteSettings.get("hotel_name");
expect(findMany).toHaveBeenCalledTimes(2);
expect(selectFrom).toHaveBeenCalledTimes(2);
});
});
+8 -9
View File
@@ -1,8 +1,8 @@
import "server-only";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
const DEFAULTS: Record<string, string> = {
@@ -21,9 +21,9 @@ class SiteSettings {
private async loadFromDb(): Promise<Map<string, string>> {
try {
const rows = await prisma.websiteSetting.findMany({
select: { key: true, value: true },
});
const rows = await db
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting);
return new Map(rows.map((r) => [r.key, r.value]));
} catch {
logger.warn("Failed to load site settings from database, using defaults");
@@ -98,11 +98,10 @@ class SiteSettings {
}
async update(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({
where: { key },
create: { key, value },
update: { value },
});
await db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await this.reload();
}
+9 -11
View File
@@ -1,5 +1,5 @@
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { db, StaffActivities } from "@/lib/db";
/**
* Append a staff-action audit entry (AtomCMS StaffActivity). Never throws —
@@ -23,16 +23,14 @@ export async function logStaffActivity(opts: {
} catch {
ip = null;
}
await prisma.staffActivities.create({
data: {
userId: BigInt(opts.staffId),
action: opts.action.slice(0, 50),
description: opts.description,
targetType: opts.targetType ?? null,
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
ipAddress: ip,
createdAt: new Date(),
},
await db.insert(StaffActivities).values({
userId: BigInt(opts.staffId),
action: opts.action.slice(0, 50),
description: opts.description,
targetType: opts.targetType ?? null,
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
ipAddress: ip,
createdAt: new Date(),
});
} catch {
// swallow — audit logging is best-effort