This commit is contained in:
Nathan 2026-02-17 11:46:03 -05:00
parent 8884d23aaf
commit e612196461
5 changed files with 4833 additions and 355 deletions

4770
backend/dist/index.js vendored

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import { eq, sql } from 'drizzle-orm'
import type { PgTransaction } from 'drizzle-orm/pg-core'
import { db } from '../db'
import { projectsTable } from '../schemas/projects'
import { shopOrdersTable, refineryOrdersTable, refinerySpendingHistoryTable } from '../schemas/shop'
import { shopOrdersTable, refineryOrdersTable } from '../schemas/shop'
import { userBonusesTable } from '../schemas/users'
export const PHI = (1 + Math.sqrt(5)) / 2
@ -128,13 +128,13 @@ export async function getUserScrapsBalance(userId: number, txOrDb: DbOrTx = db):
.from(shopOrdersTable)
.where(eq(shopOrdersTable.userId, userId))
// Calculate scraps spent on probability upgrades from refinery spending history (never deleted)
// Calculate scraps spent on active refinery orders (deleted on win/undo = automatic refund)
const upgradeSpentResult = await txOrDb
.select({
total: sql<number>`COALESCE(SUM(${refinerySpendingHistoryTable.cost}), 0)`
total: sql<number>`COALESCE(SUM(${refineryOrdersTable.cost}), 0)`
})
.from(refinerySpendingHistoryTable)
.where(eq(refinerySpendingHistoryTable.userId, userId))
.from(refineryOrdersTable)
.where(eq(refineryOrdersTable.userId, userId))
const projectEarned = Number(earnedResult[0]?.total) || 0
const pending = Number(pendingResult[0]?.total) || 0

View file

@ -4,7 +4,7 @@ import { db } from '../db'
import { usersTable, userBonusesTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
import { reviewsTable } from '../schemas/reviews'
import { shopItemsTable, shopOrdersTable, shopHeartsTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable } from '../schemas/shop'
import { shopItemsTable, shopOrdersTable, shopHeartsTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable, refinerySpendingHistoryTable } from '../schemas/shop'
import { newsTable } from '../schemas/news'
import { projectActivityTable } from '../schemas/activity'
import { getUserFromSession } from '../lib/auth'
@ -1757,4 +1757,186 @@ admin.get('/export/ysws-json', async ({ headers, status }) => {
}
})
// Get detailed financial timeline for a user (admin only)
admin.get('/users/:id/timeline', async ({ params, headers, status }) => {
try {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return status(401, { error: 'Unauthorized' })
const targetUserId = parseInt(params.id)
// Fetch all data sources in parallel
const [
paidProjects,
bonusRows,
shopOrders,
refineryRows,
refineryHistory
] = await Promise.all([
db.select({
id: projectsTable.id,
name: projectsTable.name,
scrapsAwarded: projectsTable.scrapsAwarded,
scrapsPaidAt: projectsTable.scrapsPaidAt,
status: projectsTable.status,
createdAt: projectsTable.createdAt
})
.from(projectsTable)
.where(and(
eq(projectsTable.userId, targetUserId),
sql`${projectsTable.scrapsAwarded} > 0`
)),
db.select({
id: userBonusesTable.id,
amount: userBonusesTable.amount,
reason: userBonusesTable.reason,
givenBy: userBonusesTable.givenBy,
createdAt: userBonusesTable.createdAt
})
.from(userBonusesTable)
.where(eq(userBonusesTable.userId, targetUserId)),
db.select({
id: shopOrdersTable.id,
shopItemId: shopOrdersTable.shopItemId,
totalPrice: shopOrdersTable.totalPrice,
orderType: shopOrdersTable.orderType,
status: shopOrdersTable.status,
createdAt: shopOrdersTable.createdAt,
itemName: shopItemsTable.name
})
.from(shopOrdersTable)
.innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
.where(eq(shopOrdersTable.userId, targetUserId)),
db.select({
id: refineryOrdersTable.id,
shopItemId: refineryOrdersTable.shopItemId,
cost: refineryOrdersTable.cost,
boostAmount: refineryOrdersTable.boostAmount,
createdAt: refineryOrdersTable.createdAt,
itemName: shopItemsTable.name
})
.from(refineryOrdersTable)
.innerJoin(shopItemsTable, eq(refineryOrdersTable.shopItemId, shopItemsTable.id))
.where(eq(refineryOrdersTable.userId, targetUserId)),
db.select({
id: refinerySpendingHistoryTable.id,
shopItemId: refinerySpendingHistoryTable.shopItemId,
cost: refinerySpendingHistoryTable.cost,
createdAt: refinerySpendingHistoryTable.createdAt,
itemName: shopItemsTable.name
})
.from(refinerySpendingHistoryTable)
.innerJoin(shopItemsTable, eq(refinerySpendingHistoryTable.shopItemId, shopItemsTable.id))
.where(eq(refinerySpendingHistoryTable.userId, targetUserId))
])
// Build a map of most recent purchase/win per item for lock detection
const lastPurchaseByItem = new Map<number, Date>()
for (const order of shopOrders) {
if (order.orderType === 'purchase' || order.orderType === 'luck_win') {
const existing = lastPurchaseByItem.get(order.shopItemId)
const orderDate = new Date(order.createdAt)
if (!existing || orderDate > existing) {
lastPurchaseByItem.set(order.shopItemId, orderDate)
}
}
}
type TimelineEvent = {
type: string
amount: number
description: string
date: string
locked?: boolean
itemName?: string
paid?: boolean
}
const timeline: TimelineEvent[] = []
// Earned scraps from projects
for (const p of paidProjects) {
timeline.push({
type: 'earned',
amount: p.scrapsAwarded,
description: `project "${p.name}"`,
date: (p.scrapsPaidAt ?? p.createdAt ?? new Date()).toISOString(),
paid: !!p.scrapsPaidAt
})
}
// Bonuses
for (const b of bonusRows) {
timeline.push({
type: 'bonus',
amount: b.amount,
description: b.reason,
date: b.createdAt.toISOString()
})
}
// Shop orders
for (const o of shopOrders) {
timeline.push({
type: `shop_${o.orderType}`,
amount: -o.totalPrice,
description: o.itemName,
date: o.createdAt.toISOString(),
itemName: o.itemName
})
}
// Active refinery orders with lock status
for (const r of refineryRows) {
const lastPurchase = lastPurchaseByItem.get(r.shopItemId)
const locked = !!lastPurchase && new Date(r.createdAt) <= lastPurchase
timeline.push({
type: 'refinery_upgrade',
amount: -r.cost,
description: `+${r.boostAmount}% boost for "${r.itemName}"`,
date: r.createdAt.toISOString(),
locked,
itemName: r.itemName
})
}
// Undone refinery entries (in spending history but no matching active order)
// Match by finding history entries that don't correspond to any active order
// We pair them by item + cost + date proximity
const usedOrderIds = new Set<number>()
for (const h of refineryHistory) {
// Try to find a matching active refinery order
const matchingOrder = refineryRows.find(r =>
r.shopItemId === h.shopItemId &&
r.cost === h.cost &&
!usedOrderIds.has(r.id) &&
Math.abs(new Date(r.createdAt).getTime() - new Date(h.createdAt).getTime()) < 2000
)
if (matchingOrder) {
usedOrderIds.add(matchingOrder.id)
} else {
// This was undone - show it as a historical entry
timeline.push({
type: 'refinery_undone',
amount: 0,
description: `undone +boost for "${h.itemName}" (was ${h.cost} scraps)`,
date: h.createdAt.toISOString(),
itemName: h.itemName
})
}
}
// Sort newest first
timeline.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
const balance = await getUserScrapsBalance(targetUserId)
return { timeline, balance }
} catch (err) {
console.error(err)
return status(500, { error: 'Failed to fetch user timeline' })
}
})
export default admin

View file

@ -1,5 +1,5 @@
import { Elysia } from 'elysia'
import { eq, sql, and, desc, isNull, ne } from 'drizzle-orm'
import { eq, sql, and, desc, isNull, ne, or, gt } from 'drizzle-orm'
import { db } from '../db'
import { shopItemsTable, shopHeartsTable, shopOrdersTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable, refinerySpendingHistoryTable } from '../schemas/shop'
import { usersTable } from '../schemas/users'
@ -940,17 +940,41 @@ shop.post('/items/:id/refinery/undo', async ({ params, headers }) => {
const result = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM users WHERE id = ${user.id} FOR UPDATE`)
// Check if user has purchased or won this item - only allow undoing upgrades made AFTER the most recent purchase
const lastPurchase = await tx
.select({ createdAt: shopOrdersTable.createdAt })
.from(shopOrdersTable)
.where(and(
eq(shopOrdersTable.userId, user.id),
eq(shopOrdersTable.shopItemId, itemId),
or(
eq(shopOrdersTable.orderType, 'purchase'),
eq(shopOrdersTable.orderType, 'luck_win')
)
))
.orderBy(desc(shopOrdersTable.createdAt))
.limit(1)
const orderConditions = [
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
]
if (lastPurchase.length > 0) {
orderConditions.push(gt(refineryOrdersTable.createdAt, lastPurchase[0].createdAt))
}
const orders = await tx
.select()
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
.where(and(...orderConditions))
.orderBy(desc(refineryOrdersTable.createdAt))
.limit(1)
if (orders.length === 0) {
if (lastPurchase.length > 0) {
return { error: 'Cannot undo refinery upgrades from before your last purchase' }
}
return { error: 'No refinery upgrades to undo' }
}

View file

@ -10,7 +10,16 @@
AlertTriangle,
Plus,
Gift,
Spool
Spool,
Lock,
Unlock,
History,
TrendingUp,
TrendingDown,
Undo2,
ShoppingCart,
Dices,
FileText
} from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
@ -81,6 +90,21 @@
let savingBonus = $state(false);
let bonusError = $state<string | null>(null);
interface TimelineEvent {
type: string;
amount: number;
description: string;
date: string;
locked?: boolean;
itemName?: string;
paid?: boolean;
}
let timeline = $state<TimelineEvent[]>([]);
let timelineBalance = $state<{ earned: number; pending: number; spent: number; balance: number } | null>(null);
let timelineLoading = $state(false);
let showTimeline = $state(false);
onMount(async () => {
currentUser = await getUser();
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'reviewer')) {
@ -182,6 +206,74 @@
}
}
async function fetchTimeline() {
if (!targetUser) return;
timelineLoading = true;
try {
const res = await fetch(`${API_URL}/admin/users/${targetUser.id}/timeline`, {
credentials: 'include'
});
if (res.ok) {
const result = await res.json();
timeline = result.timeline;
timelineBalance = result.balance;
}
} catch (e) {
console.error('Failed to fetch timeline:', e);
} finally {
timelineLoading = false;
}
}
function getTimelineIcon(type: string) {
switch (type) {
case 'earned':
return TrendingUp;
case 'bonus':
return Gift;
case 'shop_purchase':
return ShoppingCart;
case 'shop_luck_win':
return Dices;
case 'shop_consolation':
return FileText;
case 'refinery_upgrade':
return Spool;
case 'refinery_undone':
return Undo2;
default:
return Clock;
}
}
function getTimelineColor(type: string, amount: number) {
if (type === 'refinery_undone') return 'text-gray-400';
if (amount > 0) return 'text-green-600';
if (amount < 0) return 'text-red-600';
return 'text-gray-600';
}
function getTimelineLabel(type: string) {
switch (type) {
case 'earned':
return 'scraps earned';
case 'bonus':
return 'bonus';
case 'shop_purchase':
return 'shop purchase';
case 'shop_luck_win':
return 'lucky win';
case 'shop_consolation':
return 'consolation roll';
case 'refinery_upgrade':
return 'refinery upgrade';
case 'refinery_undone':
return 'refinery undone';
default:
return type;
}
}
function getRoleBadgeColor(role: string) {
switch (role) {
case 'admin':
@ -486,6 +578,102 @@
</div>
{/if}
</div>
<!-- Financial Timeline -->
<div class="mt-6 rounded-2xl border-4 border-black p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="flex items-center gap-2 text-xl font-bold">
<History size={20} />
financial timeline
</h2>
{#if !showTimeline}
<button
onclick={() => { showTimeline = true; fetchTimeline(); }}
class="cursor-pointer rounded-full border-4 border-black px-4 py-2 text-sm font-bold transition-all duration-200 hover:border-dashed"
>
load timeline
</button>
{/if}
</div>
{#if !showTimeline}
<p class="text-gray-500">click "load timeline" to view the full financial history</p>
{:else if timelineLoading}
<div class="py-8 text-center text-gray-500">{$t.common.loading}</div>
{:else}
{#if timelineBalance}
<div class="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
<div class="rounded-lg border-2 border-black p-3 text-center">
<p class="text-xl font-bold text-green-600">{timelineBalance.earned}</p>
<p class="text-xs text-gray-500">earned</p>
</div>
<div class="rounded-lg border-2 border-black p-3 text-center">
<p class="text-xl font-bold text-yellow-600">{timelineBalance.pending}</p>
<p class="text-xs text-gray-500">pending</p>
</div>
<div class="rounded-lg border-2 border-black p-3 text-center">
<p class="text-xl font-bold text-red-600">{timelineBalance.spent}</p>
<p class="text-xs text-gray-500">spent</p>
</div>
<div class="rounded-lg border-2 border-black p-3 text-center">
<p class="text-xl font-bold">{timelineBalance.balance}</p>
<p class="text-xs text-gray-500">balance</p>
</div>
</div>
{/if}
{#if timeline.length === 0}
<p class="text-gray-500">no financial activity yet</p>
{:else}
<div class="space-y-2">
{#each timeline as event}
{@const Icon = getTimelineIcon(event.type)}
<div class="flex items-center gap-3 rounded-lg border-2 border-black p-3 {event.type === 'refinery_undone' ? 'border-dashed opacity-60' : ''}">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full {event.amount > 0 ? 'bg-green-100' : event.amount < 0 ? 'bg-red-100' : 'bg-gray-100'}">
<Icon size={16} class={getTimelineColor(event.type, event.amount)} />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs font-bold uppercase {getTimelineColor(event.type, event.amount)}">
{getTimelineLabel(event.type)}
</span>
{#if event.type === 'refinery_upgrade'}
{#if event.locked}
<span class="flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-bold text-red-700">
<Lock size={10} />
locked
</span>
{:else}
<span class="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-bold text-green-700">
<Unlock size={10} />
undoable
</span>
{/if}
{/if}
{#if event.type === 'earned' && !event.paid}
<span class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-bold text-yellow-700">
unpaid
</span>
{/if}
</div>
<p class="truncate text-sm text-gray-700">{event.description}</p>
</div>
<div class="shrink-0 text-right">
<p class="font-bold {getTimelineColor(event.type, event.amount)}">
{#if event.amount !== 0}
{event.amount > 0 ? '+' : ''}{event.amount}
{/if}
</p>
<p class="text-xs text-gray-500">
{new Date(event.date).toLocaleDateString()}
</p>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
{/if}
{/if}
</div>