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";
|
"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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in new issue
Block a user