mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 18:35:20 +00:00
finish orders and stuff
This commit is contained in:
parent
b88c62c6a7
commit
25f84c1344
14 changed files with 1289 additions and 642 deletions
453
backend/dist/index.js
vendored
453
backend/dist/index.js
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@
|
|||
: 'hover:border-dashed'}"
|
||||
>
|
||||
<Heart size={16} />
|
||||
wishlist
|
||||
wishlist ({localHeartCount})
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'buyers')}
|
||||
|
|
|
|||
320
frontend/src/routes/admin/orders/+page.svelte
Normal file
320
frontend/src/routes/admin/orders/+page.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue