Rebuild atomcms-next foundation on Prisma (align to habbo-next reference)
Replace the superseded Drizzle monorepo scaffold with a single-app Prisma 7 data layer, mirroring the proven habbo-next approach to the shared Arcturus emulator DB (introspect/conform only, idempotent SQL migrations via a custom runner; never migrate-diff). - prisma/schema.prisma: User, UsersCurrency (composite PK), Ban, WebsiteSetting mapped to AtomCMS's default.sql columns; client generated to src/generated/prisma - src/lib/prisma.ts: PrismaMariaDb adapter singleton; src/env.ts zod env - src/lib/services: siteSettings (cached setting() equivalent) + CurrencyType enum - prisma/migrations + scripts/apply-migrations.ts: idempotent CMS-table runner Verified: prisma generate OK, tsc --noEmit exit 0, vitest 6/6 pass.
This commit is contained in:
1 parent
7bd4f2b28e
commit
b5bc7f6daf
44 files changed
+1279
-511
No files matched your search
+20
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Minimal validated env for the foundation. When the Next.js app is added this
|
||||
// will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer
|
||||
// only needs the DB connection + a couple of values today.
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
DATABASE_URL: z.string().url(),
|
||||
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(40),
|
||||
DATABASE_IDLE_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000),
|
||||
HOTEL_NAME: z.string().default("Atom"),
|
||||
});
|
||||
|
||||
type Env = z.infer<typeof schema>;
|
||||
|
||||
// SKIP_ENV_VALIDATION lets tooling (typecheck, tests that don't touch the DB)
|
||||
// import modules transitively without a populated .env.
|
||||
export const env: Env = process.env.SKIP_ENV_VALIDATION
|
||||
? (process.env as unknown as Env)
|
||||
: schema.parse(process.env);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { env } from "@/env";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
function createPrismaClient(): PrismaClient {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||
connectTimeout: 10_000,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CurrencyType, currencyLabel } from "./currency";
|
||||
|
||||
describe("CurrencyType", () => {
|
||||
it("uses the emulator-fixed numeric values", () => {
|
||||
expect(CurrencyType.Credits).toBe(-1);
|
||||
expect(CurrencyType.Duckets).toBe(0);
|
||||
expect(CurrencyType.Diamonds).toBe(5);
|
||||
expect(CurrencyType.Points).toBe(101);
|
||||
});
|
||||
|
||||
it("maps each type to a human label", () => {
|
||||
expect(currencyLabel(CurrencyType.Credits)).toBe("Credits");
|
||||
expect(currencyLabel(CurrencyType.Diamonds)).toBe("Diamonds");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// Numeric values are dictated by the Arcturus emulator and MUST NOT change.
|
||||
export enum CurrencyType {
|
||||
Credits = -1,
|
||||
Duckets = 0,
|
||||
Diamonds = 5,
|
||||
Points = 101,
|
||||
}
|
||||
|
||||
const LABELS: Record<CurrencyType, string> = {
|
||||
[CurrencyType.Credits]: "Credits",
|
||||
[CurrencyType.Duckets]: "Duckets",
|
||||
[CurrencyType.Diamonds]: "Diamonds",
|
||||
[CurrencyType.Points]: "Points",
|
||||
};
|
||||
|
||||
export function currencyLabel(type: CurrencyType): string {
|
||||
return LABELS[type];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: { websiteSetting: { findMany } },
|
||||
}));
|
||||
|
||||
import { siteSettings } from "./site-settings";
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
siteSettings.reload();
|
||||
});
|
||||
|
||||
describe("siteSettings", () => {
|
||||
it("returns a value by key", async () => {
|
||||
findMany.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([]);
|
||||
expect(await siteSettings.get("missing", "fallback")).toBe("fallback");
|
||||
expect(await siteSettings.get("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("coerces '1'/'0' string booleans", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ key: "maintenance_enabled", value: "1" },
|
||||
{ key: "radio_enabled", value: "0" },
|
||||
]);
|
||||
expect(await siteSettings.getBool("maintenance_enabled")).toBe(true);
|
||||
expect(await siteSettings.getBool("radio_enabled")).toBe(false);
|
||||
expect(await siteSettings.getBool("missing", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches once and caches until reload", async () => {
|
||||
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
|
||||
await siteSettings.get("hotel_name");
|
||||
await siteSettings.getBool("hotel_name");
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
siteSettings.reload();
|
||||
await siteSettings.get("hotel_name");
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* DB-driven CMS config, mirroring AtomCMS's `setting()` / habbo-next's
|
||||
* `siteSettings`. Loads the whole website_settings table into a key→value map,
|
||||
* cached in-process. Booleans are stored as the strings '1' / '0'.
|
||||
*/
|
||||
class SiteSettings {
|
||||
private cache: Map<string, string> | null = null;
|
||||
|
||||
private async load(): Promise<Map<string, string>> {
|
||||
if (this.cache === null) {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
this.cache = new Map(rows.map((r) => [r.key, r.value]));
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
async get(key: string, fallback: string | null = null): Promise<string | null> {
|
||||
const map = await this.load();
|
||||
return map.has(key) ? (map.get(key) as string) : fallback;
|
||||
}
|
||||
|
||||
async getBool(key: string, fallback = false): Promise<boolean> {
|
||||
const map = await this.load();
|
||||
if (!map.has(key)) return fallback;
|
||||
const v = (map.get(key) as string).toLowerCase();
|
||||
return v === "1" || v === "true";
|
||||
}
|
||||
|
||||
/** Invalidate the in-process cache after a settings write. */
|
||||
reload(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const siteSettings = new SiteSettings();
|
||||
Reference in new issue
Block a user