feat(housekeeping): deliver economy vertical
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import "server-only";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, eq, max } from "drizzle-orm";
|
||||
import { db, User, UsersBadges } from "@/lib/db";
|
||||
import { toBadgeGif } from "@/lib/images/badge-gif";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import {
|
||||
notFound,
|
||||
positiveInteger,
|
||||
record,
|
||||
text,
|
||||
validation,
|
||||
} from "./mutation-runtime-input";
|
||||
import type {
|
||||
EconomyMutationContext,
|
||||
EconomyMutationOperation,
|
||||
EconomyMutationSnapshot,
|
||||
} from "./mutations";
|
||||
import { EconomyCommittedExternalFailure } from "./mutations-production";
|
||||
|
||||
const BADGE_CODE = /^[A-Za-z0-9_-]{1,32}$/u;
|
||||
const BADGE_TYPES = new Set(["image/gif", "image/png"]);
|
||||
const MAX_BADGE_BYTES = 1024 * 1024;
|
||||
|
||||
async function badgeGive(input: unknown): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const code = text(data.badge ?? data.code, 32, true);
|
||||
if (!BADGE_CODE.test(code))
|
||||
throw validation({ badge: ["invalid badge code"] });
|
||||
let userId: number;
|
||||
let username: string;
|
||||
if (data.userId) {
|
||||
userId = positiveInteger(data.userId);
|
||||
const [user] = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!user) throw notFound();
|
||||
username = user.username;
|
||||
} else {
|
||||
username = text(data.username, 25, true);
|
||||
const [user] = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (!user) throw notFound();
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const inserted = await db.transaction(async (transaction) => {
|
||||
const [existing] = await transaction
|
||||
.select({ id: UsersBadges.id, slotId: UsersBadges.slotId })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) return false;
|
||||
const [aggregate] = await transaction
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
await transaction.insert(UsersBadges).values({
|
||||
userId,
|
||||
slotId: (aggregate?.maxSlot ?? 0) + 1,
|
||||
badgeCode: code,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
const snapshot: EconomyMutationSnapshot = {
|
||||
before: { userId: String(userId), badge: code, present: !inserted },
|
||||
after: { userId: String(userId), username, badge: code, present: true },
|
||||
output: { userId: String(userId), badge: code, inserted },
|
||||
};
|
||||
try {
|
||||
const delivered = await rcon.giveBadge(userId, code);
|
||||
if (!delivered) throw new Error("badge grant rejected");
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new EconomyCommittedExternalFailure(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async function badgeUpload(input: unknown): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const code = text(data.code, 32, true);
|
||||
if (!BADGE_CODE.test(code))
|
||||
throw validation({ code: ["invalid badge code"] });
|
||||
const file = data.file;
|
||||
if (!(file instanceof File)) throw validation({ file: ["file required"] });
|
||||
if (
|
||||
file.size <= 0 ||
|
||||
file.size > MAX_BADGE_BYTES ||
|
||||
!BADGE_TYPES.has(file.type)
|
||||
) {
|
||||
throw validation({ file: ["GIF or PNG up to 1 MB required"] });
|
||||
}
|
||||
const configuredDirectory = process.env.BADGE_UPLOAD_DIR;
|
||||
if (!configuredDirectory) {
|
||||
throw new Error("badge upload directory unavailable");
|
||||
}
|
||||
const baseDirectory = path.resolve(configuredDirectory);
|
||||
const target = path.resolve(baseDirectory, `${code}.gif`);
|
||||
if (!target.startsWith(baseDirectory + path.sep)) throw validation();
|
||||
const gif = await toBadgeGif(Buffer.from(await file.arrayBuffer()));
|
||||
await mkdir(baseDirectory, { recursive: true });
|
||||
await writeFile(target, gif);
|
||||
return {
|
||||
before: null,
|
||||
after: { code, filename: `${code}.gif`, size: gif.byteLength },
|
||||
output: { code },
|
||||
};
|
||||
}
|
||||
|
||||
const HANDLERS: Partial<
|
||||
Record<
|
||||
EconomyMutationOperation,
|
||||
(
|
||||
input: unknown,
|
||||
context: EconomyMutationContext,
|
||||
) => Promise<EconomyMutationSnapshot>
|
||||
>
|
||||
> = {
|
||||
"badge.give": (input) => badgeGive(input),
|
||||
"badge.upload": (input) => badgeUpload(input),
|
||||
};
|
||||
|
||||
export async function executeEconomyRewardsMutation(
|
||||
operation: EconomyMutationOperation,
|
||||
input: unknown,
|
||||
context: EconomyMutationContext,
|
||||
): Promise<EconomyMutationSnapshot | null> {
|
||||
const handler = HANDLERS[operation];
|
||||
return handler ? handler(input, context) : null;
|
||||
}
|
||||
Reference in New Issue
Block a user