finish orders and stuff

This commit is contained in:
Nathan 2026-02-03 16:45:43 -05:00
parent b88c62c6a7
commit 25f84c1344
14 changed files with 1289 additions and 642 deletions

453
backend/dist/index.js vendored
View file

@ -28277,8 +28277,9 @@ var usersTable = pgTable("users", {
var userBonusesTable = pgTable("user_bonuses", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer("user_id").notNull().references(() => usersTable.id),
type: varchar().notNull(),
amount: integer().notNull(),
reason: text().notNull(),
givenBy: integer("given_by").references(() => usersTable.id),
createdAt: timestamp("created_at").defaultNow().notNull()
});
@ -28363,7 +28364,7 @@ function getAuthorizationUrl() {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "openid profile email slack_id verification_status"
scope: "openid email name profile birthdate address verification_status slack_id basic_info"
});
return `${HACKCLUB_AUTH_URL}/oauth/authorize?${params.toString()}`;
}
@ -28422,38 +28423,29 @@ async function createOrUpdateUser(identity, tokens) {
console.log("[AUTH] Slack profile fetched:", { username, avatarUrl });
}
}
const existingUser = await db.select().from(usersTable).where(eq(usersTable.sub, identity.id)).limit(1);
if (existingUser.length > 0) {
const updated = await db.update(usersTable).set({
const [user] = await db.insert(usersTable).values({
sub: identity.id,
slackId: identity.slack_id,
username,
email: identity.primary_email || "",
avatar: avatarUrl,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token
}).onConflictDoUpdate({
target: usersTable.sub,
set: {
username,
email: identity.primary_email || existingUser[0].email,
email: sql`COALESCE(${identity.primary_email || null}, ${usersTable.email})`,
slackId: identity.slack_id,
avatar: avatarUrl || existingUser[0].avatar,
avatar: sql`COALESCE(${avatarUrl}, ${usersTable.avatar})`,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
updatedAt: new Date
}).where(eq(usersTable.sub, identity.id)).returning();
return updated[0];
} else {
console.log("[AUTH] New user signup:", {
id: identity.id,
username,
email: identity.primary_email,
slackId: identity.slack_id
});
const newUser = await db.insert(usersTable).values({
sub: identity.id,
slackId: identity.slack_id,
username,
email: identity.primary_email || "",
avatar: avatarUrl,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token
}).returning();
return newUser[0];
}
}
}).returning();
return user;
}
async function createSession(userId) {
const token = crypto.randomUUID();
@ -28877,6 +28869,7 @@ var shopOrdersTable = pgTable("shop_orders", {
orderType: varchar("order_type").notNull().default("purchase"),
shippingAddress: text("shipping_address"),
notes: text(),
isFulfilled: boolean("is_fulfilled").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull()
});
@ -28914,17 +28907,17 @@ var MULTIPLIER = 120;
function calculateScrapsFromHours(hours) {
return Math.floor(hours * PHI * MULTIPLIER);
}
async function getUserScrapsBalance(userId) {
const earnedResult = await db.select({
async function getUserScrapsBalance(userId, txOrDb = db) {
const earnedResult = await txOrDb.select({
total: sql`COALESCE(SUM(${projectsTable.scrapsAwarded}), 0)`
}).from(projectsTable).where(eq(projectsTable.userId, userId));
const bonusResult = await db.select({
const bonusResult = await txOrDb.select({
total: sql`COALESCE(SUM(${userBonusesTable.amount}), 0)`
}).from(userBonusesTable).where(eq(userBonusesTable.userId, userId));
const spentResult = await db.select({
const spentResult = await txOrDb.select({
total: sql`COALESCE(SUM(${shopOrdersTable.totalPrice}), 0)`
}).from(shopOrdersTable).where(eq(shopOrdersTable.userId, userId));
const upgradeSpentResult = await db.select({
const upgradeSpentResult = await txOrDb.select({
total: sql`COALESCE(SUM(${refineryOrdersTable.cost}), 0)`
}).from(refineryOrdersTable).where(eq(refineryOrdersTable.userId, userId));
const projectEarned = Number(earnedResult[0]?.total) || 0;
@ -28936,8 +28929,8 @@ async function getUserScrapsBalance(userId) {
const balance = earned - spent;
return { earned, spent, balance };
}
async function canAfford(userId, cost) {
const { balance } = await getUserScrapsBalance(userId);
async function canAfford(userId, cost, txOrDb = db) {
const { balance } = await getUserScrapsBalance(userId, txOrDb);
return balance >= cost;
}
@ -29081,7 +29074,12 @@ user.get("/profile/:id", async ({ params, headers }) => {
const currentUser = await getUserFromSession(headers);
if (!currentUser)
return { error: "Unauthorized" };
const targetUser = await db.select().from(usersTable).where(eq(usersTable.id, parseInt(params.id))).limit(1);
const targetUser = await db.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
createdAt: usersTable.createdAt
}).from(usersTable).where(eq(usersTable.id, parseInt(params.id))).limit(1);
if (!targetUser[0])
return { error: "User not found" };
const allProjects = await db.select().from(projectsTable).where(eq(projectsTable.userId, parseInt(params.id)));
@ -29258,15 +29256,23 @@ shop.post("/items/:id/heart", async ({ params, headers }) => {
if (item.length === 0) {
return { error: "Item not found" };
}
const deleted = await db.delete(shopHeartsTable).where(and(eq(shopHeartsTable.userId, user2.id), eq(shopHeartsTable.shopItemId, itemId))).returning({ userId: shopHeartsTable.userId });
if (deleted.length > 0) {
return { hearted: false };
}
await db.insert(shopHeartsTable).values({
userId: user2.id,
shopItemId: itemId
}).onConflictDoNothing();
return { hearted: true };
const result = await db.execute(sql`
WITH del AS (
DELETE FROM shop_hearts
WHERE user_id = ${user2.id} AND shop_item_id = ${itemId}
RETURNING 1
),
ins AS (
INSERT INTO shop_hearts (user_id, shop_item_id)
SELECT ${user2.id}, ${itemId}
WHERE NOT EXISTS (SELECT 1 FROM del)
ON CONFLICT DO NOTHING
RETURNING 1
)
SELECT EXISTS(SELECT 1 FROM ins) AS hearted
`);
const hearted = result.rows[0]?.hearted ?? false;
return { hearted };
});
shop.get("/categories", async () => {
const result = await db.selectDistinct({ category: shopItemsTable.category }).from(shopItemsTable);
@ -29301,41 +29307,53 @@ shop.post("/items/:id/purchase", async ({ params, body, headers }) => {
return { error: "Not enough stock available" };
}
const totalPrice = item.price * quantity;
const affordable = await canAfford(user2.id, totalPrice);
if (!affordable) {
const { balance } = await getUserScrapsBalance(user2.id);
return { error: "Insufficient scraps", required: totalPrice, available: balance };
try {
const order = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM users WHERE id = ${user2.id} FOR UPDATE`);
const affordable = await canAfford(user2.id, totalPrice, tx);
if (!affordable) {
const { balance } = await getUserScrapsBalance(user2.id, tx);
throw { type: "insufficient_funds", balance };
}
const currentItem = await tx.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
if (currentItem.length === 0 || currentItem[0].count < quantity) {
throw { type: "out_of_stock" };
}
await tx.update(shopItemsTable).set({
count: currentItem[0].count - quantity,
updatedAt: new Date
}).where(eq(shopItemsTable.id, itemId));
const newOrder = await tx.insert(shopOrdersTable).values({
userId: user2.id,
shopItemId: itemId,
quantity,
pricePerItem: item.price,
totalPrice,
shippingAddress: shippingAddress || null,
status: "pending"
}).returning();
return newOrder[0];
});
return {
success: true,
order: {
id: order.id,
itemName: item.name,
quantity: order.quantity,
totalPrice: order.totalPrice,
status: order.status
}
};
} catch (e) {
const err = e;
if (err.type === "insufficient_funds") {
return { error: "Insufficient scraps", required: totalPrice, available: err.balance };
}
if (err.type === "out_of_stock") {
return { error: "Not enough stock" };
}
throw e;
}
const order = await db.transaction(async (tx) => {
const currentItem = await tx.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
if (currentItem.length === 0 || currentItem[0].count < quantity) {
throw new Error("Not enough stock");
}
await tx.update(shopItemsTable).set({
count: currentItem[0].count - quantity,
updatedAt: new Date
}).where(eq(shopItemsTable.id, itemId));
const newOrder = await tx.insert(shopOrdersTable).values({
userId: user2.id,
shopItemId: itemId,
quantity,
pricePerItem: item.price,
totalPrice,
shippingAddress: shippingAddress || null,
status: "pending"
}).returning();
return newOrder[0];
});
return {
success: true,
order: {
id: order.id,
itemName: item.name,
quantity: order.quantity,
totalPrice: order.totalPrice,
status: order.status
}
};
});
shop.get("/orders", async ({ headers }) => {
const user2 = await getUserFromSession(headers);
@ -29372,68 +29390,83 @@ shop.post("/items/:id/try-luck", async ({ params, headers }) => {
if (item.count < 1) {
return { error: "Out of stock" };
}
const boostResult = await db.select({
boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
}).from(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
const penaltyResult = await db.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
const boostPercent = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0;
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100;
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100);
const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100);
const affordable = await canAfford(user2.id, item.price);
if (!affordable) {
const { balance } = await getUserScrapsBalance(user2.id);
return { error: "Insufficient scraps", required: item.price, available: balance };
}
const rolled = Math.floor(Math.random() * 100) + 1;
const won = rolled <= effectiveProbability;
await db.insert(shopRollsTable).values({
userId: user2.id,
shopItemId: itemId,
rolled,
threshold: effectiveProbability,
won
});
if (won) {
const order = await db.transaction(async (tx) => {
try {
const result = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM users WHERE id = ${user2.id} FOR UPDATE`);
const affordable = await canAfford(user2.id, item.price, tx);
if (!affordable) {
const { balance } = await getUserScrapsBalance(user2.id, tx);
throw { type: "insufficient_funds", balance };
}
const currentItem = await tx.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
if (currentItem.length === 0 || currentItem[0].count < 1) {
throw new Error("Out of stock");
throw { type: "out_of_stock" };
}
await tx.update(shopItemsTable).set({
count: currentItem[0].count - 1,
updatedAt: new Date
}).where(eq(shopItemsTable.id, itemId));
const newOrder = await tx.insert(shopOrdersTable).values({
const boostResult = await tx.select({
boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
}).from(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
const penaltyResult = await tx.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
const boostPercent = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0;
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100;
const adjustedBaseProbability = Math.floor(currentItem[0].baseProbability * penaltyMultiplier / 100);
const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100);
const rolled = Math.floor(Math.random() * 100) + 1;
const won = rolled <= effectiveProbability;
await tx.insert(shopRollsTable).values({
userId: user2.id,
shopItemId: itemId,
quantity: 1,
pricePerItem: item.price,
totalPrice: item.price,
shippingAddress: null,
status: "pending",
orderType: "luck_win"
}).returning();
await tx.delete(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
const existingPenalty = await tx.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
if (existingPenalty.length > 0) {
const newMultiplier = Math.max(1, Math.floor(existingPenalty[0].probabilityMultiplier / 2));
await tx.update(shopPenaltiesTable).set({
probabilityMultiplier: newMultiplier,
rolled,
threshold: effectiveProbability,
won
});
if (won) {
await tx.update(shopItemsTable).set({
count: currentItem[0].count - 1,
updatedAt: new Date
}).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId)));
} else {
await tx.insert(shopPenaltiesTable).values({
}).where(eq(shopItemsTable.id, itemId));
const newOrder = await tx.insert(shopOrdersTable).values({
userId: user2.id,
shopItemId: itemId,
probabilityMultiplier: 50
});
quantity: 1,
pricePerItem: item.price,
totalPrice: item.price,
shippingAddress: null,
status: "pending",
orderType: "luck_win"
}).returning();
await tx.delete(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
const existingPenalty = await tx.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
if (existingPenalty.length > 0) {
const newMultiplier = Math.max(1, Math.floor(existingPenalty[0].probabilityMultiplier / 2));
await tx.update(shopPenaltiesTable).set({
probabilityMultiplier: newMultiplier,
updatedAt: new Date
}).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId)));
} else {
await tx.insert(shopPenaltiesTable).values({
userId: user2.id,
shopItemId: itemId,
probabilityMultiplier: 50
});
}
return { won: true, orderId: newOrder[0].id, effectiveProbability, rolled };
}
return newOrder[0];
return { won: false, effectiveProbability, rolled };
});
return { success: true, won: true, orderId: order.id, effectiveProbability, rolled, refineryReset: true, probabilityHalved: true };
if (result.won) {
return { success: true, won: true, orderId: result.orderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, refineryReset: true, probabilityHalved: true };
}
return { success: true, won: false, effectiveProbability: result.effectiveProbability, rolled: result.rolled };
} catch (e) {
const err = e;
if (err.type === "insufficient_funds") {
return { error: "Insufficient scraps", required: item.price, available: err.balance };
}
if (err.type === "out_of_stock") {
return { error: "Out of stock" };
}
throw e;
}
return { success: true, won: false, effectiveProbability, rolled };
});
shop.post("/items/:id/upgrade-probability", async ({ params, headers }) => {
const user2 = await getUserFromSession(headers);
@ -29449,36 +29482,47 @@ shop.post("/items/:id/upgrade-probability", async ({ params, headers }) => {
return { error: "Item not found" };
}
const item = items2[0];
const boostResult = await db.select({
boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
}).from(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
const currentBoost = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0;
const penaltyResult = await db.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100;
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100);
const maxBoost = 100 - adjustedBaseProbability;
if (currentBoost >= maxBoost) {
return { error: "Already at maximum probability" };
try {
const result = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM users WHERE id = ${user2.id} FOR UPDATE`);
const boostResult = await tx.select({
boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
}).from(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
const currentBoost = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0;
const penaltyResult = await tx.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100;
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100);
const maxBoost = 100 - adjustedBaseProbability;
if (currentBoost >= maxBoost) {
throw { type: "max_probability" };
}
const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost));
const affordable = await canAfford(user2.id, cost, tx);
if (!affordable) {
const { balance } = await getUserScrapsBalance(user2.id, tx);
throw { type: "insufficient_funds", balance, cost };
}
const newBoost = currentBoost + 1;
await tx.insert(refineryOrdersTable).values({
userId: user2.id,
shopItemId: itemId,
cost,
boostAmount: 1
});
const nextCost = newBoost >= maxBoost ? null : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost));
return { boostPercent: newBoost, nextCost, effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100) };
});
return result;
} catch (e) {
const err = e;
if (err.type === "max_probability") {
return { error: "Already at maximum probability" };
}
if (err.type === "insufficient_funds") {
return { error: "Insufficient scraps", required: err.cost, available: err.balance };
}
throw e;
}
const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost));
const affordable = await canAfford(user2.id, cost);
if (!affordable) {
const { balance } = await getUserScrapsBalance(user2.id);
return { error: "Insufficient scraps", required: cost, available: balance };
}
const newBoost = currentBoost + 1;
await db.insert(refineryOrdersTable).values({
userId: user2.id,
shopItemId: itemId,
cost,
boostAmount: 1
});
const nextCost = newBoost >= maxBoost ? null : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost));
return {
boostPercent: newBoost,
nextCost,
effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100)
};
});
shop.get("/items/:id/leaderboard", async ({ params }) => {
const itemId = parseInt(params.id);
@ -29512,7 +29556,7 @@ shop.get("/items/:id/buyers", async ({ params }) => {
username: usersTable.username,
avatar: usersTable.avatar,
quantity: shopOrdersTable.quantity,
createdAt: shopOrdersTable.createdAt
purchasedAt: shopOrdersTable.createdAt
}).from(shopOrdersTable).innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id)).where(eq(shopOrdersTable.shopItemId, itemId)).orderBy(desc(shopOrdersTable.createdAt)).limit(20);
return buyers;
});
@ -29532,10 +29576,12 @@ shop.get("/items/:id/hearts", async ({ params }) => {
shop.get("/addresses", async ({ headers }) => {
const user2 = await getUserFromSession(headers);
if (!user2) {
console.log("[/shop/addresses] Unauthorized - no user session");
return { error: "Unauthorized" };
}
const userData = await db.select({ accessToken: usersTable.accessToken }).from(usersTable).where(eq(usersTable.id, user2.id)).limit(1);
if (userData.length === 0 || !userData[0].accessToken) {
console.log("[/shop/addresses] No access token found for user", user2.id);
return [];
}
try {
@ -29545,11 +29591,15 @@ shop.get("/addresses", async ({ headers }) => {
}
});
if (!response.ok) {
const errorText = await response.text();
console.log("[/shop/addresses] Hack Club API error:", response.status, errorText);
return [];
}
const data = await response.json();
console.log("[/shop/addresses] Got addresses:", data.identity?.addresses?.length ?? 0);
return data.identity?.addresses ?? [];
} catch {
} catch (e) {
console.error("[/shop/addresses] Error fetching from Hack Club:", e);
return [];
}
});
@ -29840,7 +29890,6 @@ admin.get("/users", async ({ headers, query }) => {
db.select({
id: usersTable.id,
username: usersTable.username,
email: usersTable.email,
avatar: usersTable.avatar,
slackId: usersTable.slackId,
role: usersTable.role,
@ -29856,7 +29905,6 @@ admin.get("/users", async ({ headers, query }) => {
data: users.map((u) => ({
id: u.id,
username: u.username,
email: user2.role === "admin" ? u.email : undefined,
avatar: u.avatar,
slackId: u.slackId,
scraps: Number(u.scrapsEarned) - Number(u.scrapsSpent),
@ -29877,7 +29925,15 @@ admin.get("/users/:id", async ({ params, headers }) => {
if (!user2)
return { error: "Unauthorized" };
const targetUserId = parseInt(params.id);
const targetUser = await db.select().from(usersTable).where(eq(usersTable.id, targetUserId)).limit(1);
const targetUser = await db.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
slackId: usersTable.slackId,
role: usersTable.role,
internalNotes: usersTable.internalNotes,
createdAt: usersTable.createdAt
}).from(usersTable).where(eq(usersTable.id, targetUserId)).limit(1);
if (!targetUser[0])
return { error: "User not found" };
const projects2 = await db.select().from(projectsTable).where(eq(projectsTable.userId, targetUserId)).orderBy(desc(projectsTable.updatedAt));
@ -29894,7 +29950,6 @@ admin.get("/users/:id", async ({ params, headers }) => {
user: {
id: targetUser[0].id,
username: targetUser[0].username,
email: user2.role === "admin" ? targetUser[0].email : undefined,
avatar: targetUser[0].avatar,
slackId: targetUser[0].slackId,
scraps: scrapsBalance.balance,
@ -29931,6 +29986,47 @@ admin.put("/users/:id/notes", async ({ params, body, headers }) => {
const updated = await db.update(usersTable).set({ internalNotes, updatedAt: new Date }).where(eq(usersTable.id, parseInt(params.id))).returning();
return updated[0] || { error: "Not found" };
});
admin.post("/users/:id/bonus", async ({ params, body, headers }) => {
const admin2 = await requireAdmin(headers);
if (!admin2)
return { error: "Unauthorized" };
const { amount, reason } = body;
if (!amount || typeof amount !== "number") {
return { error: "Amount is required and must be a number" };
}
if (!reason || typeof reason !== "string" || reason.trim().length === 0) {
return { error: "Reason is required" };
}
if (reason.length > 500) {
return { error: "Reason is too long (max 500 characters)" };
}
const targetUserId = parseInt(params.id);
const targetUser = await db.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.id, targetUserId)).limit(1);
if (!targetUser[0])
return { error: "User not found" };
const bonus = await db.insert(userBonusesTable).values({
userId: targetUserId,
amount,
reason: reason.trim(),
givenBy: admin2.id
}).returning();
return bonus[0];
});
admin.get("/users/:id/bonuses", async ({ params, headers }) => {
const user2 = await requireAdmin(headers);
if (!user2)
return { error: "Unauthorized" };
const targetUserId = parseInt(params.id);
const bonuses = await db.select({
id: userBonusesTable.id,
amount: userBonusesTable.amount,
reason: userBonusesTable.reason,
givenBy: userBonusesTable.givenBy,
givenByUsername: usersTable.username,
createdAt: userBonusesTable.createdAt
}).from(userBonusesTable).leftJoin(usersTable, eq(userBonusesTable.givenBy, usersTable.id)).where(eq(userBonusesTable.userId, targetUserId)).orderBy(desc(userBonusesTable.createdAt));
return bonuses;
});
admin.get("/reviews", async ({ headers, query }) => {
const user2 = await requireReviewer(headers);
if (!user2)
@ -29960,7 +30056,12 @@ admin.get("/reviews/:id", async ({ params, headers }) => {
const project = await db.select().from(projectsTable).where(eq(projectsTable.id, parseInt(params.id))).limit(1);
if (project.length <= 0)
return { error: "Project not found!" };
const projectUser = await db.select().from(usersTable).where(eq(usersTable.id, project[0].userId)).limit(1);
const projectUser = await db.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
internalNotes: usersTable.internalNotes
}).from(usersTable).where(eq(usersTable.id, project[0].userId)).limit(1);
const reviews = await db.select().from(reviewsTable).where(eq(reviewsTable.projectId, parseInt(params.id)));
const reviewerIds = reviews.map((r) => r.reviewerId);
let reviewers = [];
@ -29972,7 +30073,6 @@ admin.get("/reviews/:id", async ({ params, headers }) => {
user: projectUser[0] ? {
id: projectUser[0].id,
username: projectUser[0].username,
email: user2.role === "admin" ? projectUser[0].email : undefined,
avatar: projectUser[0].avatar,
internalNotes: projectUser[0].internalNotes
} : null,
@ -30188,15 +30288,16 @@ admin.get("/orders", async ({ headers, query }) => {
pricePerItem: shopOrdersTable.pricePerItem,
totalPrice: shopOrdersTable.totalPrice,
status: shopOrdersTable.status,
shippingAddress: shopOrdersTable.shippingAddress,
orderType: shopOrdersTable.orderType,
notes: shopOrdersTable.notes,
isFulfilled: shopOrdersTable.isFulfilled,
shippingAddress: shopOrdersTable.shippingAddress,
createdAt: shopOrdersTable.createdAt,
itemId: shopItemsTable.id,
itemName: shopItemsTable.name,
itemImage: shopItemsTable.image,
userId: usersTable.id,
username: usersTable.username,
userEmail: usersTable.email
username: usersTable.username
}).from(shopOrdersTable).innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id)).innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id)).orderBy(desc(shopOrdersTable.createdAt));
if (status2) {
ordersQuery = ordersQuery.where(eq(shopOrdersTable.status, status2));
@ -30207,7 +30308,7 @@ admin.patch("/orders/:id", async ({ params, body, headers }) => {
const user2 = await requireAdmin(headers);
if (!user2)
return { error: "Unauthorized" };
const { status: status2, notes } = body;
const { status: status2, notes, isFulfilled } = body;
const validStatuses = ["pending", "processing", "shipped", "delivered", "cancelled"];
if (status2 && !validStatuses.includes(status2)) {
return { error: "Invalid status" };
@ -30217,7 +30318,15 @@ admin.patch("/orders/:id", async ({ params, body, headers }) => {
updateData.status = status2;
if (notes !== undefined)
updateData.notes = notes;
const updated = await db.update(shopOrdersTable).set(updateData).where(eq(shopOrdersTable.id, parseInt(params.id))).returning();
if (isFulfilled !== undefined)
updateData.isFulfilled = isFulfilled;
const updated = await db.update(shopOrdersTable).set(updateData).where(eq(shopOrdersTable.id, parseInt(params.id))).returning({
id: shopOrdersTable.id,
status: shopOrdersTable.status,
notes: shopOrdersTable.notes,
isFulfilled: shopOrdersTable.isFulfilled,
updatedAt: shopOrdersTable.updatedAt
});
return updated[0] || { error: "Not found" };
});
var admin_default = admin;

View file

@ -1,4 +1,4 @@
import { eq, and, gt } from "drizzle-orm"
import { eq, and, gt, sql } from "drizzle-orm"
import { db } from "../db"
import { usersTable } from "../schemas/users"
import { sessionsTable } from "../schemas/sessions"
@ -35,7 +35,7 @@ export function getAuthorizationUrl(): string {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "openid profile email slack_id verification_status"
scope: "openid email name profile birthdate address verification_status slack_id basic_info"
})
return `${HACKCLUB_AUTH_URL}/oauth/authorize?${params.toString()}`
}
@ -105,50 +105,35 @@ export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OID
}
}
const existingUser = await db
.select()
.from(usersTable)
.where(eq(usersTable.sub, identity.id))
.limit(1)
if (existingUser.length > 0) {
const updated = await db
.update(usersTable)
.set({
// Use UPSERT to avoid race condition with concurrent logins
const [user] = await db
.insert(usersTable)
.values({
sub: identity.id,
slackId: identity.slack_id,
username,
email: identity.primary_email || "",
avatar: avatarUrl,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token
})
.onConflictDoUpdate({
target: usersTable.sub,
set: {
username,
email: identity.primary_email || existingUser[0].email,
email: sql`COALESCE(${identity.primary_email || null}, ${usersTable.email})`,
slackId: identity.slack_id,
avatar: avatarUrl || existingUser[0].avatar,
avatar: sql`COALESCE(${avatarUrl}, ${usersTable.avatar})`,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
updatedAt: new Date()
})
.where(eq(usersTable.sub, identity.id))
.returning()
return updated[0]
} else {
console.log("[AUTH] New user signup:", {
id: identity.id,
username,
email: identity.primary_email,
slackId: identity.slack_id
}
})
const newUser = await db
.insert(usersTable)
.values({
sub: identity.id,
slackId: identity.slack_id,
username,
email: identity.primary_email || "",
avatar: avatarUrl,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token
})
.returning()
return newUser[0]
}
.returning()
return user
}
export async function createSession(userId: number): Promise<string> {

View file

@ -1,4 +1,5 @@
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 } from '../schemas/shop'
@ -11,26 +12,29 @@ export function calculateScrapsFromHours(hours: number): number {
return Math.floor(hours * PHI * MULTIPLIER)
}
export async function getUserScrapsBalance(userId: number): Promise<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DbOrTx = typeof db | PgTransaction<any, any, any>
export async function getUserScrapsBalance(userId: number, txOrDb: DbOrTx = db): Promise<{
earned: number
spent: number
balance: number
}> {
const earnedResult = await db
const earnedResult = await txOrDb
.select({
total: sql<number>`COALESCE(SUM(${projectsTable.scrapsAwarded}), 0)`
})
.from(projectsTable)
.where(eq(projectsTable.userId, userId))
const bonusResult = await db
const bonusResult = await txOrDb
.select({
total: sql<number>`COALESCE(SUM(${userBonusesTable.amount}), 0)`
})
.from(userBonusesTable)
.where(eq(userBonusesTable.userId, userId))
const spentResult = await db
const spentResult = await txOrDb
.select({
total: sql<number>`COALESCE(SUM(${shopOrdersTable.totalPrice}), 0)`
})
@ -38,7 +42,7 @@ export async function getUserScrapsBalance(userId: number): Promise<{
.where(eq(shopOrdersTable.userId, userId))
// Calculate scraps spent on probability upgrades from refinery_orders
const upgradeSpentResult = await db
const upgradeSpentResult = await txOrDb
.select({
total: sql<number>`COALESCE(SUM(${refineryOrdersTable.cost}), 0)`
})
@ -56,7 +60,7 @@ export async function getUserScrapsBalance(userId: number): Promise<{
return { earned, spent, balance }
}
export async function canAfford(userId: number, cost: number): Promise<boolean> {
const { balance } = await getUserScrapsBalance(userId)
export async function canAfford(userId: number, cost: number, txOrDb: DbOrTx = db): Promise<boolean> {
const { balance } = await getUserScrapsBalance(userId, txOrDb)
return balance >= cost
}

View file

@ -1,7 +1,7 @@
import { Elysia } from 'elysia'
import { eq, and, inArray, sql, desc, or } from 'drizzle-orm'
import { db } from '../db'
import { usersTable } from '../schemas/users'
import { usersTable, userBonusesTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
import { reviewsTable } from '../schemas/reviews'
import { shopItemsTable, shopOrdersTable, shopHeartsTable } from '../schemas/shop'
@ -48,7 +48,6 @@ admin.get('/users', async ({ headers, query }) => {
db.select({
id: usersTable.id,
username: usersTable.username,
email: usersTable.email,
avatar: usersTable.avatar,
slackId: usersTable.slackId,
role: usersTable.role,
@ -66,7 +65,6 @@ admin.get('/users', async ({ headers, query }) => {
data: users.map(u => ({
id: u.id,
username: u.username,
email: user.role === 'admin' ? u.email : undefined,
avatar: u.avatar,
slackId: u.slackId,
scraps: Number(u.scrapsEarned) - Number(u.scrapsSpent),
@ -91,7 +89,15 @@ admin.get('/users/:id', async ({ params, headers }) => {
const targetUserId = parseInt(params.id)
const targetUser = await db
.select()
.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
slackId: usersTable.slackId,
role: usersTable.role,
internalNotes: usersTable.internalNotes,
createdAt: usersTable.createdAt
})
.from(usersTable)
.where(eq(usersTable.id, targetUserId))
.limit(1)
@ -120,7 +126,6 @@ admin.get('/users/:id', async ({ params, headers }) => {
user: {
id: targetUser[0].id,
username: targetUser[0].username,
email: user.role === 'admin' ? targetUser[0].email : undefined,
avatar: targetUser[0].avatar,
slackId: targetUser[0].slackId,
scraps: scrapsBalance.balance,
@ -175,6 +180,72 @@ admin.put('/users/:id/notes', async ({ params, body, headers }) => {
return updated[0] || { error: 'Not found' }
})
// Give bonus scraps to user (admin only)
admin.post('/users/:id/bonus', async ({ params, body, headers }) => {
const admin = await requireAdmin(headers as Record<string, string>)
if (!admin) return { error: 'Unauthorized' }
const { amount, reason } = body as { amount: number; reason: string }
if (!amount || typeof amount !== 'number') {
return { error: 'Amount is required and must be a number' }
}
if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {
return { error: 'Reason is required' }
}
if (reason.length > 500) {
return { error: 'Reason is too long (max 500 characters)' }
}
const targetUserId = parseInt(params.id)
const targetUser = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(eq(usersTable.id, targetUserId))
.limit(1)
if (!targetUser[0]) return { error: 'User not found' }
const bonus = await db
.insert(userBonusesTable)
.values({
userId: targetUserId,
amount,
reason: reason.trim(),
givenBy: admin.id
})
.returning()
return bonus[0]
})
// Get user bonuses (admin only)
admin.get('/users/:id/bonuses', async ({ params, headers }) => {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const targetUserId = parseInt(params.id)
const bonuses = await db
.select({
id: userBonusesTable.id,
amount: userBonusesTable.amount,
reason: userBonusesTable.reason,
givenBy: userBonusesTable.givenBy,
givenByUsername: usersTable.username,
createdAt: userBonusesTable.createdAt
})
.from(userBonusesTable)
.leftJoin(usersTable, eq(userBonusesTable.givenBy, usersTable.id))
.where(eq(userBonusesTable.userId, targetUserId))
.orderBy(desc(userBonusesTable.createdAt))
return bonuses
})
// Get projects waiting for review
admin.get('/reviews', async ({ headers, query }) => {
const user = await requireReviewer(headers as Record<string, string>)
@ -221,7 +292,12 @@ admin.get('/reviews/:id', async ({ params, headers }) => {
if (project.length <= 0) return { error: "Project not found!" };
const projectUser = await db
.select()
.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
internalNotes: usersTable.internalNotes
})
.from(usersTable)
.where(eq(usersTable.id, project[0].userId))
.limit(1)
@ -245,7 +321,6 @@ admin.get('/reviews/:id', async ({ params, headers }) => {
user: projectUser[0] ? {
id: projectUser[0].id,
username: projectUser[0].username,
email: user.role === 'admin' ? projectUser[0].email : undefined,
avatar: projectUser[0].avatar,
internalNotes: projectUser[0].internalNotes
} : null,
@ -578,15 +653,16 @@ admin.get('/orders', async ({ headers, query }) => {
pricePerItem: shopOrdersTable.pricePerItem,
totalPrice: shopOrdersTable.totalPrice,
status: shopOrdersTable.status,
shippingAddress: shopOrdersTable.shippingAddress,
orderType: shopOrdersTable.orderType,
notes: shopOrdersTable.notes,
isFulfilled: shopOrdersTable.isFulfilled,
shippingAddress: shopOrdersTable.shippingAddress,
createdAt: shopOrdersTable.createdAt,
itemId: shopItemsTable.id,
itemName: shopItemsTable.name,
itemImage: shopItemsTable.image,
userId: usersTable.id,
username: usersTable.username,
userEmail: usersTable.email
username: usersTable.username
})
.from(shopOrdersTable)
.innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
@ -604,7 +680,7 @@ admin.patch('/orders/:id', async ({ params, body, headers }) => {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const { status, notes } = body as { status?: string; notes?: string }
const { status, notes, isFulfilled } = body as { status?: string; notes?: string; isFulfilled?: boolean }
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled']
if (status && !validStatuses.includes(status)) {
@ -614,12 +690,19 @@ admin.patch('/orders/:id', async ({ params, body, headers }) => {
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (status) updateData.status = status
if (notes !== undefined) updateData.notes = notes
if (isFulfilled !== undefined) updateData.isFulfilled = isFulfilled
const updated = await db
.update(shopOrdersTable)
.set(updateData)
.where(eq(shopOrdersTable.id, parseInt(params.id)))
.returning()
.returning({
id: shopOrdersTable.id,
status: shopOrdersTable.status,
notes: shopOrdersTable.notes,
isFulfilled: shopOrdersTable.isFulfilled,
updatedAt: shopOrdersTable.updatedAt
})
return updated[0] || { error: 'Not found' }
})

View file

@ -182,27 +182,25 @@ shop.post('/items/:id/heart', async ({ params, headers }) => {
return { error: 'Item not found' }
}
const deleted = await db
.delete(shopHeartsTable)
.where(and(
eq(shopHeartsTable.userId, user.id),
eq(shopHeartsTable.shopItemId, itemId)
))
.returning({ userId: shopHeartsTable.userId })
// Atomic toggle using CTE to avoid race conditions
const result = await db.execute(sql`
WITH del AS (
DELETE FROM shop_hearts
WHERE user_id = ${user.id} AND shop_item_id = ${itemId}
RETURNING 1
),
ins AS (
INSERT INTO shop_hearts (user_id, shop_item_id)
SELECT ${user.id}, ${itemId}
WHERE NOT EXISTS (SELECT 1 FROM del)
ON CONFLICT DO NOTHING
RETURNING 1
)
SELECT EXISTS(SELECT 1 FROM ins) AS hearted
`)
if (deleted.length > 0) {
return { hearted: false }
}
await db
.insert(shopHeartsTable)
.values({
userId: user.id,
shopItemId: itemId
})
.onConflictDoNothing()
return { hearted: true }
const hearted = (result.rows[0] as { hearted: boolean })?.hearted ?? false
return { hearted }
})
shop.get('/categories', async () => {
@ -257,56 +255,71 @@ shop.post('/items/:id/purchase', async ({ params, body, headers }) => {
const totalPrice = item.price * quantity
const affordable = await canAfford(user.id, totalPrice)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id)
return { error: 'Insufficient scraps', required: totalPrice, available: balance }
}
try {
const order = await db.transaction(async (tx) => {
// Lock the user row to serialize spend operations and prevent race conditions
await tx.execute(sql`SELECT 1 FROM users WHERE id = ${user.id} FOR UPDATE`)
const order = await db.transaction(async (tx) => {
const currentItem = await tx
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
// Re-check affordability inside the transaction
const affordable = await canAfford(user.id, totalPrice, tx)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id, tx)
throw { type: 'insufficient_funds', balance }
}
if (currentItem.length === 0 || currentItem[0].count < quantity) {
throw new Error('Not enough stock')
const currentItem = await tx
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (currentItem.length === 0 || currentItem[0].count < quantity) {
throw { type: 'out_of_stock' }
}
await tx
.update(shopItemsTable)
.set({
count: currentItem[0].count - quantity,
updatedAt: new Date()
})
.where(eq(shopItemsTable.id, itemId))
const newOrder = await tx
.insert(shopOrdersTable)
.values({
userId: user.id,
shopItemId: itemId,
quantity,
pricePerItem: item.price,
totalPrice,
shippingAddress: shippingAddress || null,
status: 'pending'
})
.returning()
return newOrder[0]
})
return {
success: true,
order: {
id: order.id,
itemName: item.name,
quantity: order.quantity,
totalPrice: order.totalPrice,
status: order.status
}
}
await tx
.update(shopItemsTable)
.set({
count: currentItem[0].count - quantity,
updatedAt: new Date()
})
.where(eq(shopItemsTable.id, itemId))
const newOrder = await tx
.insert(shopOrdersTable)
.values({
userId: user.id,
shopItemId: itemId,
quantity,
pricePerItem: item.price,
totalPrice,
shippingAddress: shippingAddress || null,
status: 'pending'
})
.returning()
return newOrder[0]
})
return {
success: true,
order: {
id: order.id,
itemName: item.name,
quantity: order.quantity,
totalPrice: order.totalPrice,
status: order.status
} catch (e) {
const err = e as { type?: string; balance?: number }
if (err.type === 'insufficient_funds') {
return { error: 'Insufficient scraps', required: totalPrice, available: err.balance }
}
if (err.type === 'out_of_stock') {
return { error: 'Not enough stock' }
}
throw e
}
})
@ -363,50 +376,19 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => {
return { error: 'Out of stock' }
}
const boostResult = await db
.select({
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
try {
const result = await db.transaction(async (tx) => {
// Lock the user row to serialize spend operations and prevent race conditions
await tx.execute(sql`SELECT 1 FROM users WHERE id = ${user.id} FOR UPDATE`)
const penaltyResult = await db
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
.limit(1)
// Re-check affordability inside the transaction
const affordable = await canAfford(user.id, item.price, tx)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id, tx)
throw { type: 'insufficient_funds', balance }
}
const boostPercent = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100)
const affordable = await canAfford(user.id, item.price)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id)
return { error: 'Insufficient scraps', required: item.price, available: balance }
}
const rolled = Math.floor(Math.random() * 100) + 1
const won = rolled <= effectiveProbability
// Record the roll
await db.insert(shopRollsTable).values({
userId: user.id,
shopItemId: itemId,
rolled,
threshold: effectiveProbability,
won
})
if (won) {
const order = await db.transaction(async (tx) => {
// Re-check stock inside transaction
const currentItem = await tx
.select()
.from(shopItemsTable)
@ -414,39 +396,21 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => {
.limit(1)
if (currentItem.length === 0 || currentItem[0].count < 1) {
throw new Error('Out of stock')
throw { type: 'out_of_stock' }
}
await tx
.update(shopItemsTable)
.set({
count: currentItem[0].count - 1,
updatedAt: new Date()
// Compute boost and penalty inside transaction
const boostResult = await tx
.select({
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.where(eq(shopItemsTable.id, itemId))
const newOrder = await tx
.insert(shopOrdersTable)
.values({
userId: user.id,
shopItemId: itemId,
quantity: 1,
pricePerItem: item.price,
totalPrice: item.price,
shippingAddress: null,
status: 'pending',
orderType: 'luck_win'
})
.returning()
await tx
.delete(refineryOrdersTable)
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
const existingPenalty = await tx
const penaltyResult = await tx
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
@ -455,35 +419,104 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => {
))
.limit(1)
if (existingPenalty.length > 0) {
const newMultiplier = Math.max(1, Math.floor(existingPenalty[0].probabilityMultiplier / 2))
const boostPercent = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100
const adjustedBaseProbability = Math.floor(currentItem[0].baseProbability * penaltyMultiplier / 100)
const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100)
const rolled = Math.floor(Math.random() * 100) + 1
const won = rolled <= effectiveProbability
// Record the roll inside the transaction
await tx.insert(shopRollsTable).values({
userId: user.id,
shopItemId: itemId,
rolled,
threshold: effectiveProbability,
won
})
if (won) {
await tx
.update(shopPenaltiesTable)
.update(shopItemsTable)
.set({
probabilityMultiplier: newMultiplier,
count: currentItem[0].count - 1,
updatedAt: new Date()
})
.where(eq(shopItemsTable.id, itemId))
const newOrder = await tx
.insert(shopOrdersTable)
.values({
userId: user.id,
shopItemId: itemId,
quantity: 1,
pricePerItem: item.price,
totalPrice: item.price,
shippingAddress: null,
status: 'pending',
orderType: 'luck_win'
})
.returning()
await tx
.delete(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
const existingPenalty = await tx
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
} else {
await tx
.insert(shopPenaltiesTable)
.values({
userId: user.id,
shopItemId: itemId,
probabilityMultiplier: 50
})
.limit(1)
if (existingPenalty.length > 0) {
const newMultiplier = Math.max(1, Math.floor(existingPenalty[0].probabilityMultiplier / 2))
await tx
.update(shopPenaltiesTable)
.set({
probabilityMultiplier: newMultiplier,
updatedAt: new Date()
})
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
} else {
await tx
.insert(shopPenaltiesTable)
.values({
userId: user.id,
shopItemId: itemId,
probabilityMultiplier: 50
})
}
return { won: true, orderId: newOrder[0].id, effectiveProbability, rolled }
}
return newOrder[0]
return { won: false, effectiveProbability, rolled }
})
return { success: true, won: true, orderId: order.id, effectiveProbability, rolled, refineryReset: true, probabilityHalved: true }
if (result.won) {
return { success: true, won: true, orderId: result.orderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, refineryReset: true, probabilityHalved: true }
}
return { success: true, won: false, effectiveProbability: result.effectiveProbability, rolled: result.rolled }
} catch (e) {
const err = e as { type?: string; balance?: number }
if (err.type === 'insufficient_funds') {
return { error: 'Insufficient scraps', required: item.price, available: err.balance }
}
if (err.type === 'out_of_stock') {
return { error: 'Out of stock' }
}
throw e
}
return { success: true, won: false, effectiveProbability, rolled }
})
shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => {
@ -509,61 +542,75 @@ shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => {
const item = items[0]
const boostResult = await db
.select({
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
try {
const result = await db.transaction(async (tx) => {
// Lock the user row to serialize spend operations and prevent race conditions
await tx.execute(sql`SELECT 1 FROM users WHERE id = ${user.id} FOR UPDATE`)
const boostResult = await tx
.select({
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
const currentBoost = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0
const penaltyResult = await tx
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
.limit(1)
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
const maxBoost = 100 - adjustedBaseProbability
if (currentBoost >= maxBoost) {
throw { type: 'max_probability' }
}
const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost))
const affordable = await canAfford(user.id, cost, tx)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id, tx)
throw { type: 'insufficient_funds', balance, cost }
}
const newBoost = currentBoost + 1
// Record the refinery order
await tx.insert(refineryOrdersTable).values({
userId: user.id,
shopItemId: itemId,
cost,
boostAmount: 1
})
const nextCost = newBoost >= maxBoost
? null
: Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost))
return { boostPercent: newBoost, nextCost, effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100) }
})
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
const currentBoost = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0
const penaltyResult = await db
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
.limit(1)
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
const maxBoost = 100 - adjustedBaseProbability
if (currentBoost >= maxBoost) {
return { error: 'Already at maximum probability' }
}
const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost))
const affordable = await canAfford(user.id, cost)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id)
return { error: 'Insufficient scraps', required: cost, available: balance }
}
const newBoost = currentBoost + 1
// Record the refinery order
await db.insert(refineryOrdersTable).values({
userId: user.id,
shopItemId: itemId,
cost,
boostAmount: 1
})
const nextCost = newBoost >= maxBoost
? null
: Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost))
return {
boostPercent: newBoost,
nextCost,
effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100)
return result
} catch (e) {
const err = e as { type?: string; balance?: number; cost?: number }
if (err.type === 'max_probability') {
return { error: 'Already at maximum probability' }
}
if (err.type === 'insufficient_funds') {
return { error: 'Insufficient scraps', required: err.cost, available: err.balance }
}
throw e
}
})
@ -618,7 +665,7 @@ shop.get('/items/:id/buyers', async ({ params }) => {
username: usersTable.username,
avatar: usersTable.avatar,
quantity: shopOrdersTable.quantity,
createdAt: shopOrdersTable.createdAt
purchasedAt: shopOrdersTable.createdAt
})
.from(shopOrdersTable)
.innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id))
@ -654,6 +701,7 @@ shop.get('/items/:id/hearts', async ({ params }) => {
shop.get('/addresses', async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
console.log('[/shop/addresses] Unauthorized - no user session')
return { error: 'Unauthorized' }
}
@ -664,6 +712,7 @@ shop.get('/addresses', async ({ headers }) => {
.limit(1)
if (userData.length === 0 || !userData[0].accessToken) {
console.log('[/shop/addresses] No access token found for user', user.id)
return []
}
@ -675,6 +724,8 @@ shop.get('/addresses', async ({ headers }) => {
})
if (!response.ok) {
const errorText = await response.text()
console.log('[/shop/addresses] Hack Club API error:', response.status, errorText)
return []
}
@ -696,8 +747,10 @@ shop.get('/addresses', async ({ headers }) => {
}
}
console.log('[/shop/addresses] Got addresses:', data.identity?.addresses?.length ?? 0)
return data.identity?.addresses ?? []
} catch {
} catch (e) {
console.error('[/shop/addresses] Error fetching from Hack Club:', e)
return []
}
})

View file

@ -69,7 +69,12 @@ user.get('/profile/:id', async ({ params, headers }) => {
if (!currentUser) return { error: 'Unauthorized' }
const targetUser = await db
.select()
.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
createdAt: usersTable.createdAt
})
.from(usersTable)
.where(eq(usersTable.id, parseInt(params.id)))
.limit(1)

View file

@ -37,6 +37,7 @@ export const shopOrdersTable = pgTable('shop_orders', {
orderType: varchar('order_type').notNull().default('purchase'),
shippingAddress: text('shipping_address'),
notes: text(),
isFulfilled: boolean('is_fulfilled').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})

View file

@ -31,7 +31,8 @@ export const usersTable = pgTable('users', {
export const userBonusesTable = pgTable('user_bonuses', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => usersTable.id),
type: varchar().notNull(),
amount: integer().notNull(),
reason: text().notNull(),
givenBy: integer('given_by').references(() => usersTable.id),
createdAt: timestamp('created_at').defaultNow().notNull()
})

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { X, ChevronDown } from '@lucide/svelte'
import { ChevronDown, ExternalLink } from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { onMount } from 'svelte'
@ -36,27 +36,8 @@
let loadingAddresses = $state(true)
let error = $state<string | null>(null)
let firstName = $state('')
let lastName = $state('')
let address1 = $state('')
let address2 = $state('')
let city = $state('')
let stateProvince = $state('')
let postalCode = $state('')
let country = $state('')
let phone = $state('')
let useNewAddress = $derived(addresses.length === 0 || selectedAddressId === 'new')
let formValid = $derived(
firstName.trim() !== '' &&
lastName.trim() !== '' &&
address1.trim() !== '' &&
city.trim() !== '' &&
stateProvince.trim() !== '' &&
postalCode.trim() !== '' &&
country.trim() !== ''
)
let canSubmit = $derived(useNewAddress ? formValid : selectedAddressId !== null)
let selectedAddress = $derived(addresses.find((a) => a.id === selectedAddressId))
let canSubmit = $derived(selectedAddressId !== null)
onMount(async () => {
try {
@ -66,6 +47,12 @@
if (response.ok) {
const data = await response.json()
addresses = Array.isArray(data) ? data : []
const primary = addresses.find((a) => a.primary)
if (primary) {
selectedAddressId = primary.id
} else if (addresses.length === 1) {
selectedAddressId = addresses[0].id
}
}
} catch (e) {
console.error('Failed to fetch addresses:', e)
@ -77,55 +64,30 @@
function selectAddress(id: string) {
selectedAddressId = id
showDropdown = false
if (id !== 'new') {
const addr = addresses.find(a => a.id === id)
if (addr) {
firstName = addr.first_name
lastName = addr.last_name
address1 = addr.line_1
address2 = addr.line_2 || ''
city = addr.city
stateProvince = addr.state
postalCode = addr.postal_code
country = addr.country
phone = addr.phone_number || ''
}
} else {
firstName = ''
lastName = ''
address1 = ''
address2 = ''
city = ''
stateProvince = ''
postalCode = ''
country = ''
phone = ''
}
}
function getSelectedAddressLabel(): string {
if (selectedAddressId === 'new') return 'Enter new address'
const addr = addresses.find(a => a.id === selectedAddressId)
const addr = addresses.find((a) => a.id === selectedAddressId)
if (addr) return `${addr.first_name} ${addr.last_name}, ${addr.city}`
return 'Select an address'
return 'select an address'
}
async function handleSubmit() {
if (!canSubmit) return
if (!canSubmit || !selectedAddress) return
loading = true
error = null
const shippingAddress = JSON.stringify({
firstName,
lastName,
address1,
address2: address2 || null,
city,
state: stateProvince,
postalCode,
country,
phone: phone || null
firstName: selectedAddress.first_name,
lastName: selectedAddress.last_name,
address1: selectedAddress.line_1,
address2: selectedAddress.line_2 || null,
city: selectedAddress.city,
state: selectedAddress.state,
postalCode: selectedAddress.postal_code,
country: selectedAddress.country,
phone: selectedAddress.phone_number || null
})
try {
@ -151,32 +113,26 @@
loading = false
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose()
}
}
</script>
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<div
class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto"
>
<div class="mb-6">
<h2 class="text-2xl font-bold">shipping address</h2>
<button onclick={onClose} class="p-1 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<X size={24} />
</button>
</div>
<div class="mb-6 p-4 border-2 border-black rounded-lg bg-gray-50">
<p class="text-lg font-bold">🎉 congratulations!</p>
<p class="text-gray-600 mt-1">you won <span class="font-bold">{itemName}</span>! enter your shipping address to receive it.</p>
<p class="text-gray-600 mt-1">
you won <span class="font-bold">{itemName}</span>! select your shipping address to receive
it.
</p>
</div>
{#if error}
@ -190,153 +146,104 @@
<div class="text-center py-4 text-gray-500">loading addresses...</div>
{:else if addresses.length > 0}
<div>
<label class="block text-sm font-bold mb-1">saved addresses</label>
<label class="block text-sm font-bold mb-1">your addresses</label>
<div class="relative">
<button
type="button"
onclick={() => (showDropdown = !showDropdown)}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed text-left flex items-center justify-between cursor-pointer"
>
<span class={selectedAddressId ? '' : 'text-gray-500'}>{getSelectedAddressLabel()}</span>
<ChevronDown size={20} class={showDropdown ? 'rotate-180 transition-transform' : 'transition-transform'} />
<span class={selectedAddressId ? '' : 'text-gray-500'}
>{getSelectedAddressLabel()}</span
>
<ChevronDown
size={20}
class={showDropdown ? 'rotate-180 transition-transform' : 'transition-transform'}
/>
</button>
{#if showDropdown}
<div class="absolute top-full left-0 right-0 mt-1 bg-white border-2 border-black rounded-lg max-h-48 overflow-y-auto z-10">
<div
class="absolute top-full left-0 right-0 mt-1 bg-white border-2 border-black rounded-lg max-h-48 overflow-y-auto z-10"
>
{#each addresses as addr}
<button
type="button"
onclick={() => selectAddress(addr.id)}
class="w-full px-4 py-2 text-left hover:bg-gray-100 cursor-pointer"
class="w-full px-4 py-2 text-left hover:bg-gray-100 cursor-pointer {addr.id ===
selectedAddressId
? 'bg-gray-100'
: ''}"
>
<span class="font-medium">{addr.first_name} {addr.last_name}</span>
<span class="font-medium"
>{addr.first_name} {addr.last_name}
{#if addr.primary}<span class="text-xs text-gray-500">(primary)</span>{/if}</span
>
<span class="text-gray-500 text-sm block">{addr.line_1}, {addr.city}</span>
</button>
{/each}
<button
type="button"
onclick={() => selectAddress('new')}
class="w-full px-4 py-2 text-left hover:bg-gray-100 border-t border-gray-200 cursor-pointer"
>
<span class="font-medium">+ enter new address</span>
</button>
</div>
{/if}
</div>
</div>
{/if}
{#if useNewAddress || selectedAddressId}
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-bold mb-1">first name <span class="text-red-500">*</span></label>
<input
id="firstName"
type="text"
bind:value={firstName}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
{#if selectedAddress}
<div class="p-4 border-2 border-black rounded-lg bg-gray-50">
<p class="text-sm font-bold mb-2">selected address:</p>
<p class="text-sm">{selectedAddress.first_name} {selectedAddress.last_name}</p>
<p class="text-sm">{selectedAddress.line_1}</p>
{#if selectedAddress.line_2}
<p class="text-sm">{selectedAddress.line_2}</p>
{/if}
<p class="text-sm">
{selectedAddress.city}, {selectedAddress.state}
{selectedAddress.postal_code}
</p>
<p class="text-sm">{selectedAddress.country}</p>
{#if selectedAddress.phone_number}
<p class="text-sm text-gray-500 mt-1">📞 {selectedAddress.phone_number}</p>
{/if}
</div>
<div>
<label for="lastName" class="block text-sm font-bold mb-1">last name <span class="text-red-500">*</span></label>
<input
id="lastName"
type="text"
bind:value={lastName}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
{/if}
<div>
<label for="address1" class="block text-sm font-bold mb-1">address line 1 <span class="text-red-500">*</span></label>
<input
id="address1"
type="text"
bind:value={address1}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="address2" class="block text-sm font-bold mb-1">address line 2</label>
<input
id="address2"
type="text"
bind:value={address2}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="city" class="block text-sm font-bold mb-1">city <span class="text-red-500">*</span></label>
<input
id="city"
type="text"
bind:value={city}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="stateProvince" class="block text-sm font-bold mb-1">state <span class="text-red-500">*</span></label>
<input
id="stateProvince"
type="text"
bind:value={stateProvince}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="postalCode" class="block text-sm font-bold mb-1">postal code <span class="text-red-500">*</span></label>
<input
id="postalCode"
type="text"
bind:value={postalCode}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="country" class="block text-sm font-bold mb-1">country <span class="text-red-500">*</span></label>
<input
id="country"
type="text"
bind:value={country}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div>
<label for="phone" class="block text-sm font-bold mb-1">phone number</label>
<input
id="phone"
type="tel"
bind:value={phone}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<a
href="https://auth.hackclub.com"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-black transition-colors"
>
<ExternalLink size={14} />
manage addresses on hack club
</a>
{:else}
<div class="text-center py-6">
<p class="text-gray-600 mb-4">you don't have any saved addresses yet.</p>
<a
href="https://auth.hackclub.com"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 cursor-pointer"
>
<ExternalLink size={16} />
add an address on hack club
</a>
<p class="text-sm text-gray-500 mt-4">
after adding an address, refresh this page to select it.
</p>
</div>
{/if}
</div>
<div class="flex gap-3 mt-6">
<button
onclick={onClose}
disabled={loading}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer"
>
cancel
</button>
<button
onclick={handleSubmit}
disabled={loading || !canSubmit}
class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 cursor-pointer"
>
{loading ? 'saving...' : 'confirm shipping address'}
</button>
</div>
{#if addresses.length > 0}
<div class="mt-6">
<button
onclick={handleSubmit}
disabled={loading || !canSubmit}
class="w-full px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{loading ? 'saving...' : 'confirm shipping address'}
</button>
</div>
{/if}
</div>
</div>

View file

@ -231,9 +231,11 @@
<div class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto {tutorialMode ? 'z-[250]' : ''}" data-tutorial="create-project-modal">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold">new project</h2>
<button onclick={handleClose} class="p-1 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<X size={24} />
</button>
{#if !tutorialMode}
<button onclick={handleClose} class="p-1 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<X size={24} />
</button>
{/if}
</div>
{#if error}
@ -375,8 +377,8 @@
<div class="flex gap-3 mt-6">
<button
onclick={handleClose}
disabled={loading}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer"
disabled={loading || tutorialMode}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
cancel
</button>

View file

@ -15,7 +15,8 @@
ClipboardList,
Users,
ShoppingBag,
Newspaper
Newspaper,
PackageCheck
} from '@lucide/svelte'
import { logout, getUser, userScrapsStore } from '$lib/auth-client'
@ -206,6 +207,15 @@
<Newspaper size={18} />
<span class="text-lg font-bold">news</span>
</a>
<a
href="/admin/orders"
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 cursor-pointer {currentPath.startsWith('/admin/orders')
? 'bg-black text-white border-black'
: 'border-black hover:border-dashed'}"
>
<PackageCheck size={18} />
<span class="text-lg font-bold">orders</span>
</a>
{/if}
{/if}
</div>

View file

@ -252,7 +252,7 @@
: 'hover:border-dashed'}"
>
<Heart size={16} />
wishlist
wishlist ({localHeartCount})
</button>
<button
onclick={() => (activeTab = 'buyers')}

View file

@ -0,0 +1,320 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { Check, X, Package, Clock, Truck, CheckCircle, XCircle } from '@lucide/svelte'
import { getUser } from '$lib/auth-client'
import { API_URL } from '$lib/config'
interface ShippingAddress {
firstName: string
lastName: string
address1: string
address2: string | null
city: string
state: string
postalCode: string
country: string
phone: string | null
}
interface Order {
id: number
quantity: number
pricePerItem: number
totalPrice: number
status: string
orderType: string
notes: string | null
isFulfilled: boolean
shippingAddress: string | null
createdAt: string
itemId: number
itemName: string
itemImage: string
userId: number
username: string
}
function parseShippingAddress(addr: string | null): ShippingAddress | null {
if (!addr) return null
try {
return JSON.parse(addr)
} catch {
return null
}
}
function formatAddress(addr: ShippingAddress): string {
const parts = [
`${addr.firstName} ${addr.lastName}`,
addr.address1,
addr.address2,
`${addr.city}, ${addr.state} ${addr.postalCode}`,
addr.country
].filter(Boolean)
return parts.join(', ')
}
interface User {
id: number
role: string
}
let user = $state<User | null>(null)
let orders = $state<Order[]>([])
let loading = $state(true)
let filter = $state<'all' | 'pending' | 'fulfilled'>('all')
let filteredOrders = $derived(
filter === 'all'
? orders
: filter === 'pending'
? orders.filter((o) => !o.isFulfilled)
: orders.filter((o) => o.isFulfilled)
)
onMount(async () => {
user = await getUser()
if (!user || user.role !== 'admin') {
goto('/dashboard')
return
}
await fetchOrders()
})
async function fetchOrders() {
loading = true
try {
const response = await fetch(`${API_URL}/admin/orders`, {
credentials: 'include'
})
if (response.ok) {
orders = await response.json()
}
} catch (e) {
console.error('Failed to fetch orders:', e)
} finally {
loading = false
}
}
async function toggleFulfilled(order: Order) {
try {
const response = await fetch(`${API_URL}/admin/orders/${order.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ isFulfilled: !order.isFulfilled })
})
if (response.ok) {
orders = orders.map((o) =>
o.id === order.id ? { ...o, isFulfilled: !o.isFulfilled } : o
)
}
} catch (e) {
console.error('Failed to update order:', e)
}
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function getStatusIcon(status: string) {
switch (status) {
case 'pending':
return Clock
case 'processing':
return Package
case 'shipped':
return Truck
case 'delivered':
return CheckCircle
case 'cancelled':
return XCircle
default:
return Clock
}
}
function getStatusColor(status: string) {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-700 border-yellow-600'
case 'processing':
return 'bg-blue-100 text-blue-700 border-blue-600'
case 'shipped':
return 'bg-purple-100 text-purple-700 border-purple-600'
case 'delivered':
return 'bg-green-100 text-green-700 border-green-600'
case 'cancelled':
return 'bg-red-100 text-red-700 border-red-600'
default:
return 'bg-gray-100 text-gray-700 border-gray-600'
}
}
</script>
<svelte:head>
<title>orders - admin - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-4xl md:text-5xl font-bold mb-2">orders</h1>
<p class="text-lg text-gray-600">manage shop orders and fulfillment</p>
</div>
</div>
<!-- Filter tabs -->
<div class="flex gap-2 mb-6">
<button
onclick={() => (filter = 'all')}
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {filter ===
'all'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
all ({orders.length})
</button>
<button
onclick={() => (filter = 'pending')}
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {filter ===
'pending'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
pending ({orders.filter((o) => !o.isFulfilled).length})
</button>
<button
onclick={() => (filter = 'fulfilled')}
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {filter ===
'fulfilled'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
fulfilled ({orders.filter((o) => o.isFulfilled).length})
</button>
</div>
{#if loading}
<div class="text-center py-12 text-gray-500">loading...</div>
{:else if filteredOrders.length === 0}
<div class="text-center py-12 text-gray-500">no orders found</div>
{:else}
<div class="grid gap-4">
{#each filteredOrders as order}
{@const StatusIcon = getStatusIcon(order.status)}
<div
class="border-4 border-black rounded-2xl p-4 {order.isFulfilled
? 'bg-green-50'
: ''}"
>
<div class="flex items-start gap-4">
<!-- Item image -->
<img
src={order.itemImage}
alt={order.itemName}
class="w-16 h-16 rounded-lg border-2 border-black object-cover shrink-0"
/>
<!-- Order details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-bold text-lg">{order.itemName}</h3>
<span class="text-gray-500">×{order.quantity}</span>
</div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<a
href="/admin/users/{order.userId}"
class="text-sm font-bold hover:underline"
>
@{order.username}
</a>
<span class="text-gray-400"></span>
<span class="text-sm text-gray-500">{formatDate(order.createdAt)}</span>
<span class="text-gray-400"></span>
<span class="text-sm font-bold">{order.totalPrice} scraps</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold border-2 {getStatusColor(
order.status
)}"
>
<StatusIcon size={12} />
{order.status}
</span>
<span
class="px-2 py-0.5 rounded-full text-xs font-bold border-2 {order.orderType ===
'win'
? 'bg-purple-100 text-purple-700 border-purple-600'
: 'bg-gray-100 text-gray-700 border-gray-600'}"
>
{order.orderType}
</span>
{#if order.isFulfilled}
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold border-2 bg-green-100 text-green-700 border-green-600"
>
<Check size={12} />
fulfilled
</span>
{/if}
</div>
{#if order.notes}
<p class="text-sm text-gray-600 mt-2">{order.notes}</p>
{/if}
{#if parseShippingAddress(order.shippingAddress)}
{@const shippingAddr = parseShippingAddress(order.shippingAddress)!}
<div class="mt-2 p-2 bg-gray-100 rounded-lg border border-gray-300">
<p class="text-xs font-bold text-gray-500 mb-1">shipping address</p>
<p class="text-sm">{formatAddress(shippingAddr)}</p>
{#if shippingAddr.phone}
<p class="text-xs text-gray-500 mt-1">📞 {shippingAddr.phone}</p>
{/if}
</div>
{:else if order.orderType === 'win'}
<div class="mt-2 p-2 bg-yellow-100 rounded-lg border border-yellow-300">
<p class="text-xs font-bold text-yellow-700">⚠️ no shipping address provided</p>
</div>
{/if}
</div>
<!-- Actions -->
<div class="shrink-0">
<button
onclick={() => toggleFulfilled(order)}
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer flex items-center gap-2 {order.isFulfilled
? 'bg-gray-200 hover:bg-gray-300'
: 'bg-green-500 text-white hover:bg-green-600'}"
>
{#if order.isFulfilled}
<X size={16} />
unfulfill
{:else}
<Check size={16} />
fulfill
{/if}
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { ArrowLeft, Package, Clock, CheckCircle, XCircle, AlertTriangle } from '@lucide/svelte'
import { ArrowLeft, Package, Clock, CheckCircle, XCircle, AlertTriangle, Plus, Gift } from '@lucide/svelte'
import { getUser } from '$lib/auth-client'
import { API_URL } from '$lib/config'
import { formatHours } from '$lib/utils'
@ -45,15 +45,31 @@
role: string
}
interface Bonus {
id: number
amount: number
reason: string
givenBy: number | null
givenByUsername: string | null
createdAt: string
}
let currentUser = $state<CurrentUser | null>(null)
let targetUser = $state<TargetUser | null>(null)
let projects = $state<Project[]>([])
let stats = $state<UserStats | null>(null)
let bonuses = $state<Bonus[]>([])
let loading = $state(true)
let saving = $state(false)
let editingNotes = $state('')
let editingRole = $state('')
let showBonusModal = $state(false)
let bonusAmount = $state<number | null>(null)
let bonusReason = $state('')
let savingBonus = $state(false)
let bonusError = $state<string | null>(null)
onMount(async () => {
currentUser = await getUser()
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'reviewer')) {
@ -62,17 +78,25 @@
}
try {
const response = await fetch(`${API_URL}/admin/users/${data.id}`, {
credentials: 'include'
})
if (response.ok) {
const result = await response.json()
const [userResponse, bonusesResponse] = await Promise.all([
fetch(`${API_URL}/admin/users/${data.id}`, { credentials: 'include' }),
currentUser.role === 'admin'
? fetch(`${API_URL}/admin/users/${data.id}/bonuses`, { credentials: 'include' })
: Promise.resolve(null)
])
if (userResponse.ok) {
const result = await userResponse.json()
targetUser = result.user
projects = result.projects || []
stats = result.stats
editingNotes = result.user?.internalNotes || ''
editingRole = result.user?.role || 'member'
}
if (bonusesResponse?.ok) {
bonuses = await bonusesResponse.json()
}
} catch (e) {
console.error('Failed to fetch user:', e)
} finally {
@ -110,6 +134,43 @@
}
}
async function saveBonus() {
if (!targetUser || !bonusAmount || !bonusReason.trim()) return
savingBonus = true
bonusError = null
try {
const response = await fetch(`${API_URL}/admin/users/${targetUser.id}/bonus`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ amount: bonusAmount, reason: bonusReason.trim() })
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Failed to save bonus')
}
bonuses = [
{
...result,
givenByUsername: currentUser?.id === result.givenBy ? 'you' : null
},
...bonuses
]
targetUser.scraps += bonusAmount
showBonusModal = false
bonusAmount = null
bonusReason = ''
} catch (e) {
bonusError = e instanceof Error ? e.message : 'Failed to save bonus'
} finally {
savingBonus = false
}
}
function getRoleBadgeColor(role: string) {
switch (role) {
case 'admin':
@ -217,6 +278,15 @@
<div class="text-right">
<p class="text-4xl font-bold">{targetUser.scraps}</p>
<p class="text-sm text-gray-500">scraps</p>
{#if currentUser?.role === 'admin'}
<button
onclick={() => (showBonusModal = true)}
class="mt-2 px-3 py-1 bg-green-500 text-white rounded-full text-sm font-bold hover:bg-green-600 transition-all cursor-pointer flex items-center gap-1"
>
<Plus size={14} />
give bonus
</button>
{/if}
</div>
</div>
</div>
@ -337,5 +407,102 @@
</div>
{/if}
</div>
<!-- Bonuses (admin only) -->
{#if currentUser?.role === 'admin'}
<div class="border-4 border-black rounded-2xl p-6 mt-6 bg-green-50">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<Gift size={20} />
bonus history ({bonuses.length})
</h2>
</div>
{#if bonuses.length === 0}
<p class="text-gray-500">no bonuses given yet</p>
{:else}
<div class="space-y-3">
{#each bonuses as bonus}
<div class="p-3 bg-white border-2 border-black rounded-lg">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="font-bold text-lg {bonus.amount >= 0 ? 'text-green-600' : 'text-red-600'}">
{bonus.amount >= 0 ? '+' : ''}{bonus.amount} scraps
</p>
<p class="text-sm text-gray-700">{bonus.reason}</p>
</div>
<div class="text-right text-xs text-gray-500">
<p>{new Date(bonus.createdAt).toLocaleDateString()}</p>
<p>by @{bonus.givenByUsername || 'unknown'}</p>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Bonus Modal -->
{#if showBonusModal}
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={(e) => e.target === e.currentTarget && (showBonusModal = false)}
onkeydown={(e) => e.key === 'Escape' && (showBonusModal = false)}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black">
<h2 class="text-2xl font-bold mb-4">give bonus scraps</h2>
{#if bonusError}
<div class="mb-4 p-3 bg-red-100 border-2 border-red-500 rounded-lg text-red-700 text-sm">
{bonusError}
</div>
{/if}
<div class="space-y-4">
<div>
<label for="bonusAmount" class="block text-sm font-bold mb-1">amount <span class="text-red-500">*</span></label>
<input
id="bonusAmount"
type="number"
bind:value={bonusAmount}
placeholder="e.g. 100 or -50"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<p class="text-xs text-gray-500 mt-1">use negative numbers to deduct scraps</p>
</div>
<div>
<label for="bonusReason" class="block text-sm font-bold mb-1">reason <span class="text-red-500">*</span></label>
<textarea
id="bonusReason"
bind:value={bonusReason}
rows="3"
placeholder="why are you giving this bonus?"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed resize-none"
></textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
onclick={() => (showBonusModal = false)}
disabled={savingBonus}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer"
>
cancel
</button>
<button
onclick={saveBonus}
disabled={savingBonus || !bonusAmount || !bonusReason.trim()}
class="flex-1 px-4 py-2 bg-green-500 text-white rounded-full font-bold hover:bg-green-600 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{savingBonus ? 'saving...' : 'give bonus'}
</button>
</div>
</div>
</div>
{/if}