refactor(db): migrate staff and app actions from Prisma facade to Drizzle
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
9854719cfd
commit
ed9c23c702
85 files changed
+2612
-1832
No files matched your search
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user