fix(housekeeping): coordinate all rank mutation paths
CI / check (pull_request) Successful in 1m44s
CI / deploy (pull_request) Skipped
CI / e2e (pull_request) Skipped

This commit is contained in:
Simo committed 2026-09-05 09:48:30 +02:00
1 parent 3d53321575
commit 866f38818b
14 files changed
+1070 -317

No files matched your search

+61 -1
View File
@@ -1,5 +1,6 @@
"use server";
import crypto from "node:crypto";
import { and, eq, max, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
@@ -7,7 +8,12 @@ import { auth } from "@/lib/auth";
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { logAudit } from "@/lib/services/audit";
import { creditsPerUnit } from "@/lib/services/paypal";
import {
lockConfiguredRank,
rankAssignmentCoordinator,
} from "@/lib/services/rank-assignment";
import { rcon } from "@/lib/services/rcon";
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
@@ -118,8 +124,38 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
outcome = "credits";
} else {
const badgeCodes = parseBadgeCodes(article.badges);
let rankChanged = false;
const rankRecoveryId = crypto.randomUUID();
const rankAudit = {
userId,
action: "system.external-sync",
target: "rcon.set-rank",
targetId: userId,
correlationId: rankRecoveryId,
domain: "system" as const,
after: {
kind: "set-rank",
operation: "rcon.set-rank",
userId,
rank: article.giveRank,
},
};
await db.transaction(async (tx) => {
if (
article.giveRank != null &&
article.giveRank > 0 &&
!(await lockConfiguredRank(tx, article.giveRank))
) {
throw new Error("Package rank no longer exists");
}
const [lockedBuyer] = await tx
.select({ credits: User.credits, rank: User.rank })
.from(User)
.where(eq(User.id, userId))
.for("update");
if (!lockedBuyer || lockedBuyer.credits < price)
throw new Error("Insufficient credits");
if (price > 0) {
await tx
.update(User)
@@ -130,12 +166,14 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
if (
article.giveRank != null &&
article.giveRank > 0 &&
article.giveRank > buyer.rank
article.giveRank > lockedBuyer.rank
) {
await tx
.update(User)
.set({ rank: article.giveRank })
.where(eq(User.id, userId));
rankChanged = true;
await logAudit({ ...rankAudit, outcome: "intent" }, tx);
}
for (const code of badgeCodes) {
@@ -164,6 +202,28 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
}
});
if (rankChanged) {
try {
const delivery =
await rankAssignmentCoordinator.synchronize(userId);
await logAudit({
...rankAudit,
after: {
...rankAudit.after,
...(delivery.status === "superseded"
? { superseded: true }
: { rank: delivery.rank }),
},
outcome:
delivery.status === "pending" ? "partial" : "success",
});
} catch (error) {
logServerError("shop.rank_sync_pending", error, {
userId,
correlationId: rankRecoveryId,
});
}
}
await sendCurrency(
{ rcon, db: currencyDb },
userId,