Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/economy/services/mutation-runtime-rewards.ts
T

140 lines
4.2 KiB
TypeScript

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;
}