add refinery undo, fix displaying balances at admin & leaderboard due to bonuses

This commit is contained in:
sbeltranc 2026-02-05 21:41:11 -05:00
parent 03131c6eb9
commit ceec8c9c2b
6 changed files with 163 additions and 13 deletions

View file

@ -100,7 +100,7 @@ admin.get('/users', async ({ headers, query, status }) => {
)
: undefined
const [users, countResult] = await Promise.all([
const [userIds, countResult] = await Promise.all([
db.select({
id: usersTable.id,
username: usersTable.username,
@ -109,23 +109,32 @@ admin.get('/users', async ({ headers, query, status }) => {
email: usersTable.email,
role: usersTable.role,
internalNotes: usersTable.internalNotes,
createdAt: usersTable.createdAt,
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
scrapsSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent')
createdAt: usersTable.createdAt
}).from(usersTable).where(searchCondition).orderBy(desc(usersTable.createdAt)).limit(limit).offset(offset),
db.select({ count: sql<number>`count(*)` }).from(usersTable).where(searchCondition)
])
const total = Number(countResult[0]?.count || 0)
// Get scraps balance for each user
const usersWithScraps = await Promise.all(
userIds.map(async (u) => {
const balance = await getUserScrapsBalance(u.id)
return {
...u,
scraps: balance.balance
}
})
)
return {
data: users.map(u => ({
data: usersWithScraps.map(u => ({
id: u.id,
username: u.username,
avatar: u.avatar,
slackId: u.slackId,
email: user.role === 'admin' ? u.email : undefined,
scraps: Number(u.scrapsEarned) - Number(u.scrapsSpent),
scraps: u.scraps,
role: u.role,
internalNotes: u.internalNotes,
createdAt: u.createdAt

View file

@ -17,7 +17,9 @@ leaderboard.get('/', async ({ query }) => {
username: usersTable.username,
avatar: usersTable.avatar,
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0)`.as('scraps_earned'),
scrapsSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent'),
scrapsBonus: sql<number>`COALESCE((SELECT SUM(amount) FROM user_bonuses WHERE user_id = ${usersTable.id}), 0)`.as('scraps_bonus'),
scrapsShopSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_shop_spent'),
scrapsRefinerySpent: sql<number>`COALESCE((SELECT SUM(cost) FROM refinery_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_refinery_spent'),
hours: sql<number>`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql<number>`COUNT(${projectsTable.id})`.as('project_count')
})
@ -37,7 +39,7 @@ leaderboard.get('/', async ({ query }) => {
username: user.username,
avatar: user.avatar,
hours: Number(user.hours),
scraps: Number(user.scrapsEarned) - Number(user.scrapsSpent),
scraps: Number(user.scrapsEarned) + Number(user.scrapsBonus) - Number(user.scrapsShopSpent) - Number(user.scrapsRefinerySpent),
scrapsEarned: Number(user.scrapsEarned),
projectCount: Number(user.projectCount)
}))
@ -49,7 +51,9 @@ leaderboard.get('/', async ({ query }) => {
username: usersTable.username,
avatar: usersTable.avatar,
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0)`.as('scraps_earned'),
scrapsSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent'),
scrapsBonus: sql<number>`COALESCE((SELECT SUM(amount) FROM user_bonuses WHERE user_id = ${usersTable.id}), 0)`.as('scraps_bonus'),
scrapsShopSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_shop_spent'),
scrapsRefinerySpent: sql<number>`COALESCE((SELECT SUM(cost) FROM refinery_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_refinery_spent'),
hours: sql<number>`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql<number>`COUNT(${projectsTable.id})`.as('project_count')
})
@ -60,7 +64,7 @@ leaderboard.get('/', async ({ query }) => {
sql`${projectsTable.status} != 'permanently_rejected'`
))
.groupBy(usersTable.id)
.orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`))
.orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0) + COALESCE((SELECT SUM(amount) FROM user_bonuses WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(cost) FROM refinery_orders WHERE user_id = ${usersTable.id}), 0)`))
.limit(10)
return results.map((user, index) => ({
@ -69,7 +73,7 @@ leaderboard.get('/', async ({ query }) => {
username: user.username,
avatar: user.avatar,
hours: Number(user.hours),
scraps: Number(user.scrapsEarned) - Number(user.scrapsSpent),
scraps: Number(user.scrapsEarned) + Number(user.scrapsBonus) - Number(user.scrapsShopSpent) - Number(user.scrapsRefinerySpent),
scrapsEarned: Number(user.scrapsEarned),
projectCount: Number(user.projectCount)
}))

View file

@ -882,4 +882,89 @@ shop.post('/orders/:id/address', async ({ params, body, headers }) => {
return { success: true }
})
shop.post('/items/:id/refinery/undo', async ({ params, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const itemId = parseInt(params.id)
if (!Number.isInteger(itemId)) {
return { error: 'Invalid item id' }
}
const item = await db
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (item.length === 0) {
return { error: 'Item not found' }
}
// Get the most recent refinery order for this user and item
const orders = await db
.select()
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
.orderBy(desc(refineryOrdersTable.createdAt))
.limit(1)
if (orders.length === 0) {
return { error: 'No refinery upgrades to undo' }
}
const order = orders[0]
// Delete the order (balance is calculated dynamically, so deleting this removes the cost from the spent total)
await db
.delete(refineryOrdersTable)
.where(eq(refineryOrdersTable.id, order.id))
// Get updated boost and upgrade count
const boost = await db
.select({
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`,
upgradeCount: sql<number>`COUNT(*)`
})
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
const newBoostPercent = boost.length > 0 ? Number(boost[0].boostPercent) : 0
const newUpgradeCount = boost.length > 0 ? Number(boost[0].upgradeCount) : 0
const refundedCost = order.cost
// Get penalty for effective probability calculation
const penalty = await db
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
.limit(1)
const penaltyMultiplier = penalty.length > 0 ? penalty[0].probabilityMultiplier : 100
const adjustedBaseProbability = Math.floor(item[0].baseProbability * penaltyMultiplier / 100)
const maxBoost = 100 - adjustedBaseProbability
const nextCost = newBoostPercent >= maxBoost
? null
: Math.floor(item[0].baseUpgradeCost * Math.pow(item[0].costMultiplier / 100, newUpgradeCount))
return {
boostPercent: newBoostPercent,
upgradeCount: newUpgradeCount,
refundedCost,
effectiveProbability: Math.min(adjustedBaseProbability + newBoostPercent, 100),
nextCost
}
})
export default shop

View file

@ -167,6 +167,8 @@ export default {
fromPreviousBuy: 'from previous buy',
noItemsAvailable: 'no items available for upgrades',
failedToUpgrade: 'Failed to upgrade probability',
failedToUndo: 'Failed to undo upgrade',
undo: 'undo',
ok: 'ok'
},
leaderboard: {

View file

@ -167,6 +167,8 @@ export default {
fromPreviousBuy: 'de compra anterior',
noItemsAvailable: 'no hay artículos disponibles para mejorar',
failedToUpgrade: 'Error al mejorar probabilidad',
failedToUndo: 'Error al deshacer mejora',
undo: 'deshacer',
ok: 'ok'
},
leaderboard: {

View file

@ -1,11 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Undo2 } from '@lucide/svelte';
import { getUser, refreshUserScraps, userScrapsStore } from '$lib/auth-client';
import { shopItemsStore, shopLoading, fetchShopItems, type ShopItem } from '$lib/stores';
import { t } from '$lib/i18n';
let probabilityItems = $derived($shopItemsStore.filter((item) => item.baseProbability > 0));
let upgrading = $state<number | null>(null);
let undoing = $state<number | null>(null);
let alertMessage = $state<string | null>(null);
function getProbabilityColor(probability: number): string {
@ -49,6 +51,43 @@
}
}
async function undoRefinery(item: ShopItem) {
undoing = item.id;
try {
const res = await fetch(
`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/shop/items/${item.id}/refinery/undo`,
{
method: 'POST',
credentials: 'include'
}
);
const data = await res.json();
if (data.error) {
alertMessage = data.error;
return;
}
shopItemsStore.update((items) =>
items.map((i) =>
i.id === item.id
? {
...i,
userBoostPercent: data.boostPercent,
upgradeCount: data.upgradeCount,
effectiveProbability: data.effectiveProbability,
nextUpgradeCost: data.nextCost
}
: i
)
);
await refreshUserScraps();
alertMessage = `Refunded ${data.refundedCost} scraps`;
} catch (e) {
alertMessage = $t.refinery.failedToUndo || 'Failed to undo upgrade';
} finally {
undoing = null;
}
}
onMount(async () => {
await getUser();
fetchShopItems();
@ -111,10 +150,19 @@
</div>
</div>
</div>
<div class="sm:text-right">
<div class="flex items-center gap-2 sm:text-right">
{#if item.userBoostPercent > 0}
<button
onclick={() => undoRefinery(item)}
disabled={undoing === item.id}
class="cursor-pointer rounded-full border-4 border-black px-4 py-2 text-sm font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50 sm:text-base"
>
{$t.refinery.undo}
</button>
{/if}
{#if maxed}
<span
class="inline-block rounded-full bg-gray-200 px-4 py-2 font-bold text-gray-600"
class="rounded-full bg-gray-200 px-4 py-2 font-bold text-gray-600"
>{$t.refinery.maxed}</span
>
{:else if nextCost !== null}