perm del and shop ricing

This commit is contained in:
Nathan 2026-02-17 17:41:24 -05:00
parent c177a69a51
commit 7ec39c4ccc
8 changed files with 333 additions and 30 deletions

View file

@ -14,6 +14,7 @@ import slack from './routes/slack'
import { startHackatimeSync } from './lib/hackatime-sync'
import { startAirtableSync } from './lib/airtable-sync'
import { startScrapsPayout } from './lib/scraps-payout'
import { updateShopItemPricing } from './lib/shop-pricing'
const api = new Elysia()
.use(authRoutes)
@ -40,6 +41,9 @@ console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
)
// Update shop item pricing on startup
updateShopItemPricing()
// Start background syncs (skip in dev mode)
if (process.env.NODE_ENV !== 'development') {
startHackatimeSync()

View file

@ -0,0 +1,40 @@
import { db } from '../db'
import { shopItemsTable } from '../schemas/shop'
import { eq } from 'drizzle-orm'
import { calculateShopItemPricing, SCRAPS_PER_DOLLAR } from './scraps'
export async function updateShopItemPricing() {
try {
const items = await db
.select({
id: shopItemsTable.id,
name: shopItemsTable.name,
price: shopItemsTable.price,
count: shopItemsTable.count
})
.from(shopItemsTable)
let updated = 0
for (const item of items) {
const monetaryValue = item.price / SCRAPS_PER_DOLLAR
const pricing = calculateShopItemPricing(monetaryValue, item.count)
await db
.update(shopItemsTable)
.set({
baseProbability: pricing.baseProbability,
baseUpgradeCost: pricing.baseUpgradeCost,
costMultiplier: pricing.costMultiplier,
boostAmount: pricing.boostAmount,
updatedAt: new Date()
})
.where(eq(shopItemsTable.id, item.id))
updated++
}
console.log(`[STARTUP] Updated pricing for ${updated} shop items`)
} catch (err) {
console.error('[STARTUP] Failed to update shop item pricing:', err)
}
}

View file

@ -1553,7 +1553,7 @@ admin.patch('/orders/:id', async ({ params, body, headers, status }) => {
const { status: orderStatus, notes, isFulfilled } = body as { status?: string; notes?: string; isFulfilled?: boolean }
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled']
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'deleted']
if (orderStatus && !validStatuses.includes(orderStatus)) {
return status(400, { error: 'Invalid status' })
}
@ -1768,8 +1768,67 @@ admin.get('/export/review-json', async ({ headers, status }) => {
}
})
// Undo a shop order (admin only) - deletes the order and restores stock
admin.post('/orders/:id/undo', async ({ params, headers, status }) => {
// Soft-delete a shop order (admin only) - marks the order as deleted
admin.post('/orders/:id/soft-delete', async ({ params, headers, status }) => {
try {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return status(401, { error: 'Unauthorized' })
const orderId = parseInt(params.id)
const order = await db
.select({ id: shopOrdersTable.id, status: shopOrdersTable.status })
.from(shopOrdersTable)
.where(eq(shopOrdersTable.id, orderId))
.limit(1)
if (!order[0]) return status(404, { error: 'Order not found' })
if (order[0].status === 'deleted') return status(400, { error: 'Order is already deleted' })
await db
.update(shopOrdersTable)
.set({ status: 'deleted', updatedAt: new Date() })
.where(eq(shopOrdersTable.id, orderId))
return { success: true }
} catch (err) {
console.error(err)
return status(500, { error: 'Failed to soft-delete order' })
}
})
// Restore a soft-deleted shop order (admin only)
admin.post('/orders/:id/restore', async ({ params, headers, status }) => {
try {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return status(401, { error: 'Unauthorized' })
const orderId = parseInt(params.id)
const order = await db
.select({ id: shopOrdersTable.id, status: shopOrdersTable.status })
.from(shopOrdersTable)
.where(eq(shopOrdersTable.id, orderId))
.limit(1)
if (!order[0]) return status(404, { error: 'Order not found' })
if (order[0].status !== 'deleted') return status(400, { error: 'Order is not deleted' })
await db
.update(shopOrdersTable)
.set({ status: 'pending', updatedAt: new Date() })
.where(eq(shopOrdersTable.id, orderId))
return { success: true }
} catch (err) {
console.error(err)
return status(500, { error: 'Failed to restore order' })
}
})
// Undo/refund a shop order (admin only) - must be soft-deleted first
// Adds a bonus to refund scraps, restores inventory, keeps order row for timeline
admin.delete('/orders/:id', async ({ params, headers, status }) => {
try {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return status(401, { error: 'Unauthorized' })
@ -1779,25 +1838,29 @@ admin.post('/orders/:id/undo', async ({ params, headers, status }) => {
const order = await db
.select({
id: shopOrdersTable.id,
userId: shopOrdersTable.userId,
status: shopOrdersTable.status,
shopItemId: shopOrdersTable.shopItemId,
quantity: shopOrdersTable.quantity,
totalPrice: shopOrdersTable.totalPrice,
orderType: shopOrdersTable.orderType
userId: shopOrdersTable.userId,
itemName: shopItemsTable.name
})
.from(shopOrdersTable)
.innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
.where(eq(shopOrdersTable.id, orderId))
.limit(1)
if (!order[0]) {
return status(404, { error: 'Order not found' })
}
if (!order[0]) return status(404, { error: 'Order not found' })
if (order[0].status !== 'deleted') return status(400, { error: 'Order must be marked as deleted before it can be refunded' })
await db.transaction(async (tx) => {
// Delete the order (scraps refund happens automatically since spent calculation uses shop_orders)
await tx.delete(shopOrdersTable).where(eq(shopOrdersTable.id, orderId))
await tx.insert(userBonusesTable).values({
userId: order[0].userId,
amount: order[0].totalPrice,
reason: `order refund: ${order[0].itemName} (order #${orderId})`,
givenBy: user.id
})
// Restore item stock
await tx
.update(shopItemsTable)
.set({
@ -1805,16 +1868,12 @@ admin.post('/orders/:id/undo', async ({ params, headers, status }) => {
updatedAt: new Date()
})
.where(eq(shopItemsTable.id, order[0].shopItemId))
// If this was a luck_win, also delete related refinery orders and spending history
// (the win consumed refinery orders, so undoing the win should restore them...
// but we can't restore deleted refinery orders. Just undo the purchase itself.)
})
return { success: true, refundedScraps: order[0].totalPrice }
} catch (err) {
console.error(err)
return status(500, { error: 'Failed to undo order' })
return status(500, { error: 'Failed to refund order' })
}
})

View file

@ -554,7 +554,14 @@ export default {
of: 'of',
sortOldestFirst: 'oldest first',
sortNewestFirst: 'newest first',
sort: 'sort'
sort: 'sort',
removed: 'removed',
softDelete: 'remove',
restore: 'restore',
permanentDelete: 'permanently delete',
confirmSoftDelete: 'are you sure you want to remove this order? it can be restored later.',
confirmPermanentDelete: 'are you sure you want to permanently delete this order? this will refund the scraps and restore inventory. this cannot be undone.',
adminUserPage: 'admin user page'
},
auth: {
error: 'error',

View file

@ -557,7 +557,14 @@ export default {
of: 'de',
sortOldestFirst: 'más antiguos primero',
sortNewestFirst: 'más recientes primero',
sort: 'ordenar'
sort: 'ordenar',
removed: 'eliminados',
softDelete: 'eliminar',
restore: 'restaurar',
permanentDelete: 'eliminar permanentemente',
confirmSoftDelete: '¿estás seguro de que quieres eliminar este pedido? se puede restaurar después.',
confirmPermanentDelete: '¿estás seguro de que quieres eliminar permanentemente este pedido? esto reembolsará los scraps y restaurará el inventario. esto no se puede deshacer.',
adminUserPage: 'página de admin del usuario'
},
auth: {
error: 'error',

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { Check, X, Package, Clock, Truck, CheckCircle, XCircle } from '@lucide/svelte';
import { Check, X, Package, Clock, Truck, CheckCircle, XCircle, Trash2, RotateCcw } from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { t } from '$lib/i18n';
@ -59,13 +59,18 @@
let orders = $state<Order[]>([]);
let loading = $state(true);
let filter = $state<'all' | 'pending' | 'fulfilled'>('all');
let confirmModal = $state<{ type: 'soft-delete' | 'permanent-delete'; order: Order } | null>(null);
let actionLoading = $state(false);
let activeOrders = $derived(orders.filter((o) => o.status !== 'deleted'));
let deletedOrders = $derived(orders.filter((o) => o.status === 'deleted'));
let filteredOrders = $derived(
filter === 'all'
? orders
? activeOrders
: filter === 'pending'
? orders.filter((o) => !o.isFulfilled)
: orders.filter((o) => o.isFulfilled)
? activeOrders.filter((o) => !o.isFulfilled)
: activeOrders.filter((o) => o.isFulfilled)
);
onMount(async () => {
@ -110,6 +115,56 @@
}
}
async function softDeleteOrder(order: Order) {
actionLoading = true;
try {
const response = await fetch(`${API_URL}/admin/orders/${order.id}/soft-delete`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
orders = orders.map((o) => (o.id === order.id ? { ...o, status: 'deleted' } : o));
}
} catch (e) {
console.error('Failed to soft-delete order:', e);
} finally {
actionLoading = false;
confirmModal = null;
}
}
async function restoreOrder(order: Order) {
try {
const response = await fetch(`${API_URL}/admin/orders/${order.id}/restore`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
orders = orders.map((o) => (o.id === order.id ? { ...o, status: 'pending' } : o));
}
} catch (e) {
console.error('Failed to restore order:', e);
}
}
async function permanentDeleteOrder(order: Order) {
actionLoading = true;
try {
const response = await fetch(`${API_URL}/admin/orders/${order.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
orders = orders.filter((o) => o.id !== order.id);
}
} catch (e) {
console.error('Failed to permanently delete order:', e);
} finally {
actionLoading = false;
confirmModal = null;
}
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
@ -132,6 +187,8 @@
return CheckCircle;
case 'cancelled':
return XCircle;
case 'deleted':
return Trash2;
default:
return Clock;
}
@ -149,6 +206,8 @@
return 'bg-green-100 text-green-700 border-green-600';
case 'cancelled':
return 'bg-red-100 text-red-700 border-red-600';
case 'deleted':
return 'bg-gray-100 text-gray-700 border-gray-600';
default:
return 'bg-gray-100 text-gray-700 border-gray-600';
}
@ -176,7 +235,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{$t.admin.all} ({orders.length})
{$t.admin.all} ({activeOrders.length})
</button>
<button
onclick={() => (filter = 'pending')}
@ -185,7 +244,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{$t.admin.pending} ({orders.filter((o) => !o.isFulfilled).length})
{$t.admin.pending} ({activeOrders.filter((o) => !o.isFulfilled).length})
</button>
<button
onclick={() => (filter = 'fulfilled')}
@ -194,7 +253,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{$t.admin.fulfilled} ({orders.filter((o) => o.isFulfilled).length})
{$t.admin.fulfilled} ({activeOrders.filter((o) => o.isFulfilled).length})
</button>
</div>
@ -308,7 +367,7 @@
</div>
<!-- Actions -->
<div class="shrink-0">
<div class="flex shrink-0 gap-2">
<button
onclick={() => toggleFulfilled(order)}
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 {order.isFulfilled
@ -323,10 +382,120 @@
{$t.admin.fulfill}
{/if}
</button>
<button
onclick={() => (confirmModal = { type: 'soft-delete', order })}
class="flex cursor-pointer items-center gap-1 rounded-full border-4 border-black px-3 py-2 font-bold transition-all duration-200 hover:border-dashed"
title={$t.admin.softDelete}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- Removed Orders Section -->
{#if deletedOrders.length > 0}
<div class="mt-12">
<h2 class="mb-4 flex items-center gap-2 text-2xl font-bold text-gray-400">
<Trash2 size={24} />
{$t.admin.removed} ({deletedOrders.length})
</h2>
<div class="grid gap-4">
{#each deletedOrders as order}
<div class="rounded-2xl border-4 border-gray-300 bg-gray-50 p-4 opacity-60">
<div class="flex items-start gap-4">
<img
src={order.itemImage}
alt={order.itemName}
class="h-16 w-16 shrink-0 rounded-lg border-2 border-gray-300 object-cover grayscale"
/>
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<h3 class="text-lg font-bold text-gray-500">{order.itemName}</h3>
<span class="text-gray-400">×{order.quantity}</span>
</div>
<div class="mb-2 flex flex-wrap items-center gap-2">
<a href="/admin/users/{order.userId}" class="text-sm font-bold text-gray-500 hover:underline">
@{order.username}
</a>
<span class="text-gray-300"></span>
<span class="text-sm text-gray-400">{formatDate(order.createdAt)}</span>
<span class="text-gray-300"></span>
<span class="text-sm font-bold text-gray-500">{order.totalPrice} scraps</span>
</div>
</div>
<div class="flex shrink-0 gap-2">
<button
onclick={() => restoreOrder(order)}
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<RotateCcw size={16} />
{$t.admin.restore}
</button>
<button
onclick={() => (confirmModal = { type: 'permanent-delete', order })}
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-red-600 bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
>
<Trash2 size={16} />
{$t.admin.permanentDelete}
</button>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Confirmation Modal -->
{#if confirmModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => e.target === e.currentTarget && (confirmModal = null)}
onkeydown={(e) => e.key === 'Escape' && (confirmModal = null)}
role="dialog"
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">
{confirmModal.type === 'soft-delete' ? $t.admin.softDelete : $t.admin.permanentDelete}
</h2>
<p class="mb-6 text-gray-600">
{confirmModal.type === 'soft-delete' ? $t.admin.confirmSoftDelete : $t.admin.confirmPermanentDelete}
</p>
<div class="flex gap-3">
<button
onclick={() => (confirmModal = null)}
disabled={actionLoading}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
{$t.common.cancel}
</button>
{#if confirmModal.type === 'soft-delete'}
<button
onclick={() => confirmModal && softDeleteOrder(confirmModal.order)}
disabled={actionLoading}
class="flex-1 cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
{actionLoading ? '...' : $t.admin.softDelete}
</button>
{:else}
<button
onclick={() => confirmModal && permanentDeleteOrder(confirmModal.order)}
disabled={actionLoading}
class="flex-1 cursor-pointer rounded-full border-4 border-red-600 bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
{actionLoading ? '...' : $t.admin.permanentDelete}
</button>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -240,13 +240,23 @@
undoingOrder = orderId;
showUndoConfirm = null;
try {
const res = await fetch(`${API_URL}/admin/orders/${orderId}/undo`, {
const softDeleteRes = await fetch(`${API_URL}/admin/orders/${orderId}/soft-delete`, {
method: 'POST',
credentials: 'include'
});
const result = await res.json();
if (result.error) {
alert(result.error);
const softDeleteResult = await softDeleteRes.json();
if (softDeleteResult.error) {
console.error(softDeleteResult.error);
return;
}
const refundRes = await fetch(`${API_URL}/admin/orders/${orderId}`, {
method: 'DELETE',
credentials: 'include'
});
const refundResult = await refundRes.json();
if (refundResult.error) {
console.error(refundResult.error);
return;
}
fetchTimeline();

View file

@ -209,6 +209,13 @@
<Pencil size={12} />
{$t.profile.editRole}
</button>
<a
href="/admin/users/{profileUser.id}"
class="flex cursor-pointer items-center gap-1 rounded-full border-2 border-black px-2 py-0.5 text-xs font-bold transition-all hover:border-dashed"
>
<Shield size={12} />
{$t.admin.adminUserPage}
</a>
{/if}
</div>
<p class="text-sm text-gray-500">