fix stuff and cph

This commit is contained in:
NotARoomba 2026-02-20 16:17:55 -05:00
parent cac54211c0
commit ec76d38eee
9 changed files with 73 additions and 48 deletions

View 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;

View file

@ -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
}
]
}

View file

@ -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) {

View file

@ -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`)

View file

@ -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

View file

@ -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;

View file

@ -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,
});
}

View file

@ -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) => ({

View file

@ -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),