Git history was lost when the previous Gitea instance was wiped and reinstalled due to an unresolved corruption bug — this commit is the last known-good file content, exported before the reinstall. Prior commit history is not recoverable through this path.
106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
import { eq, desc } from "drizzle-orm";
|
|
import { db } from "@/lib/db/client";
|
|
import { wallets, coinTransactions } from "@/lib/db/schema";
|
|
|
|
export type CoinReason = "signup_bonus" | "manual_save" | "purchase";
|
|
|
|
export interface WalletDTO {
|
|
userId: string;
|
|
balance: number;
|
|
}
|
|
|
|
export interface CoinTransactionDTO {
|
|
id: string;
|
|
amount: number;
|
|
reason: CoinReason;
|
|
relatedGameId: string | null;
|
|
createdAt: number;
|
|
}
|
|
|
|
export async function getOrCreateWallet(userId: string): Promise<WalletDTO> {
|
|
const existing = db.select().from(wallets).where(eq(wallets.userId, userId)).get();
|
|
if (existing) return existing;
|
|
|
|
db.insert(wallets).values({ userId, balance: 0 }).onConflictDoNothing().run();
|
|
const created = db.select().from(wallets).where(eq(wallets.userId, userId)).get();
|
|
return created!;
|
|
}
|
|
|
|
export async function getBalance(userId: string): Promise<number> {
|
|
const wallet = await getOrCreateWallet(userId);
|
|
return wallet.balance;
|
|
}
|
|
|
|
export async function listRecentTransactions(userId: string, limit = 20): Promise<CoinTransactionDTO[]> {
|
|
const rows = await db.query.coinTransactions.findMany({
|
|
where: eq(coinTransactions.userId, userId),
|
|
orderBy: desc(coinTransactions.createdAt),
|
|
limit,
|
|
});
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
amount: row.amount,
|
|
reason: row.reason,
|
|
relatedGameId: row.relatedGameId,
|
|
createdAt: row.createdAt.getTime(),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Credits coins and logs the ledger entry atomically. The better-sqlite3
|
|
* driver's db.transaction() callback must stay fully synchronous — no
|
|
* await inside — or the transaction commits before the awaited work
|
|
* finishes; every query here uses the sync .get()/.run() methods.
|
|
*/
|
|
export async function grantCoins(
|
|
userId: string,
|
|
amount: number,
|
|
reason: CoinReason,
|
|
relatedGameId?: string,
|
|
): Promise<void> {
|
|
await getOrCreateWallet(userId);
|
|
|
|
db.transaction((tx) => {
|
|
const wallet = tx.select().from(wallets).where(eq(wallets.userId, userId)).get();
|
|
const balance = wallet?.balance ?? 0;
|
|
tx.update(wallets)
|
|
.set({ balance: balance + amount, updatedAt: new Date() })
|
|
.where(eq(wallets.userId, userId))
|
|
.run();
|
|
tx.insert(coinTransactions)
|
|
.values({ userId, amount, reason, relatedGameId: relatedGameId ?? null })
|
|
.run();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Debits coins and logs the ledger entry atomically — returns false without
|
|
* writing anything if the balance is insufficient. This is the one place
|
|
* correctness really matters: the balance check and the debit happen inside
|
|
* the same transaction, so two concurrent spends can't both pass the check
|
|
* against a stale balance.
|
|
*/
|
|
export async function spendCoins(
|
|
userId: string,
|
|
amount: number,
|
|
reason: CoinReason,
|
|
relatedGameId?: string,
|
|
): Promise<boolean> {
|
|
await getOrCreateWallet(userId);
|
|
|
|
return db.transaction((tx) => {
|
|
const wallet = tx.select().from(wallets).where(eq(wallets.userId, userId)).get();
|
|
const balance = wallet?.balance ?? 0;
|
|
if (balance < amount) return false;
|
|
|
|
tx.update(wallets)
|
|
.set({ balance: balance - amount, updatedAt: new Date() })
|
|
.where(eq(wallets.userId, userId))
|
|
.run();
|
|
tx.insert(coinTransactions)
|
|
.values({ userId, amount: -amount, reason, relatedGameId: relatedGameId ?? null })
|
|
.run();
|
|
return true;
|
|
});
|
|
}
|