This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -6,92 +6,110 @@ import { prisma } from "@/lib/prisma";
|
||||
const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]);
|
||||
|
||||
function csvEscape(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
const str = String(value);
|
||||
if (/[",\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
if (value == null) return "";
|
||||
const str = String(value);
|
||||
if (/[",\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function csvRow(values: unknown[]): string {
|
||||
return `${values.map(csvEscape).join(",")}\n`;
|
||||
return `${values.map(csvEscape).join(",")}\n`;
|
||||
}
|
||||
|
||||
async function streamRegistrations(): Promise<ReadableStream<Uint8Array>> {
|
||||
const rows = await prisma.$queryRaw<{ day: string; cnt: bigint }[]>`
|
||||
const rows = await prisma.$queryRaw<{ day: string; cnt: bigint }[]>`
|
||||
SELECT FROM_UNIXTIME(account_created, '%Y-%m-%d') AS day, COUNT(*) AS cnt
|
||||
FROM users
|
||||
WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY)
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`;
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(["day", "registrations"])));
|
||||
for (const r of rows) {
|
||||
controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(["day", "registrations"])));
|
||||
for (const r of rows) {
|
||||
controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> {
|
||||
const rows = await prisma.$queryRaw<{ hour: number; cnt: bigint }[]>`
|
||||
const rows = await prisma.$queryRaw<{ hour: number; cnt: bigint }[]>`
|
||||
SELECT HOUR(FROM_UNIXTIME(last_online)) AS hour, COUNT(*) AS cnt
|
||||
FROM users
|
||||
WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC
|
||||
`;
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(["hour", "active_users"])));
|
||||
for (const r of rows) {
|
||||
controller.enqueue(encoder.encode(csvRow([Number(r.hour), Number(r.cnt)])));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(["hour", "active_users"])));
|
||||
for (const r of rows) {
|
||||
controller.enqueue(
|
||||
encoder.encode(csvRow([Number(r.hour), Number(r.cnt)])),
|
||||
);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function streamEconomy(): Promise<ReadableStream<Uint8Array>> {
|
||||
const [totals] = await prisma.$queryRaw<{ total_credits: bigint; total_pixels: bigint }[]>`
|
||||
const [totals] = await prisma.$queryRaw<
|
||||
{ total_credits: bigint; total_pixels: bigint }[]
|
||||
>`
|
||||
SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users
|
||||
`;
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(["metric", "value"])));
|
||||
controller.enqueue(encoder.encode(csvRow(["total_credits", Number(totals?.total_credits ?? 0)])));
|
||||
controller.enqueue(encoder.encode(csvRow(["total_pixels", Number(totals?.total_pixels ?? 0)])));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(["metric", "value"])));
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
csvRow(["total_credits", Number(totals?.total_credits ?? 0)]),
|
||||
),
|
||||
);
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
csvRow(["total_pixels", Number(totals?.total_pixels ?? 0)]),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.ANALYTICS_EXPORT }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const report = url.searchParams.get("report") ?? "";
|
||||
if (!VALID_REPORTS.has(report)) {
|
||||
return apiError(`Unknown report. Use one of: ${[...VALID_REPORTS].join(", ")}`, 400);
|
||||
}
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ANALYTICS_EXPORT },
|
||||
async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const report = url.searchParams.get("report") ?? "";
|
||||
if (!VALID_REPORTS.has(report)) {
|
||||
return apiError(
|
||||
`Unknown report. Use one of: ${[...VALID_REPORTS].join(", ")}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const stream =
|
||||
report === "registrations"
|
||||
? await streamRegistrations()
|
||||
: report === "online-by-hour"
|
||||
? await streamOnlineByHour()
|
||||
: await streamEconomy();
|
||||
const stream =
|
||||
report === "registrations"
|
||||
? await streamRegistrations()
|
||||
: report === "online-by-hour"
|
||||
? await streamOnlineByHour()
|
||||
: await streamEconomy();
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${report}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${report}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -6,54 +6,54 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function checkRcon(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const host = env.RCON_HOST;
|
||||
const port = env.RCON_PORT;
|
||||
return new Promise((resolve) => {
|
||||
const host = env.RCON_HOST;
|
||||
const port = env.RCON_PORT;
|
||||
|
||||
const socket = new net.Socket();
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 3000);
|
||||
const socket = new net.Socket();
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 3000);
|
||||
|
||||
socket.connect(port, host, () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.connect(port, host, () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
socket.on("error", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
|
||||
// DB health
|
||||
let dbOk = false;
|
||||
let dbLatency = 0;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
dbLatency = Date.now() - start;
|
||||
dbOk = true;
|
||||
} catch {
|
||||
dbOk = false;
|
||||
}
|
||||
// DB health
|
||||
let dbOk = false;
|
||||
let dbLatency = 0;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
dbLatency = Date.now() - start;
|
||||
dbOk = true;
|
||||
} catch {
|
||||
dbOk = false;
|
||||
}
|
||||
|
||||
// Emulator health (RCON TCP check)
|
||||
const emulator = await checkRcon();
|
||||
// Emulator health (RCON TCP check)
|
||||
const emulator = await checkRcon();
|
||||
|
||||
// Online users
|
||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
||||
// Online users
|
||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
||||
|
||||
return NextResponse.json({
|
||||
database: dbOk,
|
||||
dbLatency,
|
||||
emulator,
|
||||
onlineUsers,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return NextResponse.json({
|
||||
database: dbOk,
|
||||
dbLatency,
|
||||
emulator,
|
||||
onlineUsers,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -4,22 +4,30 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const EXTERNAL_TEXTS_PATH = path.join(process.cwd(), "public/nitro-assets/gamedata/ExternalTexts.json");
|
||||
const EXTERNAL_TEXTS_PATH = path.join(
|
||||
process.cwd(),
|
||||
"public/nitro-assets/gamedata/ExternalTexts.json",
|
||||
);
|
||||
|
||||
/** GET - Read badge name/desc from ExternalTexts.json */
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const code = request.nextUrl.searchParams.get("code")?.toUpperCase();
|
||||
if (!code) return apiError("Missing code", 400);
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const code = request.nextUrl.searchParams.get("code")?.toUpperCase();
|
||||
if (!code) return apiError("Missing code", 400);
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts: Record<string, string> = JSON.parse(raw);
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts: Record<string, string> = JSON.parse(raw);
|
||||
|
||||
const name = texts[`badge_name_${code}`] ?? texts[`${code}_badge_name`] ?? "";
|
||||
const desc = texts[`badge_desc_${code}`] ?? texts[`${code}_badge_desc`] ?? "";
|
||||
const name =
|
||||
texts[`badge_name_${code}`] ?? texts[`${code}_badge_name`] ?? "";
|
||||
const desc =
|
||||
texts[`badge_desc_${code}`] ?? texts[`${code}_badge_desc`] ?? "";
|
||||
|
||||
return apiOk({ name, desc });
|
||||
} catch {
|
||||
return apiError("Failed to read ExternalTexts.json", 500);
|
||||
}
|
||||
});
|
||||
return apiOk({ name, desc });
|
||||
} catch {
|
||||
return apiError("Failed to read ExternalTexts.json", 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -6,71 +6,95 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { searchBadges } from "@/lib/services/habboassets";
|
||||
|
||||
const EXTERNAL_TEXTS_PATH = path.join(process.cwd(), "public/nitro-assets/gamedata/ExternalTexts.json");
|
||||
const EXTERNAL_TEXTS_PATH = path.join(
|
||||
process.cwd(),
|
||||
"public/nitro-assets/gamedata/ExternalTexts.json",
|
||||
);
|
||||
|
||||
// ── Search badges via official Habbo external texts ────────────────
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const offset = parseInt(request.nextUrl.searchParams.get("offset") || "0", 10);
|
||||
const allHotels = request.nextUrl.searchParams.get("allHotels") === "1";
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const offset = parseInt(
|
||||
request.nextUrl.searchParams.get("offset") || "0",
|
||||
10,
|
||||
);
|
||||
const allHotels = request.nextUrl.searchParams.get("allHotels") === "1";
|
||||
|
||||
const badges = await searchBadges(search, 100, offset, allHotels ? null : undefined);
|
||||
const badges = await searchBadges(
|
||||
search,
|
||||
100,
|
||||
offset,
|
||||
allHotels ? null : undefined,
|
||||
);
|
||||
|
||||
// Check which badges already exist in ExternalTexts.json
|
||||
const existingCodes = new Set<string>();
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts = JSON.parse(raw);
|
||||
for (const badge of badges) {
|
||||
if (texts[`badge_name_${badge.code}`] !== undefined) {
|
||||
existingCodes.add(badge.code);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File not found or invalid - treat all as not imported
|
||||
}
|
||||
// Check which badges already exist in ExternalTexts.json
|
||||
const existingCodes = new Set<string>();
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts = JSON.parse(raw);
|
||||
for (const badge of badges) {
|
||||
if (texts[`badge_name_${badge.code}`] !== undefined) {
|
||||
existingCodes.add(badge.code);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File not found or invalid - treat all as not imported
|
||||
}
|
||||
|
||||
const enriched = badges.map((badge) => ({
|
||||
...badge,
|
||||
alreadyImported: existingCodes.has(badge.code),
|
||||
}));
|
||||
const enriched = badges.map((badge) => ({
|
||||
...badge,
|
||||
alreadyImported: existingCodes.has(badge.code),
|
||||
}));
|
||||
|
||||
return apiOk({ badges: enriched });
|
||||
});
|
||||
return apiOk({ badges: enriched });
|
||||
},
|
||||
);
|
||||
|
||||
// ── Import a single badge ───────────────────────────────────────────
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const { code, name, description } = body;
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const { code, name, description } = body;
|
||||
|
||||
if (!code || !name) return apiError("Missing code or name", 400);
|
||||
if (!code || !name) return apiError("Missing code or name", 400);
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts = JSON.parse(raw);
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts = JSON.parse(raw);
|
||||
|
||||
const nameKey = `badge_name_${code}`;
|
||||
const descKey = `badge_desc_${code}`;
|
||||
const nameKey = `badge_name_${code}`;
|
||||
const descKey = `badge_desc_${code}`;
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return apiError("Badge already exists in ExternalTexts.json", 409);
|
||||
}
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return apiError("Badge already exists in ExternalTexts.json", 409);
|
||||
}
|
||||
|
||||
texts[nameKey] = name;
|
||||
texts[descKey] = description || "";
|
||||
texts[nameKey] = name;
|
||||
texts[descKey] = description || "";
|
||||
|
||||
await fs.writeFile(EXTERNAL_TEXTS_PATH, JSON.stringify(texts, null, 4), "utf-8");
|
||||
} catch (err) {
|
||||
console.warn("[import-badges] Failed to update ExternalTexts.json:", (err as Error).message);
|
||||
return apiError("Failed to update ExternalTexts.json", 500);
|
||||
}
|
||||
await fs.writeFile(
|
||||
EXTERNAL_TEXTS_PATH,
|
||||
JSON.stringify(texts, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[import-badges] Failed to update ExternalTexts.json:",
|
||||
(err as Error).message,
|
||||
);
|
||||
return apiError("Failed to update ExternalTexts.json", 500);
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "badge_import",
|
||||
target: "ExternalTexts",
|
||||
after: { code, name, description },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "badge_import",
|
||||
target: "ExternalTexts",
|
||||
after: { code, name, description },
|
||||
});
|
||||
|
||||
return apiOk();
|
||||
});
|
||||
return apiOk();
|
||||
},
|
||||
);
|
||||
@@ -2,70 +2,96 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { cloneSingleFurni, fetchSourceFurnidata } from "@/lib/services/clone-import";
|
||||
import {
|
||||
cloneSingleFurni,
|
||||
fetchSourceFurnidata,
|
||||
} from "@/lib/services/clone-import";
|
||||
import { getSource } from "@/lib/services/clone-sources";
|
||||
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
|
||||
|
||||
interface BatchItem {
|
||||
classname: string;
|
||||
classname: string;
|
||||
}
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const sourceId: string = body.sourceId || "";
|
||||
const rawClassnames: string[] = body.classnames || [];
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const sourceId: string = body.sourceId || "";
|
||||
const rawClassnames: string[] = body.classnames || [];
|
||||
|
||||
if (!sourceId) return apiError("Missing sourceId", 400);
|
||||
if (rawClassnames.length === 0) return apiError("No classnames to clone", 400);
|
||||
if (!sourceId) return apiError("Missing sourceId", 400);
|
||||
if (rawClassnames.length === 0)
|
||||
return apiError("No classnames to clone", 400);
|
||||
|
||||
const source = await getSource(sourceId);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
const source = await getSource(sourceId);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
|
||||
// Fetch furnidata once so we can resolve entries by classname.
|
||||
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>>;
|
||||
try {
|
||||
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
} catch (err) {
|
||||
return apiError(`Failed to fetch furnidata: ${(err as Error).message}`, 502);
|
||||
}
|
||||
// Fetch furnidata once so we can resolve entries by classname.
|
||||
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>>;
|
||||
try {
|
||||
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
} catch (err) {
|
||||
return apiError(
|
||||
`Failed to fetch furnidata: ${(err as Error).message}`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const entryMap = new Map(allEntries.map((e) => [e.classname, e]));
|
||||
const entryMap = new Map(allEntries.map((e) => [e.classname, e]));
|
||||
|
||||
// Deduplicate classnames preserving order.
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const cn of rawClassnames) {
|
||||
if (!seen.has(cn)) {
|
||||
seen.add(cn);
|
||||
items.push({ classname: cn });
|
||||
}
|
||||
}
|
||||
// Deduplicate classnames preserving order.
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const cn of rawClassnames) {
|
||||
if (!seen.has(cn)) {
|
||||
seen.add(cn);
|
||||
items.push({ classname: cn });
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 500) return apiError("Too many classnames (max 500)", 400);
|
||||
if (items.length > 500)
|
||||
return apiError("Too many classnames (max 500)", 400);
|
||||
|
||||
return runSseBatch<BatchItem>({
|
||||
items,
|
||||
concurrency: body.concurrency || 2,
|
||||
labelOf: (it) => it.classname,
|
||||
worker: async (it, _index, report) => {
|
||||
const entry = entryMap.get(it.classname);
|
||||
if (!entry) {
|
||||
return { ok: false, error: "classname not found in source furnidata" };
|
||||
}
|
||||
return runSseBatch<BatchItem>({
|
||||
items,
|
||||
concurrency: body.concurrency || 2,
|
||||
labelOf: (it) => it.classname,
|
||||
worker: async (it, _index, report) => {
|
||||
const entry = entryMap.get(it.classname);
|
||||
if (!entry) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "classname not found in source furnidata",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await cloneSingleFurni({ source, entry, onProgress: report });
|
||||
const result = await cloneSingleFurni({
|
||||
source,
|
||||
entry,
|
||||
onProgress: report,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "clone_furni",
|
||||
target: "FurniAsset",
|
||||
targetId: 0,
|
||||
after: { classname: it.classname, sourceId, sourceName: source.name },
|
||||
});
|
||||
}
|
||||
if (result.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "clone_furni",
|
||||
target: "FurniAsset",
|
||||
targetId: 0,
|
||||
after: {
|
||||
classname: it.classname,
|
||||
sourceId,
|
||||
sourceName: source.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: result.ok, warnings: result.warnings, error: result.error };
|
||||
},
|
||||
});
|
||||
});
|
||||
return {
|
||||
ok: result.ok,
|
||||
warnings: result.warnings,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -12,41 +12,46 @@ const iconCache = new Map<string, Buffer | null>();
|
||||
// Fetches the source's .nitro bundle and returns the embedded furni icon as a
|
||||
// PNG. Used by the clone preview grid for sources that don't serve standalone
|
||||
// {classname}_icon.png files (icons live inside the .nitro).
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const sp = request.nextUrl.searchParams;
|
||||
const sourceId = sp.get("source");
|
||||
const classname = sp.get("classname");
|
||||
if (!sourceId || !classname) return apiError("Missing source or classname", 400);
|
||||
if (!/^[\w\-.*]+$/.test(classname)) return apiError("Invalid classname", 400);
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const sp = request.nextUrl.searchParams;
|
||||
const sourceId = sp.get("source");
|
||||
const classname = sp.get("classname");
|
||||
if (!sourceId || !classname)
|
||||
return apiError("Missing source or classname", 400);
|
||||
if (!/^[\w\-.*]+$/.test(classname))
|
||||
return apiError("Invalid classname", 400);
|
||||
|
||||
const cacheKey = `${sourceId}:${classname}`;
|
||||
let icon = iconCache.get(cacheKey);
|
||||
const cacheKey = `${sourceId}:${classname}`;
|
||||
let icon = iconCache.get(cacheKey);
|
||||
|
||||
if (icon === undefined) {
|
||||
const source = await getSource(sourceId);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
try {
|
||||
const res = await fetch(`${source.nitroBaseUrl}/${classname}.nitro`, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
iconCache.set(cacheKey, null);
|
||||
return apiError("Bundle not found", 404);
|
||||
}
|
||||
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
|
||||
iconCache.set(cacheKey, icon);
|
||||
} catch {
|
||||
iconCache.set(cacheKey, null);
|
||||
return apiError("Failed to fetch bundle", 502);
|
||||
}
|
||||
}
|
||||
if (icon === undefined) {
|
||||
const source = await getSource(sourceId);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
try {
|
||||
const res = await fetch(`${source.nitroBaseUrl}/${classname}.nitro`, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
iconCache.set(cacheKey, null);
|
||||
return apiError("Bundle not found", 404);
|
||||
}
|
||||
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
|
||||
iconCache.set(cacheKey, icon);
|
||||
} catch {
|
||||
iconCache.set(cacheKey, null);
|
||||
return apiError("Failed to fetch bundle", 502);
|
||||
}
|
||||
}
|
||||
|
||||
if (!icon) return apiError("No icon in bundle", 404);
|
||||
if (!icon) return apiError("No icon in bundle", 404);
|
||||
|
||||
return new Response(new Uint8Array(icon), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
});
|
||||
return new Response(new Uint8Array(icon), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2,93 +2,128 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { getClonableClassnames, getCloneList, getCloneStats } from "@/lib/services/clone-import";
|
||||
import { deleteSource, getSource, listSources, upsertSource } from "@/lib/services/clone-sources";
|
||||
import {
|
||||
getClonableClassnames,
|
||||
getCloneList,
|
||||
getCloneStats,
|
||||
} from "@/lib/services/clone-import";
|
||||
import {
|
||||
deleteSource,
|
||||
getSource,
|
||||
listSources,
|
||||
upsertSource,
|
||||
} from "@/lib/services/clone-sources";
|
||||
|
||||
// GET ?action=sources → list presets
|
||||
// GET ?source=<id>&search=&page=&perPage= → { items, meta, stats }
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const sp = request.nextUrl.searchParams;
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const sp = request.nextUrl.searchParams;
|
||||
|
||||
if (sp.get("action") === "sources") {
|
||||
return apiOk({ sources: await listSources() });
|
||||
}
|
||||
if (sp.get("action") === "sources") {
|
||||
return apiOk({ sources: await listSources() });
|
||||
}
|
||||
|
||||
const sourceId = sp.get("source");
|
||||
if (!sourceId) return apiError("Missing source or action", 400);
|
||||
const sourceId = sp.get("source");
|
||||
if (!sourceId) return apiError("Missing source or action", 400);
|
||||
|
||||
const source = await getSource(sourceId);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
const source = await getSource(sourceId);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
|
||||
// GET ?source=<id>&action=clonable → all not-yet-present classnames (for "clone all")
|
||||
if (sp.get("action") === "clonable") {
|
||||
return apiOk({ classnames: await getClonableClassnames(source) });
|
||||
}
|
||||
// GET ?source=<id>&action=clonable → all not-yet-present classnames (for "clone all")
|
||||
if (sp.get("action") === "clonable") {
|
||||
return apiOk({ classnames: await getClonableClassnames(source) });
|
||||
}
|
||||
|
||||
const search = sp.get("search") || "";
|
||||
const page = Math.max(Number.parseInt(sp.get("page") || "1", 10) || 1, 1);
|
||||
const perPage = Math.min(Math.max(Number.parseInt(sp.get("perPage") || "50", 10) || 50, 1), 200);
|
||||
const filterParam = sp.get("filter");
|
||||
const filter = filterParam === "missing" || filterParam === "present" ? filterParam : "all";
|
||||
const search = sp.get("search") || "";
|
||||
const page = Math.max(Number.parseInt(sp.get("page") || "1", 10) || 1, 1);
|
||||
const perPage = Math.min(
|
||||
Math.max(Number.parseInt(sp.get("perPage") || "50", 10) || 50, 1),
|
||||
200,
|
||||
);
|
||||
const filterParam = sp.get("filter");
|
||||
const filter =
|
||||
filterParam === "missing" || filterParam === "present"
|
||||
? filterParam
|
||||
: "all";
|
||||
|
||||
const [listResult, stats] = await Promise.all([
|
||||
getCloneList({ source, search, page, perPage, filter }),
|
||||
getCloneStats(source),
|
||||
]);
|
||||
const [listResult, stats] = await Promise.all([
|
||||
getCloneList({ source, search, page, perPage, filter }),
|
||||
getCloneStats(source),
|
||||
]);
|
||||
|
||||
return apiOk({ items: listResult.items, meta: listResult.meta, stats });
|
||||
});
|
||||
return apiOk({ items: listResult.items, meta: listResult.meta, stats });
|
||||
},
|
||||
);
|
||||
|
||||
// POST — upsert a source preset
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const { name, furnidataUrl, nitroBaseUrl, iconBaseUrl, id } = body as Record<string, string>;
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const { name, furnidataUrl, nitroBaseUrl, iconBaseUrl, id } =
|
||||
body as Record<string, string>;
|
||||
|
||||
const isHttps = (u: string) => {
|
||||
try {
|
||||
return new URL(u).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const isHttps = (u: string) => {
|
||||
try {
|
||||
return new URL(u).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!name?.trim()) return apiError("name is required", 400);
|
||||
if (!furnidataUrl?.trim()) return apiError("furnidataUrl is required", 400);
|
||||
if (!nitroBaseUrl?.trim()) return apiError("nitroBaseUrl is required", 400);
|
||||
if (!iconBaseUrl?.trim()) return apiError("iconBaseUrl is required", 400);
|
||||
if (!isHttps(furnidataUrl) || !isHttps(nitroBaseUrl) || !isHttps(iconBaseUrl))
|
||||
return apiError("Source URLs must be valid https:// URLs", 400);
|
||||
if (!name?.trim()) return apiError("name is required", 400);
|
||||
if (!furnidataUrl?.trim()) return apiError("furnidataUrl is required", 400);
|
||||
if (!nitroBaseUrl?.trim()) return apiError("nitroBaseUrl is required", 400);
|
||||
if (!iconBaseUrl?.trim()) return apiError("iconBaseUrl is required", 400);
|
||||
if (
|
||||
!isHttps(furnidataUrl) ||
|
||||
!isHttps(nitroBaseUrl) ||
|
||||
!isHttps(iconBaseUrl)
|
||||
)
|
||||
return apiError("Source URLs must be valid https:// URLs", 400);
|
||||
|
||||
const entry = await upsertSource({ id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl });
|
||||
const entry = await upsertSource({
|
||||
id,
|
||||
name,
|
||||
furnidataUrl,
|
||||
nitroBaseUrl,
|
||||
iconBaseUrl,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: id ? "clone_source_update" : "clone_source_create",
|
||||
target: "CloneSource",
|
||||
targetId: 0,
|
||||
after: { id: entry.id, name: entry.name },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: id ? "clone_source_update" : "clone_source_create",
|
||||
target: "CloneSource",
|
||||
targetId: 0,
|
||||
after: { id: entry.id, name: entry.name },
|
||||
});
|
||||
|
||||
return apiOk({ source: entry });
|
||||
});
|
||||
return apiOk({ source: entry });
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE ?id=<id>
|
||||
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const id = request.nextUrl.searchParams.get("id");
|
||||
if (!id) return apiError("Missing id", 400);
|
||||
export const DELETE = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const id = request.nextUrl.searchParams.get("id");
|
||||
if (!id) return apiError("Missing id", 400);
|
||||
|
||||
const source = await getSource(id);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
const source = await getSource(id);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
|
||||
await deleteSource(id);
|
||||
await deleteSource(id);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "clone_source_delete",
|
||||
target: "CloneSource",
|
||||
targetId: 0,
|
||||
after: { id, name: source.name },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "clone_source_delete",
|
||||
target: "CloneSource",
|
||||
targetId: 0,
|
||||
after: { id, name: source.name },
|
||||
});
|
||||
|
||||
return apiOk({ id });
|
||||
});
|
||||
return apiOk({ id });
|
||||
},
|
||||
);
|
||||
@@ -2,44 +2,50 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { ensureFigureDirs, importSingleFigure } from "@/lib/services/figure-import";
|
||||
import {
|
||||
ensureFigureDirs,
|
||||
importSingleFigure,
|
||||
} from "@/lib/services/figure-import";
|
||||
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
|
||||
|
||||
interface FigureBatchItem {
|
||||
lib: string;
|
||||
lib: string;
|
||||
}
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: FigureBatchItem[] = body.items || [];
|
||||
const concurrency = body.concurrency || 3;
|
||||
if (rawItems.length === 0) return apiError("No libraries to import", 400);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: FigureBatchItem[] = body.items || [];
|
||||
const concurrency = body.concurrency || 3;
|
||||
if (rawItems.length === 0) return apiError("No libraries to import", 400);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
|
||||
await ensureFigureDirs();
|
||||
await ensureFigureDirs();
|
||||
|
||||
return runSseBatch<FigureBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSingleFigure({ lib: it.lib, onProgress: report });
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "figure_import",
|
||||
target: "FigureAsset",
|
||||
targetId: 0,
|
||||
after: { lib: it.lib, batch: true },
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
});
|
||||
});
|
||||
return runSseBatch<FigureBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSingleFigure({ lib: it.lib, onProgress: report });
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "figure_import",
|
||||
target: "FigureAsset",
|
||||
targetId: 0,
|
||||
after: { lib: it.lib, batch: true },
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2,27 +2,45 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { deleteImportedFigure, getFigureList, getFigureStats } from "@/lib/services/figure-import";
|
||||
import {
|
||||
deleteImportedFigure,
|
||||
getFigureList,
|
||||
getFigureStats,
|
||||
} from "@/lib/services/figure-import";
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
if (request.nextUrl.searchParams.get("action") === "stats") return apiOk(await getFigureStats());
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const page = Number.parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
|
||||
const { items, meta } = await getFigureList({ search, page: page || 1, perPage: 100 });
|
||||
return apiOk({ items, meta });
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
if (request.nextUrl.searchParams.get("action") === "stats")
|
||||
return apiOk(await getFigureStats());
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const page = Number.parseInt(
|
||||
request.nextUrl.searchParams.get("page") || "1",
|
||||
10,
|
||||
);
|
||||
const { items, meta } = await getFigureList({
|
||||
search,
|
||||
page: page || 1,
|
||||
perPage: 100,
|
||||
});
|
||||
return apiOk({ items, meta });
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const lib = request.nextUrl.searchParams.get("lib");
|
||||
if (!lib) return apiError("Missing lib", 400);
|
||||
const { deletedFile } = await deleteImportedFigure(lib);
|
||||
if (!deletedFile) return apiError(`Library "${lib}" not found`, 404);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "figure_delete",
|
||||
target: "FigureAsset",
|
||||
targetId: 0,
|
||||
after: { lib },
|
||||
});
|
||||
return apiOk({ lib, deletedFile });
|
||||
});
|
||||
export const DELETE = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const lib = request.nextUrl.searchParams.get("lib");
|
||||
if (!lib) return apiError("Missing lib", 400);
|
||||
const { deletedFile } = await deleteImportedFigure(lib);
|
||||
if (!deletedFile) return apiError(`Library "${lib}" not found`, 404);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "figure_delete",
|
||||
target: "FigureAsset",
|
||||
targetId: 0,
|
||||
after: { lib },
|
||||
});
|
||||
return apiOk({ lib, deletedFile });
|
||||
},
|
||||
);
|
||||
@@ -6,44 +6,48 @@ import { importClothingSet } from "@/lib/services/clothing-set-import";
|
||||
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
|
||||
|
||||
interface ClothingSetBatchItem {
|
||||
setType: string;
|
||||
setId: number;
|
||||
setType: string;
|
||||
setId: number;
|
||||
}
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: ClothingSetBatchItem[] = body.items || [];
|
||||
const concurrency = body.concurrency || 3;
|
||||
if (rawItems.length === 0) return apiError("No clothing sets to import", 400);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: ClothingSetBatchItem[] = body.items || [];
|
||||
const concurrency = body.concurrency || 3;
|
||||
if (rawItems.length === 0)
|
||||
return apiError("No clothing sets to import", 400);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
const key = `${it.setType}:${it.setId}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
const key = `${it.setType}:${it.setId}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
return runSseBatch<ClothingSetBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
labelOf: (it) => `${it.setType}-${it.setId}`,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importClothingSet({
|
||||
setType: it.setType,
|
||||
setId: it.setId,
|
||||
onProgress: report,
|
||||
});
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "clothing_set_import",
|
||||
target: "ClothingSet",
|
||||
targetId: it.setId,
|
||||
after: { setType: it.setType, setId: it.setId, batch: true },
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
});
|
||||
});
|
||||
return runSseBatch<ClothingSetBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
labelOf: (it) => `${it.setType}-${it.setId}`,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importClothingSet({
|
||||
setType: it.setType,
|
||||
setId: it.setId,
|
||||
onProgress: report,
|
||||
});
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "clothing_set_import",
|
||||
target: "ClothingSet",
|
||||
targetId: it.setId,
|
||||
after: { setType: it.setType, setId: it.setId, batch: true },
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,20 +1,31 @@
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { getClothingSetList, getClothingSetStats } from "@/lib/services/clothing-set-import";
|
||||
import {
|
||||
getClothingSetList,
|
||||
getClothingSetStats,
|
||||
} from "@/lib/services/clothing-set-import";
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
if (request.nextUrl.searchParams.get("action") === "stats") {
|
||||
return apiOk((await getClothingSetStats()) as unknown as Record<string, unknown>);
|
||||
}
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const page = Number.parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
|
||||
const setType = request.nextUrl.searchParams.get("setType") || undefined;
|
||||
const { items, meta } = await getClothingSetList({
|
||||
search,
|
||||
page: page || 1,
|
||||
perPage: 100,
|
||||
setType,
|
||||
});
|
||||
return apiOk({ items, meta });
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
if (request.nextUrl.searchParams.get("action") === "stats") {
|
||||
return apiOk(
|
||||
(await getClothingSetStats()) as unknown as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const page = Number.parseInt(
|
||||
request.nextUrl.searchParams.get("page") || "1",
|
||||
10,
|
||||
);
|
||||
const setType = request.nextUrl.searchParams.get("setType") || undefined;
|
||||
const { items, meta } = await getClothingSetList({
|
||||
search,
|
||||
page: page || 1,
|
||||
perPage: 100,
|
||||
setType,
|
||||
});
|
||||
return apiOk({ items, meta });
|
||||
},
|
||||
);
|
||||
@@ -2,48 +2,54 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { ensureEffectDirs, importSingleEffect } from "@/lib/services/effect-import";
|
||||
import {
|
||||
ensureEffectDirs,
|
||||
importSingleEffect,
|
||||
} from "@/lib/services/effect-import";
|
||||
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
|
||||
import type { EffectMapEntry } from "@/types/effects";
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: EffectMapEntry[] = body.items || [];
|
||||
const concurrency = body.concurrency || 3;
|
||||
if (rawItems.length === 0) return apiError("No effects to import", 400);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: EffectMapEntry[] = body.items || [];
|
||||
const concurrency = body.concurrency || 3;
|
||||
if (rawItems.length === 0) return apiError("No effects to import", 400);
|
||||
|
||||
// Dedupe by lib (the .nitro filename) to avoid double work in one request.
|
||||
const seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
// Dedupe by lib (the .nitro filename) to avoid double work in one request.
|
||||
const seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
|
||||
await ensureEffectDirs();
|
||||
await ensureEffectDirs();
|
||||
|
||||
return runSseBatch<EffectMapEntry>({
|
||||
items,
|
||||
concurrency,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSingleEffect({
|
||||
id: it.id,
|
||||
lib: it.lib,
|
||||
type: it.type,
|
||||
revision: it.revision,
|
||||
onProgress: report,
|
||||
});
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "effect_import",
|
||||
target: "EffectMap",
|
||||
targetId: 0,
|
||||
after: { id: it.id, lib: it.lib, type: it.type, batch: true },
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
});
|
||||
});
|
||||
return runSseBatch<EffectMapEntry>({
|
||||
items,
|
||||
concurrency,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSingleEffect({
|
||||
id: it.id,
|
||||
lib: it.lib,
|
||||
type: it.type,
|
||||
revision: it.revision,
|
||||
onProgress: report,
|
||||
});
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "effect_import",
|
||||
target: "EffectMap",
|
||||
targetId: 0,
|
||||
after: { id: it.id, lib: it.lib, type: it.type, batch: true },
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2,29 +2,42 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { deleteImportedEffect, getEffectList, getEffectStats } from "@/lib/services/effect-import";
|
||||
import {
|
||||
deleteImportedEffect,
|
||||
getEffectList,
|
||||
getEffectStats,
|
||||
} from "@/lib/services/effect-import";
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const action = request.nextUrl.searchParams.get("action");
|
||||
if (action === "stats") {
|
||||
return apiOk(await getEffectStats());
|
||||
}
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const effects = await getEffectList(search);
|
||||
return apiOk({ effects });
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const action = request.nextUrl.searchParams.get("action");
|
||||
if (action === "stats") {
|
||||
return apiOk(await getEffectStats());
|
||||
}
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const effects = await getEffectList(search);
|
||||
return apiOk({ effects });
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const idOrLib = request.nextUrl.searchParams.get("lib") || request.nextUrl.searchParams.get("id");
|
||||
if (!idOrLib) return apiError("Missing lib/id", 400);
|
||||
const { removed, deletedFile } = await deleteImportedEffect(idOrLib);
|
||||
if (!removed && !deletedFile) return apiError(`Effect "${idOrLib}" not found`, 404);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "effect_delete",
|
||||
target: "EffectMap",
|
||||
targetId: 0,
|
||||
after: { idOrLib, removed, deletedFile },
|
||||
});
|
||||
return apiOk({ idOrLib, removed, deletedFile });
|
||||
});
|
||||
export const DELETE = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const idOrLib =
|
||||
request.nextUrl.searchParams.get("lib") ||
|
||||
request.nextUrl.searchParams.get("id");
|
||||
if (!idOrLib) return apiError("Missing lib/id", 400);
|
||||
const { removed, deletedFile } = await deleteImportedEffect(idOrLib);
|
||||
if (!removed && !deletedFile)
|
||||
return apiError(`Effect "${idOrLib}" not found`, 404);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "effect_delete",
|
||||
target: "EffectMap",
|
||||
targetId: 0,
|
||||
after: { idOrLib, removed, deletedFile },
|
||||
});
|
||||
return apiOk({ idOrLib, removed, deletedFile });
|
||||
},
|
||||
);
|
||||
@@ -5,225 +5,272 @@ import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { appendFurniEntriesBatch, buildFurniEntry, readFurniData } from "@/lib/services/furni-data";
|
||||
import {
|
||||
appendFurniEntriesBatch,
|
||||
buildFurniEntry,
|
||||
readFurniData,
|
||||
} from "@/lib/services/furni-data";
|
||||
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
|
||||
import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||
|
||||
interface RegenItem {
|
||||
classname: string;
|
||||
revision: number;
|
||||
classname: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE batch regenerate .nitro files.
|
||||
* Streams progress events for each item.
|
||||
*/
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const body = await request.json();
|
||||
const items: RegenItem[] = body.items || [];
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const body = await request.json();
|
||||
const items: RegenItem[] = body.items || [];
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
|
||||
|
||||
if (items.length === 0) return apiError("No items to regenerate", 400);
|
||||
if (items.length === 0) return apiError("No items to regenerate", 400);
|
||||
|
||||
await ensureDirectories();
|
||||
const { swfDir, nitroDir } = await getFurniAssetDirs();
|
||||
const encoder = new TextEncoder();
|
||||
await ensureDirectories();
|
||||
const { swfDir, nitroDir } = await getFurniAssetDirs();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
};
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
send({ type: "regen_start", total: items.length });
|
||||
const startTime = Date.now();
|
||||
send({ type: "regen_start", total: items.length });
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const furniDataEntries: Array<{ entry: Record<string, unknown>; itemType: string }> = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const furniDataEntries: Array<{
|
||||
entry: Record<string, unknown>;
|
||||
itemType: string;
|
||||
}> = [];
|
||||
|
||||
// Load furnidata once for all items
|
||||
const furnidata = await getHabboItFurnidata();
|
||||
// Load furnidata once for all items
|
||||
const furnidata = await getHabboItFurnidata();
|
||||
|
||||
// Load existing FurnitureData.json classnames for dedup
|
||||
const existingFD = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
};
|
||||
const fdClassnames = new Set([
|
||||
...(existingFD.roomitemtypes?.furnitype || []).map((e) => e.classname),
|
||||
...(existingFD.wallitemtypes?.furnitype || []).map((e) => e.classname),
|
||||
]);
|
||||
// Load existing FurnitureData.json classnames for dedup
|
||||
const existingFD = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
};
|
||||
const fdClassnames = new Set([
|
||||
...(existingFD.roomitemtypes?.furnitype || []).map(
|
||||
(e) => e.classname,
|
||||
),
|
||||
...(existingFD.wallitemtypes?.furnitype || []).map(
|
||||
(e) => e.classname,
|
||||
),
|
||||
]);
|
||||
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
|
||||
const promises = chunk.map(async (item) => {
|
||||
const starIdx = item.classname.indexOf("*");
|
||||
const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname;
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`);
|
||||
const promises = chunk.map(async (item) => {
|
||||
const starIdx = item.classname.indexOf("*");
|
||||
const baseClassname =
|
||||
starIdx !== -1
|
||||
? item.classname.substring(0, starIdx)
|
||||
: item.classname;
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
);
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
skipped++;
|
||||
send({ type: "regen_progress", classname: item.classname, status: "skipped" });
|
||||
return;
|
||||
}
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
skipped++;
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "skipped",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
send({ type: "regen_progress", classname: item.classname, status: "downloading" });
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "downloading",
|
||||
});
|
||||
|
||||
// Resolve revision
|
||||
let rev = item.revision || 0;
|
||||
if (rev === 0) {
|
||||
const fd =
|
||||
furnidata.get(item.classname) ||
|
||||
furnidata.get(baseClassname) ||
|
||||
furnidata.get(`${baseClassname}*0`);
|
||||
if (fd) rev = fd.revision;
|
||||
}
|
||||
// Resolve revision
|
||||
let rev = item.revision || 0;
|
||||
if (rev === 0) {
|
||||
const fd =
|
||||
furnidata.get(item.classname) ||
|
||||
furnidata.get(baseClassname) ||
|
||||
furnidata.get(`${baseClassname}*0`);
|
||||
if (fd) rev = fd.revision;
|
||||
}
|
||||
|
||||
// Try spriteId as fallback revision
|
||||
const revsToTry: number[] = [];
|
||||
if (rev > 0) revsToTry.push(rev);
|
||||
const dbItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: item.classname },
|
||||
select: { id: true, spriteId: true, publicName: true, type: true },
|
||||
});
|
||||
if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) {
|
||||
revsToTry.push(dbItem.spriteId);
|
||||
}
|
||||
// Try spriteId as fallback revision
|
||||
const revsToTry: number[] = [];
|
||||
if (rev > 0) revsToTry.push(rev);
|
||||
const dbItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: item.classname },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
publicName: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) {
|
||||
revsToTry.push(dbItem.spriteId);
|
||||
}
|
||||
|
||||
if (revsToTry.length === 0) {
|
||||
failed++;
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
message: "No revision found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (revsToTry.length === 0) {
|
||||
failed++;
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
message: "No revision found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const swfPath = path.join(swfDir, `${baseClassname}.swf`);
|
||||
const swfPath = path.join(swfDir, `${baseClassname}.swf`);
|
||||
|
||||
// Download SWF if needed
|
||||
if (!existsSync(swfPath)) {
|
||||
let downloaded = false;
|
||||
for (const tryRev of revsToTry) {
|
||||
const dl = await downloadFile(
|
||||
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
|
||||
swfPath,
|
||||
{ validate: "swf" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
rev = tryRev;
|
||||
downloaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!downloaded) {
|
||||
failed++;
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
message: `SWF not found (revs: ${revsToTry.join(", ")})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Download SWF if needed
|
||||
if (!existsSync(swfPath)) {
|
||||
let downloaded = false;
|
||||
for (const tryRev of revsToTry) {
|
||||
const dl = await downloadFile(
|
||||
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
|
||||
swfPath,
|
||||
{ validate: "swf" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
rev = tryRev;
|
||||
downloaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!downloaded) {
|
||||
failed++;
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
message: `SWF not found (revs: ${revsToTry.join(", ")})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert
|
||||
send({ type: "regen_progress", classname: item.classname, status: "converting" });
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(swfPath);
|
||||
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
|
||||
await fs.writeFile(nitroPath, conversion.bundle);
|
||||
// Convert
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "converting",
|
||||
});
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(swfPath);
|
||||
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
|
||||
await fs.writeFile(nitroPath, conversion.bundle);
|
||||
|
||||
// Build FurnitureData entry if missing
|
||||
if (!fdClassnames.has(baseClassname) && !fdClassnames.has(item.classname)) {
|
||||
const fd = furnidata.get(item.classname) || furnidata.get(baseClassname);
|
||||
const itemType = dbItem?.type === "i" ? "i" : "s";
|
||||
const entry = buildFurniEntry({
|
||||
id: dbItem?.spriteId || 0,
|
||||
classname: baseClassname,
|
||||
revision: rev,
|
||||
category: fd?.category || "",
|
||||
name: dbItem?.publicName || baseClassname,
|
||||
description: fd?.description || "",
|
||||
spriteId: dbItem?.spriteId || 0,
|
||||
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
|
||||
metadata: conversion.metadata || null,
|
||||
habboIt: fd || null,
|
||||
});
|
||||
furniDataEntries.push({ entry, itemType });
|
||||
fdClassnames.add(baseClassname);
|
||||
}
|
||||
// Build FurnitureData entry if missing
|
||||
if (
|
||||
!fdClassnames.has(baseClassname) &&
|
||||
!fdClassnames.has(item.classname)
|
||||
) {
|
||||
const fd =
|
||||
furnidata.get(item.classname) || furnidata.get(baseClassname);
|
||||
const itemType = dbItem?.type === "i" ? "i" : "s";
|
||||
const entry = buildFurniEntry({
|
||||
id: dbItem?.spriteId || 0,
|
||||
classname: baseClassname,
|
||||
revision: rev,
|
||||
category: fd?.category || "",
|
||||
name: dbItem?.publicName || baseClassname,
|
||||
description: fd?.description || "",
|
||||
spriteId: dbItem?.spriteId || 0,
|
||||
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
|
||||
metadata: conversion.metadata || null,
|
||||
habboIt: fd || null,
|
||||
});
|
||||
furniDataEntries.push({ entry, itemType });
|
||||
fdClassnames.add(baseClassname);
|
||||
}
|
||||
|
||||
succeeded++;
|
||||
send({ type: "regen_progress", classname: item.classname, status: "done" });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
succeeded++;
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "done",
|
||||
});
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// Batch write FurnitureData.json
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
send({
|
||||
type: "regen_info",
|
||||
message: `Added ${furniDataEntries.length} entries to FurnitureData.json`,
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "regen_info",
|
||||
message: `FurnitureData.json write failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Batch write FurnitureData.json
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
send({
|
||||
type: "regen_info",
|
||||
message: `Added ${furniDataEntries.length} entries to FurnitureData.json`,
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "regen_info",
|
||||
message: `FurnitureData.json write failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// RCON refresh
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
// RCON refresh
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
send({
|
||||
type: "regen_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
furniDataAdded: furniDataEntries.length,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
send({
|
||||
type: "regen_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
furniDataAdded: furniDataEntries.length,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3,18 +3,21 @@ import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { appendFurniEntriesBatch } from "@/lib/services/furni-data";
|
||||
import { ensureDirectories, importSingleFurni } from "@/lib/services/furni-import";
|
||||
import {
|
||||
ensureDirectories,
|
||||
importSingleFurni,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import type { ImportSingleResult } from "@/types/furni";
|
||||
|
||||
interface BatchItem {
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
revision: number;
|
||||
category: string;
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
revision: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,176 +25,199 @@ interface BatchItem {
|
||||
* Processes multiple furniture items with configurable concurrency,
|
||||
* streaming real-time progress events to the client.
|
||||
*/
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: BatchItem[] = body.items || [];
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: BatchItem[] = body.items || [];
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
|
||||
|
||||
if (rawItems.length === 0) return apiError("No items to import", 400);
|
||||
if (rawItems.length === 0) return apiError("No items to import", 400);
|
||||
|
||||
// Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on
|
||||
// items_base.id when the same item is listed twice in the request body.
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const it of rawItems) {
|
||||
const key = `${it.id}:${it.classname}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push(it);
|
||||
}
|
||||
// Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on
|
||||
// items_base.id when the same item is listed twice in the request body.
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const it of rawItems) {
|
||||
const key = `${it.id}:${it.classname}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push(it);
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
const encoder = new TextEncoder();
|
||||
await ensureDirectories();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
/* stream closed by client */
|
||||
}
|
||||
};
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* stream closed by client */
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
send({ type: "batch_start", total: items.length, concurrency });
|
||||
const startTime = Date.now();
|
||||
send({ type: "batch_start", total: items.length, concurrency });
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let withWarnings = 0;
|
||||
const furniDataEntries: Array<{ entry: Record<string, unknown>; itemType: string }> = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let withWarnings = 0;
|
||||
const furniDataEntries: Array<{
|
||||
entry: Record<string, unknown>;
|
||||
itemType: string;
|
||||
}> = [];
|
||||
|
||||
// Process in chunks of `concurrency`
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
// Process in chunks of `concurrency`
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
|
||||
const promises = chunk.map(async (item, chunkIdx) => {
|
||||
const index = i + chunkIdx;
|
||||
send({ type: "item_progress", classname: item.classname, status: "started", index });
|
||||
const promises = chunk.map(async (item, chunkIdx) => {
|
||||
const index = i + chunkIdx;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "started",
|
||||
index,
|
||||
});
|
||||
|
||||
try {
|
||||
const result: ImportSingleResult = await importSingleFurni({
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? "unknown",
|
||||
skipFurniDataWrite: true,
|
||||
onProgress: (status: string) => {
|
||||
send({ type: "item_progress", classname: item.classname, status, index });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result: ImportSingleResult = await importSingleFurni({
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? "unknown",
|
||||
skipFurniDataWrite: true,
|
||||
onProgress: (status: string) => {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status,
|
||||
index,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings.length > 0) withWarnings++;
|
||||
if (result.furniDataEntry) furniDataEntries.push(result.furniDataEntry);
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings.length > 0) withWarnings++;
|
||||
if (result.furniDataEntry)
|
||||
furniDataEntries.push(result.furniDataEntry);
|
||||
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "done",
|
||||
index,
|
||||
itemId: result.itemId,
|
||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
||||
});
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "done",
|
||||
index,
|
||||
itemId: result.itemId,
|
||||
warnings:
|
||||
result.warnings.length > 0 ? result.warnings : undefined,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === "wallitem" ? "i" : "s",
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
batch: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === "wallitem" ? "i" : "s",
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
batch: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// Batch write FurnitureData.json once at the end — BEFORE RCON so the
|
||||
// emulator never sees items that the Nitro client can't render.
|
||||
let furniDataWriteOk = true;
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
} catch (err) {
|
||||
furniDataWriteOk = false;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_batch_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Batch write FurnitureData.json once at the end — BEFORE RCON so the
|
||||
// emulator never sees items that the Nitro client can't render.
|
||||
let furniDataWriteOk = true;
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
} catch (err) {
|
||||
furniDataWriteOk = false;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_batch_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh game server caches once — skip if FurnitureData write failed
|
||||
// to avoid an inconsistent state (emulator sees items, client doesn't).
|
||||
if (furniDataWriteOk) {
|
||||
try {
|
||||
const okCat = await rcon.updateCatalog();
|
||||
const okItems = await rcon.updateItems();
|
||||
if (!okCat || !okItems) {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_rcon_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: "RCON refresh failed — emulator cache may be stale",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[import-furni] RCON update failed after batch:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
// Refresh game server caches once — skip if FurnitureData write failed
|
||||
// to avoid an inconsistent state (emulator sees items, client doesn't).
|
||||
if (furniDataWriteOk) {
|
||||
try {
|
||||
const okCat = await rcon.updateCatalog();
|
||||
const okItems = await rcon.updateItems();
|
||||
if (!okCat || !okItems) {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_rcon_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: "RCON refresh failed — emulator cache may be stale",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[import-furni] RCON update failed after batch:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -7,121 +7,153 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { readFurniData, withFurniDataLock, writeFurniData } from "@/lib/services/furni-data";
|
||||
import {
|
||||
readFurniData,
|
||||
withFurniDataLock,
|
||||
writeFurniData,
|
||||
} from "@/lib/services/furni-data";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import {
|
||||
computeDefaultDir,
|
||||
createNitroBundle,
|
||||
extractPartColors,
|
||||
parseNitroBundle,
|
||||
computeDefaultDir,
|
||||
createNitroBundle,
|
||||
extractPartColors,
|
||||
parseNitroBundle,
|
||||
} from "@/lib/services/swf-to-nitro";
|
||||
|
||||
function baseClassname(classname: string): string {
|
||||
return classname.includes("*") ? classname.split("*")[0] : classname;
|
||||
return classname.includes("*") ? classname.split("*")[0] : classname;
|
||||
}
|
||||
|
||||
// ── GET: Read .nitro metadata ──────────────────────────────────────
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request: NextRequest) => {
|
||||
const rawClassname = request.nextUrl.searchParams.get("classname");
|
||||
if (!rawClassname) return apiError("Missing classname", 400);
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request: NextRequest) => {
|
||||
const rawClassname = request.nextUrl.searchParams.get("classname");
|
||||
if (!rawClassname) return apiError("Missing classname", 400);
|
||||
|
||||
const base = baseClassname(rawClassname);
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
|
||||
const base = baseClassname(rawClassname);
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${base}.nitro`,
|
||||
);
|
||||
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) return apiError("Nitro file not found", 404);
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
|
||||
return apiError("Nitro file not found", 404);
|
||||
|
||||
try {
|
||||
const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
|
||||
const { json } = parseNitroBundle(buffer);
|
||||
try {
|
||||
const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
|
||||
const { json } = parseNitroBundle(buffer);
|
||||
|
||||
// Fetch interaction flags from DB
|
||||
const items = await prisma.$queryRaw<
|
||||
Array<{
|
||||
allow_sit: string;
|
||||
allow_lay: string;
|
||||
allow_walk: string;
|
||||
}>
|
||||
>`
|
||||
// Fetch interaction flags from DB
|
||||
const items = await prisma.$queryRaw<
|
||||
Array<{
|
||||
allow_sit: string;
|
||||
allow_lay: string;
|
||||
allow_walk: string;
|
||||
}>
|
||||
>`
|
||||
SELECT allow_sit, allow_lay, allow_walk
|
||||
FROM items_base WHERE item_name = ${rawClassname} LIMIT 1
|
||||
`;
|
||||
|
||||
const dbRow = items[0];
|
||||
const flags = {
|
||||
canstandon: dbRow?.allow_walk === "1",
|
||||
cansiton: dbRow?.allow_sit === "1",
|
||||
canlayon: dbRow?.allow_lay === "1",
|
||||
};
|
||||
const dbRow = items[0];
|
||||
const flags = {
|
||||
canstandon: dbRow?.allow_walk === "1",
|
||||
cansiton: dbRow?.allow_sit === "1",
|
||||
canlayon: dbRow?.allow_lay === "1",
|
||||
};
|
||||
|
||||
return apiOk({ metadata: json, flags });
|
||||
} catch (err) {
|
||||
console.error("[nitro-editor] Failed to parse .nitro:", (err as Error).message);
|
||||
return apiError("Failed to parse .nitro file", 500);
|
||||
}
|
||||
});
|
||||
return apiOk({ metadata: json, flags });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[nitro-editor] Failed to parse .nitro:",
|
||||
(err as Error).message,
|
||||
);
|
||||
return apiError("Failed to parse .nitro file", 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── PUT: Save .nitro metadata ──────────────────────────────────────
|
||||
|
||||
export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request: NextRequest, ctx) => {
|
||||
const body = await request.json();
|
||||
const { classname: rawClassname, metadata, canstandon, cansiton, canlayon } = body;
|
||||
export const PUT = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request: NextRequest, ctx) => {
|
||||
const body = await request.json();
|
||||
const {
|
||||
classname: rawClassname,
|
||||
metadata,
|
||||
canstandon,
|
||||
cansiton,
|
||||
canlayon,
|
||||
} = body;
|
||||
|
||||
if (!rawClassname || !metadata) return apiError("Missing classname or metadata", 400);
|
||||
if (!rawClassname || !metadata)
|
||||
return apiError("Missing classname or metadata", 400);
|
||||
|
||||
const base = baseClassname(rawClassname);
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
|
||||
const base = baseClassname(rawClassname);
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${base}.nitro`,
|
||||
);
|
||||
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) return apiError("Nitro file not found", 404);
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
|
||||
return apiError("Nitro file not found", 404);
|
||||
|
||||
// 1. Read existing .nitro to extract PNG
|
||||
const existingBuffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
|
||||
const { png } = parseNitroBundle(existingBuffer);
|
||||
// 1. Read existing .nitro to extract PNG
|
||||
const existingBuffer = await fs.readFile(
|
||||
/*turbopackIgnore: true*/ nitroPath,
|
||||
);
|
||||
const { png } = parseNitroBundle(existingBuffer);
|
||||
|
||||
// 2. Rebuild .nitro with updated JSON + same PNG
|
||||
const newBundle = createNitroBundle(metadata, png, base);
|
||||
await fs.writeFile(nitroPath, newBundle);
|
||||
// 2. Rebuild .nitro with updated JSON + same PNG
|
||||
const newBundle = createNitroBundle(metadata, png, base);
|
||||
await fs.writeFile(nitroPath, newBundle);
|
||||
|
||||
// 3. Extract dimensions from metadata
|
||||
const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 };
|
||||
const directions: number[] = metadata.logic?.model?.directions || [0];
|
||||
const visualizations = metadata.visualizations || [];
|
||||
// 3. Extract dimensions from metadata
|
||||
const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 };
|
||||
const directions: number[] = metadata.logic?.model?.directions || [0];
|
||||
const visualizations = metadata.visualizations || [];
|
||||
|
||||
// 4. Update FurnitureData.json (best-effort)
|
||||
try {
|
||||
await withFurniDataLock(async () => {
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
// 4. Update FurnitureData.json (best-effort)
|
||||
try {
|
||||
await withFurniDataLock(async () => {
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
const types = furniData[section]?.furnitype;
|
||||
if (!Array.isArray(types)) continue;
|
||||
const idx = types.findIndex((e) => e.classname === rawClassname);
|
||||
if (idx !== -1) {
|
||||
types[idx].xdim = dims.x;
|
||||
types[idx].ydim = dims.y;
|
||||
types[idx].defaultdir = computeDefaultDir(directions);
|
||||
types[idx].partcolors = extractPartColors(visualizations);
|
||||
types[idx].canstandon = canstandon ?? false;
|
||||
types[idx].cansiton = cansiton ?? false;
|
||||
types[idx].canlayon = canlayon ?? false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
const types = furniData[section]?.furnitype;
|
||||
if (!Array.isArray(types)) continue;
|
||||
const idx = types.findIndex((e) => e.classname === rawClassname);
|
||||
if (idx !== -1) {
|
||||
types[idx].xdim = dims.x;
|
||||
types[idx].ydim = dims.y;
|
||||
types[idx].defaultdir = computeDefaultDir(directions);
|
||||
types[idx].partcolors = extractPartColors(visualizations);
|
||||
types[idx].canstandon = canstandon ?? false;
|
||||
types[idx].cansiton = cansiton ?? false;
|
||||
types[idx].canlayon = canlayon ?? false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] FurnitureData.json update failed:", (err as Error).message);
|
||||
}
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[nitro-editor] FurnitureData.json update failed:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Update items_base (best-effort)
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
// 5. Update items_base (best-effort)
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE items_base
|
||||
SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z},
|
||||
allow_sit = ${cansiton ? "1" : "0"},
|
||||
@@ -129,33 +161,46 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
allow_walk = ${canstandon ? "1" : "0"}
|
||||
WHERE item_name = ${rawClassname}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] items_base update failed:", (err as Error).message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[nitro-editor] items_base update failed:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. RCON refresh (best-effort)
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] RCON updateItems failed:", (err as Error).message);
|
||||
}
|
||||
// 6. RCON refresh (best-effort)
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[nitro-editor] RCON updateItems failed:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Audit log
|
||||
try {
|
||||
const dbItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: rawClassname },
|
||||
select: { id: true },
|
||||
});
|
||||
await logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_edit_nitro",
|
||||
target: "ItemsBase",
|
||||
targetId: dbItem?.id ?? 0,
|
||||
after: { classname: rawClassname, dimensions: dims, canstandon, cansiton, canlayon },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] Audit log failed:", (err as Error).message);
|
||||
}
|
||||
// 7. Audit log
|
||||
try {
|
||||
const dbItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: rawClassname },
|
||||
select: { id: true },
|
||||
});
|
||||
await logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_edit_nitro",
|
||||
target: "ItemsBase",
|
||||
targetId: dbItem?.id ?? 0,
|
||||
after: {
|
||||
classname: rawClassname,
|
||||
dimensions: dims,
|
||||
canstandon,
|
||||
cansiton,
|
||||
canlayon,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] Audit log failed:", (err as Error).message);
|
||||
}
|
||||
|
||||
return apiOk({ dimensions: dims });
|
||||
});
|
||||
return apiOk({ dimensions: dims });
|
||||
},
|
||||
);
|
||||
@@ -4,10 +4,10 @@ import { apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
appendFurniEntriesBatch,
|
||||
buildFurniEntry,
|
||||
getFurnitureDataPath,
|
||||
readFurniData,
|
||||
appendFurniEntriesBatch,
|
||||
buildFurniEntry,
|
||||
getFurnitureDataPath,
|
||||
readFurniData,
|
||||
} from "@/lib/services/furni-data";
|
||||
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
@@ -26,171 +26,201 @@ import { rcon } from "@/lib/services/rcon";
|
||||
* Entries are upserted by classname so running this repeatedly is safe.
|
||||
* Triggers RCON updateCatalog + updateItems at the end.
|
||||
*/
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const qp = request.nextUrl.searchParams;
|
||||
const mode: "days" | "missing" | "broken" | "all" =
|
||||
qp.get("all") === "1"
|
||||
? "all"
|
||||
: qp.get("broken") === "1"
|
||||
? "broken"
|
||||
: qp.get("missing") === "1"
|
||||
? "missing"
|
||||
: "days";
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const qp = request.nextUrl.searchParams;
|
||||
const mode: "days" | "missing" | "broken" | "all" =
|
||||
qp.get("all") === "1"
|
||||
? "all"
|
||||
: qp.get("broken") === "1"
|
||||
? "broken"
|
||||
: qp.get("missing") === "1"
|
||||
? "missing"
|
||||
: "days";
|
||||
|
||||
const days = Math.max(1, Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365));
|
||||
const days = Math.max(
|
||||
1,
|
||||
Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365),
|
||||
);
|
||||
|
||||
const targetIds = await resolveTargetIds(mode, days);
|
||||
const targetIds = await resolveTargetIds(mode, days);
|
||||
|
||||
if (targetIds.length === 0) {
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === "days" ? days : undefined,
|
||||
examined: 0,
|
||||
resynced: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
});
|
||||
}
|
||||
if (targetIds.length === 0) {
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === "days" ? days : undefined,
|
||||
examined: 0,
|
||||
resynced: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Process in chunks to keep memory flat and make progress visible.
|
||||
const CHUNK = 500;
|
||||
let resynced = 0;
|
||||
const errors: Array<{ classname: string; message: string }> = [];
|
||||
// Process in chunks to keep memory flat and make progress visible.
|
||||
const CHUNK = 500;
|
||||
let resynced = 0;
|
||||
const errors: Array<{ classname: string; message: string }> = [];
|
||||
|
||||
for (let i = 0; i < targetIds.length; i += CHUNK) {
|
||||
const slice = targetIds.slice(i, i + CHUNK);
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
where: { id: { in: slice } },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
itemName: true,
|
||||
publicName: true,
|
||||
type: true,
|
||||
width: true,
|
||||
length: true,
|
||||
stackHeight: true,
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < targetIds.length; i += CHUNK) {
|
||||
const slice = targetIds.slice(i, i + CHUNK);
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
where: { id: { in: slice } },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
itemName: true,
|
||||
publicName: true,
|
||||
type: true,
|
||||
width: true,
|
||||
length: true,
|
||||
stackHeight: true,
|
||||
},
|
||||
});
|
||||
|
||||
const entries: Array<{ entry: Record<string, unknown>; itemType: string }> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const classname = row.itemName;
|
||||
let habboIt = null;
|
||||
try {
|
||||
habboIt = await lookupHabboIt(classname);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
const entries: Array<{
|
||||
entry: Record<string, unknown>;
|
||||
itemType: string;
|
||||
}> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const classname = row.itemName;
|
||||
let habboIt = null;
|
||||
try {
|
||||
habboIt = await lookupHabboIt(classname);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
|
||||
const entry = buildFurniEntry({
|
||||
id: row.id,
|
||||
classname,
|
||||
revision: habboIt?.revision ?? 0,
|
||||
category: habboIt?.category || "unknown",
|
||||
name: row.publicName || habboIt?.name || classname,
|
||||
description: habboIt?.description || "",
|
||||
spriteId: row.spriteId,
|
||||
dims: { x: row.width, y: row.length, z: Math.round(Number(row.stackHeight)) },
|
||||
metadata: null,
|
||||
habboIt,
|
||||
});
|
||||
const entry = buildFurniEntry({
|
||||
id: row.id,
|
||||
classname,
|
||||
revision: habboIt?.revision ?? 0,
|
||||
category: habboIt?.category || "unknown",
|
||||
name: row.publicName || habboIt?.name || classname,
|
||||
description: habboIt?.description || "",
|
||||
spriteId: row.spriteId,
|
||||
dims: {
|
||||
x: row.width,
|
||||
y: row.length,
|
||||
z: Math.round(Number(row.stackHeight)),
|
||||
},
|
||||
metadata: null,
|
||||
habboIt,
|
||||
});
|
||||
|
||||
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
|
||||
} catch (err) {
|
||||
errors.push({ classname: row.itemName, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
classname: row.itemName,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(entries);
|
||||
resynced += entries.length;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
classname: `_chunk_${i}_${i + slice.length}_`,
|
||||
message: `FurnitureData.json write failed: ${(err as Error).message}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(entries);
|
||||
resynced += entries.length;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
classname: `_chunk_${i}_${i + slice.length}_`,
|
||||
message: `FurnitureData.json write failed: ${(err as Error).message}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rconOk = false;
|
||||
try {
|
||||
const a = await rcon.updateCatalog();
|
||||
const b = await rcon.updateItems();
|
||||
rconOk = a && b;
|
||||
} catch {
|
||||
/* logged in rcon service */
|
||||
}
|
||||
let rconOk = false;
|
||||
try {
|
||||
const a = await rcon.updateCatalog();
|
||||
const b = await rcon.updateItems();
|
||||
rconOk = a && b;
|
||||
} catch {
|
||||
/* logged in rcon service */
|
||||
}
|
||||
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === "days" ? days : undefined,
|
||||
examined: targetIds.length,
|
||||
resynced,
|
||||
failed: errors.length,
|
||||
rconOk,
|
||||
errors: errors.slice(0, 50),
|
||||
});
|
||||
});
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === "days" ? days : undefined,
|
||||
examined: targetIds.length,
|
||||
resynced,
|
||||
failed: errors.length,
|
||||
rconOk,
|
||||
errors: errors.slice(0, 50),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
async function resolveTargetIds(
|
||||
mode: "days" | "missing" | "broken" | "all",
|
||||
days: number,
|
||||
mode: "days" | "missing" | "broken" | "all",
|
||||
days: number,
|
||||
): Promise<number[]> {
|
||||
if (mode === "all") {
|
||||
const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" } });
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
if (mode === "all") {
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
if (mode === "missing" || mode === "broken") {
|
||||
const furniDataPath = await getFurnitureDataPath();
|
||||
if (!existsSync(furniDataPath)) {
|
||||
const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" } });
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
const furniData = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype: Array<Record<string, unknown>> };
|
||||
wallitemtypes?: { furnitype: Array<Record<string, unknown>> };
|
||||
};
|
||||
const byClassname = new Map<string, Record<string, unknown>>();
|
||||
for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) {
|
||||
for (const e of section?.furnitype ?? []) {
|
||||
if (typeof e?.classname === "string") byClassname.set(e.classname, e);
|
||||
}
|
||||
}
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: { id: true, itemName: true, spriteId: true, width: true, length: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
if (mode === "missing" || mode === "broken") {
|
||||
const furniDataPath = await getFurnitureDataPath();
|
||||
if (!existsSync(furniDataPath)) {
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
const furniData = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype: Array<Record<string, unknown>> };
|
||||
wallitemtypes?: { furnitype: Array<Record<string, unknown>> };
|
||||
};
|
||||
const byClassname = new Map<string, Record<string, unknown>>();
|
||||
for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) {
|
||||
for (const e of section?.furnitype ?? []) {
|
||||
if (typeof e?.classname === "string") byClassname.set(e.classname, e);
|
||||
}
|
||||
}
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
itemName: true,
|
||||
spriteId: true,
|
||||
width: true,
|
||||
length: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
if (mode === "missing") {
|
||||
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
|
||||
}
|
||||
if (mode === "missing") {
|
||||
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
|
||||
}
|
||||
|
||||
// broken: entry present but inconsistent with items_base
|
||||
return rows
|
||||
.filter((r) => {
|
||||
const e = byClassname.get(r.itemName);
|
||||
if (!e) return true;
|
||||
const entryId = Number(e.id ?? -1);
|
||||
if (entryId !== r.id && entryId !== r.spriteId) return true;
|
||||
const xdim = Number(e.xdim ?? 0);
|
||||
const ydim = Number(e.ydim ?? 0);
|
||||
if (xdim <= 0 || ydim <= 0) return true;
|
||||
if (xdim !== r.width || ydim !== r.length) return true;
|
||||
return false;
|
||||
})
|
||||
.map((r) => r.id);
|
||||
}
|
||||
// broken: entry present but inconsistent with items_base
|
||||
return rows
|
||||
.filter((r) => {
|
||||
const e = byClassname.get(r.itemName);
|
||||
if (!e) return true;
|
||||
const entryId = Number(e.id ?? -1);
|
||||
if (entryId !== r.id && entryId !== r.spriteId) return true;
|
||||
const xdim = Number(e.xdim ?? 0);
|
||||
const ydim = Number(e.ydim ?? 0);
|
||||
if (xdim <= 0 || ydim <= 0) return true;
|
||||
if (xdim !== r.width || ydim !== r.length) return true;
|
||||
return false;
|
||||
})
|
||||
.map((r) => r.id);
|
||||
}
|
||||
|
||||
// mode === 'days'
|
||||
const sinceIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
|
||||
const auditRows = await prisma.$queryRaw<Array<{ target_id: number }>>`
|
||||
// mode === 'days'
|
||||
const sinceIso = new Date(
|
||||
Date.now() - days * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
|
||||
const auditRows = await prisma.$queryRaw<Array<{ target_id: number }>>`
|
||||
SELECT DISTINCT target_id
|
||||
FROM admin_audit_log
|
||||
WHERE action = 'furni_import'
|
||||
@@ -199,5 +229,5 @@ async function resolveTargetIds(
|
||||
AND created_at >= ${sinceMysql}
|
||||
ORDER BY target_id ASC
|
||||
`;
|
||||
return auditRows.map((r) => r.target_id).filter(Boolean);
|
||||
return auditRows.map((r) => r.target_id).filter(Boolean);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,40 +6,43 @@ import { runSseBatch } from "@/lib/services/import/core/sse-batch";
|
||||
import { ensurePetDirs, importSinglePet } from "@/lib/services/pet-import";
|
||||
|
||||
interface PetBatchItem {
|
||||
lib: string;
|
||||
lib: string;
|
||||
}
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: PetBatchItem[] = body.items || [];
|
||||
const concurrency = body.concurrency || 3;
|
||||
if (rawItems.length === 0) return apiError("No pets to import", 400);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: PetBatchItem[] = body.items || [];
|
||||
const concurrency = body.concurrency || 3;
|
||||
if (rawItems.length === 0) return apiError("No pets to import", 400);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
|
||||
await ensurePetDirs();
|
||||
await ensurePetDirs();
|
||||
|
||||
return runSseBatch<PetBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSinglePet({ lib: it.lib, onProgress: report });
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "pet_import",
|
||||
target: "PetAsset",
|
||||
targetId: 0,
|
||||
after: { lib: it.lib, batch: true },
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
});
|
||||
});
|
||||
return runSseBatch<PetBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSinglePet({ lib: it.lib, onProgress: report });
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "pet_import",
|
||||
target: "PetAsset",
|
||||
targetId: 0,
|
||||
after: { lib: it.lib, batch: true },
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3,16 +3,19 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { getPetIconPng } from "@/lib/services/pet-icon";
|
||||
|
||||
// Serves a cropped pet preview PNG extracted from the pet's .nitro spritesheet.
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const lib = request.nextUrl.searchParams.get("lib") || "";
|
||||
const png = getPetIconPng(lib);
|
||||
if (!png) return new Response(null, { status: 404 });
|
||||
return new Response(new Uint8Array(png), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
// short cache so generator tweaks show up quickly; the in-process icon
|
||||
// cache keeps regeneration cheap. Client bumps a ?v= when output changes.
|
||||
"Cache-Control": "public, max-age=300",
|
||||
},
|
||||
});
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const lib = request.nextUrl.searchParams.get("lib") || "";
|
||||
const png = getPetIconPng(lib);
|
||||
if (!png) return new Response(null, { status: 404 });
|
||||
return new Response(new Uint8Array(png), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
// short cache so generator tweaks show up quickly; the in-process icon
|
||||
// cache keeps regeneration cheap. Client bumps a ?v= when output changes.
|
||||
"Cache-Control": "public, max-age=300",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2,25 +2,36 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { deleteImportedPet, getPetList, getPetStats } from "@/lib/services/pet-import";
|
||||
import {
|
||||
deleteImportedPet,
|
||||
getPetList,
|
||||
getPetStats,
|
||||
} from "@/lib/services/pet-import";
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
if (request.nextUrl.searchParams.get("action") === "stats") return apiOk(getPetStats());
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
return apiOk({ pets: getPetList(search) });
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
if (request.nextUrl.searchParams.get("action") === "stats")
|
||||
return apiOk(getPetStats());
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
return apiOk({ pets: getPetList(search) });
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const lib = request.nextUrl.searchParams.get("lib");
|
||||
if (!lib) return apiError("Missing lib", 400);
|
||||
const { deletedFile } = await deleteImportedPet(lib);
|
||||
if (!deletedFile) return apiError(`Pet "${lib}" not found`, 404);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "pet_delete",
|
||||
target: "PetAsset",
|
||||
targetId: 0,
|
||||
after: { lib },
|
||||
});
|
||||
return apiOk({ lib, deletedFile });
|
||||
});
|
||||
export const DELETE = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const lib = request.nextUrl.searchParams.get("lib");
|
||||
if (!lib) return apiError("Missing lib", 400);
|
||||
const { deletedFile } = await deleteImportedPet(lib);
|
||||
if (!deletedFile) return apiError(`Pet "${lib}" not found`, 404);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "pet_delete",
|
||||
target: "PetAsset",
|
||||
targetId: 0,
|
||||
after: { lib },
|
||||
});
|
||||
return apiOk({ lib, deletedFile });
|
||||
},
|
||||
);
|
||||
@@ -6,6 +6,6 @@ import { auditImportedAssets } from "@/lib/services/furni-repair";
|
||||
// Audit is expensive (scans every imported item's files on disk).
|
||||
// Using POST keeps it out of any cache layer that might front GET.
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||
const summary = await auditImportedAssets();
|
||||
return apiOk(summary as unknown as Record<string, unknown>);
|
||||
const summary = await auditImportedAssets();
|
||||
return apiOk(summary as unknown as Record<string, unknown>);
|
||||
});
|
||||
@@ -9,119 +9,127 @@ import { rcon } from "@/lib/services/rcon";
|
||||
* Body: { classnames: string[], concurrency?: number }
|
||||
* Streams one `data: {...}` event per item.
|
||||
*/
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const body = await request.json();
|
||||
const classnames: string[] = Array.isArray(body.classnames) ? body.classnames : [];
|
||||
// Bumped default to 6 (cap 8). Since downloadFile no longer retries on
|
||||
// deterministic 404s, misses complete in ~1 request instead of ~4 × 15s.
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 6, 1), 8);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const body = await request.json();
|
||||
const classnames: string[] = Array.isArray(body.classnames)
|
||||
? body.classnames
|
||||
: [];
|
||||
// Bumped default to 6 (cap 8). Since downloadFile no longer retries on
|
||||
// deterministic 404s, misses complete in ~1 request instead of ~4 × 15s.
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 6, 1), 8);
|
||||
|
||||
if (classnames.length === 0) return apiError("No classnames provided", 400);
|
||||
if (classnames.length === 0) return apiError("No classnames provided", 400);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
};
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
send({ type: "repair_start", total: classnames.length });
|
||||
const startTime = Date.now();
|
||||
send({ type: "repair_start", total: classnames.length });
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let partial = 0;
|
||||
let unrepairable = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let partial = 0;
|
||||
let unrepairable = 0;
|
||||
|
||||
for (let i = 0; i < classnames.length; i += concurrency) {
|
||||
const chunk = classnames.slice(i, i + concurrency);
|
||||
for (let i = 0; i < classnames.length; i += concurrency) {
|
||||
const chunk = classnames.slice(i, i + concurrency);
|
||||
|
||||
const promises = chunk.map(async (classname) => {
|
||||
send({ type: "repair_progress", classname, status: "working" });
|
||||
try {
|
||||
const result = await repairItem(classname);
|
||||
if (result.unrepairable) unrepairable++;
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
send({
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: "done",
|
||||
nitroRepaired: result.nitroRepaired,
|
||||
iconRepaired: result.iconRepaired,
|
||||
warnings: result.warnings,
|
||||
});
|
||||
} else {
|
||||
// Counts as partial if at least one asset was fixed
|
||||
if (result.iconRepaired || result.nitroRepaired) {
|
||||
partial++;
|
||||
send({
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: "partial",
|
||||
nitroRepaired: result.nitroRepaired,
|
||||
iconRepaired: result.iconRepaired,
|
||||
warnings: result.warnings,
|
||||
error: result.error,
|
||||
unrepairable: result.unrepairable,
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
warnings: result.warnings,
|
||||
error: result.error || result.warnings[0] || "Unknown error",
|
||||
unrepairable: result.unrepairable,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
error: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
const promises = chunk.map(async (classname) => {
|
||||
send({ type: "repair_progress", classname, status: "working" });
|
||||
try {
|
||||
const result = await repairItem(classname);
|
||||
if (result.unrepairable) unrepairable++;
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
send({
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: "done",
|
||||
nitroRepaired: result.nitroRepaired,
|
||||
iconRepaired: result.iconRepaired,
|
||||
warnings: result.warnings,
|
||||
});
|
||||
} else {
|
||||
// Counts as partial if at least one asset was fixed
|
||||
if (result.iconRepaired || result.nitroRepaired) {
|
||||
partial++;
|
||||
send({
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: "partial",
|
||||
nitroRepaired: result.nitroRepaired,
|
||||
iconRepaired: result.iconRepaired,
|
||||
warnings: result.warnings,
|
||||
error: result.error,
|
||||
unrepairable: result.unrepairable,
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
warnings: result.warnings,
|
||||
error:
|
||||
result.error || result.warnings[0] || "Unknown error",
|
||||
unrepairable: result.unrepairable,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
error: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// RCON refresh so the emulator picks up any items_base changes
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
// RCON refresh so the emulator picks up any items_base changes
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
send({
|
||||
type: "repair_complete",
|
||||
succeeded,
|
||||
partial,
|
||||
failed,
|
||||
unrepairable,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
send({
|
||||
type: "repair_complete",
|
||||
succeeded,
|
||||
partial,
|
||||
failed,
|
||||
unrepairable,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -5,21 +5,34 @@ import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => {
|
||||
const body = await request.json();
|
||||
const userId = Number(body.userId);
|
||||
const roleId = Number(body.roleId);
|
||||
if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(roleId) || roleId <= 0) {
|
||||
return apiError("Valid userId and roleId required", 400);
|
||||
}
|
||||
if (body.action === "remove") {
|
||||
await prisma.aclModelRole.deleteMany({ where: { modelType: "User", modelId: userId, roleId } });
|
||||
} else {
|
||||
const existing = await prisma.aclModelRole.findFirst({
|
||||
where: { modelType: "User", modelId: userId, roleId },
|
||||
});
|
||||
if (!existing) await prisma.aclModelRole.create({ data: { modelType: "User", modelId: userId, roleId } });
|
||||
}
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ success: true });
|
||||
});
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (request) => {
|
||||
const body = await request.json();
|
||||
const userId = Number(body.userId);
|
||||
const roleId = Number(body.roleId);
|
||||
if (
|
||||
!Number.isInteger(userId) ||
|
||||
userId <= 0 ||
|
||||
!Number.isInteger(roleId) ||
|
||||
roleId <= 0
|
||||
) {
|
||||
return apiError("Valid userId and roleId required", 400);
|
||||
}
|
||||
if (body.action === "remove") {
|
||||
await prisma.aclModelRole.deleteMany({
|
||||
where: { modelType: "User", modelId: userId, roleId },
|
||||
});
|
||||
} else {
|
||||
const existing = await prisma.aclModelRole.findFirst({
|
||||
where: { modelType: "User", modelId: userId, roleId },
|
||||
});
|
||||
if (!existing)
|
||||
await prisma.aclModelRole.create({
|
||||
data: { modelType: "User", modelId: userId, roleId },
|
||||
});
|
||||
}
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
);
|
||||
@@ -5,30 +5,43 @@ import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => {
|
||||
const body = await request.json();
|
||||
const slug = String(body.slug ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const title = String(body.title ?? "").trim();
|
||||
if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title) return apiError("Valid slug and title required", 400);
|
||||
const role = await prisma.aclRole.create({ data: { slug, title } });
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ role });
|
||||
});
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (request) => {
|
||||
const body = await request.json();
|
||||
const slug = String(body.slug ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const title = String(body.title ?? "").trim();
|
||||
if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title)
|
||||
return apiError("Valid slug and title required", 400);
|
||||
const role = await prisma.aclRole.create({ data: { slug, title } });
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ role });
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => {
|
||||
const id = Number(request.nextUrl.searchParams.get("id"));
|
||||
if (!Number.isInteger(id) || id <= 0) return apiError("Valid ID required", 400);
|
||||
const role = await prisma.aclRole.findUnique({ where: { id }, select: { slug: true } });
|
||||
if (!role) return apiError("Role not found", 404);
|
||||
if (role.slug.startsWith("rank_"))
|
||||
return apiError("Rank roles must be managed through emulator ranks", 409);
|
||||
await prisma.$transaction([
|
||||
prisma.aclModelPermission.deleteMany({ where: { modelType: "Role", modelId: id } }),
|
||||
prisma.aclModelRole.deleteMany({ where: { roleId: id } }),
|
||||
prisma.aclRole.delete({ where: { id } }),
|
||||
]);
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ success: true });
|
||||
});
|
||||
export const DELETE = withAdmin(
|
||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (request) => {
|
||||
const id = Number(request.nextUrl.searchParams.get("id"));
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return apiError("Valid ID required", 400);
|
||||
const role = await prisma.aclRole.findUnique({
|
||||
where: { id },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!role) return apiError("Role not found", 404);
|
||||
if (role.slug.startsWith("rank_"))
|
||||
return apiError("Rank roles must be managed through emulator ranks", 409);
|
||||
await prisma.$transaction([
|
||||
prisma.aclModelPermission.deleteMany({
|
||||
where: { modelType: "Role", modelId: id },
|
||||
}),
|
||||
prisma.aclModelRole.deleteMany({ where: { roleId: id } }),
|
||||
prisma.aclRole.delete({ where: { id } }),
|
||||
]);
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
);
|
||||
@@ -2,138 +2,207 @@
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const giveCurrency = async ({
|
||||
rconClient: _rconClient,
|
||||
db: _db,
|
||||
userId,
|
||||
type,
|
||||
amount,
|
||||
rconClient: _rconClient,
|
||||
db: _db,
|
||||
userId,
|
||||
type,
|
||||
amount,
|
||||
}: {
|
||||
rconClient: typeof rcon;
|
||||
db: typeof prisma;
|
||||
userId: number;
|
||||
type: string;
|
||||
amount: number;
|
||||
rconClient: typeof rcon;
|
||||
db: typeof prisma;
|
||||
userId: number;
|
||||
type: string;
|
||||
amount: number;
|
||||
}) => {
|
||||
await logStaffActivity({
|
||||
staffId: 1,
|
||||
action: "give_currency",
|
||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
return { success: true, message: `Gave ${amount} ${type} to user #${userId}` };
|
||||
await logStaffActivity({
|
||||
staffId: 1,
|
||||
action: "give_currency",
|
||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: `Gave ${amount} ${type} to user #${userId}`,
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const staff = await requireStaff();
|
||||
const formData = await request.formData();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") || "");
|
||||
const action = String(formData.get("action") || "");
|
||||
try {
|
||||
const staff = await requireStaff();
|
||||
const formData = await request.formData();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") || "");
|
||||
const action = String(formData.get("action") || "");
|
||||
|
||||
if (!userId || !username) {
|
||||
return NextResponse.json({ success: false, message: "Invalid user data" }, { status: 400 });
|
||||
}
|
||||
if (!userId || !username) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid user data" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "set_rank") {
|
||||
const rank = Number(formData.get("rank") || "0");
|
||||
if (!rank || rank < 0 || rank > 10) {
|
||||
return NextResponse.json({ success: false, message: "Invalid rank value" }, { status: 400 });
|
||||
}
|
||||
if (action === "set_rank") {
|
||||
const rank = Number(formData.get("rank") || "0");
|
||||
if (!rank || rank < 0 || rank > 10) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid rank value" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||
await rcon.setRank(userId, rank);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "rank_change",
|
||||
description: `Set rank of user #${userId} to ${rank}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||
await rcon.setRank(userId, rank);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "rank_change",
|
||||
description: `Set rank of user #${userId} to ${rank}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Set rank of user #${userId} to ${rank}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Set rank of user #${userId} to ${rank}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "disconnect") {
|
||||
await rcon.disconnectUser(userId, username);
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Disconnected user #${userId} (${username})` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (action === "disconnect") {
|
||||
await rcon.disconnectUser(userId, username);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: `Disconnected user #${userId} (${username})`,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "alert") {
|
||||
const message = String(formData.get("message") || "")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!message) {
|
||||
return NextResponse.json({ success: false, message: "Alert message is required" }, { status: 400 });
|
||||
}
|
||||
await rcon.alertUser(userId, message);
|
||||
return NextResponse.json({ success: true, message: `Sent alert to user #${userId}` }, { status: 200 });
|
||||
}
|
||||
if (action === "alert") {
|
||||
const message = String(formData.get("message") || "")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!message) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Alert message is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await rcon.alertUser(userId, message);
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Sent alert to user #${userId}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "give_credits") {
|
||||
const credits = Number(formData.get("credits") || "0");
|
||||
if (!credits || credits <= 0) {
|
||||
return NextResponse.json({ success: false, message: "Invalid credit amount" }, { status: 400 });
|
||||
}
|
||||
await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "credits", amount: credits });
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Gave ${credits} credits to user #${userId}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (action === "give_credits") {
|
||||
const credits = Number(formData.get("credits") || "0");
|
||||
if (!credits || credits <= 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid credit amount" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await giveCurrency({
|
||||
rconClient: rcon,
|
||||
db: prisma,
|
||||
userId,
|
||||
type: "credits",
|
||||
amount: credits,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: `Gave ${credits} credits to user #${userId}`,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "give_duckets") {
|
||||
const amount = Number(formData.get("amount") || "0");
|
||||
if (!amount || amount <= 0) {
|
||||
return NextResponse.json({ success: false, message: "Invalid duckets amount" }, { status: 400 });
|
||||
}
|
||||
await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "duckets", amount });
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Gave ${amount} duckets to user #${userId}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (action === "give_duckets") {
|
||||
const amount = Number(formData.get("amount") || "0");
|
||||
if (!amount || amount <= 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid duckets amount" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await giveCurrency({
|
||||
rconClient: rcon,
|
||||
db: prisma,
|
||||
userId,
|
||||
type: "duckets",
|
||||
amount,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Gave ${amount} duckets to user #${userId}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "give_diamonds") {
|
||||
const amount = Number(formData.get("amount") || "0");
|
||||
if (!amount || amount <= 0) {
|
||||
return NextResponse.json({ success: false, message: "Invalid diamonds amount" }, { status: 400 });
|
||||
}
|
||||
await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "diamonds", amount });
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Gave ${amount} diamonds to user #${userId}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (action === "give_diamonds") {
|
||||
const amount = Number(formData.get("amount") || "0");
|
||||
if (!amount || amount <= 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid diamonds amount" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await giveCurrency({
|
||||
rconClient: rcon,
|
||||
db: prisma,
|
||||
userId,
|
||||
type: "diamonds",
|
||||
amount,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: `Gave ${amount} diamonds to user #${userId}`,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "give_points") {
|
||||
const amount = Number(formData.get("amount") || "0");
|
||||
if (!amount || amount <= 0) {
|
||||
return NextResponse.json({ success: false, message: "Invalid points amount" }, { status: 400 });
|
||||
}
|
||||
await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "points", amount });
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Gave ${amount} points to user #${userId}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (action === "give_points") {
|
||||
const amount = Number(formData.get("amount") || "0");
|
||||
if (!amount || amount <= 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid points amount" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await giveCurrency({
|
||||
rconClient: rcon,
|
||||
db: prisma,
|
||||
userId,
|
||||
type: "points",
|
||||
amount,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Gave ${amount} points to user #${userId}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, message: `Unknown action: ${action}` }, { status: 400 });
|
||||
} catch (error) {
|
||||
logger.error("Admin users actions error", { module: "admin/users/actions", error: String(error) });
|
||||
return NextResponse.json({ success: false, message: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ success: false, message: `Unknown action: ${action}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Admin users actions error", {
|
||||
module: "admin/users/actions",
|
||||
error: String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,44 +12,47 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const { slug } = await params;
|
||||
const { slug } = await params;
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { comment?: unknown };
|
||||
const comment = typeof body.comment === "string" ? body.comment.trim() : "";
|
||||
if (!comment) {
|
||||
return apiError("Comment is required", 422);
|
||||
}
|
||||
if (comment.length > 255) {
|
||||
return apiError("Comment may not be longer than 255 characters", 422);
|
||||
}
|
||||
const body = (await req.json().catch(() => ({}))) as { comment?: unknown };
|
||||
const comment = typeof body.comment === "string" ? body.comment.trim() : "";
|
||||
if (!comment) {
|
||||
return apiError("Comment is required", 422);
|
||||
}
|
||||
if (comment.length > 255) {
|
||||
return apiError("Comment may not be longer than 255 characters", 422);
|
||||
}
|
||||
|
||||
try {
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!article) {
|
||||
return apiError("Article not found", 404);
|
||||
}
|
||||
try {
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!article) {
|
||||
return apiError("Article not found", 404);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteArticleComments.create({
|
||||
data: {
|
||||
articleId: article.id,
|
||||
userId: uid,
|
||||
comment,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const now = new Date();
|
||||
await prisma.websiteArticleComments.create({
|
||||
data: {
|
||||
articleId: article.id,
|
||||
userId: uid,
|
||||
comment,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
return apiError("Could not post comment", 400);
|
||||
}
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
return apiError("Could not post comment", 400);
|
||||
}
|
||||
}
|
||||
@@ -7,31 +7,34 @@ export const dynamic = "force-dynamic";
|
||||
* GET /api/articles/:slug — single website_article by slug, including the
|
||||
* fullStory body. Returns { error } (404) when the slug is unknown.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
try {
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
fullStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
fullStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
return apiJson({ error: "Article not found" }, { status: 404 });
|
||||
}
|
||||
if (!article) {
|
||||
return apiJson({ error: "Article not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return apiJson({ data: article });
|
||||
} catch {
|
||||
// DB unavailable — treat as not found rather than a 500.
|
||||
return apiJson({ error: "Article not found" }, { status: 200 });
|
||||
}
|
||||
return apiJson({ data: article });
|
||||
} catch {
|
||||
// DB unavailable — treat as not found rather than a 500.
|
||||
return apiJson({ error: "Article not found" }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -9,38 +9,41 @@ export const dynamic = "force-dynamic";
|
||||
* (shortStory only, never fullStory) plus pagination metadata.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
try {
|
||||
const [total, articles] = await Promise.all([
|
||||
prisma.websiteArticles.count(),
|
||||
prisma.websiteArticles.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
const [total, articles] = await Promise.all([
|
||||
prisma.websiteArticles.count(),
|
||||
prisma.websiteArticles.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiJson({
|
||||
data: articles,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson({ data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, { status: 200 });
|
||||
}
|
||||
return apiJson({
|
||||
data: articles,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,58 +2,72 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type BadgeRarityKey = "common" | "rare" | "epic" | "legendary" | "mythical" | "unique";
|
||||
type BadgeRarityKey =
|
||||
| "common"
|
||||
| "rare"
|
||||
| "epic"
|
||||
| "legendary"
|
||||
| "mythical"
|
||||
| "unique";
|
||||
|
||||
interface BadgeLeaderboardEntry {
|
||||
userId: number;
|
||||
username: string;
|
||||
figure: string;
|
||||
score: number;
|
||||
rank: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
figure: string;
|
||||
score: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
interface BadgeLeaderboardBoard {
|
||||
entries: BadgeLeaderboardEntry[];
|
||||
totalPlayers: number;
|
||||
viewerEntry?: Partial<BadgeLeaderboardEntry>;
|
||||
entries: BadgeLeaderboardEntry[];
|
||||
totalPlayers: number;
|
||||
viewerEntry?: Partial<BadgeLeaderboardEntry>;
|
||||
}
|
||||
|
||||
interface BadgeLeaderboardStat {
|
||||
badgeCode: string;
|
||||
ownerCount: number;
|
||||
rarity: BadgeRarityKey;
|
||||
badgeCode: string;
|
||||
ownerCount: number;
|
||||
rarity: BadgeRarityKey;
|
||||
}
|
||||
|
||||
const THRESHOLDS = {
|
||||
commonMinOwners: 500,
|
||||
rareMinOwners: 100,
|
||||
epicMinOwners: 20,
|
||||
legendaryMinOwners: 5,
|
||||
mythicalMinOwners: 2,
|
||||
uniqueOwners: 1,
|
||||
commonMinOwners: 500,
|
||||
rareMinOwners: 100,
|
||||
epicMinOwners: 20,
|
||||
legendaryMinOwners: 5,
|
||||
mythicalMinOwners: 2,
|
||||
uniqueOwners: 1,
|
||||
};
|
||||
|
||||
function assignRarity(ownerCount: number): BadgeRarityKey {
|
||||
if (ownerCount >= THRESHOLDS.commonMinOwners) return "common";
|
||||
if (ownerCount >= THRESHOLDS.rareMinOwners) return "rare";
|
||||
if (ownerCount >= THRESHOLDS.epicMinOwners) return "epic";
|
||||
if (ownerCount >= THRESHOLDS.legendaryMinOwners) return "legendary";
|
||||
if (ownerCount >= THRESHOLDS.mythicalMinOwners) return "mythical";
|
||||
return "unique";
|
||||
if (ownerCount >= THRESHOLDS.commonMinOwners) return "common";
|
||||
if (ownerCount >= THRESHOLDS.rareMinOwners) return "rare";
|
||||
if (ownerCount >= THRESHOLDS.epicMinOwners) return "epic";
|
||||
if (ownerCount >= THRESHOLDS.legendaryMinOwners) return "legendary";
|
||||
if (ownerCount >= THRESHOLDS.mythicalMinOwners) return "mythical";
|
||||
return "unique";
|
||||
}
|
||||
|
||||
function rankEntries(entries: BadgeLeaderboardEntry[]): BadgeLeaderboardEntry[] {
|
||||
return entries.sort((a, b) => b.score - a.score).map((e, i) => ({ ...e, rank: i + 1 }));
|
||||
function rankEntries(
|
||||
entries: BadgeLeaderboardEntry[],
|
||||
): BadgeLeaderboardEntry[] {
|
||||
return entries
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((e, i) => ({ ...e, rank: i + 1 }));
|
||||
}
|
||||
|
||||
async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderboardBoard> {
|
||||
const rows = await prisma.$queryRaw<Array<{ userId: bigint; username: string; look: string; cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
async function loadTotalBadgesBoard(
|
||||
userId: number | null,
|
||||
): Promise<BadgeLeaderboardBoard> {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ userId: bigint; username: string; look: string; cnt: bigint }>
|
||||
>(
|
||||
Prisma.sql`
|
||||
SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt
|
||||
FROM users_badges ub
|
||||
JOIN users u ON u.id = ub.user_id
|
||||
@@ -61,149 +75,153 @@ async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderb
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 20
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
const entries = rankEntries(
|
||||
rows.map((r) => ({
|
||||
userId: Number(r.userId),
|
||||
username: r.username,
|
||||
figure: r.look,
|
||||
score: Number(r.cnt),
|
||||
rank: 0,
|
||||
})),
|
||||
);
|
||||
const entries = rankEntries(
|
||||
rows.map((r) => ({
|
||||
userId: Number(r.userId),
|
||||
username: r.username,
|
||||
figure: r.look,
|
||||
score: Number(r.cnt),
|
||||
rank: 0,
|
||||
})),
|
||||
);
|
||||
|
||||
const totalPlayers =
|
||||
entries.length > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`,
|
||||
)
|
||||
)[0]?.cnt ?? 0,
|
||||
)
|
||||
: 0;
|
||||
const totalPlayers =
|
||||
entries.length > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`,
|
||||
)
|
||||
)[0]?.cnt ?? 0,
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerRow = await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`,
|
||||
);
|
||||
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
|
||||
if (viewerScore > 0) {
|
||||
const viewerRank =
|
||||
entries.length > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerRow = await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`,
|
||||
);
|
||||
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
|
||||
if (viewerScore > 0) {
|
||||
const viewerRank =
|
||||
entries.length > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
SELECT COUNT(*) + 1 AS cnt
|
||||
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t
|
||||
WHERE t.total > ${viewerScore}
|
||||
`,
|
||||
)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
)
|
||||
: 1;
|
||||
const viewerUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
if (viewerUser) {
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
)
|
||||
: 1;
|
||||
const viewerUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
if (viewerUser) {
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
}
|
||||
|
||||
async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderboardBoard> {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ userId: bigint; username: string; look: string; score: number }>
|
||||
>(
|
||||
Prisma.sql`
|
||||
async function loadAchievementBoard(
|
||||
userId: number | null,
|
||||
): Promise<BadgeLeaderboardBoard> {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ userId: bigint; username: string; look: string; score: number }>
|
||||
>(
|
||||
Prisma.sql`
|
||||
SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score
|
||||
FROM users_settings us
|
||||
JOIN users u ON u.id = us.user_id
|
||||
ORDER BY us.achievement_score DESC
|
||||
LIMIT 20
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
const entries = rankEntries(
|
||||
rows.map((r) => ({
|
||||
userId: Number(r.userId),
|
||||
username: r.username,
|
||||
figure: r.look,
|
||||
score: r.score,
|
||||
rank: 0,
|
||||
})),
|
||||
);
|
||||
const entries = rankEntries(
|
||||
rows.map((r) => ({
|
||||
userId: Number(r.userId),
|
||||
username: r.username,
|
||||
figure: r.look,
|
||||
score: r.score,
|
||||
rank: 0,
|
||||
})),
|
||||
);
|
||||
|
||||
const totalPlayers =
|
||||
entries.length > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`,
|
||||
)
|
||||
)[0]?.cnt ?? 0,
|
||||
)
|
||||
: 0;
|
||||
const totalPlayers =
|
||||
entries.length > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`,
|
||||
)
|
||||
)[0]?.cnt ?? 0,
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
if (viewerUser) {
|
||||
const viewerSettings = await prisma.usersSettings.findUnique({
|
||||
where: { userId },
|
||||
select: { achievementScore: true },
|
||||
});
|
||||
if (viewerSettings && viewerSettings.achievementScore > 0) {
|
||||
const viewerRank = Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
if (viewerUser) {
|
||||
const viewerSettings = await prisma.usersSettings.findUnique({
|
||||
where: { userId },
|
||||
select: { achievementScore: true },
|
||||
});
|
||||
if (viewerSettings && viewerSettings.achievementScore > 0) {
|
||||
const viewerRank = Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore}
|
||||
`,
|
||||
)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
);
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerSettings.achievementScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
);
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerSettings.achievementScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
}
|
||||
|
||||
async function loadRarityBoard(
|
||||
rarity: BadgeRarityKey,
|
||||
badgeCodes: string[],
|
||||
userId: number | null,
|
||||
_rarity: BadgeRarityKey,
|
||||
badgeCodes: string[],
|
||||
userId: number | null,
|
||||
): Promise<BadgeLeaderboardBoard> {
|
||||
if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 };
|
||||
if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 };
|
||||
|
||||
const codes = Prisma.join(badgeCodes);
|
||||
const codes = Prisma.join(badgeCodes);
|
||||
|
||||
const rows = await prisma.$queryRaw<Array<{ userId: bigint; username: string; look: string; cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ userId: bigint; username: string; look: string; cnt: bigint }>
|
||||
>(
|
||||
Prisma.sql`
|
||||
SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt
|
||||
FROM users_badges ub
|
||||
JOIN users u ON u.id = ub.user_id
|
||||
@@ -212,137 +230,151 @@ async function loadRarityBoard(
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 20
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
const entries = rankEntries(
|
||||
rows.map((r) => ({
|
||||
userId: Number(r.userId),
|
||||
username: r.username,
|
||||
figure: r.look,
|
||||
score: Number(r.cnt),
|
||||
rank: 0,
|
||||
})),
|
||||
);
|
||||
const entries = rankEntries(
|
||||
rows.map((r) => ({
|
||||
userId: Number(r.userId),
|
||||
username: r.username,
|
||||
figure: r.look,
|
||||
score: Number(r.cnt),
|
||||
rank: 0,
|
||||
})),
|
||||
);
|
||||
|
||||
const totalPlayers =
|
||||
rows.length > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
const totalPlayers =
|
||||
rows.length > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${codes})
|
||||
`,
|
||||
)
|
||||
)[0]?.cnt ?? 0,
|
||||
)
|
||||
: 0;
|
||||
)
|
||||
)[0]?.cnt ?? 0,
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerRow = await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerRow = await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
SELECT COUNT(*) AS cnt FROM users_badges
|
||||
WHERE user_id = ${userId} AND badge_code IN (${codes})
|
||||
`,
|
||||
);
|
||||
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
|
||||
if (viewerScore > 0) {
|
||||
const viewerUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
if (viewerUser) {
|
||||
const viewerRank = Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
);
|
||||
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
|
||||
if (viewerScore > 0) {
|
||||
const viewerUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
if (viewerUser) {
|
||||
const viewerRank = Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
|
||||
Prisma.sql`
|
||||
SELECT COUNT(*) + 1 AS cnt
|
||||
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t
|
||||
WHERE t.total > ${viewerScore}
|
||||
`,
|
||||
)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
);
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
);
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
let userId: number | null = await bearerUserId(req);
|
||||
if (!userId) {
|
||||
const session = await auth();
|
||||
userId = session?.user?.id ? Number(session.user.id) : null;
|
||||
}
|
||||
try {
|
||||
let userId: number | null = await bearerUserId(req);
|
||||
if (!userId) {
|
||||
const session = await auth();
|
||||
userId = session?.user?.id ? Number(session.user.id) : null;
|
||||
}
|
||||
|
||||
const badgeStatsRaw = await prisma.$queryRaw<Array<{ badgeCode: string; ownerCount: bigint }>>(
|
||||
Prisma.sql`
|
||||
const badgeStatsRaw = await prisma.$queryRaw<
|
||||
Array<{ badgeCode: string; ownerCount: bigint }>
|
||||
>(
|
||||
Prisma.sql`
|
||||
SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount
|
||||
FROM users_badges
|
||||
GROUP BY badge_code
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
|
||||
badgeCode: r.badgeCode,
|
||||
ownerCount: Number(r.ownerCount),
|
||||
rarity: assignRarity(Number(r.ownerCount)),
|
||||
}));
|
||||
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
|
||||
badgeCode: r.badgeCode,
|
||||
ownerCount: Number(r.ownerCount),
|
||||
rarity: assignRarity(Number(r.ownerCount)),
|
||||
}));
|
||||
|
||||
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
|
||||
for (const stat of badgeStats) {
|
||||
const list = badgesByRarity.get(stat.rarity) ?? [];
|
||||
list.push(stat.badgeCode);
|
||||
badgesByRarity.set(stat.rarity, list);
|
||||
}
|
||||
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
|
||||
for (const stat of badgeStats) {
|
||||
const list = badgesByRarity.get(stat.rarity) ?? [];
|
||||
list.push(stat.badgeCode);
|
||||
badgesByRarity.set(stat.rarity, list);
|
||||
}
|
||||
|
||||
const rarityKeys: BadgeRarityKey[] = ["common", "rare", "epic", "legendary", "mythical", "unique"];
|
||||
const rarityKeys: BadgeRarityKey[] = [
|
||||
"common",
|
||||
"rare",
|
||||
"epic",
|
||||
"legendary",
|
||||
"mythical",
|
||||
"unique",
|
||||
];
|
||||
|
||||
const [totalBadges, achievementLevel, ...rarityBoards] = await Promise.all([
|
||||
loadTotalBadgesBoard(userId),
|
||||
loadAchievementBoard(userId),
|
||||
...rarityKeys.map((rk) => loadRarityBoard(rk, badgesByRarity.get(rk) ?? [], userId)),
|
||||
]);
|
||||
const [totalBadges, achievementLevel, ...rarityBoards] = await Promise.all([
|
||||
loadTotalBadgesBoard(userId),
|
||||
loadAchievementBoard(userId),
|
||||
...rarityKeys.map((rk) =>
|
||||
loadRarityBoard(rk, badgesByRarity.get(rk) ?? [], userId),
|
||||
),
|
||||
]);
|
||||
|
||||
const rarity = Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
||||
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
|
||||
const rarity = Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
||||
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
|
||||
|
||||
return apiJson({
|
||||
viewerUserId: userId ?? 0,
|
||||
badgeStats,
|
||||
thresholds: THRESHOLDS,
|
||||
leaderboards: { totalBadges, achievementLevel, rarity },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Badge leaderboard error", { module: "badges/leaderboard", error: String(err) });
|
||||
return apiJson({
|
||||
viewerUserId: 0,
|
||||
badgeStats: [],
|
||||
thresholds: THRESHOLDS,
|
||||
leaderboards: {
|
||||
totalBadges: { entries: [], totalPlayers: 0 },
|
||||
achievementLevel: { entries: [], totalPlayers: 0 },
|
||||
rarity: {
|
||||
common: { entries: [], totalPlayers: 0 },
|
||||
rare: { entries: [], totalPlayers: 0 },
|
||||
epic: { entries: [], totalPlayers: 0 },
|
||||
legendary: { entries: [], totalPlayers: 0 },
|
||||
mythical: { entries: [], totalPlayers: 0 },
|
||||
unique: { entries: [], totalPlayers: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return apiJson({
|
||||
viewerUserId: userId ?? 0,
|
||||
badgeStats,
|
||||
thresholds: THRESHOLDS,
|
||||
leaderboards: { totalBadges, achievementLevel, rarity },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Badge leaderboard error", {
|
||||
module: "badges/leaderboard",
|
||||
error: String(err),
|
||||
});
|
||||
return apiJson({
|
||||
viewerUserId: 0,
|
||||
badgeStats: [],
|
||||
thresholds: THRESHOLDS,
|
||||
leaderboards: {
|
||||
totalBadges: { entries: [], totalPlayers: 0 },
|
||||
achievementLevel: { entries: [], totalPlayers: 0 },
|
||||
rarity: {
|
||||
common: { entries: [], totalPlayers: 0 },
|
||||
rare: { entries: [], totalPlayers: 0 },
|
||||
epic: { entries: [], totalPlayers: 0 },
|
||||
legendary: { entries: [], totalPlayers: 0 },
|
||||
mythical: { entries: [], totalPlayers: 0 },
|
||||
unique: { entries: [], totalPlayers: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,46 +7,49 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return apiJson({ error: "Guild not found" }, { status: 404 });
|
||||
}
|
||||
const guildId = Number(id);
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return apiJson({ error: "Guild not found" }, { status: 404 });
|
||||
}
|
||||
const guildId = Number(id);
|
||||
|
||||
try {
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id: guildId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
userId: true,
|
||||
roomId: true,
|
||||
badge: true,
|
||||
dateCreated: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id: guildId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
userId: true,
|
||||
roomId: true,
|
||||
badge: true,
|
||||
dateCreated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!guild) {
|
||||
return apiJson({ error: "Guild not found" }, { status: 404 });
|
||||
}
|
||||
if (!guild) {
|
||||
return apiJson({ error: "Guild not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Member count is a separate guarded query (no relation is modelled).
|
||||
let memberCount = 0;
|
||||
try {
|
||||
memberCount = await prisma.guildsMembers.count({
|
||||
where: { guildId: guild.id },
|
||||
});
|
||||
} catch {
|
||||
memberCount = 0;
|
||||
}
|
||||
// Member count is a separate guarded query (no relation is modelled).
|
||||
let memberCount = 0;
|
||||
try {
|
||||
memberCount = await prisma.guildsMembers.count({
|
||||
where: { guildId: guild.id },
|
||||
});
|
||||
} catch {
|
||||
memberCount = 0;
|
||||
}
|
||||
|
||||
const { userId, ...rest } = guild;
|
||||
return apiJson({ data: { ...rest, ownerId: userId, memberCount } });
|
||||
} catch {
|
||||
// DB unavailable — treat as not found rather than a 500.
|
||||
return apiJson({ error: "Guild not found" }, { status: 200 });
|
||||
}
|
||||
const { userId, ...rest } = guild;
|
||||
return apiJson({ data: { ...rest, ownerId: userId, memberCount } });
|
||||
} catch {
|
||||
// DB unavailable — treat as not found rather than a 500.
|
||||
return apiJson({ error: "Guild not found" }, { status: 200 });
|
||||
}
|
||||
}
|
||||
+38
-30
@@ -8,37 +8,45 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
try {
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.guilds.count(),
|
||||
prisma.guilds.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
userId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.guilds.count(),
|
||||
prisma.guilds.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
userId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Rename userId → ownerId for the public payload.
|
||||
const data = rows.map(({ userId, ...rest }) => ({
|
||||
...rest,
|
||||
ownerId: userId,
|
||||
}));
|
||||
// Rename userId → ownerId for the public payload.
|
||||
const data = rows.map(({ userId, ...rest }) => ({
|
||||
...rest,
|
||||
ownerId: userId,
|
||||
}));
|
||||
|
||||
return apiJson({
|
||||
data,
|
||||
meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||
return apiJson({ data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, { status: 200 });
|
||||
}
|
||||
return apiJson({
|
||||
data,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+30
-26
@@ -1,7 +1,7 @@
|
||||
import { env } from "@/env";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,32 +12,36 @@ export const dynamic = "force-dynamic";
|
||||
* about reachability.
|
||||
*/
|
||||
export async function GET() {
|
||||
const database = await prisma.$queryRaw`SELECT 1`.then(() => true).catch(() => false);
|
||||
const database = await prisma.$queryRaw`SELECT 1`
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||
|
||||
let smtp = null;
|
||||
if (env.SMTP_HOST) {
|
||||
const nodemailer = await import("nodemailer");
|
||||
const test = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT,
|
||||
secure: env.SMTP_SECURE,
|
||||
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD ?? "" } : undefined,
|
||||
});
|
||||
smtp = await test
|
||||
.verify()
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
let smtp = null;
|
||||
if (env.SMTP_HOST) {
|
||||
const nodemailer = await import("nodemailer");
|
||||
const test = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT,
|
||||
secure: env.SMTP_SECURE,
|
||||
auth: env.SMTP_USER
|
||||
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD ?? "" }
|
||||
: undefined,
|
||||
});
|
||||
smtp = await test
|
||||
.verify()
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
return apiJson({
|
||||
status: database ? "ok" : "degraded",
|
||||
database,
|
||||
emulator,
|
||||
smtp,
|
||||
node: process.version,
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
return apiJson({
|
||||
status: database ? "ok" : "degraded",
|
||||
database,
|
||||
emulator,
|
||||
smtp,
|
||||
node: process.version,
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
+25
-25
@@ -10,31 +10,31 @@ export const dynamic = "force-dynamic";
|
||||
* used by the public pages and the admin dashboard.
|
||||
*/
|
||||
export async function GET(_req: Request) {
|
||||
let articles: unknown[] = [];
|
||||
let online = 0;
|
||||
let hotelName = "Atom";
|
||||
let articles: unknown[] = [];
|
||||
let online = 0;
|
||||
let hotelName = "Atom";
|
||||
|
||||
try {
|
||||
[articles, online, hotelName] = await Promise.all([
|
||||
prisma.websiteArticles.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4,
|
||||
}),
|
||||
prisma.user.count({ where: { online: "1" } }),
|
||||
siteSettings.get("hotel_name", "Atom").then((v) => v ?? "Atom"),
|
||||
]);
|
||||
try {
|
||||
[articles, online, hotelName] = await Promise.all([
|
||||
prisma.websiteArticles.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4,
|
||||
}),
|
||||
prisma.user.count({ where: { online: "1" } }),
|
||||
siteSettings.get("hotel_name", "Atom").then((v) => v ?? "Atom"),
|
||||
]);
|
||||
|
||||
return apiJson({ articles, online, hotelName });
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson({ articles: [], online: 0, hotelName }, { status: 200 });
|
||||
}
|
||||
return apiJson({ articles, online, hotelName });
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson({ articles: [], online: 0, hotelName }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -9,65 +9,68 @@ export const dynamic = "force-dynamic";
|
||||
type LeaderboardType = "credits" | "diamonds" | "duckets";
|
||||
|
||||
const CURRENCY_TYPE: Record<Exclude<LeaderboardType, "credits">, number> = {
|
||||
diamonds: 5,
|
||||
duckets: 0,
|
||||
diamonds: 5,
|
||||
duckets: 0,
|
||||
};
|
||||
|
||||
type Row = { rank: number; username: string; look: string; value: number };
|
||||
|
||||
async function loadCreditsRows(): Promise<Row[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { credits: "desc" },
|
||||
take: 20,
|
||||
select: { username: true, look: true, credits: true },
|
||||
});
|
||||
return users.map((u, i) => ({
|
||||
rank: i + 1,
|
||||
username: u.username,
|
||||
look: u.look,
|
||||
value: u.credits,
|
||||
}));
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { credits: "desc" },
|
||||
take: 20,
|
||||
select: { username: true, look: true, credits: true },
|
||||
});
|
||||
return users.map((u, i) => ({
|
||||
rank: i + 1,
|
||||
username: u.username,
|
||||
look: u.look,
|
||||
value: u.credits,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadCurrencyRows(type: number): Promise<Row[]> {
|
||||
const top = await prisma.usersCurrency.findMany({
|
||||
where: { type },
|
||||
orderBy: { amount: "desc" },
|
||||
take: 20,
|
||||
select: { userId: true, amount: true },
|
||||
});
|
||||
if (top.length === 0) return [];
|
||||
const top = await prisma.usersCurrency.findMany({
|
||||
where: { type },
|
||||
orderBy: { amount: "desc" },
|
||||
take: 20,
|
||||
select: { userId: true, amount: true },
|
||||
});
|
||||
if (top.length === 0) return [];
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: top.map((t) => t.userId) } },
|
||||
select: { id: true, username: true, look: true },
|
||||
});
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: top.map((t) => t.userId) } },
|
||||
select: { id: true, username: true, look: true },
|
||||
});
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return top
|
||||
.map((t) => {
|
||||
const u = byId.get(t.userId);
|
||||
if (!u) return null;
|
||||
return { username: u.username, look: u.look, value: t.amount };
|
||||
})
|
||||
.filter((r): r is Omit<Row, "rank"> => r !== null)
|
||||
.map((r, i) => ({ rank: i + 1, ...r }));
|
||||
return top
|
||||
.map((t) => {
|
||||
const u = byId.get(t.userId);
|
||||
if (!u) return null;
|
||||
return { username: u.username, look: u.look, value: t.amount };
|
||||
})
|
||||
.filter((r): r is Omit<Row, "rank"> => r !== null)
|
||||
.map((r, i) => ({ rank: i + 1, ...r }));
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const requested = sp.get("type");
|
||||
const type: LeaderboardType = requested === "diamonds" || requested === "duckets" ? requested : "credits";
|
||||
try {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const requested = sp.get("type");
|
||||
const type: LeaderboardType =
|
||||
requested === "diamonds" || requested === "duckets"
|
||||
? requested
|
||||
: "credits";
|
||||
|
||||
const rows =
|
||||
type === "credits"
|
||||
? await loadCreditsRows()
|
||||
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
||||
await loadCurrencyRows(CURRENCY_TYPE[type]);
|
||||
const rows =
|
||||
type === "credits"
|
||||
? await loadCreditsRows()
|
||||
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
||||
await loadCurrencyRows(CURRENCY_TYPE[type]);
|
||||
|
||||
return apiJson({ type, data: rows }, { status: 200 });
|
||||
} catch {
|
||||
return apiJson({ type: "credits", data: [] }, { status: 200 });
|
||||
}
|
||||
return apiJson({ type, data: rows }, { status: 200 });
|
||||
} catch {
|
||||
return apiJson({ type: "credits", data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
+44
-44
@@ -6,56 +6,56 @@
|
||||
|
||||
import { apiJson, apiUnavailable } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
const session = await auth();
|
||||
const id = sessionUserId(session?.user?.id);
|
||||
if (!id) {
|
||||
return apiJson({ user: null });
|
||||
}
|
||||
const session = await auth();
|
||||
const id = sessionUserId(session?.user?.id);
|
||||
if (!id) {
|
||||
return apiJson({ user: null });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
look: true,
|
||||
motto: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
points: true,
|
||||
gender: true,
|
||||
online: true,
|
||||
accountCreated: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
look: true,
|
||||
motto: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
points: true,
|
||||
gender: true,
|
||||
online: true,
|
||||
accountCreated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiJson({ user: null });
|
||||
}
|
||||
if (!user) {
|
||||
return apiJson({ user: null });
|
||||
}
|
||||
|
||||
return apiJson({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
look: user.look,
|
||||
motto: user.motto,
|
||||
rank: user.rank,
|
||||
credits: user.credits,
|
||||
pixels: user.pixels,
|
||||
points: user.points,
|
||||
gender: user.gender,
|
||||
online: user.online === "1",
|
||||
accountCreated: user.accountCreated,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return apiUnavailable("Account data is temporarily unavailable");
|
||||
}
|
||||
return apiJson({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
look: user.look,
|
||||
motto: user.motto,
|
||||
rank: user.rank,
|
||||
credits: user.credits,
|
||||
pixels: user.pixels,
|
||||
points: user.points,
|
||||
gender: user.gender,
|
||||
online: user.online === "1",
|
||||
accountCreated: user.accountCreated,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return apiUnavailable("Account data is temporarily unavailable");
|
||||
}
|
||||
}
|
||||
@@ -10,54 +10,54 @@
|
||||
|
||||
import { apiError, apiJson, apiUnavailable, positiveBigInt } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function currentUserId(): Promise<number | null> {
|
||||
const session = await auth();
|
||||
return sessionUserId(session?.user?.id);
|
||||
const session = await auth();
|
||||
return sessionUserId(session?.user?.id);
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
const id = await currentUserId();
|
||||
if (!id) return apiError("Unauthorized", 401);
|
||||
const id = await currentUserId();
|
||||
if (!id) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
const tokens = await prisma.personalAccessTokens.findMany({
|
||||
where: personalTokenScope(id),
|
||||
select: { id: true, name: true, lastUsedAt: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
// Never expose the token hash.
|
||||
return apiJson({ data: tokens });
|
||||
} catch {
|
||||
return apiUnavailable("Token data is temporarily unavailable");
|
||||
}
|
||||
try {
|
||||
const tokens = await prisma.personalAccessTokens.findMany({
|
||||
where: personalTokenScope(id),
|
||||
select: { id: true, name: true, lastUsedAt: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
// Never expose the token hash.
|
||||
return apiJson({ data: tokens });
|
||||
} catch {
|
||||
return apiUnavailable("Token data is temporarily unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
const id = await currentUserId();
|
||||
if (!id) return apiError("Unauthorized", 401);
|
||||
const id = await currentUserId();
|
||||
if (!id) return apiError("Unauthorized", 401);
|
||||
|
||||
const tokenId = new URL(req.url).searchParams.get("id");
|
||||
const parsedTokenId = positiveBigInt(tokenId);
|
||||
if (!parsedTokenId) {
|
||||
return apiError("A valid token id is required", 422);
|
||||
}
|
||||
const tokenId = new URL(req.url).searchParams.get("id");
|
||||
const parsedTokenId = positiveBigInt(tokenId);
|
||||
if (!parsedTokenId) {
|
||||
return apiError("A valid token id is required", 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Scope the delete to the owner so users cannot revoke others' tokens.
|
||||
const result = await prisma.personalAccessTokens.deleteMany({
|
||||
where: { id: parsedTokenId, ...personalTokenScope(id) },
|
||||
});
|
||||
if (result.count === 0) {
|
||||
return apiError("Token not found", 404);
|
||||
}
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
return apiUnavailable("Could not revoke token");
|
||||
}
|
||||
try {
|
||||
// Scope the delete to the owner so users cannot revoke others' tokens.
|
||||
const result = await prisma.personalAccessTokens.deleteMany({
|
||||
where: { id: parsedTokenId, ...personalTokenScope(id) },
|
||||
});
|
||||
if (result.count === 0) {
|
||||
return apiError("Token not found", 404);
|
||||
}
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
return apiUnavailable("Could not revoke token");
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,55 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MEDIA_DIR = "public/assets/images/media";
|
||||
const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"];
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path: segments } = await params;
|
||||
const name = segments.join("/");
|
||||
// Prevent path traversal
|
||||
if (name.includes("..") || name.includes("\\")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (!ALLOWED_EXT.includes(ext)) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path: segments } = await params;
|
||||
const name = segments.join("/");
|
||||
// Prevent path traversal
|
||||
if (name.includes("..") || name.includes("\\")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (!ALLOWED_EXT.includes(ext)) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(filePath)) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(filePath)) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const bytes = await readFile(filePath);
|
||||
const mime: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const bytes = await readFile(filePath);
|
||||
const mime: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
|
||||
return new NextResponse(bytes, {
|
||||
headers: {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
return new NextResponse(bytes, {
|
||||
headers: {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
+16
-16
@@ -1,25 +1,25 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { readdirSync, existsSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MEDIA_DIR = "assets/images/media";
|
||||
|
||||
export async function GET() {
|
||||
const dir = path.resolve(process.cwd(), "public", MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(dir)) {
|
||||
return NextResponse.json({ files: [] });
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
url: `/api/media/${f}`,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const dir = path.resolve(process.cwd(), "public", MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(dir)) {
|
||||
return NextResponse.json({ files: [] });
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
url: `/api/media/${f}`,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return NextResponse.json({ files });
|
||||
return NextResponse.json({ files });
|
||||
}
|
||||
@@ -3,29 +3,39 @@ import path from "node:path";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
|
||||
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" };
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
};
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const segments = (await params).path;
|
||||
const filename = segments.at(-1);
|
||||
if (!filename || filename !== path.basename(filename) || !filename.endsWith(".nitro")) {
|
||||
return new NextResponse(null, { status: 404, headers: CORS_HEADERS });
|
||||
}
|
||||
try {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const file = await fs.readFile(path.join(nitroDir, filename));
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse(null, { status: 404, headers: CORS_HEADERS });
|
||||
}
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const segments = (await params).path;
|
||||
const filename = segments.at(-1);
|
||||
if (
|
||||
!filename ||
|
||||
filename !== path.basename(filename) ||
|
||||
!filename.endsWith(".nitro")
|
||||
) {
|
||||
return new NextResponse(null, { status: 404, headers: CORS_HEADERS });
|
||||
}
|
||||
try {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const file = await fs.readFile(path.join(nitroDir, filename));
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse(null, { status: 404, headers: CORS_HEADERS });
|
||||
}
|
||||
}
|
||||
|
||||
export function OPTIONS() {
|
||||
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
|
||||
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
@@ -10,10 +10,12 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const count = await cached("online_count", 10_000, () => prisma.user.count({ where: { online: "1" } }));
|
||||
return apiJson({ count });
|
||||
} catch {
|
||||
return apiJson({ count: 0 });
|
||||
}
|
||||
try {
|
||||
const count = await cached("online_count", 10_000, () =>
|
||||
prisma.user.count({ where: { online: "1" } }),
|
||||
);
|
||||
return apiJson({ count });
|
||||
} catch {
|
||||
return apiJson({ count: 0 });
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -10,15 +10,15 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { online: "1" },
|
||||
select: { username: true, look: true },
|
||||
take: 100,
|
||||
});
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { online: "1" },
|
||||
select: { username: true, look: true },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return apiJson({ users });
|
||||
} catch {
|
||||
return apiJson({ users: [] });
|
||||
}
|
||||
return apiJson({ users });
|
||||
} catch {
|
||||
return apiJson({ users: [] });
|
||||
}
|
||||
}
|
||||
+217
-163
@@ -1,14 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { captureOrder, creditsPerUnit, isPayPalConfigured } from "@/lib/services/paypal";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import {
|
||||
captureOrder,
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
} from "@/lib/services/paypal";
|
||||
import {
|
||||
authorizeTopupCapture,
|
||||
claimTopupDelivery,
|
||||
TopupCaptureError,
|
||||
} from "@/lib/services/paypal-topup";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { authorizeTopupCapture, claimTopupDelivery, TopupCaptureError } from "@/lib/services/paypal-topup";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -28,173 +36,219 @@ export const dynamic = "force-dynamic";
|
||||
* field, so a captured order always credits the person who is signed in.
|
||||
*/
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
|
||||
}
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You must be signed in." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const orderId = String((body as { orderId?: unknown })?.orderId ?? "").trim();
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ error: "Missing orderId." }, { status: 422 });
|
||||
}
|
||||
const orderId = String((body as { orderId?: unknown })?.orderId ?? "").trim();
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ error: "Missing orderId." }, { status: 422 });
|
||||
}
|
||||
|
||||
// The create endpoint records ownership before returning the approval URL.
|
||||
// Do not let a signed-in user submit somebody else's approved order id.
|
||||
let authorized;
|
||||
try {
|
||||
authorized = await authorizeTopupCapture(userId, orderId, async (transactionId) =>
|
||||
prisma.websitePaypalTransactions.findFirst({
|
||||
where: { transactionId },
|
||||
select: { userId: true, status: true, amount: true },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TopupCaptureError) {
|
||||
const status = error.code === "ORDER_NOT_FOUND" ? 404 : 409;
|
||||
return NextResponse.json({ error: "Order not found or already processed." }, { status });
|
||||
}
|
||||
return NextResponse.json({ error: "Could not verify the payment order." }, { status: 500 });
|
||||
}
|
||||
// The create endpoint records ownership before returning the approval URL.
|
||||
// Do not let a signed-in user submit somebody else's approved order id.
|
||||
let authorized;
|
||||
try {
|
||||
authorized = await authorizeTopupCapture(
|
||||
userId,
|
||||
orderId,
|
||||
async (transactionId) =>
|
||||
prisma.websitePaypalTransactions.findFirst({
|
||||
where: { transactionId },
|
||||
select: { userId: true, status: true, amount: true },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TopupCaptureError) {
|
||||
const status = error.code === "ORDER_NOT_FOUND" ? 404 : 409;
|
||||
return NextResponse.json(
|
||||
{ error: "Order not found or already processed." },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Could not verify the payment order." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (authorized.action === "DELIVER_CREDITS") {
|
||||
const amount = authorized.amount ?? 0;
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
try {
|
||||
await claimTopupDelivery(() =>
|
||||
prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
|
||||
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
|
||||
}),
|
||||
);
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
|
||||
data: { status: "COMPLETED", updatedAt: new Date() },
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
recovered: true,
|
||||
status: "COMPLETED",
|
||||
amount,
|
||||
credits,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("paypal.credit_recovery_failed", error, { userId, orderId });
|
||||
return NextResponse.json(
|
||||
{ error: "Payment is recorded but credits could not be delivered. Contact staff." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (authorized.action === "DELIVER_CREDITS") {
|
||||
const amount = authorized.amount ?? 0;
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
try {
|
||||
await claimTopupDelivery(() =>
|
||||
prisma.websitePaypalTransactions.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
transactionId: orderId,
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
},
|
||||
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
|
||||
}),
|
||||
);
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
|
||||
data: { status: "COMPLETED", updatedAt: new Date() },
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
recovered: true,
|
||||
status: "COMPLETED",
|
||||
amount,
|
||||
credits,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("paypal.credit_recovery_failed", error, {
|
||||
userId,
|
||||
orderId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Payment is recorded but credits could not be delivered. Contact staff.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await captureOrder(orderId);
|
||||
} catch (e) {
|
||||
logger.error("PayPal capture failed", { module: "paypal/capture", error: (e as Error).message });
|
||||
return NextResponse.json(
|
||||
{ error: "Could not capture the PayPal payment. If you were charged, contact staff." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await captureOrder(orderId);
|
||||
} catch (e) {
|
||||
logger.error("PayPal capture failed", {
|
||||
module: "paypal/capture",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Could not capture the PayPal payment. If you were charged, contact staff.",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status !== "COMPLETED") {
|
||||
// Record the non-completed attempt so support can trace it.
|
||||
try {
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREATED" },
|
||||
data: {
|
||||
status: result.status,
|
||||
description: `${env.HOTEL_NAME} top-up (not completed)`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* best-effort logging */
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ ok: false, status: result.status, error: "Payment was not completed." },
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
if (result.status !== "COMPLETED") {
|
||||
// Record the non-completed attempt so support can trace it.
|
||||
try {
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREATED" },
|
||||
data: {
|
||||
status: result.status,
|
||||
description: `${env.HOTEL_NAME} top-up (not completed)`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* best-effort logging */
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ ok: false, status: result.status, error: "Payment was not completed." },
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
|
||||
const credits = Math.floor(result.amount * creditsPerUnit());
|
||||
const credits = Math.floor(result.amount * creditsPerUnit());
|
||||
|
||||
// Record the transaction BEFORE crediting so a crash mid-grant can't be
|
||||
// reprocessed into a double credit (the idempotency check above keys on this).
|
||||
try {
|
||||
const claimed = await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREATED" },
|
||||
data: {
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) throw new Error("Top-up order was already claimed");
|
||||
} catch (e) {
|
||||
logger.error("PayPal capture record failed", { module: "paypal/capture", error: (e as Error).message });
|
||||
return NextResponse.json(
|
||||
{ error: "Payment captured but could not be recorded. Contact staff with your order id." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
// Record the transaction BEFORE crediting so a crash mid-grant can't be
|
||||
// reprocessed into a double credit (the idempotency check above keys on this).
|
||||
try {
|
||||
const claimed = await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREATED" },
|
||||
data: {
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1)
|
||||
throw new Error("Top-up order was already claimed");
|
||||
} catch (e) {
|
||||
logger.error("PayPal capture record failed", {
|
||||
module: "paypal/capture",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Payment captured but could not be recorded. Contact staff with your order id.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Atomically claim delivery so concurrent retries cannot grant twice. If the
|
||||
// external delivery outcome is ambiguous, CREDIT_DELIVERING remains visible
|
||||
// for staff reconciliation instead of automatically risking a second grant.
|
||||
try {
|
||||
await claimTopupDelivery(() =>
|
||||
prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
|
||||
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
|
||||
}),
|
||||
);
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
|
||||
data: { status: "COMPLETED", updatedAt: new Date() },
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("PayPal capture credit failed", { module: "paypal/capture", error: (e as Error).message });
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Payment recorded but credits could not be delivered. Contact staff.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
// Atomically claim delivery so concurrent retries cannot grant twice. If the
|
||||
// external delivery outcome is ambiguous, CREDIT_DELIVERING remains visible
|
||||
// for staff reconciliation instead of automatically risking a second grant.
|
||||
try {
|
||||
await claimTopupDelivery(() =>
|
||||
prisma.websitePaypalTransactions.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
transactionId: orderId,
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
},
|
||||
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
|
||||
}),
|
||||
);
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
|
||||
data: { status: "COMPLETED", updatedAt: new Date() },
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("PayPal capture credit failed", {
|
||||
module: "paypal/capture",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
"Payment recorded but credits could not be delivered. Contact staff.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: "COMPLETED",
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
credits,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: "COMPLETED",
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
credits,
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createOrder, creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
createOrder,
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
PAYPAL_CURRENCY,
|
||||
} from "@/lib/services/paypal";
|
||||
import { recordCreatedTopup } from "@/lib/services/paypal-topup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -20,73 +25,84 @@ const MAX_AMOUNT = 500;
|
||||
* Auth-gated via auth(): the order is tied to the session, never to a body field.
|
||||
*/
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "You must be signed in to top up." }, { status: 401 });
|
||||
}
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You must be signed in to top up." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fail fast (and clearly) when the sandbox/live keys aren't set.
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
// Fail fast (and clearly) when the sandbox/live keys aren't set.
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = (body as { amount?: unknown })?.amount;
|
||||
const amount = Math.round(Number(raw) * 100) / 100;
|
||||
if (!Number.isFinite(amount) || amount < MIN_AMOUNT || amount > MAX_AMOUNT) {
|
||||
return NextResponse.json(
|
||||
{ error: `Enter an amount between ${MIN_AMOUNT} and ${MAX_AMOUNT} ${PAYPAL_CURRENCY}.` },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const raw = (body as { amount?: unknown })?.amount;
|
||||
const amount = Math.round(Number(raw) * 100) / 100;
|
||||
if (!Number.isFinite(amount) || amount < MIN_AMOUNT || amount > MAX_AMOUNT) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Enter an amount between ${MIN_AMOUNT} and ${MAX_AMOUNT} ${PAYPAL_CURRENCY}.`,
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
|
||||
try {
|
||||
const order = await createOrder(amount, {
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
returnUrl: `${base}/shop/topup?status=success`,
|
||||
cancelUrl: `${base}/shop/topup?status=cancel`,
|
||||
});
|
||||
try {
|
||||
const order = await createOrder(amount, {
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
returnUrl: `${base}/shop/topup?status=success`,
|
||||
cancelUrl: `${base}/shop/topup?status=cancel`,
|
||||
});
|
||||
|
||||
if (!order.approveUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal did not return an approval link. Try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
if (!order.approveUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal did not return an approval link. Try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
await recordCreatedTopup(
|
||||
{ userId, orderId: order.id, amount, currency: PAYPAL_CURRENCY, credits },
|
||||
(data) => prisma.websitePaypalTransactions.create({ data }),
|
||||
);
|
||||
await recordCreatedTopup(
|
||||
{ userId, orderId: order.id, amount, currency: PAYPAL_CURRENCY, credits },
|
||||
(data) => prisma.websitePaypalTransactions.create({ data }),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
approveUrl: order.approveUrl,
|
||||
amount,
|
||||
currency: PAYPAL_CURRENCY,
|
||||
credits,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("PayPal create order failed", { module: "paypal/create", error: (e as Error).message });
|
||||
return NextResponse.json(
|
||||
{ error: "Could not start the PayPal checkout. Please try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
approveUrl: order.approveUrl,
|
||||
amount,
|
||||
currency: PAYPAL_CURRENCY,
|
||||
credits,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("PayPal create order failed", {
|
||||
module: "paypal/create",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Could not start the PayPal checkout. Please try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
-30
@@ -9,36 +9,39 @@ export const dynamic = "force-dynamic";
|
||||
* and timestamp plus pagination metadata.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
try {
|
||||
const [total, photos] = await Promise.all([
|
||||
prisma.cameraWeb.count(),
|
||||
prisma.cameraWeb.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
url: true,
|
||||
timestamp: true,
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
const [total, photos] = await Promise.all([
|
||||
prisma.cameraWeb.count(),
|
||||
prisma.cameraWeb.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
url: true,
|
||||
timestamp: true,
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiJson({
|
||||
data: photos,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson({ data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, { status: 200 });
|
||||
}
|
||||
return apiJson({
|
||||
data: photos,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,29 +10,29 @@ export const dynamic = "force-dynamic";
|
||||
const AUTOPLAY_KEYS = ["radio_auto_play", "radio_autoplay"];
|
||||
|
||||
function truthy(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
const s = value.trim().toLowerCase();
|
||||
return s === "1" || s === "true";
|
||||
if (value === undefined) return false;
|
||||
const s = value.trim().toLowerCase();
|
||||
return s === "1" || s === "true";
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: AUTOPLAY_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: AUTOPLAY_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
map[row.key] = row.value;
|
||||
}
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
map[row.key] = row.value;
|
||||
}
|
||||
|
||||
// Prefer the canonical key the settings page uses; fall back to the alias.
|
||||
const autoplay = truthy(map.radio_auto_play ?? map.radio_autoplay);
|
||||
// Prefer the canonical key the settings page uses; fall back to the alias.
|
||||
const autoplay = truthy(map.radio_auto_play ?? map.radio_autoplay);
|
||||
|
||||
return apiJson({ autoplay });
|
||||
} catch {
|
||||
// DB unavailable — default to no autoplay rather than a 500.
|
||||
return apiJson({ autoplay: false }, { status: 200 });
|
||||
}
|
||||
return apiJson({ autoplay });
|
||||
} catch {
|
||||
// DB unavailable — default to no autoplay rather than a 500.
|
||||
return apiJson({ autoplay: false }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -9,37 +9,37 @@ export const dynamic = "force-dynamic";
|
||||
// radio_* keys relevant to the public player widget. Mirrors what the AtomCMS
|
||||
// radio-player blade requests from /api/radio/config.
|
||||
const CONFIG_KEYS = new Set([
|
||||
"radio_enabled",
|
||||
"radio_name",
|
||||
"radio_stream_url",
|
||||
"radio_stream_backup_url",
|
||||
"radio_auto_play",
|
||||
"radio_auto_play_delay",
|
||||
"radio_mute_on_start",
|
||||
"radio_volume",
|
||||
"radio_style",
|
||||
"radio_player_type",
|
||||
"radio_logo_url",
|
||||
"radio_widget_enabled",
|
||||
"radio_widget_show_globally",
|
||||
"radio_widget_position",
|
||||
"radio_enabled",
|
||||
"radio_name",
|
||||
"radio_stream_url",
|
||||
"radio_stream_backup_url",
|
||||
"radio_auto_play",
|
||||
"radio_auto_play_delay",
|
||||
"radio_mute_on_start",
|
||||
"radio_volume",
|
||||
"radio_style",
|
||||
"radio_player_type",
|
||||
"radio_logo_url",
|
||||
"radio_widget_enabled",
|
||||
"radio_widget_show_globally",
|
||||
"radio_widget_position",
|
||||
]);
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: Array.from(CONFIG_KEYS) } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: Array.from(CONFIG_KEYS) } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -8,27 +8,27 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const raw = await siteSettings.get("radio_current_dj_id", "");
|
||||
const id = Number(raw);
|
||||
try {
|
||||
const raw = await siteSettings.get("radio_current_dj_id", "");
|
||||
const id = Number(raw);
|
||||
|
||||
// No DJ configured (empty / non-numeric / zero).
|
||||
if (!raw || !Number.isFinite(id) || id <= 0) {
|
||||
return apiJson({ dj: null });
|
||||
}
|
||||
// No DJ configured (empty / non-numeric / zero).
|
||||
if (!raw || !Number.isFinite(id) || id <= 0) {
|
||||
return apiJson({ dj: null });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiJson({ dj: null });
|
||||
}
|
||||
if (!user) {
|
||||
return apiJson({ dj: null });
|
||||
}
|
||||
|
||||
return apiJson({ dj: { username: user.username, look: user.look } });
|
||||
} catch {
|
||||
// DB / settings unavailable — no DJ rather than a 500.
|
||||
return apiJson({ dj: null }, { status: 200 });
|
||||
}
|
||||
return apiJson({ dj: { username: user.username, look: user.look } });
|
||||
} catch {
|
||||
// DB / settings unavailable — no DJ rather than a 500.
|
||||
return apiJson({ dj: null }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,28 @@ import { prisma } from "@/lib/prisma";
|
||||
// flat { key: value } map. None of these keys are secrets.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const EMBED_KEYS = ["radio_enabled", "radio_name", "radio_stream_url", "radio_auto_play"];
|
||||
const EMBED_KEYS = [
|
||||
"radio_enabled",
|
||||
"radio_name",
|
||||
"radio_stream_url",
|
||||
"radio_auto_play",
|
||||
];
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: EMBED_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: EMBED_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -9,80 +9,97 @@ export const dynamic = "force-dynamic";
|
||||
const FETCH_TIMEOUT_MS = 4000;
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
// Best-effort listener-count extraction across the common provider shapes
|
||||
// (AzureCast nests under listeners.current/total; others expose num_listeners,
|
||||
// listeners, unique_listeners, count, or a bare number).
|
||||
function findCount(value: unknown, depth = 0): number | null {
|
||||
if (depth > 4) return null;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
||||
return Number(value);
|
||||
}
|
||||
if (!isRecord(value)) return null;
|
||||
if (depth > 4) return null;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.trim() !== "" &&
|
||||
Number.isFinite(Number(value))
|
||||
) {
|
||||
return Number(value);
|
||||
}
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const keys = ["current", "total", "num_listeners", "listeners", "unique_listeners", "count"];
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) {
|
||||
return Number(v);
|
||||
}
|
||||
}
|
||||
for (const v of Object.values(value)) {
|
||||
if (isRecord(v)) {
|
||||
const found = findCount(v, depth + 1);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const keys = [
|
||||
"current",
|
||||
"total",
|
||||
"num_listeners",
|
||||
"listeners",
|
||||
"unique_listeners",
|
||||
"count",
|
||||
];
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (
|
||||
typeof v === "string" &&
|
||||
v.trim() !== "" &&
|
||||
Number.isFinite(Number(v))
|
||||
) {
|
||||
return Number(v);
|
||||
}
|
||||
}
|
||||
for (const v of Object.values(value)) {
|
||||
if (isRecord(v)) {
|
||||
const found = findCount(v, depth + 1);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await siteSettings.get("radio_listeners_api_url", "")) || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await siteSettings.get("radio_listeners_api_url", "")) || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
|
||||
// Not configured — no listener data available.
|
||||
if (!url) {
|
||||
return apiJson({ listeners: null });
|
||||
}
|
||||
// Not configured — no listener data available.
|
||||
if (!url) {
|
||||
return apiJson({ listeners: null });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { accept: "application/json, text/plain, */*" },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { accept: "application/json, text/plain, */*" },
|
||||
});
|
||||
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw === "") {
|
||||
return apiJson({ listeners: null });
|
||||
}
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw === "") {
|
||||
return apiJson({ listeners: null });
|
||||
}
|
||||
|
||||
let parsed: unknown = raw;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// leave as raw string; findCount handles numeric strings.
|
||||
}
|
||||
let parsed: unknown = raw;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// leave as raw string; findCount handles numeric strings.
|
||||
}
|
||||
|
||||
return apiJson({ listeners: findCount(parsed) });
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
return apiJson({
|
||||
listeners: null,
|
||||
error: aborted ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` : "Fetch failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return apiJson({ listeners: findCount(parsed) });
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
return apiJson({
|
||||
listeners: null,
|
||||
error: aborted
|
||||
? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s`
|
||||
: "Fetch failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -9,44 +9,46 @@ export const dynamic = "force-dynamic";
|
||||
const FETCH_TIMEOUT_MS = 4000;
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await siteSettings.get("radio_now_playing_api_url", "")) || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await siteSettings.get("radio_now_playing_api_url", "")) || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
|
||||
// Not configured — there is nothing to play.
|
||||
if (!url) {
|
||||
return apiJson({ nowPlaying: null });
|
||||
}
|
||||
// Not configured — there is nothing to play.
|
||||
if (!url) {
|
||||
return apiJson({ nowPlaying: null });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { accept: "application/json, text/plain, */*" },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { accept: "application/json, text/plain, */*" },
|
||||
});
|
||||
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw === "") {
|
||||
return apiJson({ nowPlaying: null });
|
||||
}
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw === "") {
|
||||
return apiJson({ nowPlaying: null });
|
||||
}
|
||||
|
||||
// Return parsed JSON when the provider speaks JSON, else the raw text body.
|
||||
try {
|
||||
return apiJson(JSON.parse(raw));
|
||||
} catch {
|
||||
return apiJson({ nowPlaying: raw.slice(0, 2000) });
|
||||
}
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
return apiJson({
|
||||
error: aborted ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` : "Fetch failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
// Return parsed JSON when the provider speaks JSON, else the raw text body.
|
||||
try {
|
||||
return apiJson(JSON.parse(raw));
|
||||
} catch {
|
||||
return apiJson({ nowPlaying: raw.slice(0, 2000) });
|
||||
}
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
return apiJson({
|
||||
error: aborted
|
||||
? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s`
|
||||
: "Fetch failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -7,42 +7,42 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
// Sum points per user. Sort/slice in JS so we stay adapter-agnostic about
|
||||
// aggregate ordering, then resolve the top 20 to usernames/looks.
|
||||
const grouped = await prisma.radioListenerPoints.groupBy({
|
||||
by: ["userId"],
|
||||
_sum: { points: true },
|
||||
});
|
||||
try {
|
||||
// Sum points per user. Sort/slice in JS so we stay adapter-agnostic about
|
||||
// aggregate ordering, then resolve the top 20 to usernames/looks.
|
||||
const grouped = await prisma.radioListenerPoints.groupBy({
|
||||
by: ["userId"],
|
||||
_sum: { points: true },
|
||||
});
|
||||
|
||||
const ranked = grouped
|
||||
.map((g) => ({ userId: g.userId, points: g._sum.points ?? 0 }))
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.slice(0, 20);
|
||||
const ranked = grouped
|
||||
.map((g) => ({ userId: g.userId, points: g._sum.points ?? 0 }))
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.slice(0, 20);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return apiJson({ data: [] });
|
||||
}
|
||||
if (ranked.length === 0) {
|
||||
return apiJson({ data: [] });
|
||||
}
|
||||
|
||||
const userIds = ranked.map((r) => r.userId);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
});
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
const userIds = ranked.map((r) => r.userId);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
});
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const data = ranked.map((r) => {
|
||||
const u = userById.get(r.userId);
|
||||
return {
|
||||
username: u?.username ?? null,
|
||||
look: u?.look ?? null,
|
||||
points: r.points,
|
||||
};
|
||||
});
|
||||
const data = ranked.map((r) => {
|
||||
const u = userById.get(r.userId);
|
||||
return {
|
||||
username: u?.username ?? null,
|
||||
look: u?.look ?? null,
|
||||
points: r.points,
|
||||
};
|
||||
});
|
||||
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unavailable — serve an empty leaderboard rather than a 500.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unavailable — serve an empty leaderboard rather than a 500.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import { apiJson, apiError } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// The Bearer-authed user's total radio listener points: the sum of all
|
||||
// radio_listener_points.points rows for that user_id.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
const agg = await prisma.radioListenerPoints.aggregate({
|
||||
where: { userId: uid },
|
||||
_sum: { points: true },
|
||||
});
|
||||
try {
|
||||
const agg = await prisma.radioListenerPoints.aggregate({
|
||||
where: { userId: uid },
|
||||
_sum: { points: true },
|
||||
});
|
||||
|
||||
return apiJson({ points: agg._sum.points ?? 0 });
|
||||
} catch {
|
||||
// DB unavailable — report zero rather than a 500.
|
||||
return apiJson({ points: 0 }, { status: 200 });
|
||||
}
|
||||
return apiJson({ points: agg._sum.points ?? 0 });
|
||||
} catch {
|
||||
// DB unavailable — report zero rather than a 500.
|
||||
return apiJson({ points: 0 }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiJson, apiError } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Latest 50 radio shouts with their author's username/look resolved. Mirrors the
|
||||
// query behind the public /radio/shouts page (radio_shouts ordered by created_at
|
||||
@@ -11,72 +11,75 @@ export const dynamic = "force-dynamic";
|
||||
const MAX_MESSAGE_LENGTH = 255;
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const shouts = await prisma.radioShouts.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
try {
|
||||
const shouts = await prisma.radioShouts.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt
|
||||
// while users.id is an Int, so narrow to Number for the lookup.
|
||||
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
|
||||
const authors = authorIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
})
|
||||
: [];
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
// Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt
|
||||
// while users.id is an Int, so narrow to Number for the lookup.
|
||||
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
|
||||
const authors = authorIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
})
|
||||
: [];
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
const data = shouts.map((s) => {
|
||||
const author = authorById.get(Number(s.userId));
|
||||
return {
|
||||
id: s.id,
|
||||
userId: s.userId,
|
||||
username: author?.username ?? null,
|
||||
look: author?.look ?? null,
|
||||
message: s.message,
|
||||
createdAt: s.createdAt,
|
||||
};
|
||||
});
|
||||
const data = shouts.map((s) => {
|
||||
const author = authorById.get(Number(s.userId));
|
||||
return {
|
||||
id: s.id,
|
||||
userId: s.userId,
|
||||
username: author?.username ?? null,
|
||||
look: author?.look ?? null,
|
||||
message: s.message,
|
||||
createdAt: s.createdAt,
|
||||
};
|
||||
});
|
||||
|
||||
return apiJson({ shouts: data });
|
||||
} catch {
|
||||
// DB unavailable — serve an empty list rather than a 500.
|
||||
return apiJson({ shouts: [] }, { status: 200 });
|
||||
}
|
||||
return apiJson({ shouts: data });
|
||||
} catch {
|
||||
// DB unavailable — serve an empty list rather than a 500.
|
||||
return apiJson({ shouts: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// Post a new radio shout as the Bearer-authed user into radio_shouts.
|
||||
export async function POST(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { message?: unknown };
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
const body = (await req.json().catch(() => ({}))) as { message?: unknown };
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
|
||||
if (!message) {
|
||||
return apiError("Message is required", 422);
|
||||
}
|
||||
if (message.length > MAX_MESSAGE_LENGTH) {
|
||||
return apiError(`Message must be at most ${MAX_MESSAGE_LENGTH} characters`, 422);
|
||||
}
|
||||
if (!message) {
|
||||
return apiError("Message is required", 422);
|
||||
}
|
||||
if (message.length > MAX_MESSAGE_LENGTH) {
|
||||
return apiError(
|
||||
`Message must be at most ${MAX_MESSAGE_LENGTH} characters`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(uid),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
try {
|
||||
const now = new Date();
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(uid),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
// DB write failed — fail soft rather than a 500.
|
||||
return apiError("Could not post shout", 503);
|
||||
}
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
// DB write failed — fail soft rather than a 500.
|
||||
return apiError("Could not post shout", 503);
|
||||
}
|
||||
}
|
||||
@@ -8,59 +8,63 @@ export const dynamic = "force-dynamic";
|
||||
* updates without polling. Closes cleanly when the client disconnects.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const encoder = new TextEncoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
let closed = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
let closed = false;
|
||||
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
const [nowPlaying, listeners] = await Promise.all([
|
||||
fetchNowPlaying().catch(() => null),
|
||||
fetchListeners().catch(() => null),
|
||||
]);
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ nowPlaying, listeners })}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
const [nowPlaying, listeners] = await Promise.all([
|
||||
fetchNowPlaying().catch(() => null),
|
||||
fetchListeners().catch(() => null),
|
||||
]);
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ nowPlaying, listeners })}\n\n`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial event immediately, then on an interval.
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 10_000);
|
||||
// SSE comment as a keep-alive ping between data events.
|
||||
const ping = setInterval(() => {
|
||||
if (!closed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}, 25_000);
|
||||
// Initial event immediately, then on an interval.
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 10_000);
|
||||
// SSE comment as a keep-alive ping between data events.
|
||||
const ping = setInterval(() => {
|
||||
if (!closed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}, 25_000);
|
||||
|
||||
const stop = () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
clearInterval(ping);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
req.signal.addEventListener("abort", stop);
|
||||
},
|
||||
});
|
||||
const stop = () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
clearInterval(ping);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
req.signal.addEventListener("abort", stop);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
connection: "keep-alive",
|
||||
"access-control-allow-origin": "*",
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
connection: "keep-alive",
|
||||
"access-control-allow-origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,25 +10,25 @@ export const dynamic = "force-dynamic";
|
||||
const SENSITIVE = ["secret", "api_key", "password", "token", "webhook"];
|
||||
|
||||
function isSensitive(key: string): boolean {
|
||||
const k = key.toLowerCase();
|
||||
return SENSITIVE.some((needle) => k.includes(needle));
|
||||
const k = key.toLowerCase();
|
||||
return SENSITIVE.some((needle) => k.includes(needle));
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
if (isSensitive(row.key)) continue;
|
||||
settings[row.key] = row.value;
|
||||
}
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
if (isSensitive(row.key)) continue;
|
||||
settings[row.key] = row.value;
|
||||
}
|
||||
|
||||
return apiJson(settings);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty settings map rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
return apiJson(settings);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty settings map rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,21 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const data = await prisma.websiteShopCategories.findMany({
|
||||
orderBy: [{ order: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
order: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const data = await prisma.websiteShopCategories.findMany({
|
||||
orderBy: [{ order: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
order: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unreachable — never 500; return empty data.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unreachable — never 500; return empty data.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
+53
-45
@@ -7,52 +7,60 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
// Optional ?category=<id> filter (website_shop_category_id is a BigInt).
|
||||
const categoryRaw = sp.get("category");
|
||||
let where: { categoryId?: bigint } = {};
|
||||
if (categoryRaw !== null) {
|
||||
const categoryId = positiveBigInt(categoryRaw);
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
where = { categoryId };
|
||||
}
|
||||
// Optional ?category=<id> filter (website_shop_category_id is a BigInt).
|
||||
const categoryRaw = sp.get("category");
|
||||
let where: { categoryId?: bigint } = {};
|
||||
if (categoryRaw !== null) {
|
||||
const categoryId = positiveBigInt(categoryRaw);
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
where = { categoryId };
|
||||
}
|
||||
|
||||
try {
|
||||
const [total, data] = await Promise.all([
|
||||
prisma.websiteShopArticles.count({ where }),
|
||||
prisma.websiteShopArticles.findMany({
|
||||
where,
|
||||
orderBy: [{ position: "asc" }, { name: "asc" }],
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
name: true,
|
||||
info: true,
|
||||
iconUrl: true,
|
||||
color: true,
|
||||
costs: true,
|
||||
giveRank: true,
|
||||
isGiftable: true,
|
||||
credits: true,
|
||||
duckets: true,
|
||||
diamonds: true,
|
||||
badges: true,
|
||||
furniture: true,
|
||||
position: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
const [total, data] = await Promise.all([
|
||||
prisma.websiteShopArticles.count({ where }),
|
||||
prisma.websiteShopArticles.findMany({
|
||||
where,
|
||||
orderBy: [{ position: "asc" }, { name: "asc" }],
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
name: true,
|
||||
info: true,
|
||||
iconUrl: true,
|
||||
color: true,
|
||||
costs: true,
|
||||
giveRank: true,
|
||||
isGiftable: true,
|
||||
credits: true,
|
||||
duckets: true,
|
||||
diamonds: true,
|
||||
badges: true,
|
||||
furniture: true,
|
||||
position: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiJson({
|
||||
data,
|
||||
meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||
return apiJson({ data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, { status: 200 });
|
||||
}
|
||||
return apiJson({
|
||||
data,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-13
@@ -7,19 +7,20 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7;
|
||||
try {
|
||||
const minStaffRank =
|
||||
Number(await siteSettings.get("min_staff_rank", "7")) || 7;
|
||||
|
||||
const staff = await prisma.user.findMany({
|
||||
where: { rank: { gte: minStaffRank } },
|
||||
select: { username: true, look: true, rank: true, motto: true },
|
||||
orderBy: [{ rank: "desc" }, { username: "asc" }],
|
||||
take: 100,
|
||||
});
|
||||
const staff = await prisma.user.findMany({
|
||||
where: { rank: { gte: minStaffRank } },
|
||||
select: { username: true, look: true, rank: true, motto: true },
|
||||
orderBy: [{ rank: "desc" }, { username: "asc" }],
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return apiJson({ data: staff }, { status: 200 });
|
||||
} catch {
|
||||
// Never 500 — serve an empty payload if the DB is unreachable.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
return apiJson({ data: staff }, { status: 200 });
|
||||
} catch {
|
||||
// Never 500 — serve an empty payload if the DB is unreachable.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -6,22 +6,22 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteTeams.findMany({
|
||||
where: { hiddenRank: false },
|
||||
select: {
|
||||
id: true,
|
||||
rankName: true,
|
||||
badge: true,
|
||||
jobDescription: true,
|
||||
staffColor: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.websiteTeams.findMany({
|
||||
where: { hiddenRank: false },
|
||||
select: {
|
||||
id: true,
|
||||
rankName: true,
|
||||
badge: true,
|
||||
jobDescription: true,
|
||||
staffColor: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
// apiJson serialises BigInt ids → string automatically.
|
||||
return apiJson({ data: rows }, { status: 200 });
|
||||
} catch {
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
// apiJson serialises BigInt ids → string automatically.
|
||||
return apiJson({ data: rows }, { status: 200 });
|
||||
} catch {
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -12,59 +12,67 @@ import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// POST /api/tickets/:id/reply body: { content }
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const { id } = await params;
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) return apiError("Invalid ticket id", 422);
|
||||
const { id } = await params;
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) return apiError("Invalid ticket id", 422);
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { content?: unknown };
|
||||
const content = String(body.content ?? "")
|
||||
.trim()
|
||||
.slice(0, 5000);
|
||||
if (!content) return apiError("Content is required");
|
||||
const body = (await req.json().catch(() => ({}))) as { content?: unknown };
|
||||
const content = String(body.content ?? "")
|
||||
.trim()
|
||||
.slice(0, 5000);
|
||||
if (!content) return apiError("Content is required");
|
||||
|
||||
try {
|
||||
// Ownership check — only the ticket owner may reply.
|
||||
const reply = await prisma.$transaction((tx) =>
|
||||
createOwnedTicketReply(
|
||||
{
|
||||
findTicket: (ticketId) =>
|
||||
tx.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
createReply: (data) =>
|
||||
tx.websiteHelpCenterTicketReplies.create({
|
||||
data,
|
||||
select: { id: true, userId: true, content: true, createdAt: true },
|
||||
}),
|
||||
touchTicket: (ticketId, updatedAt) =>
|
||||
tx.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { updatedAt },
|
||||
select: { id: true },
|
||||
}),
|
||||
},
|
||||
{ ticketId, userId: uid, content },
|
||||
),
|
||||
);
|
||||
if (!reply) return apiError("Ticket not found", 404);
|
||||
try {
|
||||
// Ownership check — only the ticket owner may reply.
|
||||
const reply = await prisma.$transaction((tx) =>
|
||||
createOwnedTicketReply(
|
||||
{
|
||||
findTicket: (ticketId) =>
|
||||
tx.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
createReply: (data) =>
|
||||
tx.websiteHelpCenterTicketReplies.create({
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
touchTicket: (ticketId, updatedAt) =>
|
||||
tx.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { updatedAt },
|
||||
select: { id: true },
|
||||
}),
|
||||
},
|
||||
{ ticketId, userId: uid, content },
|
||||
),
|
||||
);
|
||||
if (!reply) return apiError("Ticket not found", 404);
|
||||
|
||||
return apiJson(
|
||||
{
|
||||
reply: {
|
||||
id: reply.id,
|
||||
userId: reply.userId,
|
||||
content: reply.content,
|
||||
createdAt: reply.createdAt,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch {
|
||||
return apiError("Failed to post reply", 503);
|
||||
}
|
||||
return apiJson(
|
||||
{
|
||||
reply: {
|
||||
id: reply.id,
|
||||
userId: reply.userId,
|
||||
content: reply.content,
|
||||
createdAt: reply.createdAt,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch {
|
||||
return apiError("Failed to post reply", 503);
|
||||
}
|
||||
}
|
||||
@@ -11,66 +11,70 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/tickets/:id
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const { id } = await params;
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) return apiError("Invalid ticket id", 422);
|
||||
const { id } = await params;
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) return apiError("Invalid ticket id", 422);
|
||||
|
||||
try {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
categoryId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
open: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
categoryId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
open: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Ownership check — return 404 (not 403) so a foreign id is indistinguishable
|
||||
// from a missing one.
|
||||
if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404);
|
||||
// Ownership check — return 404 (not 403) so a foreign id is indistinguishable
|
||||
// from a missing one.
|
||||
if (!ticket || ticket.userId !== uid)
|
||||
return apiError("Ticket not found", 404);
|
||||
|
||||
const replies = await prisma.websiteHelpCenterTicketReplies.findMany({
|
||||
where: { ticketId },
|
||||
select: { id: true, userId: true, content: true, createdAt: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
const replies = await prisma.websiteHelpCenterTicketReplies.findMany({
|
||||
where: { ticketId },
|
||||
select: { id: true, userId: true, content: true, createdAt: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
// Resolve author usernames in one query.
|
||||
const authorIds = [...new Set(replies.map((r) => r.userId))];
|
||||
const authors = authorIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
const nameById = new Map(authors.map((a) => [a.id, a.username]));
|
||||
// Resolve author usernames in one query.
|
||||
const authorIds = [...new Set(replies.map((r) => r.userId))];
|
||||
const authors = authorIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
const nameById = new Map(authors.map((a) => [a.id, a.username]));
|
||||
|
||||
return apiJson({
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
categoryId: ticket.categoryId,
|
||||
title: ticket.title,
|
||||
content: ticket.content,
|
||||
open: ticket.open,
|
||||
createdAt: ticket.createdAt,
|
||||
replies: replies.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
username: nameById.get(r.userId) ?? null,
|
||||
content: r.content,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return apiError("Failed to load ticket", 503);
|
||||
}
|
||||
return apiJson({
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
categoryId: ticket.categoryId,
|
||||
title: ticket.title,
|
||||
content: ticket.content,
|
||||
open: ticket.open,
|
||||
createdAt: ticket.createdAt,
|
||||
replies: replies.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
username: nameById.get(r.userId) ?? null,
|
||||
content: r.content,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return apiError("Failed to load ticket", 503);
|
||||
}
|
||||
}
|
||||
@@ -12,84 +12,88 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/tickets — the authed user's tickets (newest first).
|
||||
export async function GET(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
const tickets = await prisma.websiteHelpCenterTickets.findMany({
|
||||
where: { userId: uid },
|
||||
select: { id: true, title: true, open: true, createdAt: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
try {
|
||||
const tickets = await prisma.websiteHelpCenterTickets.findMany({
|
||||
where: { userId: uid },
|
||||
select: { id: true, title: true, open: true, createdAt: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
|
||||
return apiJson({
|
||||
tickets: tickets.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
open: t.open,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
return apiError("Failed to load tickets", 503);
|
||||
}
|
||||
return apiJson({
|
||||
tickets: tickets.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
open: t.open,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
return apiError("Failed to load tickets", 503);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tickets — open a new ticket ({ title, content, categoryId? }).
|
||||
export async function POST(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
title?: unknown;
|
||||
content?: unknown;
|
||||
categoryId?: unknown;
|
||||
};
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
title?: unknown;
|
||||
content?: unknown;
|
||||
categoryId?: unknown;
|
||||
};
|
||||
|
||||
const title = String(body.title ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const content = String(body.content ?? "")
|
||||
.trim()
|
||||
.slice(0, 5000);
|
||||
if (!title) return apiError("Title is required");
|
||||
if (!content) return apiError("Content is required");
|
||||
const title = String(body.title ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const content = String(body.content ?? "")
|
||||
.trim()
|
||||
.slice(0, 5000);
|
||||
if (!title) return apiError("Title is required");
|
||||
if (!content) return apiError("Content is required");
|
||||
|
||||
// categoryId is an optional unsigned BigInt FK — accept a positive numeric
|
||||
// value, otherwise leave it null.
|
||||
let categoryId: bigint | null = null;
|
||||
if (body.categoryId !== undefined && body.categoryId !== null && body.categoryId !== "") {
|
||||
categoryId = positiveBigInt(String(body.categoryId));
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
}
|
||||
// categoryId is an optional unsigned BigInt FK — accept a positive numeric
|
||||
// value, otherwise leave it null.
|
||||
let categoryId: bigint | null = null;
|
||||
if (
|
||||
body.categoryId !== undefined &&
|
||||
body.categoryId !== null &&
|
||||
body.categoryId !== ""
|
||||
) {
|
||||
categoryId = positiveBigInt(String(body.categoryId));
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const ticket = await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId: uid,
|
||||
categoryId,
|
||||
title,
|
||||
content,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true, title: true, open: true, createdAt: true },
|
||||
});
|
||||
try {
|
||||
const now = new Date();
|
||||
const ticket = await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId: uid,
|
||||
categoryId,
|
||||
title,
|
||||
content,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true, title: true, open: true, createdAt: true },
|
||||
});
|
||||
|
||||
return apiJson(
|
||||
{
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
title: ticket.title,
|
||||
open: ticket.open,
|
||||
createdAt: ticket.createdAt,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch {
|
||||
return apiError("Failed to create ticket", 503);
|
||||
}
|
||||
return apiJson(
|
||||
{
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
title: ticket.title,
|
||||
open: ticket.open,
|
||||
createdAt: ticket.createdAt,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch {
|
||||
return apiError("Failed to create ticket", 503);
|
||||
}
|
||||
}
|
||||
+18
-18
@@ -13,24 +13,24 @@ import { sessionUserId } from "@/lib/auth/session-user";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
const id = sessionUserId(session?.user?.id);
|
||||
if (!id) {
|
||||
return apiError("Unauthorized", 401);
|
||||
}
|
||||
const session = await auth();
|
||||
const id = sessionUserId(session?.user?.id);
|
||||
if (!id) {
|
||||
return apiError("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { name?: unknown };
|
||||
const rawName = typeof body.name === "string" ? body.name.trim() : "";
|
||||
const name = rawName ? rawName.slice(0, 100) : "api";
|
||||
const body = (await req.json().catch(() => ({}))) as { name?: unknown };
|
||||
const rawName = typeof body.name === "string" ? body.name.trim() : "";
|
||||
const name = rawName ? rawName.slice(0, 100) : "api";
|
||||
|
||||
try {
|
||||
const token = await issueToken(id, name);
|
||||
if (!token) {
|
||||
return apiError("Could not issue token", 500);
|
||||
}
|
||||
// Plaintext token — shown only once, never recoverable afterwards.
|
||||
return apiJson({ token });
|
||||
} catch {
|
||||
return apiError("Could not issue token", 500);
|
||||
}
|
||||
try {
|
||||
const token = await issueToken(id, name);
|
||||
if (!token) {
|
||||
return apiError("Could not issue token", 500);
|
||||
}
|
||||
// Plaintext token — shown only once, never recoverable afterwards.
|
||||
return apiJson({ token });
|
||||
} catch {
|
||||
return apiError("Could not issue token", 500);
|
||||
}
|
||||
}
|
||||
@@ -10,38 +10,41 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ username: string }> }) {
|
||||
const { username } = await params;
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ username: string }> },
|
||||
) {
|
||||
const { username } = await params;
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: {
|
||||
username: true,
|
||||
look: true,
|
||||
motto: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
online: true,
|
||||
accountCreated: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: {
|
||||
username: true,
|
||||
look: true,
|
||||
motto: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
online: true,
|
||||
accountCreated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiJson({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
if (!user) {
|
||||
return apiJson({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return apiJson({
|
||||
username: user.username,
|
||||
look: user.look,
|
||||
motto: user.motto,
|
||||
rank: user.rank,
|
||||
credits: user.credits,
|
||||
online: user.online === "1",
|
||||
accountCreated: user.accountCreated,
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — behave as "not found" rather than 500.
|
||||
return apiJson({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
return apiJson({
|
||||
username: user.username,
|
||||
look: user.look,
|
||||
motto: user.motto,
|
||||
rank: user.rank,
|
||||
credits: user.credits,
|
||||
online: user.online === "1",
|
||||
accountCreated: user.accountCreated,
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — behave as "not found" rather than 500.
|
||||
return apiJson({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -6,62 +6,65 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
// The primary key is a BigInt; reject non-numeric ids up front.
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||
}
|
||||
// The primary key is a BigInt; reject non-numeric ids up front.
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let valueId: bigint;
|
||||
try {
|
||||
valueId = BigInt(id);
|
||||
} catch {
|
||||
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||
}
|
||||
let valueId: bigint;
|
||||
try {
|
||||
valueId = BigInt(id);
|
||||
} catch {
|
||||
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await prisma.websiteRareValues.findUnique({
|
||||
where: { id: valueId },
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
itemId: true,
|
||||
name: true,
|
||||
creditValue: true,
|
||||
currencyValue: true,
|
||||
currencyType: true,
|
||||
furnitureIcon: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const value = await prisma.websiteRareValues.findUnique({
|
||||
where: { id: valueId },
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
itemId: true,
|
||||
name: true,
|
||||
creditValue: true,
|
||||
currencyValue: true,
|
||||
currencyType: true,
|
||||
furnitureIcon: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!value) {
|
||||
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||
}
|
||||
if (!value) {
|
||||
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// No relation is modelled between the value and its category, so resolve the
|
||||
// category in a second guarded query.
|
||||
let category: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
badge: string;
|
||||
priority: number;
|
||||
} | null = null;
|
||||
try {
|
||||
category = await prisma.websiteRareValueCategories.findUnique({
|
||||
where: { id: value.categoryId },
|
||||
select: { id: true, name: true, badge: true, priority: true },
|
||||
});
|
||||
} catch {
|
||||
category = null;
|
||||
}
|
||||
// No relation is modelled between the value and its category, so resolve the
|
||||
// category in a second guarded query.
|
||||
let category: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
badge: string;
|
||||
priority: number;
|
||||
} | null = null;
|
||||
try {
|
||||
category = await prisma.websiteRareValueCategories.findUnique({
|
||||
where: { id: value.categoryId },
|
||||
select: { id: true, name: true, badge: true, priority: true },
|
||||
});
|
||||
} catch {
|
||||
category = null;
|
||||
}
|
||||
|
||||
return apiJson({ data: { ...value, category } });
|
||||
} catch {
|
||||
// DB unavailable — treat as not found rather than a 500.
|
||||
return apiJson({ error: "Rare value not found" }, { status: 200 });
|
||||
}
|
||||
return apiJson({ data: { ...value, category } });
|
||||
} catch {
|
||||
// DB unavailable — treat as not found rather than a 500.
|
||||
return apiJson({ error: "Rare value not found" }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,20 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const data = await prisma.websiteRareValueCategories.findMany({
|
||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
badge: true,
|
||||
priority: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const data = await prisma.websiteRareValueCategories.findMany({
|
||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
badge: true,
|
||||
priority: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unreachable — never 500; return empty data.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unreachable — never 500; return empty data.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
+46
-38
@@ -7,45 +7,53 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
// Optional ?category=<id> filter (category_id is a BigInt).
|
||||
const categoryRaw = sp.get("category");
|
||||
let where: { categoryId?: bigint } = {};
|
||||
if (categoryRaw !== null) {
|
||||
const categoryId = positiveBigInt(categoryRaw);
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
where = { categoryId };
|
||||
}
|
||||
// Optional ?category=<id> filter (category_id is a BigInt).
|
||||
const categoryRaw = sp.get("category");
|
||||
let where: { categoryId?: bigint } = {};
|
||||
if (categoryRaw !== null) {
|
||||
const categoryId = positiveBigInt(categoryRaw);
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
where = { categoryId };
|
||||
}
|
||||
|
||||
try {
|
||||
const [total, data] = await Promise.all([
|
||||
prisma.websiteRareValues.count({ where }),
|
||||
prisma.websiteRareValues.findMany({
|
||||
where,
|
||||
orderBy: { name: "asc" },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
itemId: true,
|
||||
name: true,
|
||||
creditValue: true,
|
||||
currencyValue: true,
|
||||
currencyType: true,
|
||||
furnitureIcon: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
const [total, data] = await Promise.all([
|
||||
prisma.websiteRareValues.count({ where }),
|
||||
prisma.websiteRareValues.findMany({
|
||||
where,
|
||||
orderBy: { name: "asc" },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
itemId: true,
|
||||
name: true,
|
||||
creditValue: true,
|
||||
currencyValue: true,
|
||||
currencyType: true,
|
||||
furnitureIcon: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiJson({
|
||||
data,
|
||||
meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||
return apiJson({ data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, { status: 200 });
|
||||
}
|
||||
return apiJson({
|
||||
data,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user