mirror of
https://github.com/System-End/scraps.git
synced 2026-04-20 00:25:18 +00:00
fix stuff and cph
This commit is contained in:
parent
cac54211c0
commit
ec76d38eee
9 changed files with 73 additions and 48 deletions
3
backend/drizzle/0011_add_scraps_paid_amount.sql
Normal file
3
backend/drizzle/0011_add_scraps_paid_amount.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "projects" ADD COLUMN "scraps_paid_amount" integer DEFAULT 0 NOT NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE "projects" SET "scraps_paid_amount" = "scraps_awarded" WHERE "scraps_paid_at" IS NOT NULL;
|
||||
|
|
@ -64,6 +64,13 @@
|
|||
"when": 1771530225521,
|
||||
"tag": "0010_add_refinery_spending_history",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1771616625521,
|
||||
"tag": "0011_add_scraps_paid_amount",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -219,8 +219,9 @@ async function syncProjectsToAirtable(): Promise<void> {
|
|||
// Batch-fetch first shipped dates from project_activity for all projects
|
||||
const shippedDates = await getProjectShippedDates(projects.map(p => p.id))
|
||||
|
||||
// Track which Code URLs we've already seen to detect duplicates
|
||||
const seenCodeUrls = new Set<string>()
|
||||
// Track which Code URLs we've already seen to detect cross-user duplicates
|
||||
// Same-user duplicates are project updates and should be allowed
|
||||
const seenCodeUrls = new Map<string, number>() // url -> userId
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.githubUrl) continue // skip projects without a GitHub URL
|
||||
|
|
@ -229,13 +230,14 @@ async function syncProjectsToAirtable(): Promise<void> {
|
|||
// Skip projects already approved in Airtable — don't overwrite them
|
||||
if (approvedRecords.has(project.githubUrl)) continue
|
||||
|
||||
// Check for duplicate Code URL among shipped projects
|
||||
if (seenCodeUrls.has(project.githubUrl)) {
|
||||
// Check for cross-user duplicate Code URL among shipped projects
|
||||
const previousOwner = seenCodeUrls.get(project.githubUrl)
|
||||
if (previousOwner !== undefined && previousOwner !== project.userId) {
|
||||
console.log(`[AIRTABLE-SYNC] Duplicate Code URL detected for project ${project.id}: ${project.githubUrl}, reverting to waiting_for_review`)
|
||||
duplicateProjectIds.push(project.id)
|
||||
continue
|
||||
}
|
||||
seenCodeUrls.add(project.githubUrl)
|
||||
seenCodeUrls.set(project.githubUrl, project.userId)
|
||||
|
||||
// Fetch user identity if not cached
|
||||
if (!userInfoCache.has(project.userId) && project.accessToken) {
|
||||
|
|
|
|||
|
|
@ -82,24 +82,24 @@ async function sendChannelMessage(text: string, blocks?: unknown[]): Promise<boo
|
|||
}
|
||||
|
||||
/**
|
||||
* Pay out all pending scraps by setting scrapsPaidAt on shipped projects
|
||||
* that have scrapsAwarded > 0 but haven't been paid out yet.
|
||||
* Pay out all pending scraps for shipped projects where scrapsAwarded > scrapsPaidAmount.
|
||||
* Handles both new projects and updates with additional scraps (pays the delta).
|
||||
*/
|
||||
export async function payoutPendingScraps(): Promise<{ paidCount: number; totalScraps: number }> {
|
||||
const now = new Date()
|
||||
|
||||
// Get pending projects with user info before updating
|
||||
// Get projects with unpaid scraps (includes both new projects and updates with additional scraps)
|
||||
const pendingProjects = await db
|
||||
.select({
|
||||
id: projectsTable.id,
|
||||
scrapsAwarded: projectsTable.scrapsAwarded,
|
||||
scrapsPaidAmount: projectsTable.scrapsPaidAmount,
|
||||
userId: projectsTable.userId
|
||||
})
|
||||
.from(projectsTable)
|
||||
.where(and(
|
||||
eq(projectsTable.status, 'shipped'),
|
||||
sql`${projectsTable.scrapsAwarded} > 0`,
|
||||
isNull(projectsTable.scrapsPaidAt),
|
||||
sql`${projectsTable.scrapsAwarded} > ${projectsTable.scrapsPaidAmount}`,
|
||||
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted))
|
||||
))
|
||||
|
||||
|
|
@ -108,14 +108,17 @@ export async function payoutPendingScraps(): Promise<{ paidCount: number; totalS
|
|||
}
|
||||
|
||||
const projectIds = pendingProjects.map(p => p.id)
|
||||
const totalScraps = pendingProjects.reduce((sum, p) => sum + p.scrapsAwarded, 0)
|
||||
const totalScraps = pendingProjects.reduce((sum, p) => sum + (p.scrapsAwarded - p.scrapsPaidAmount), 0)
|
||||
const uniqueUserIds = [...new Set(pendingProjects.map(p => p.userId))]
|
||||
|
||||
// Update all pending projects
|
||||
// Mark all pending projects as fully paid (scrapsPaidAmount = scrapsAwarded)
|
||||
await db
|
||||
.update(projectsTable)
|
||||
.set({ scrapsPaidAt: now })
|
||||
.where(sql`${projectsTable.id} IN ${projectIds}`)
|
||||
.set({
|
||||
scrapsPaidAt: now,
|
||||
scrapsPaidAmount: sql`${projectsTable.scrapsAwarded}`
|
||||
})
|
||||
.where(inArray(projectsTable.id, projectIds))
|
||||
|
||||
const paidCount = pendingProjects.length
|
||||
console.log(`[SCRAPS-PAYOUT] Paid out ${totalScraps} scraps across ${paidCount} projects for ${uniqueUserIds.length} users`)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { userBonusesTable } from "../schemas/users";
|
|||
export const PHI = (1 + Math.sqrt(5)) / 2;
|
||||
export const MULTIPLIER = 10;
|
||||
export const SCRAPS_PER_HOUR = PHI * MULTIPLIER;
|
||||
export const DOLLARS_PER_HOUR = 4;
|
||||
export const DOLLARS_PER_HOUR = 5;
|
||||
export const SCRAPS_PER_DOLLAR = SCRAPS_PER_HOUR / DOLLARS_PER_HOUR;
|
||||
|
||||
export const TIER_MULTIPLIERS: Record<number, number> = {
|
||||
|
|
@ -91,6 +91,8 @@ export function calculateShopItemPricing(
|
|||
};
|
||||
}
|
||||
|
||||
const MIN_ROLL_COST_PERCENT = 0.15;
|
||||
|
||||
export function calculateRollCost(
|
||||
basePrice: number,
|
||||
effectiveProbability: number,
|
||||
|
|
@ -101,9 +103,11 @@ export function calculateRollCost(
|
|||
return rollCostOverride;
|
||||
}
|
||||
// Roll cost scales with effective probability (including upgrades).
|
||||
// This naturally prevents exploitation: as probability increases,
|
||||
// roll cost increases proportionally, keeping expected spend ≈ constant.
|
||||
return Math.max(1, Math.round(basePrice * (effectiveProbability / 100)));
|
||||
// Floor at 15% of item price prevents exploitation on rare items where
|
||||
// base probability is very low (e.g., 2% base → flat cost of 6 scraps).
|
||||
const scaledCost = Math.round(basePrice * (effectiveProbability / 100));
|
||||
const floorCost = Math.round(basePrice * MIN_ROLL_COST_PERCENT);
|
||||
return Math.max(1, scaledCost, floorCost);
|
||||
}
|
||||
|
||||
export function computeRollThreshold(probability: number): number {
|
||||
|
|
@ -131,24 +135,24 @@ export async function getUserScrapsBalance(
|
|||
spent: number;
|
||||
balance: number;
|
||||
}> {
|
||||
// Only count scraps that have been paid out (scrapsPaidAt IS NOT NULL)
|
||||
// Earned scraps: only count what has actually been paid out (scrapsPaidAmount)
|
||||
const earnedResult = await txOrDb
|
||||
.select({
|
||||
total: sql<number>`COALESCE(SUM(${projectsTable.scrapsAwarded}), 0)`,
|
||||
total: sql<number>`COALESCE(SUM(${projectsTable.scrapsPaidAmount}), 0)`,
|
||||
})
|
||||
.from(projectsTable)
|
||||
.where(
|
||||
sql`${projectsTable.userId} = ${userId} AND ${projectsTable.scrapsPaidAt} IS NOT NULL`,
|
||||
sql`${projectsTable.userId} = ${userId} AND ${projectsTable.scrapsPaidAmount} > 0`,
|
||||
);
|
||||
|
||||
// Pending scraps: awarded but not yet paid out (must be shipped & not deleted, matching payout criteria)
|
||||
// Pending scraps: the unpaid delta (scrapsAwarded - scrapsPaidAmount) for shipped projects
|
||||
const pendingResult = await txOrDb
|
||||
.select({
|
||||
total: sql<number>`COALESCE(SUM(${projectsTable.scrapsAwarded}), 0)`,
|
||||
total: sql<number>`COALESCE(SUM(${projectsTable.scrapsAwarded} - ${projectsTable.scrapsPaidAmount}), 0)`,
|
||||
})
|
||||
.from(projectsTable)
|
||||
.where(
|
||||
sql`${projectsTable.userId} = ${userId} AND ${projectsTable.scrapsAwarded} > 0 AND ${projectsTable.scrapsPaidAt} IS NULL AND ${projectsTable.status} = 'shipped' AND (${projectsTable.deleted} = 0 OR ${projectsTable.deleted} IS NULL)`,
|
||||
sql`${projectsTable.userId} = ${userId} AND ${projectsTable.scrapsAwarded} > ${projectsTable.scrapsPaidAmount} AND ${projectsTable.status} = 'shipped' AND (${projectsTable.deleted} = 0 OR ${projectsTable.deleted} IS NULL)`,
|
||||
);
|
||||
|
||||
const bonusResult = await txOrDb
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { shopItemsTable } from "../schemas/shop";
|
|||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
calculateShopItemPricing,
|
||||
calculateRollCost,
|
||||
computeRollThreshold,
|
||||
SCRAPS_PER_DOLLAR,
|
||||
} from "./scraps";
|
||||
|
|
@ -63,8 +64,8 @@ export function computeItemPricing(
|
|||
);
|
||||
}
|
||||
|
||||
// Roll cost at base probability (scales with effective probability at roll time)
|
||||
const rollCost = Math.max(1, Math.round(price * (prob / 100)));
|
||||
// Roll cost at base probability (includes 15% floor to prevent exploitation on rare items)
|
||||
const rollCost = calculateRollCost(price, prob);
|
||||
|
||||
const threshold = computeRollThreshold(prob);
|
||||
const expectedRollsAtBase = threshold > 0 ? Math.round((100 / threshold) * 10) / 10 : Infinity;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Elysia } from "elysia";
|
||||
import { eq, and, inArray, sql, desc, asc, or, isNull } from "drizzle-orm";
|
||||
import { eq, ne, and, inArray, sql, desc, asc, or, isNull } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { usersTable, userBonusesTable } from "../schemas/users";
|
||||
import { projectsTable } from "../schemas/projects";
|
||||
|
|
@ -788,16 +788,8 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
|
|||
return { error: "Project is not marked for review" };
|
||||
}
|
||||
|
||||
// Create review record
|
||||
await db.insert(reviewsTable).values({
|
||||
projectId,
|
||||
reviewerId: user.id,
|
||||
action,
|
||||
feedbackForAuthor,
|
||||
internalJustification,
|
||||
});
|
||||
|
||||
// Check for duplicate Code URL if approving
|
||||
// Check for duplicate Code URL before creating review record (only block
|
||||
// cross-user duplicates; same-user duplicates are project updates)
|
||||
if (action === "approved" && project[0].githubUrl) {
|
||||
const duplicates = await db
|
||||
.select({ id: projectsTable.id })
|
||||
|
|
@ -807,6 +799,7 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
|
|||
eq(projectsTable.githubUrl, project[0].githubUrl),
|
||||
eq(projectsTable.status, "shipped"),
|
||||
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
|
||||
ne(projectsTable.userId, project[0].userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
|
@ -814,12 +807,21 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
|
|||
if (duplicates.length > 0) {
|
||||
return {
|
||||
error:
|
||||
"A shipped project with this Code URL already exists. This project has been kept in review.",
|
||||
"A shipped project with this Code URL already exists (from another user). This project has been kept in review.",
|
||||
duplicateCodeUrl: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create review record
|
||||
await db.insert(reviewsTable).values({
|
||||
projectId,
|
||||
reviewerId: user.id,
|
||||
action,
|
||||
feedbackForAuthor,
|
||||
internalJustification,
|
||||
});
|
||||
|
||||
// Update project status
|
||||
let newStatus = "in_progress";
|
||||
const isAdmin = user.role === "admin";
|
||||
|
|
@ -1386,6 +1388,7 @@ admin.get("/scraps-payout", async ({ headers }) => {
|
|||
name: projectsTable.name,
|
||||
image: projectsTable.image,
|
||||
scrapsAwarded: projectsTable.scrapsAwarded,
|
||||
scrapsPaidAmount: projectsTable.scrapsPaidAmount,
|
||||
hours: projectsTable.hours,
|
||||
hoursOverride: projectsTable.hoursOverride,
|
||||
userId: projectsTable.userId,
|
||||
|
|
@ -1396,8 +1399,7 @@ admin.get("/scraps-payout", async ({ headers }) => {
|
|||
.where(
|
||||
and(
|
||||
eq(projectsTable.status, "shipped"),
|
||||
sql`${projectsTable.scrapsAwarded} > 0`,
|
||||
isNull(projectsTable.scrapsPaidAt),
|
||||
sql`${projectsTable.scrapsAwarded} > ${projectsTable.scrapsPaidAmount}`,
|
||||
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
|
||||
),
|
||||
);
|
||||
|
|
@ -1433,7 +1435,7 @@ admin.get("/scraps-payout", async ({ headers }) => {
|
|||
return {
|
||||
pendingProjects: pendingProjects.length,
|
||||
pendingScraps: pendingProjects.reduce(
|
||||
(sum, p) => sum + p.scrapsAwarded,
|
||||
(sum, p) => sum + (p.scrapsAwarded - p.scrapsPaidAmount),
|
||||
0,
|
||||
),
|
||||
projects: projectsWithUsers,
|
||||
|
|
@ -1481,7 +1483,7 @@ admin.post("/scraps-payout/reject", async ({ headers, body, status }) => {
|
|||
id: projectsTable.id,
|
||||
userId: projectsTable.userId,
|
||||
scrapsAwarded: projectsTable.scrapsAwarded,
|
||||
scrapsPaidAt: projectsTable.scrapsPaidAt,
|
||||
scrapsPaidAmount: projectsTable.scrapsPaidAmount,
|
||||
status: projectsTable.status,
|
||||
name: projectsTable.name,
|
||||
})
|
||||
|
|
@ -1493,7 +1495,7 @@ admin.post("/scraps-payout/reject", async ({ headers, body, status }) => {
|
|||
return status(404, { error: "Project not found" });
|
||||
}
|
||||
|
||||
if (project[0].scrapsPaidAt) {
|
||||
if (project[0].scrapsPaidAmount > 0) {
|
||||
return status(400, {
|
||||
error: "Scraps have already been paid out for this project",
|
||||
});
|
||||
|
|
@ -2346,6 +2348,7 @@ admin.post(
|
|||
.set({
|
||||
status: "in_progress",
|
||||
scrapsAwarded: 0,
|
||||
scrapsPaidAmount: 0,
|
||||
scrapsPaidAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
|
@ -2385,6 +2388,7 @@ admin.get("/users/:id/timeline", async ({ params, headers, status }) => {
|
|||
id: projectsTable.id,
|
||||
name: projectsTable.name,
|
||||
scrapsAwarded: projectsTable.scrapsAwarded,
|
||||
scrapsPaidAmount: projectsTable.scrapsPaidAmount,
|
||||
scrapsPaidAt: projectsTable.scrapsPaidAt,
|
||||
status: projectsTable.status,
|
||||
createdAt: projectsTable.createdAt,
|
||||
|
|
@ -2482,10 +2486,10 @@ admin.get("/users/:id/timeline", async ({ params, headers, status }) => {
|
|||
for (const p of paidProjects) {
|
||||
timeline.push({
|
||||
type: "earned",
|
||||
amount: p.scrapsAwarded,
|
||||
amount: p.scrapsPaidAmount,
|
||||
description: `project "${p.name}"`,
|
||||
date: (p.scrapsPaidAt ?? p.createdAt ?? new Date()).toISOString(),
|
||||
paid: !!p.scrapsPaidAt,
|
||||
paid: p.scrapsPaidAmount >= p.scrapsAwarded,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ leaderboard.get('/', async ({ query }) => {
|
|||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
avatar: usersTable.avatar,
|
||||
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND scraps_paid_at IS NOT NULL), 0)`.as('scraps_earned'),
|
||||
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_paid_amount) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0)`.as('scraps_earned'),
|
||||
scrapsBonus: sql<number>`COALESCE((SELECT SUM(amount) FROM user_bonuses WHERE user_id = ${usersTable.id}), 0)`.as('scraps_bonus'),
|
||||
scrapsShopSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_shop_spent'),
|
||||
scrapsRefinerySpent: sql<number>`COALESCE((SELECT SUM(cost) FROM refinery_spending_history WHERE user_id = ${usersTable.id}), 0)`.as('scraps_refinery_spent'),
|
||||
|
|
@ -50,7 +50,7 @@ leaderboard.get('/', async ({ query }) => {
|
|||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
avatar: usersTable.avatar,
|
||||
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND scraps_paid_at IS NOT NULL), 0)`.as('scraps_earned'),
|
||||
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_paid_amount) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0)`.as('scraps_earned'),
|
||||
scrapsBonus: sql<number>`COALESCE((SELECT SUM(amount) FROM user_bonuses WHERE user_id = ${usersTable.id}), 0)`.as('scraps_bonus'),
|
||||
scrapsShopSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_shop_spent'),
|
||||
scrapsRefinerySpent: sql<number>`COALESCE((SELECT SUM(cost) FROM refinery_spending_history WHERE user_id = ${usersTable.id}), 0)`.as('scraps_refinery_spent'),
|
||||
|
|
@ -64,7 +64,7 @@ leaderboard.get('/', async ({ query }) => {
|
|||
sql`${projectsTable.status} != 'permanently_rejected'`
|
||||
))
|
||||
.groupBy(usersTable.id)
|
||||
.orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND scraps_paid_at IS NOT NULL), 0) + COALESCE((SELECT SUM(amount) FROM user_bonuses WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(cost) FROM refinery_spending_history WHERE user_id = ${usersTable.id}), 0)`))
|
||||
.orderBy(desc(sql`COALESCE((SELECT SUM(scraps_paid_amount) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0) + COALESCE((SELECT SUM(amount) FROM user_bonuses WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(cost) FROM refinery_spending_history WHERE user_id = ${usersTable.id}), 0)`))
|
||||
.limit(10)
|
||||
|
||||
return results.map((user, index) => ({
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const projectsTable = pgTable('projects', {
|
|||
status: varchar().notNull().default('in_progress'),
|
||||
deleted: integer('deleted').default(0),
|
||||
scrapsAwarded: integer('scraps_awarded').notNull().default(0),
|
||||
scrapsPaidAmount: integer('scraps_paid_amount').notNull().default(0),
|
||||
scrapsPaidAt: timestamp('scraps_paid_at'),
|
||||
views: integer().notNull().default(0),
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue