refactor(db): finish Drizzle migration for remaining actions and services
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
22234fe102
commit
65b2fbee6a
19 files changed
+422
-342
No files matched your search
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { asc } from "drizzle-orm";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
||||
@@ -34,16 +35,19 @@ export async function bulkImportPermissions(
|
||||
export async function exportPermissions(): Promise<string> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
|
||||
const perms = await prisma.websiteHousekeepingPermissions.findMany({
|
||||
orderBy: [{ groupName: "asc" }, { permission: "asc" }],
|
||||
select: {
|
||||
permission: true,
|
||||
minRank: true,
|
||||
description: true,
|
||||
groupName: true,
|
||||
dependsOn: true,
|
||||
},
|
||||
});
|
||||
const perms = await db
|
||||
.select({
|
||||
permission: WebsiteHousekeepingPermissions.permission,
|
||||
minRank: WebsiteHousekeepingPermissions.minRank,
|
||||
description: WebsiteHousekeepingPermissions.description,
|
||||
groupName: WebsiteHousekeepingPermissions.groupName,
|
||||
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
|
||||
})
|
||||
.from(WebsiteHousekeepingPermissions)
|
||||
.orderBy(
|
||||
asc(WebsiteHousekeepingPermissions.groupName),
|
||||
asc(WebsiteHousekeepingPermissions.permission),
|
||||
);
|
||||
|
||||
return JSON.stringify(perms, null, 2);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteBadges } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function importBadgeFromRemote({
|
||||
code,
|
||||
@@ -15,21 +15,23 @@ export async function importBadgeFromRemote({
|
||||
}) {
|
||||
await requirePermission(PERMS.ASSETS_IMPORT);
|
||||
try {
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: {
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
.values({
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
return { ok: true as const };
|
||||
} catch {
|
||||
return { ok: false as const, error: "Failed to import badge" };
|
||||
|
||||
+13
-15
@@ -3,10 +3,10 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, RadioApplications } from "@/lib/db";
|
||||
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.
|
||||
const NAME_MAX = 255;
|
||||
const TEXT_MAX = 5000;
|
||||
@@ -66,19 +66,17 @@ export async function applyDj(formData: FormData): Promise<void> {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.radioApplications.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(RadioApplications).values({
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, RadioSongRequests } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SONG_MAX = 255;
|
||||
@@ -52,15 +52,13 @@ export async function submitRequest(formData: FormData): Promise<void> {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.radioSongRequests.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
songTitle: songTitle || null,
|
||||
artist: artist || null,
|
||||
submittedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(RadioSongRequests).values({
|
||||
userId: BigInt(userId),
|
||||
songTitle: songTitle || null,
|
||||
artist: artist || null,
|
||||
submittedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, RadioShouts } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
|
||||
@@ -77,13 +77,11 @@ export async function postShout(formData: FormData): Promise<void> {
|
||||
|
||||
if (!moderated) {
|
||||
const now = new Date();
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(RadioShouts).values({
|
||||
userId: BigInt(userId),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
|
||||
+86
-65
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import { env } from "@/env";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
@@ -19,11 +20,11 @@ export type OpsHealth = {
|
||||
* Semantics match `/api/health` for Redis (null when not configured).
|
||||
*/
|
||||
export async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
const [db, redisOk, emulatorOk, onlineUsers] = await Promise.all([
|
||||
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { dbOk: true, dbLatencyMs: Date.now() - start };
|
||||
} catch {
|
||||
return { dbOk: false, dbLatencyMs: 0 };
|
||||
@@ -40,12 +41,16 @@ export async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
}
|
||||
})(),
|
||||
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 {
|
||||
dbOk: db.dbOk,
|
||||
dbLatencyMs: db.dbLatencyMs,
|
||||
dbOk: dbProbe.dbOk,
|
||||
dbLatencyMs: dbProbe.dbLatencyMs,
|
||||
redisOk,
|
||||
emulatorOk: Boolean(emulatorOk),
|
||||
onlineUsers,
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
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). */
|
||||
export async function fetchOpsOnlineUsers(
|
||||
limit = 40,
|
||||
): Promise<{ count: number; users: OnlineUser[] }> {
|
||||
const [count, onlineUsersRaw] = await Promise.all([
|
||||
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
||||
prisma.user
|
||||
.findMany({
|
||||
where: { online: "1" },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
look: true,
|
||||
lastOnline: true,
|
||||
},
|
||||
orderBy: { lastOnline: "desc" },
|
||||
take: limit,
|
||||
const [countRow, onlineUsersRaw] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
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) => ({
|
||||
@@ -31,5 +45,5 @@ export async function fetchOpsOnlineUsers(
|
||||
lastActivity: u.lastOnline > 0 ? new Date(u.lastOnline * 1000) : new Date(),
|
||||
}));
|
||||
|
||||
return { count, users };
|
||||
return { count: countRow, users };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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). */
|
||||
export async function fetchTicketQueueOpenCounts(): Promise<{
|
||||
@@ -8,11 +9,17 @@ export async function fetchTicketQueueOpenCounts(): Promise<{
|
||||
helpOpen: number;
|
||||
}> {
|
||||
const [cmsOpen, helpOpen] = await Promise.all([
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: { not: "closed" } } })
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: true } })
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
]);
|
||||
return { cmsOpen, helpOpen };
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createFn = vi.hoisted(() => vi.fn());
|
||||
const sendMailFn = vi.hoisted(() => vi.fn().mockResolvedValue(true));
|
||||
const insertValues = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: { alertLogs: { create: createFn } },
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
},
|
||||
AlertLogs: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/email", () => ({
|
||||
sendMail: sendMailFn,
|
||||
sendMail: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
@@ -21,12 +23,14 @@ vi.mock("@/env", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { sendAlert } from "./alert";
|
||||
|
||||
beforeEach(() => {
|
||||
createFn.mockReset();
|
||||
sendMailFn.mockReset();
|
||||
createFn.mockResolvedValue({ id: 1 });
|
||||
insertValues.mockReset();
|
||||
vi.mocked(sendMail).mockReset();
|
||||
vi.mocked(sendMail).mockResolvedValue(true);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
});
|
||||
|
||||
describe("sendAlert", () => {
|
||||
@@ -36,14 +40,14 @@ describe("sendAlert", () => {
|
||||
severity: "warning",
|
||||
message: "test alert",
|
||||
});
|
||||
expect(createFn).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "test",
|
||||
severity: "warning",
|
||||
message: "test alert",
|
||||
isRead: false,
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await sendAlert({ type: "test", severity: "error", message: "m" });
|
||||
expect(sendMailFn).not.toHaveBeenCalled();
|
||||
expect(sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+15
-16
@@ -1,14 +1,14 @@
|
||||
import { env } from "@/env";
|
||||
import { AlertLogs, db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
|
||||
// === Alert service (AtomCMS → Next.js) ===========================================
|
||||
//
|
||||
// A pure, dependency-free server module for raising operational alerts. A single
|
||||
// sendAlert() call (a) persists a row in alert_logs (prisma.alertLogs delegate),
|
||||
// (b) pushes a Discord embed to DISCORD_WEBHOOK_URL when set, and (c) optionally
|
||||
// emails staff via sendMail() when ALERT_EMAIL is set.
|
||||
// sendAlert() call (a) persists a row in alert_logs, (b) pushes a Discord embed
|
||||
// to DISCORD_WEBHOOK_URL when set, and (c) optionally emails staff via sendMail()
|
||||
// when ALERT_EMAIL is set.
|
||||
//
|
||||
// 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
|
||||
@@ -186,18 +186,17 @@ export async function sendAlert(
|
||||
|
||||
let logged = false;
|
||||
try {
|
||||
await prisma.alertLogs.create({
|
||||
data: {
|
||||
type: input.type.slice(0, 255),
|
||||
severity: String(input.severity).slice(0, 255),
|
||||
message: input.message,
|
||||
context: input.context ? (input.context as object) : undefined,
|
||||
sentViaDiscord,
|
||||
sentViaEmail,
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
const now = new Date();
|
||||
await db.insert(AlertLogs).values({
|
||||
type: input.type.slice(0, 255),
|
||||
severity: String(input.severity).slice(0, 255),
|
||||
message: input.message,
|
||||
context: input.context ? JSON.stringify(input.context) : null,
|
||||
sentViaDiscord,
|
||||
sentViaEmail,
|
||||
isRead: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
logged = true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync } from "node:fs";
|
||||
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 { listSources } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
@@ -90,14 +91,18 @@ export async function runCatalogAudit(
|
||||
type: string;
|
||||
}>;
|
||||
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<{
|
||||
id: number;
|
||||
item_name: string;
|
||||
public_name: string;
|
||||
type: string;
|
||||
}>
|
||||
>`SELECT id, item_name, public_name, type FROM items_base ORDER BY id`;
|
||||
}>,
|
||||
unknown,
|
||||
];
|
||||
items = rows;
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
@@ -116,9 +121,13 @@ export async function runCatalogAudit(
|
||||
catalog_name: string;
|
||||
}>;
|
||||
try {
|
||||
catalogItems = await prisma.$queryRaw<
|
||||
Array<{ id: number; item_ids: string; catalog_name: string }>
|
||||
>`SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id`;
|
||||
const [rows] = (await db.execute(sql`
|
||||
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) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
|
||||
@@ -8,16 +8,16 @@ const {
|
||||
downloadFile,
|
||||
appendFurniEntry,
|
||||
parseNitroBundle,
|
||||
execRaw,
|
||||
queryRaw,
|
||||
executeFn,
|
||||
selectLimit,
|
||||
fsUnlink,
|
||||
fsReadFile,
|
||||
} = vi.hoisted(() => ({
|
||||
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
|
||||
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
|
||||
parseNitroBundle: vi.fn<AnyFn>(() => ({ json: {}, png: Buffer.alloc(0) })),
|
||||
execRaw: vi.fn<AnyFn>(async () => 1),
|
||||
queryRaw: vi.fn<AnyFn>(async () => [] as unknown[]),
|
||||
executeFn: vi.fn<AnyFn>(async () => [[{ next: 1 }], []]),
|
||||
selectLimit: vi.fn<AnyFn>(async () => []),
|
||||
fsUnlink: vi.fn<AnyFn>(async () => {}),
|
||||
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")),
|
||||
}));
|
||||
@@ -55,13 +55,18 @@ vi.mock("node:fs", async (orig) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
$executeRaw: execRaw as AnyFn,
|
||||
$queryRaw: queryRaw as AnyFn,
|
||||
$executeRawUnsafe: execRaw as AnyFn,
|
||||
$queryRawUnsafe: queryRaw as AnyFn,
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
execute: executeFn,
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: selectLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
ItemsBase: { id: "id", itemName: "itemName" },
|
||||
}));
|
||||
|
||||
import { cloneSingleFurni, parseFurnidata } from "./clone-import";
|
||||
@@ -100,11 +105,14 @@ describe("clone-import", () => {
|
||||
// Reset mocks to clear state from other tests.
|
||||
downloadFile.mockReset();
|
||||
downloadFile.mockResolvedValue({ ok: true, size: 200 });
|
||||
execRaw.mockReset();
|
||||
execRaw.mockResolvedValue(1);
|
||||
queryRaw.mockReset();
|
||||
// First call: dedup check (no existing row); second call: allocateItemsBaseId MAX(id)+1.
|
||||
queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ next: 42 }]);
|
||||
executeFn.mockReset();
|
||||
// allocateItemsBaseId MAX(id)+1, then items_base INSERT, then catalog INSERT.
|
||||
executeFn
|
||||
.mockResolvedValueOnce([[{ next: 42 }], []])
|
||||
.mockResolvedValueOnce([[], []])
|
||||
.mockResolvedValueOnce([[], []]);
|
||||
selectLimit.mockReset();
|
||||
selectLimit.mockResolvedValue([]);
|
||||
appendFurniEntry.mockReset();
|
||||
appendFurniEntry.mockResolvedValue(undefined);
|
||||
|
||||
@@ -124,12 +132,12 @@ describe("clone-import", () => {
|
||||
expect.objectContaining({ validate: "png" }),
|
||||
);
|
||||
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 () => {
|
||||
queryRaw.mockReset();
|
||||
queryRaw.mockResolvedValueOnce([{ id: 5 }]); // existing items_base row
|
||||
selectLimit.mockReset();
|
||||
selectLimit.mockResolvedValueOnce([{ id: 5 }]); // existing items_base row
|
||||
const r = await cloneSingleFurni({
|
||||
source: SOURCE,
|
||||
entry: { ...ENTRY, itemType: "s" },
|
||||
@@ -145,14 +153,14 @@ describe("clone-import", () => {
|
||||
fsUnlink.mockReset();
|
||||
fsUnlink.mockResolvedValue(undefined);
|
||||
appendFurniEntry.mockReset();
|
||||
queryRaw.mockReset();
|
||||
execRaw.mockReset();
|
||||
selectLimit.mockReset();
|
||||
executeFn.mockReset();
|
||||
// Dedup query: no existing row.
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
selectLimit.mockResolvedValueOnce([]);
|
||||
// allocateItemsBaseId: MAX(id)+1 query returns next id.
|
||||
queryRaw.mockResolvedValueOnce([{ next: 7 }]);
|
||||
executeFn.mockResolvedValueOnce([[{ next: 7 }], []]);
|
||||
// items_base INSERT rejects.
|
||||
execRaw.mockRejectedValueOnce(new Error("Duplicate entry"));
|
||||
executeFn.mockRejectedValueOnce(new Error("Duplicate entry"));
|
||||
|
||||
try {
|
||||
const r = await cloneSingleFurni({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,10 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {},
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {},
|
||||
CatalogPages: {},
|
||||
ItemsBase: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/furni-data", () => ({
|
||||
@@ -40,17 +42,11 @@ describe("buildItemsBaseEnrichmentUpdate", () => {
|
||||
interactionType: "default",
|
||||
});
|
||||
|
||||
expect(update.sql).not.toContain("specialtype");
|
||||
expect(update.values).toEqual([
|
||||
2,
|
||||
3,
|
||||
1.5,
|
||||
"1",
|
||||
"0",
|
||||
"1",
|
||||
"1",
|
||||
"default",
|
||||
123,
|
||||
]);
|
||||
const serialized = JSON.stringify(update);
|
||||
expect(serialized).not.toContain("specialtype");
|
||||
expect(serialized).toContain("UPDATE items_base");
|
||||
expect(serialized).toContain("123");
|
||||
expect(serialized).toContain("1.5");
|
||||
expect(serialized).toContain("default");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
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 { listSources } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
@@ -54,9 +55,10 @@ export async function repairMissingIcons(
|
||||
|
||||
let items: Array<{ id: number; item_name: string }>;
|
||||
try {
|
||||
items = await prisma.$queryRaw<
|
||||
Array<{ id: number; item_name: string }>
|
||||
>`SELECT id, item_name FROM items_base ORDER BY id`;
|
||||
const [rows] = (await db.execute(sql`
|
||||
SELECT id, item_name FROM items_base ORDER BY id
|
||||
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
|
||||
items = rows;
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
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 { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
||||
import {
|
||||
@@ -52,9 +53,10 @@ async function allocateItemsBaseId<T>(
|
||||
});
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
|
||||
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`;
|
||||
const nextId = Number(idRow[0]?.next ?? 1);
|
||||
const [idRows] = (await db.execute(sql`
|
||||
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base
|
||||
`)) as unknown as [Array<{ next: number }>, unknown];
|
||||
const nextId = Number(idRows[0]?.next ?? 1);
|
||||
return await insertFn(nextId);
|
||||
} finally {
|
||||
settle();
|
||||
@@ -175,8 +177,11 @@ export async function uploadSingleFurni(params: {
|
||||
return { ok: false, classname, warnings, error: "invalid classname" };
|
||||
}
|
||||
|
||||
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`;
|
||||
const existing = await db
|
||||
.select({ id: ItemsBase.id })
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.itemName, classname))
|
||||
.limit(1);
|
||||
if (existing.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -231,13 +236,13 @@ export async function uploadSingleFurni(params: {
|
||||
let newId: number;
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await prisma.$executeRaw`
|
||||
await db.execute(sql`
|
||||
INSERT INTO items_base
|
||||
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
|
||||
VALUES
|
||||
(${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;
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -272,9 +277,9 @@ export async function uploadSingleFurni(params: {
|
||||
try {
|
||||
const pageId = await getOrCreateCategoryPage(classname, itemType);
|
||||
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)
|
||||
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;
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
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
|
||||
@@ -12,10 +13,10 @@ export async function getWatchedUserIds(staffId: number): Promise<Set<number>> {
|
||||
|
||||
const getCachedWatchedIds = unstable_cache(
|
||||
async (staffId: number): Promise<number[]> => {
|
||||
const rows = await prisma.userWatch.findMany({
|
||||
where: { staffId },
|
||||
select: { targetUserId: true },
|
||||
});
|
||||
const rows = await db
|
||||
.select({ targetUserId: UserWatch.targetUserId })
|
||||
.from(UserWatch)
|
||||
.where(eq(UserWatch.staffId, staffId));
|
||||
return rows.map((r) => r.targetUserId);
|
||||
},
|
||||
["user-watch-list"],
|
||||
|
||||
Reference in new issue
Block a user