- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
191 lines
4.9 KiB
TypeScript
191 lines
4.9 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { auth } from "@/lib/auth";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
|
import { creditsPerUnit } from "@/lib/services/paypal";
|
|
import { rcon } from "@/lib/services/rcon";
|
|
import { sendCurrency } from "@/lib/services/send-currency";
|
|
|
|
/**
|
|
* Credits charged for a package row. AtomCMS stores `costs` in cents (USD display);
|
|
* we mirror the top-up rate so $1.00 of list price costs creditsPerUnit() credits.
|
|
*/
|
|
function creditPriceFromCosts(costs: number): number {
|
|
const rate = creditsPerUnit();
|
|
const dollars = costs < 100 ? 1 : costs / 100;
|
|
return Math.max(1, Math.floor(dollars * rate));
|
|
}
|
|
|
|
function parseBadgeCodes(raw: string | null | undefined): string[] {
|
|
if (!raw?.trim()) return [];
|
|
return raw
|
|
.split(/[,;]+/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0 && s.length <= 32);
|
|
}
|
|
|
|
type BuyOutcome = "bought" | "invalid" | "credits" | "ratelimit" | "error";
|
|
|
|
function shopRedirect(
|
|
categoryId: string,
|
|
outcome: BuyOutcome,
|
|
articleName?: string,
|
|
): never {
|
|
const params = new URLSearchParams();
|
|
if (categoryId) params.set("category", categoryId);
|
|
if (outcome === "bought") {
|
|
params.set("bought", "1");
|
|
if (articleName) params.set("package", articleName);
|
|
} else {
|
|
params.set("error", outcome);
|
|
}
|
|
const qs = params.toString();
|
|
redirect(qs ? `/shop?${qs}` : "/shop");
|
|
}
|
|
|
|
function isNextRedirect(e: unknown): boolean {
|
|
return (
|
|
!!e &&
|
|
typeof e === "object" &&
|
|
"digest" in e &&
|
|
typeof (e as { digest?: unknown }).digest === "string" &&
|
|
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Purchase a website shop package with in-game credits (top up via /shop/topup first).
|
|
* The buyer id is always taken from the session, never from FormData.
|
|
*/
|
|
export async function buyShopArticle(formData: FormData): Promise<void> {
|
|
const categoryId = String(formData.get("categoryId") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
const safeCategory = /^\d+$/.test(categoryId) ? categoryId : "";
|
|
|
|
let outcome: BuyOutcome = "error";
|
|
let packageName = "";
|
|
|
|
try {
|
|
const session = await auth();
|
|
const userId = Number(session?.user?.id);
|
|
if (!Number.isInteger(userId) || userId <= 0) {
|
|
redirect("/login");
|
|
}
|
|
|
|
await clientIp();
|
|
if (!(await rateLimit(`shop-buy:${userId}`, 5, 60_000)).ok) {
|
|
outcome = "ratelimit";
|
|
} else {
|
|
const rawId = String(formData.get("articleId") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
if (!/^\d+$/.test(rawId)) {
|
|
outcome = "invalid";
|
|
} else {
|
|
const article = await prisma.websiteShopArticles.findUnique({
|
|
where: { id: BigInt(rawId) },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
costs: true,
|
|
credits: true,
|
|
duckets: true,
|
|
diamonds: true,
|
|
badges: true,
|
|
giveRank: true,
|
|
},
|
|
});
|
|
|
|
if (!article) {
|
|
outcome = "invalid";
|
|
} else {
|
|
packageName = article.name;
|
|
const price = creditPriceFromCosts(article.costs);
|
|
|
|
const buyer = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { credits: true, rank: true },
|
|
});
|
|
if (!buyer || buyer.credits < price) {
|
|
outcome = "credits";
|
|
} else {
|
|
const badgeCodes = parseBadgeCodes(article.badges);
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
if (price > 0) {
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: { credits: { decrement: price } },
|
|
});
|
|
}
|
|
|
|
if (
|
|
article.giveRank != null &&
|
|
article.giveRank > 0 &&
|
|
article.giveRank > buyer.rank
|
|
) {
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: { rank: article.giveRank },
|
|
});
|
|
}
|
|
|
|
for (const code of badgeCodes) {
|
|
const existing = await tx.usersBadges.findFirst({
|
|
where: { userId, badgeCode: code },
|
|
select: { id: true },
|
|
});
|
|
if (!existing) {
|
|
const max = await tx.usersBadges.aggregate({
|
|
where: { userId },
|
|
_max: { slotId: true },
|
|
});
|
|
const slotId = (max._max.slotId ?? 0) + 1;
|
|
await tx.usersBadges.create({
|
|
data: { userId, slotId, badgeCode: code },
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
await sendCurrency(
|
|
{ rcon, db: prisma },
|
|
userId,
|
|
"credits",
|
|
article.credits,
|
|
);
|
|
await sendCurrency(
|
|
{ rcon, db: prisma },
|
|
userId,
|
|
"duckets",
|
|
article.duckets,
|
|
);
|
|
await sendCurrency(
|
|
{ rcon, db: prisma },
|
|
userId,
|
|
"diamonds",
|
|
article.diamonds,
|
|
);
|
|
|
|
for (const code of badgeCodes) {
|
|
await rcon.giveBadge(userId, code).catch(() => {});
|
|
}
|
|
|
|
outcome = "bought";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (isNextRedirect(e)) throw e;
|
|
outcome = "error";
|
|
}
|
|
|
|
revalidatePath("/shop");
|
|
shopRedirect(safeCategory, outcome, packageName || undefined);
|
|
}
|