refactor(db): finish Drizzle migration for remaining actions and services

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 13:27:59 +02:00
1 parent 22234fe102
commit 65b2fbee6a
19 files changed
+422 -342

No files matched your search

+15 -11
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { asc } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
import { redirectSafe } from "@/lib/foundation/security"; import { redirectSafe } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
/** /**
* Housekeeping table writes are retired. Runtime access uses ACL only. * Housekeeping table writes are retired. Runtime access uses ACL only.
@@ -34,16 +35,19 @@ export async function bulkImportPermissions(
export async function exportPermissions(): Promise<string> { export async function exportPermissions(): Promise<string> {
await requirePermission(PERMS.SETTINGS_VIEW); await requirePermission(PERMS.SETTINGS_VIEW);
const perms = await prisma.websiteHousekeepingPermissions.findMany({ const perms = await db
orderBy: [{ groupName: "asc" }, { permission: "asc" }], .select({
select: { permission: WebsiteHousekeepingPermissions.permission,
permission: true, minRank: WebsiteHousekeepingPermissions.minRank,
minRank: true, description: WebsiteHousekeepingPermissions.description,
description: true, groupName: WebsiteHousekeepingPermissions.groupName,
groupName: true, dependsOn: WebsiteHousekeepingPermissions.dependsOn,
dependsOn: true, })
}, .from(WebsiteHousekeepingPermissions)
}); .orderBy(
asc(WebsiteHousekeepingPermissions.groupName),
asc(WebsiteHousekeepingPermissions.permission),
);
return JSON.stringify(perms, null, 2); return JSON.stringify(perms, null, 2);
} }
+15 -13
View File
@@ -1,8 +1,8 @@
"use server"; "use server";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function importBadgeFromRemote({ export async function importBadgeFromRemote({
code, code,
@@ -15,21 +15,23 @@ export async function importBadgeFromRemote({
}) { }) {
await requirePermission(PERMS.ASSETS_IMPORT); await requirePermission(PERMS.ASSETS_IMPORT);
try { try {
await prisma.websiteBadges.upsert({ const now = new Date();
where: { badgeKey: code }, await db
update: { .insert(WebsiteBadges)
badgeName: name, .values({
badgeDescription: description,
updatedAt: new Date(),
},
create: {
badgeKey: code, badgeKey: code,
badgeName: name, badgeName: name,
badgeDescription: description, badgeDescription: description,
createdAt: new Date(), createdAt: now,
updatedAt: new Date(), updatedAt: now,
}, })
}); .onDuplicateKeyUpdate({
set: {
badgeName: name,
badgeDescription: description,
updatedAt: now,
},
});
return { ok: true as const }; return { ok: true as const };
} catch { } catch {
return { ok: false as const, error: "Failed to import badge" }; return { ok: false as const, error: "Failed to import badge" };
+13 -15
View File
@@ -3,10 +3,10 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, RadioApplications } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
// Column bounds from prisma/schema.prisma (radio_applications): // Column bounds from schema (radio_applications):
// real_name VARCHAR(255); the rest are TEXT. age is an INT. // real_name VARCHAR(255); the rest are TEXT. age is an INT.
const NAME_MAX = 255; const NAME_MAX = 255;
const TEXT_MAX = 5000; const TEXT_MAX = 5000;
@@ -66,19 +66,17 @@ export async function applyDj(formData: FormData): Promise<void> {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const now = new Date(); const now = new Date();
await prisma.radioApplications.create({ await db.insert(RadioApplications).values({
data: { userId: BigInt(userId),
userId: BigInt(userId), realName,
realName, age,
age, availability,
availability, motivation,
motivation, experience: experience || null,
experience: experience || null, musicStyle: musicStyle || null,
musicStyle: musicStyle || null, status: "pending",
status: "pending", createdAt: now,
createdAt: now, updatedAt: now,
updatedAt: now,
},
}); });
outcome = "submitted"; outcome = "submitted";
} }
+8 -10
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, RadioSongRequests } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
const SONG_MAX = 255; const SONG_MAX = 255;
@@ -52,15 +52,13 @@ export async function submitRequest(formData: FormData): Promise<void> {
outcome = "empty"; outcome = "empty";
} else { } else {
const now = new Date(); const now = new Date();
await prisma.radioSongRequests.create({ await db.insert(RadioSongRequests).values({
data: { userId: BigInt(userId),
userId: BigInt(userId), songTitle: songTitle || null,
songTitle: songTitle || null, artist: artist || null,
artist: artist || null, submittedAt: now,
submittedAt: now, createdAt: now,
createdAt: now, updatedAt: now,
updatedAt: now,
},
}); });
outcome = "posted"; outcome = "posted";
} }
+6 -8
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, RadioShouts } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation"; import { moderateOrThrow } from "@/lib/services/moderation";
@@ -77,13 +77,11 @@ export async function postShout(formData: FormData): Promise<void> {
if (!moderated) { if (!moderated) {
const now = new Date(); const now = new Date();
await prisma.radioShouts.create({ await db.insert(RadioShouts).values({
data: { userId: BigInt(userId),
userId: BigInt(userId), message,
message, createdAt: now,
createdAt: now, updatedAt: now,
updatedAt: now,
},
}); });
outcome = "posted"; outcome = "posted";
} }
+86 -65
View File
@@ -1,12 +1,22 @@
"use server"; "use server";
import { desc, eq, inArray, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import {
ClaimedReferralLogs,
db,
UserReferrals,
WebsiteSetting,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; import {
type CurrencyName,
currencyDb,
sendCurrency,
} from "@/lib/services/send-currency";
/** /**
* Claim the referral reward for the SIGNED-IN user. Faithful to AtomCMS's * Claim the referral reward for the SIGNED-IN user. Faithful to AtomCMS's
@@ -60,51 +70,54 @@ export async function claimReferral(_formData: FormData): Promise<void> {
} else { } else {
// Reward configuration (CMS-owned website_settings). AtomCMS defaults: // Reward configuration (CMS-owned website_settings). AtomCMS defaults:
// 5 referrals needed, 30 diamonds reward. // 5 referrals needed, 30 diamonds reward.
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([ const settingKeys = [
prisma.websiteSetting "referrals_needed",
.findUnique({ "referral_reward_amount",
where: { key: "referrals_needed" }, "referral_reward_currency_type",
select: { value: true }, "referral_reward_currency",
}) ] as const;
.catch(() => null),
prisma.websiteSetting
.findUnique({
where: { key: "referral_reward_amount" },
select: { value: true },
})
.catch(() => null),
// The seeded key is referral_reward_currency_type; fall back to the
// shorter referral_reward_currency name if that is what is configured.
prisma.websiteSetting
.findFirst({
where: {
key: {
in: [
"referral_reward_currency_type",
"referral_reward_currency",
],
},
},
select: { value: true },
})
.catch(() => null),
]);
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5; let settings: Array<{ key: string; value: string }> = [];
const amount = Number.parseInt(amountRaw?.value ?? "30", 10); try {
const currency = (currencyRaw?.value ?? "diamonds") settings = await db
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting)
.where(inArray(WebsiteSetting.key, [...settingKeys]));
} catch {
settings = [];
}
const byKey = new Map(settings.map((s) => [s.key, s.value]));
const neededRaw = byKey.get("referrals_needed");
const amountRaw = byKey.get("referral_reward_amount");
// The seeded key is referral_reward_currency_type; fall back to the
// shorter referral_reward_currency name if that is what is configured.
const currencyRaw =
byKey.get("referral_reward_currency_type") ??
byKey.get("referral_reward_currency");
const needed = Number.parseInt(neededRaw ?? "5", 10) || 5;
const amount = Number.parseInt(amountRaw ?? "30", 10);
const currency = (currencyRaw ?? "diamonds")
.trim() .trim()
.toLowerCase() as CurrencyName; .toLowerCase() as CurrencyName;
// The user's referral tally lives in user_referrals (one row per user). // The user's referral tally lives in user_referrals (one row per user).
const referrals = await prisma.userReferrals let referrals: { id: bigint; referralsTotal: bigint } | undefined;
.findFirst({ try {
where: { userId }, const rows = await db
select: { id: true, referralsTotal: true }, .select({
orderBy: { id: "desc" }, id: UserReferrals.id,
}) referralsTotal: UserReferrals.referralsTotal,
.catch(() => null); })
.from(UserReferrals)
.where(eq(UserReferrals.userId, userId))
.orderBy(desc(UserReferrals.id))
.limit(1);
referrals = rows[0];
} catch {
referrals = undefined;
}
const total = referrals ? Number(referrals.referralsTotal) : 0; const total = referrals ? Number(referrals.referralsTotal) : 0;
if (!referrals || total <= 0) { if (!referrals || total <= 0) {
@@ -117,40 +130,48 @@ export async function claimReferral(_formData: FormData): Promise<void> {
} else { } else {
// Spend the threshold first so a concurrent double-submit can't claim // Spend the threshold first so a concurrent double-submit can't claim
// twice off the same balance, then deliver the reward and log it. // twice off the same balance, then deliver the reward and log it.
await prisma.userReferrals.update({ await db
where: { id: referrals.id }, .update(UserReferrals)
data: { referralsTotal: { decrement: needed } }, .set({
}); referralsTotal: sql`${UserReferrals.referralsTotal} - ${needed}`,
})
.where(eq(UserReferrals.id, referrals.id));
try { try {
await sendCurrency({ rcon, db: prisma }, userId, currency, amount); await sendCurrency(
{ rcon, db: currencyDb },
userId,
currency,
amount,
);
} catch { } catch {
// sendCurrency already falls back to a direct DB write; if it still // sendCurrency already falls back to a direct DB write; if it still
// throws the spend stands. Roll the threshold back so the user isn't // throws the spend stands. Roll the threshold back so the user isn't
// charged for an undelivered reward. // charged for an undelivered reward.
await prisma.userReferrals try {
.update({ await db
where: { id: referrals.id }, .update(UserReferrals)
data: { referralsTotal: { increment: needed } }, .set({
}) referralsTotal: sql`${UserReferrals.referralsTotal} + ${needed}`,
.catch(() => {}); })
.where(eq(UserReferrals.id, referrals.id));
} catch {
/* best-effort rollback */
}
outcome = "error"; outcome = "error";
throw new Error("currency-delivery-failed"); throw new Error("currency-delivery-failed");
} }
await prisma.claimedReferralLogs try {
.create({ await db.insert(ClaimedReferralLogs).values({
data: { userId,
userId, ipAddress: await clientIp(),
ipAddress: await clientIp(), createdAt: new Date(),
createdAt: new Date(), updatedAt: new Date(),
updatedAt: new Date(),
},
})
.catch(() => {
// Best-effort audit log; the reward already landed.
}); });
} catch {
// Best-effort audit log; the reward already landed.
}
outcome = "claimed"; outcome = "claimed";
} }
} }
+12 -7
View File
@@ -1,7 +1,8 @@
import "server-only"; import "server-only";
import { count, eq, sql } from "drizzle-orm";
import { env } from "@/env"; import { env } from "@/env";
import { prisma } from "@/lib/prisma"; import { db, User } from "@/lib/db";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -19,11 +20,11 @@ export type OpsHealth = {
* Semantics match `/api/health` for Redis (null when not configured). * Semantics match `/api/health` for Redis (null when not configured).
*/ */
export async function fetchOpsHealth(): Promise<OpsHealth> { export async function fetchOpsHealth(): Promise<OpsHealth> {
const [db, redisOk, emulatorOk, onlineUsers] = await Promise.all([ const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
(async () => { (async () => {
try { try {
const start = Date.now(); const start = Date.now();
await prisma.$queryRaw`SELECT 1`; await db.execute(sql`SELECT 1`);
return { dbOk: true, dbLatencyMs: Date.now() - start }; return { dbOk: true, dbLatencyMs: Date.now() - start };
} catch { } catch {
return { dbOk: false, dbLatencyMs: 0 }; return { dbOk: false, dbLatencyMs: 0 };
@@ -40,12 +41,16 @@ export async function fetchOpsHealth(): Promise<OpsHealth> {
} }
})(), })(),
rcon.send("ping", null).catch(() => false), rcon.send("ping", null).catch(() => false),
prisma.user.count({ where: { online: "1" } }).catch(() => 0), db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
]); ]);
return { return {
dbOk: db.dbOk, dbOk: dbProbe.dbOk,
dbLatencyMs: db.dbLatencyMs, dbLatencyMs: dbProbe.dbLatencyMs,
redisOk, redisOk,
emulatorOk: Boolean(emulatorOk), emulatorOk: Boolean(emulatorOk),
onlineUsers, onlineUsers,
+30 -16
View File
@@ -1,25 +1,39 @@
import { count, desc, eq } from "drizzle-orm";
import type { OnlineUser } from "@/components/admin/dashboard"; import type { OnlineUser } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { db, User } from "@/lib/db";
/** Shared online roster used by CommandoCentrum (and linked from other ops hubs). */ /** Shared online roster used by CommandoCentrum (and linked from other ops hubs). */
export async function fetchOpsOnlineUsers( export async function fetchOpsOnlineUsers(
limit = 40, limit = 40,
): Promise<{ count: number; users: OnlineUser[] }> { ): Promise<{ count: number; users: OnlineUser[] }> {
const [count, onlineUsersRaw] = await Promise.all([ const [countRow, onlineUsersRaw] = await Promise.all([
prisma.user.count({ where: { online: "1" } }).catch(() => 0), db
prisma.user .select({ total: count() })
.findMany({ .from(User)
where: { online: "1" }, .where(eq(User.online, "1"))
select: { .then((rows) => rows[0]?.total ?? 0)
id: true, .catch(() => 0),
username: true, db
look: true, .select({
lastOnline: true, id: User.id,
}, username: User.username,
orderBy: { lastOnline: "desc" }, look: User.look,
take: limit, lastOnline: User.lastOnline,
}) })
.catch(() => []), .from(User)
.where(eq(User.online, "1"))
.orderBy(desc(User.lastOnline))
.limit(limit)
.then((rows) => rows)
.catch(
() =>
[] as Array<{
id: number;
username: string;
look: string;
lastOnline: number;
}>,
),
]); ]);
const users: OnlineUser[] = onlineUsersRaw.map((u) => ({ const users: OnlineUser[] = onlineUsersRaw.map((u) => ({
@@ -31,5 +45,5 @@ export async function fetchOpsOnlineUsers(
lastActivity: u.lastOnline > 0 ? new Date(u.lastOnline * 1000) : new Date(), lastActivity: u.lastOnline > 0 ? new Date(u.lastOnline * 1000) : new Date(),
})); }));
return { count, users }; return { count: countRow, users };
} }
+12 -5
View File
@@ -1,6 +1,7 @@
import "server-only"; import "server-only";
import { prisma } from "@/lib/prisma"; import { count, eq, ne } from "drizzle-orm";
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */ /** Open-queue sizes for dual ticket products (CMS desk vs help-center). */
export async function fetchTicketQueueOpenCounts(): Promise<{ export async function fetchTicketQueueOpenCounts(): Promise<{
@@ -8,11 +9,17 @@ export async function fetchTicketQueueOpenCounts(): Promise<{
helpOpen: number; helpOpen: number;
}> { }> {
const [cmsOpen, helpOpen] = await Promise.all([ const [cmsOpen, helpOpen] = await Promise.all([
prisma.websiteTicket db
.count({ where: { status: { not: "closed" } } }) .select({ total: count() })
.from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0), .catch(() => 0),
prisma.websiteHelpCenterTickets db
.count({ where: { open: true } }) .select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0), .catch(() => 0),
]); ]);
return { cmsOpen, helpOpen }; return { cmsOpen, helpOpen };
+16 -12
View File
@@ -1,14 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const createFn = vi.hoisted(() => vi.fn()); const insertValues = vi.hoisted(() => vi.fn());
const sendMailFn = vi.hoisted(() => vi.fn().mockResolvedValue(true));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { alertLogs: { create: createFn } }, db: {
insert: vi.fn(() => ({ values: insertValues })),
},
AlertLogs: {},
})); }));
vi.mock("@/lib/services/email", () => ({ vi.mock("@/lib/services/email", () => ({
sendMail: sendMailFn, sendMail: vi.fn().mockResolvedValue(true),
})); }));
vi.mock("@/env", () => ({ vi.mock("@/env", () => ({
@@ -21,12 +23,14 @@ vi.mock("@/env", () => ({
}, },
})); }));
import { sendMail } from "@/lib/services/email";
import { sendAlert } from "./alert"; import { sendAlert } from "./alert";
beforeEach(() => { beforeEach(() => {
createFn.mockReset(); insertValues.mockReset();
sendMailFn.mockReset(); vi.mocked(sendMail).mockReset();
createFn.mockResolvedValue({ id: 1 }); vi.mocked(sendMail).mockResolvedValue(true);
insertValues.mockResolvedValue([{ insertId: 1 }]);
}); });
describe("sendAlert", () => { describe("sendAlert", () => {
@@ -36,14 +40,14 @@ describe("sendAlert", () => {
severity: "warning", severity: "warning",
message: "test alert", message: "test alert",
}); });
expect(createFn).toHaveBeenCalledWith({ expect(insertValues).toHaveBeenCalledWith(
data: expect.objectContaining({ expect.objectContaining({
type: "test", type: "test",
severity: "warning", severity: "warning",
message: "test alert", message: "test alert",
isRead: false, isRead: false,
}), }),
}); );
}); });
it("returns logged=true when DB write succeeds", async () => { it("returns logged=true when DB write succeeds", async () => {
@@ -57,6 +61,6 @@ describe("sendAlert", () => {
it("does not call sendMail when ALERT_EMAIL is unset", async () => { it("does not call sendMail when ALERT_EMAIL is unset", async () => {
await sendAlert({ type: "test", severity: "error", message: "m" }); await sendAlert({ type: "test", severity: "error", message: "m" });
expect(sendMailFn).not.toHaveBeenCalled(); expect(sendMail).not.toHaveBeenCalled();
}); });
}); });
+15 -16
View File
@@ -1,14 +1,14 @@
import { env } from "@/env"; import { env } from "@/env";
import { AlertLogs, db } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/services/email"; import { sendMail } from "@/lib/services/email";
// === Alert service (AtomCMS → Next.js) =========================================== // === Alert service (AtomCMS → Next.js) ===========================================
// //
// A pure, dependency-free server module for raising operational alerts. A single // A pure, dependency-free server module for raising operational alerts. A single
// sendAlert() call (a) persists a row in alert_logs (prisma.alertLogs delegate), // sendAlert() call (a) persists a row in alert_logs, (b) pushes a Discord embed
// (b) pushes a Discord embed to DISCORD_WEBHOOK_URL when set, and (c) optionally // to DISCORD_WEBHOOK_URL when set, and (c) optionally emails staff via sendMail()
// emails staff via sendMail() when ALERT_EMAIL is set. // when ALERT_EMAIL is set.
// //
// Every external side-effect is wrapped in try/catch so a failing webhook, dead // Every external side-effect is wrapped in try/catch so a failing webhook, dead
// SMTP host, or unreachable DB never throws into the caller (which is usually a // SMTP host, or unreachable DB never throws into the caller (which is usually a
@@ -186,18 +186,17 @@ export async function sendAlert(
let logged = false; let logged = false;
try { try {
await prisma.alertLogs.create({ const now = new Date();
data: { await db.insert(AlertLogs).values({
type: input.type.slice(0, 255), type: input.type.slice(0, 255),
severity: String(input.severity).slice(0, 255), severity: String(input.severity).slice(0, 255),
message: input.message, message: input.message,
context: input.context ? (input.context as object) : undefined, context: input.context ? JSON.stringify(input.context) : null,
sentViaDiscord, sentViaDiscord,
sentViaEmail, sentViaEmail,
isRead: false, isRead: false,
createdAt: new Date(), createdAt: now,
updatedAt: new Date(), updatedAt: now,
},
}); });
logged = true; logged = true;
} catch (e) { } catch (e) {
+16 -7
View File
@@ -1,6 +1,7 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { prisma } from "@/lib/prisma"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { fetchSourceFurnidata } from "@/lib/services/clone-import"; import { fetchSourceFurnidata } from "@/lib/services/clone-import";
import { listSources } from "@/lib/services/clone-sources"; import { listSources } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -90,14 +91,18 @@ export async function runCatalogAudit(
type: string; type: string;
}>; }>;
try { try {
items = await prisma.$queryRaw< const [rows] = (await db.execute(sql`
SELECT id, item_name, public_name, type FROM items_base ORDER BY id
`)) as unknown as [
Array<{ Array<{
id: number; id: number;
item_name: string; item_name: string;
public_name: string; public_name: string;
type: string; type: string;
}> }>,
>`SELECT id, item_name, public_name, type FROM items_base ORDER BY id`; unknown,
];
items = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
type: "error", type: "error",
@@ -116,9 +121,13 @@ export async function runCatalogAudit(
catalog_name: string; catalog_name: string;
}>; }>;
try { try {
catalogItems = await prisma.$queryRaw< const [rows] = (await db.execute(sql`
Array<{ id: number; item_ids: string; catalog_name: string }> SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id
>`SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id`; `)) as unknown as [
Array<{ id: number; item_ids: string; catalog_name: string }>,
unknown,
];
catalogItems = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
type: "error", type: "error",
+31 -23
View File
@@ -8,16 +8,16 @@ const {
downloadFile, downloadFile,
appendFurniEntry, appendFurniEntry,
parseNitroBundle, parseNitroBundle,
execRaw, executeFn,
queryRaw, selectLimit,
fsUnlink, fsUnlink,
fsReadFile, fsReadFile,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })), downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
appendFurniEntry: vi.fn<AnyFn>(async () => {}), appendFurniEntry: vi.fn<AnyFn>(async () => {}),
parseNitroBundle: vi.fn<AnyFn>(() => ({ json: {}, png: Buffer.alloc(0) })), parseNitroBundle: vi.fn<AnyFn>(() => ({ json: {}, png: Buffer.alloc(0) })),
execRaw: vi.fn<AnyFn>(async () => 1), executeFn: vi.fn<AnyFn>(async () => [[{ next: 1 }], []]),
queryRaw: vi.fn<AnyFn>(async () => [] as unknown[]), selectLimit: vi.fn<AnyFn>(async () => []),
fsUnlink: vi.fn<AnyFn>(async () => {}), fsUnlink: vi.fn<AnyFn>(async () => {}),
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")), fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")),
})); }));
@@ -55,13 +55,18 @@ vi.mock("node:fs", async (orig) => {
}, },
}; };
}); });
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { db: {
$executeRaw: execRaw as AnyFn, execute: executeFn,
$queryRaw: queryRaw as AnyFn, select: vi.fn(() => ({
$executeRawUnsafe: execRaw as AnyFn, from: vi.fn(() => ({
$queryRawUnsafe: queryRaw as AnyFn, where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
}, },
ItemsBase: { id: "id", itemName: "itemName" },
})); }));
import { cloneSingleFurni, parseFurnidata } from "./clone-import"; import { cloneSingleFurni, parseFurnidata } from "./clone-import";
@@ -100,11 +105,14 @@ describe("clone-import", () => {
// Reset mocks to clear state from other tests. // Reset mocks to clear state from other tests.
downloadFile.mockReset(); downloadFile.mockReset();
downloadFile.mockResolvedValue({ ok: true, size: 200 }); downloadFile.mockResolvedValue({ ok: true, size: 200 });
execRaw.mockReset(); executeFn.mockReset();
execRaw.mockResolvedValue(1); // allocateItemsBaseId MAX(id)+1, then items_base INSERT, then catalog INSERT.
queryRaw.mockReset(); executeFn
// First call: dedup check (no existing row); second call: allocateItemsBaseId MAX(id)+1. .mockResolvedValueOnce([[{ next: 42 }], []])
queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ next: 42 }]); .mockResolvedValueOnce([[], []])
.mockResolvedValueOnce([[], []]);
selectLimit.mockReset();
selectLimit.mockResolvedValue([]);
appendFurniEntry.mockReset(); appendFurniEntry.mockReset();
appendFurniEntry.mockResolvedValue(undefined); appendFurniEntry.mockResolvedValue(undefined);
@@ -124,12 +132,12 @@ describe("clone-import", () => {
expect.objectContaining({ validate: "png" }), expect.objectContaining({ validate: "png" }),
); );
expect(appendFurniEntry).toHaveBeenCalled(); expect(appendFurniEntry).toHaveBeenCalled();
expect(execRaw).toHaveBeenCalled(); // items_base insert + catalog expect(executeFn).toHaveBeenCalled(); // items_base insert + catalog
}); });
it("skips when classname already exists (dedup)", async () => { it("skips when classname already exists (dedup)", async () => {
queryRaw.mockReset(); selectLimit.mockReset();
queryRaw.mockResolvedValueOnce([{ id: 5 }]); // existing items_base row selectLimit.mockResolvedValueOnce([{ id: 5 }]); // existing items_base row
const r = await cloneSingleFurni({ const r = await cloneSingleFurni({
source: SOURCE, source: SOURCE,
entry: { ...ENTRY, itemType: "s" }, entry: { ...ENTRY, itemType: "s" },
@@ -145,14 +153,14 @@ describe("clone-import", () => {
fsUnlink.mockReset(); fsUnlink.mockReset();
fsUnlink.mockResolvedValue(undefined); fsUnlink.mockResolvedValue(undefined);
appendFurniEntry.mockReset(); appendFurniEntry.mockReset();
queryRaw.mockReset(); selectLimit.mockReset();
execRaw.mockReset(); executeFn.mockReset();
// Dedup query: no existing row. // Dedup query: no existing row.
queryRaw.mockResolvedValueOnce([]); selectLimit.mockResolvedValueOnce([]);
// allocateItemsBaseId: MAX(id)+1 query returns next id. // allocateItemsBaseId: MAX(id)+1 query returns next id.
queryRaw.mockResolvedValueOnce([{ next: 7 }]); executeFn.mockResolvedValueOnce([[{ next: 7 }], []]);
// items_base INSERT rejects. // items_base INSERT rejects.
execRaw.mockRejectedValueOnce(new Error("Duplicate entry")); executeFn.mockRejectedValueOnce(new Error("Duplicate entry"));
try { try {
const r = await cloneSingleFurni({ const r = await cloneSingleFurni({
+30 -28
View File
@@ -1,7 +1,8 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { eq, inArray, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { extractFurniIconPng } from "@/lib/services/clone-icon";
import type { CloneSource } from "@/lib/services/clone-sources"; import type { CloneSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -118,9 +119,10 @@ async function allocateItemsBaseId<T>(
}); });
await prev.catch(() => {}); await prev.catch(() => {});
try { try {
const idRow = await prisma.$queryRaw<Array<{ next: number }>>` const [idRows] = (await db.execute(sql`
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`; SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base
const nextId = Number(idRow[0]?.next ?? 1); `)) as unknown as [Array<{ next: number }>, unknown];
const nextId = Number(idRows[0]?.next ?? 1);
return await insertFn(nextId); return await insertFn(nextId);
} finally { } finally {
settle(); settle();
@@ -143,8 +145,11 @@ export async function cloneSingleFurni(params: {
} }
// Dedup by classname. // Dedup by classname.
const existing = await prisma.$queryRaw<Array<{ id: number }>>` const existing = await db
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`; .select({ id: ItemsBase.id })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, classname))
.limit(1);
if (existing.length > 0) { if (existing.length > 0) {
return { return {
ok: false, ok: false,
@@ -221,13 +226,13 @@ export async function cloneSingleFurni(params: {
let newId: number; let newId: number;
try { try {
newId = await allocateItemsBaseId(async (nextId) => { newId = await allocateItemsBaseId(async (nextId) => {
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO items_base INSERT INTO items_base
(id, sprite_id, public_name, item_name, type, width, length, stack_height, (id, sprite_id, public_name, item_name, type, width, length, stack_height,
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams) allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
VALUES VALUES
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight}, (${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight},
${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`; ${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`);
return nextId; return nextId;
}); });
} catch (err) { } catch (err) {
@@ -266,9 +271,9 @@ export async function cloneSingleFurni(params: {
const pageId = await getOrCreateCategoryPage(classname, itemType); const pageId = await getOrCreateCategoryPage(classname, itemType);
const price = autoPriceFurni(classname); const price = autoPriceFurni(classname);
await allocateCatalogItemId(async (nextCatalogId) => { await allocateCatalogItemId(async (nextCatalogId) => {
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`; VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`);
return nextCatalogId; return nextCatalogId;
}); });
} catch (err) { } catch (err) {
@@ -308,12 +313,11 @@ export async function getCloneList(params: {
const allNames = searched.map((e) => e.classname); const allNames = searched.map((e) => e.classname);
const present = new Set<string>(); const present = new Set<string>();
if (allNames.length) { if (allNames.length) {
const placeholders = allNames.map(() => "?").join(","); const rows = await db
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>( .select({ itemName: ItemsBase.itemName })
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`, .from(ItemsBase)
...allNames, .where(inArray(ItemsBase.itemName, allNames));
); for (const r of rows) present.add(r.itemName);
for (const r of rows) present.add(r.item_name);
} }
const filtered = const filtered =
@@ -339,12 +343,11 @@ export async function getCloneStats(
const classnames = all.map((e) => e.classname); const classnames = all.map((e) => e.classname);
const have = new Set<string>(); const have = new Set<string>();
if (classnames.length) { if (classnames.length) {
const placeholders = classnames.map(() => "?").join(","); const rows = await db
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>( .select({ itemName: ItemsBase.itemName })
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`, .from(ItemsBase)
...classnames, .where(inArray(ItemsBase.itemName, classnames));
); for (const r of rows) have.add(r.itemName);
for (const r of rows) have.add(r.item_name);
} }
const present = all.filter((e) => have.has(e.classname)).length; const present = all.filter((e) => have.has(e.classname)).length;
return { total: all.length, present, clonable: all.length - present }; return { total: all.length, present, clonable: all.length - present };
@@ -361,12 +364,11 @@ export async function getClonableClassnames(
const classnames = all.map((e) => e.classname); const classnames = all.map((e) => e.classname);
const have = new Set<string>(); const have = new Set<string>();
if (classnames.length) { if (classnames.length) {
const placeholders = classnames.map(() => "?").join(","); const rows = await db
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>( .select({ itemName: ItemsBase.itemName })
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`, .from(ItemsBase)
...classnames, .where(inArray(ItemsBase.itemName, classnames));
); for (const r of rows) have.add(r.itemName);
for (const r of rows) have.add(r.item_name);
} }
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname); return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
} }
+10 -14
View File
@@ -1,8 +1,10 @@
// @vitest-environment node // @vitest-environment node
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: {}, db: {},
CatalogPages: {},
ItemsBase: {},
})); }));
vi.mock("@/lib/services/furni-data", () => ({ vi.mock("@/lib/services/furni-data", () => ({
@@ -40,17 +42,11 @@ describe("buildItemsBaseEnrichmentUpdate", () => {
interactionType: "default", interactionType: "default",
}); });
expect(update.sql).not.toContain("specialtype"); const serialized = JSON.stringify(update);
expect(update.values).toEqual([ expect(serialized).not.toContain("specialtype");
2, expect(serialized).toContain("UPDATE items_base");
3, expect(serialized).toContain("123");
1.5, expect(serialized).toContain("1.5");
"1", expect(serialized).toContain("default");
"0",
"1",
"1",
"default",
123,
]);
}); });
}); });
+80 -73
View File
@@ -1,7 +1,8 @@
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { import {
appendFurniEntry, appendFurniEntry,
buildFurniEntry, buildFurniEntry,
@@ -166,50 +167,39 @@ export function buildItemsBaseEnrichmentUpdate(params: {
canstandon: boolean; canstandon: boolean;
allowStack: string; allowStack: string;
interactionType: string; interactionType: string;
}): { sql: string; values: Array<number | string> } { }): SQL {
return { return sql`
sql: `
UPDATE items_base UPDATE items_base
SET width = ?, length = ?, stack_height = ?, SET width = ${params.dims.x}, length = ${params.dims.y}, stack_height = ${params.dims.z},
allow_sit = ?, allow_sit = ${params.cansiton ? "1" : "0"},
allow_lay = ?, allow_lay = ${params.canlayon ? "1" : "0"},
allow_walk = ?, allow_walk = ${params.canstandon ? "1" : "0"},
allow_stack = ?, allow_stack = ${params.allowStack},
interaction_type = ? interaction_type = ${params.interactionType}
WHERE id = ? WHERE id = ${params.id}
`, `;
values: [
params.dims.x,
params.dims.y,
params.dims.z,
params.cansiton ? "1" : "0",
params.canlayon ? "1" : "0",
params.canstandon ? "1" : "0",
params.allowStack,
params.interactionType,
params.id,
],
};
} }
/** Get or create the root "Imported Furniture" parent page (no items directly on this page). */ /** Get or create the root "Imported Furniture" parent page (no items directly on this page). */
export async function getOrCreateImportedParentPage(): Promise<number> { export async function getOrCreateImportedParentPage(): Promise<number> {
const existing = await prisma.catalogPages.findFirst({ const [existing] = await db
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE }, .select({ id: CatalogPages.id })
select: { id: true }, .from(CatalogPages)
}); .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE))
.limit(1);
if (existing) return existing.id; if (existing) return existing.id;
// Use raw SQL to avoid ENUM mismatch (page_layout, visible, enabled) // Use raw SQL to avoid ENUM mismatch (page_layout, visible, enabled)
// All string columns must be '' (not NULL) or the Java emulator may skip the page // All string columns must be '' (not NULL) or the Java emulator may skip the page
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${IMPORTED_PAGE_CAPTION_SAVE}, ${IMPORTED_PAGE_CAPTION}, 'default_3x3', -1, '1', 9999, 263, '1', '1', '', '', '', '', '', '', '', '') VALUES (${IMPORTED_PAGE_CAPTION_SAVE}, ${IMPORTED_PAGE_CAPTION}, 'default_3x3', -1, '1', 9999, 263, '1', '1', '', '', '', '', '', '', '', '')
`; `);
const created = await prisma.catalogPages.findFirst({ const [created] = await db
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE }, .select({ id: CatalogPages.id })
select: { id: true }, .from(CatalogPages)
}); .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE))
.limit(1);
if (!created) { if (!created) {
throw new Error( throw new Error(
'Failed to create "Imported Furniture" parent catalog page', 'Failed to create "Imported Furniture" parent catalog page',
@@ -281,7 +271,7 @@ export function autoPriceFurni(classname: string): {
* Allocate the next `catalog_items.id` and run the INSERT through a * Allocate the next `catalog_items.id` and run the INSERT through a
* process-level async mutex so concurrent imports in the same Node process * process-level async mutex so concurrent imports in the same Node process
* cannot race on MAX(id). A DB-level lock would require pinning a single * cannot race on MAX(id). A DB-level lock would require pinning a single
* Prisma connection for the whole operation; the in-process mutex is enough * connection for the whole operation; the in-process mutex is enough
* for the single-server deployment and avoids pool-connection gymnastics. * for the single-server deployment and avoids pool-connection gymnastics.
*/ */
let catalogIdAllocChain: Promise<unknown> = Promise.resolve(); let catalogIdAllocChain: Promise<unknown> = Promise.resolve();
@@ -296,11 +286,9 @@ export async function allocateCatalogItemId<T>(
}); });
await prev.catch(() => {}); await prev.catch(() => {});
try { try {
const maxIdResult = await prisma.$queryRaw< const [maxIdResult] = (await db.execute(sql`
Array<{ maxId: number | bigint | null }>
>`
SELECT MAX(id) as maxId FROM catalog_items SELECT MAX(id) as maxId FROM catalog_items
`; `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
const nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; const nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
return await insertFn(nextId); return await insertFn(nextId);
} finally { } finally {
@@ -318,21 +306,33 @@ export async function getOrCreateCategoryPage(
const captionSave = `imp_${catKey}`; const captionSave = `imp_${catKey}`;
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other; const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other;
const existing = await prisma.catalogPages.findFirst({ const [existing] = await db
where: { captionSave, parentId }, .select({ id: CatalogPages.id })
select: { id: true }, .from(CatalogPages)
}); .where(
and(
eq(CatalogPages.captionSave, captionSave),
eq(CatalogPages.parentId, parentId),
),
)
.limit(1);
if (existing) return existing.id; if (existing) return existing.id;
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '') VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '')
`; `);
const created = await prisma.catalogPages.findFirst({ const [created] = await db
where: { captionSave, parentId }, .select({ id: CatalogPages.id })
select: { id: true }, .from(CatalogPages)
}); .where(
and(
eq(CatalogPages.captionSave, captionSave),
eq(CatalogPages.parentId, parentId),
),
)
.limit(1);
if (!created) { if (!created) {
throw new Error( throw new Error(
`Failed to create catalog sub-page "${captionSave}" under parent ${parentId}`, `Failed to create catalog sub-page "${captionSave}" under parent ${parentId}`,
@@ -368,10 +368,11 @@ export async function importSingleFurni(params: {
const warnings: string[] = []; const warnings: string[] = [];
// Check if already exists by classname OR by spriteId (primary key collision). // Check if already exists by classname OR by spriteId (primary key collision).
const existsByName = await prisma.itemsBase.findFirst({ const [existsByName] = await db
where: { itemName: classname }, .select({ id: ItemsBase.id })
select: { id: true }, .from(ItemsBase)
}); .where(eq(ItemsBase.itemName, classname))
.limit(1);
if (existsByName) { if (existsByName) {
return { return {
ok: false, ok: false,
@@ -379,10 +380,11 @@ export async function importSingleFurni(params: {
error: `Item "${classname}" already exists (id: ${existsByName.id})`, error: `Item "${classname}" already exists (id: ${existsByName.id})`,
}; };
} }
const existsById = await prisma.itemsBase.findUnique({ const [existsById] = await db
where: { id: originalId }, .select({ itemName: ItemsBase.itemName })
select: { itemName: true }, .from(ItemsBase)
}); .where(eq(ItemsBase.id, originalId))
.limit(1);
if (existsById) { if (existsById) {
return { return {
ok: false, ok: false,
@@ -417,10 +419,10 @@ export async function importSingleFurni(params: {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns — // NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below), // those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Inserting them here throws "Unknown column 'description'". // not in the DB. Inserting them here throws "Unknown column 'description'".
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO items_base (id, sprite_id, public_name, item_name, type) INSERT INTO items_base (id, sprite_id, public_name, item_name, type)
VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType}) VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType})
`; `);
} catch (err) { } catch (err) {
return { return {
ok: false, ok: false,
@@ -547,7 +549,9 @@ export async function importSingleFurni(params: {
// ── Rollback: if conversion failed AND no .nitro exists, remove DB record ── // ── Rollback: if conversion failed AND no .nitro exists, remove DB record ──
if (!existsSync(/*turbopackIgnore: true*/ nitroPath) && !conversionResult) { if (!existsSync(/*turbopackIgnore: true*/ nitroPath) && !conversionResult) {
try { try {
await prisma.$executeRaw`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`; await db.execute(
sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`,
);
} catch { } catch {
/* best effort cleanup */ /* best effort cleanup */
} }
@@ -635,7 +639,7 @@ export async function importSingleFurni(params: {
allowStack, allowStack,
interactionType, interactionType,
}); });
await prisma.$executeRawUnsafe(update.sql, ...update.values); await db.execute(update);
} catch (err) { } catch (err) {
warnings.push( warnings.push(
`items_base dimension update failed: ${(err as Error).message}`, `items_base dimension update failed: ${(err as Error).message}`,
@@ -676,11 +680,9 @@ export async function importSingleFurni(params: {
const itemIdStr = String(spriteId); const itemIdStr = String(spriteId);
// Check if this item already exists anywhere in the catalog // Check if this item already exists anywhere in the catalog
const existingCatalog = await prisma.$queryRaw< const [existingCatalog] = (await db.execute(sql`
Array<{ id: number; page_id: string }>
>`
SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1 SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1
`; `)) as unknown as [Array<{ id: number; page_id: string }>, unknown];
if (existingCatalog.length > 0) { if (existingCatalog.length > 0) {
catalogItemId = existingCatalog[0].id; catalogItemId = existingCatalog[0].id;
@@ -696,10 +698,10 @@ export async function importSingleFurni(params: {
// across concurrent imports via a MySQL named lock to avoid MAX(id) // across concurrent imports via a MySQL named lock to avoid MAX(id)
// TOCTOU that would cause duplicate-key failures. // TOCTOU that would cause duplicate-key failures.
catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => {
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '') VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')
`; `);
return nextCatalogId; return nextCatalogId;
}); });
} }
@@ -734,10 +736,11 @@ export async function deleteImportedItem(classname: string): Promise<{
const errors: string[] = []; const errors: string[] = [];
// Look up item in DB // Look up item in DB
const item = await prisma.itemsBase.findFirst({ const [item] = await db
where: { itemName: classname }, .select({ id: ItemsBase.id, type: ItemsBase.type })
select: { id: true, type: true }, .from(ItemsBase)
}); .where(eq(ItemsBase.itemName, classname))
.limit(1);
if (!item) { if (!item) {
throw new Error(`Item "${classname}" not found in database`); throw new Error(`Item "${classname}" not found in database`);
@@ -754,14 +757,18 @@ export async function deleteImportedItem(classname: string): Promise<{
// 1. Delete catalog_items // 1. Delete catalog_items
try { try {
const itemIdStr = String(spriteId); const itemIdStr = String(spriteId);
await prisma.$executeRaw`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`; await db.execute(
sql`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`,
);
} catch (err) { } catch (err) {
errors.push(`catalog_items: ${(err as Error).message}`); errors.push(`catalog_items: ${(err as Error).message}`);
} }
// 2. Delete items_base // 2. Delete items_base
try { try {
await prisma.$executeRaw`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`; await db.execute(
sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`,
);
} catch (err) { } catch (err) {
errors.push(`items_base: ${(err as Error).message}`); errors.push(`items_base: ${(err as Error).message}`);
} }
+6 -4
View File
@@ -1,6 +1,7 @@
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { prisma } from "@/lib/prisma"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { listSources } from "@/lib/services/clone-sources"; import { listSources } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -54,9 +55,10 @@ export async function repairMissingIcons(
let items: Array<{ id: number; item_name: string }>; let items: Array<{ id: number; item_name: string }>;
try { try {
items = await prisma.$queryRaw< const [rows] = (await db.execute(sql`
Array<{ id: number; item_name: string }> SELECT id, item_name FROM items_base ORDER BY id
>`SELECT id, item_name FROM items_base ORDER BY id`; `)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
items = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
type: "error", type: "error",
+15 -10
View File
@@ -1,6 +1,7 @@
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { prisma } from "@/lib/prisma"; import { eq, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data"; import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import { import {
@@ -52,9 +53,10 @@ async function allocateItemsBaseId<T>(
}); });
await prev.catch(() => {}); await prev.catch(() => {});
try { try {
const idRow = await prisma.$queryRaw<Array<{ next: number }>>` const [idRows] = (await db.execute(sql`
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`; SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base
const nextId = Number(idRow[0]?.next ?? 1); `)) as unknown as [Array<{ next: number }>, unknown];
const nextId = Number(idRows[0]?.next ?? 1);
return await insertFn(nextId); return await insertFn(nextId);
} finally { } finally {
settle(); settle();
@@ -175,8 +177,11 @@ export async function uploadSingleFurni(params: {
return { ok: false, classname, warnings, error: "invalid classname" }; return { ok: false, classname, warnings, error: "invalid classname" };
} }
const existing = await prisma.$queryRaw<Array<{ id: number }>>` const existing = await db
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`; .select({ id: ItemsBase.id })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, classname))
.limit(1);
if (existing.length > 0) { if (existing.length > 0) {
return { return {
ok: false, ok: false,
@@ -231,13 +236,13 @@ export async function uploadSingleFurni(params: {
let newId: number; let newId: number;
try { try {
newId = await allocateItemsBaseId(async (nextId) => { newId = await allocateItemsBaseId(async (nextId) => {
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO items_base INSERT INTO items_base
(id, sprite_id, public_name, item_name, type, width, length, stack_height, (id, sprite_id, public_name, item_name, type, width, length, stack_height,
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams) allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
VALUES VALUES
(${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight}, (${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight},
${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${customparams})`; ${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${customparams})`);
return nextId; return nextId;
}); });
} catch (err) { } catch (err) {
@@ -272,9 +277,9 @@ export async function uploadSingleFurni(params: {
try { try {
const pageId = await getOrCreateCategoryPage(classname, itemType); const pageId = await getOrCreateCategoryPage(classname, itemType);
catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => {
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`; VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`);
return nextCatalogId; return nextCatalogId;
}); });
} catch (err) { } catch (err) {
+6 -5
View File
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm";
import { unstable_cache } from "next/cache"; import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma"; import { db, UserWatch } from "@/lib/db";
/** /**
* Returns the set of user IDs that the given staff member has marked as * Returns the set of user IDs that the given staff member has marked as
@@ -12,10 +13,10 @@ export async function getWatchedUserIds(staffId: number): Promise<Set<number>> {
const getCachedWatchedIds = unstable_cache( const getCachedWatchedIds = unstable_cache(
async (staffId: number): Promise<number[]> => { async (staffId: number): Promise<number[]> => {
const rows = await prisma.userWatch.findMany({ const rows = await db
where: { staffId }, .select({ targetUserId: UserWatch.targetUserId })
select: { targetUserId: true }, .from(UserWatch)
}); .where(eq(UserWatch.staffId, staffId));
return rows.map((r) => r.targetUserId); return rows.map((r) => r.targetUserId);
}, },
["user-watch-list"], ["user-watch-list"],