mirror of
https://github.com/System-End/scraps.git
synced 2026-04-20 00:25:18 +00:00
fix shop
This commit is contained in:
parent
8884d23aaf
commit
e612196461
5 changed files with 4833 additions and 355 deletions
4770
backend/dist/index.js
vendored
4770
backend/dist/index.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue