before mobile optimize

This commit is contained in:
Nathan 2026-02-04 17:04:44 -05:00
parent 687735b053
commit e7374422a0
20 changed files with 3365 additions and 83 deletions

195
backend/dist/index.js vendored
View file

@ -28305,6 +28305,7 @@ var usersTable = pgTable("users", {
idToken: text("id_token"),
role: varchar().notNull().default("member"),
internalNotes: text("internal_notes"),
verificationStatus: varchar("verification_status"),
tutorialCompleted: boolean("tutorial_completed").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull()
@ -28469,7 +28470,8 @@ async function createOrUpdateUser(identity, tokens) {
avatar: avatarUrl,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token
idToken: tokens.id_token,
verificationStatus: identity.verification_status
}).onConflictDoUpdate({
target: usersTable.sub,
set: {
@ -28480,6 +28482,7 @@ async function createOrUpdateUser(identity, tokens) {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
verificationStatus: identity.verification_status,
updatedAt: new Date
}
}).returning();
@ -28527,16 +28530,22 @@ async function checkUserEligibility(accessToken) {
// src/routes/projects.ts
var HACKATIME_API = "https://hackatime.hackclub.com/api/v1";
var SCRAPS_START_DATE = "2026-02-03";
async function fetchHackatimeHours(slackId, projectName) {
try {
const url = `${HACKATIME_API}/users/${encodeURIComponent(slackId)}/projects/details`;
const params = new URLSearchParams({
features: "projects",
start_date: SCRAPS_START_DATE,
filter_by_project: projectName
});
const url = `${HACKATIME_API}/users/${encodeURIComponent(slackId)}/stats?${params}`;
const response = await fetch(url, {
headers: { Accept: "application/json" }
});
if (!response.ok)
return 0;
const data = await response.json();
const project = data.projects.find((p) => p.name === projectName);
const project = data.data?.projects?.find((p) => p.name === projectName);
if (!project)
return 0;
return Math.round(project.total_seconds / 3600 * 10) / 10;
@ -28556,6 +28565,67 @@ function parseHackatimeProject(hackatimeProject) {
};
}
var projects = new Elysia({ prefix: "/projects" });
projects.get("/explore", async ({ query }) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 50);
const offset = (page - 1) * limit;
const search = query.search?.trim() || "";
const tier = query.tier ? parseInt(query.tier) : null;
const status2 = query.status || null;
const conditions = [
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
or(eq(projectsTable.status, "shipped"), eq(projectsTable.status, "in_progress"))
];
if (search) {
conditions.push(or(ilike(projectsTable.name, `%${search}%`), ilike(projectsTable.description, `%${search}%`)));
}
if (tier && tier >= 1 && tier <= 4) {
conditions.push(eq(projectsTable.tier, tier));
}
if (status2 === "shipped" || status2 === "in_progress") {
conditions[1] = eq(projectsTable.status, status2);
}
const whereClause = and(...conditions);
const [projectsList, countResult] = await Promise.all([
db.select({
id: projectsTable.id,
name: projectsTable.name,
description: projectsTable.description,
image: projectsTable.image,
hours: projectsTable.hours,
tier: projectsTable.tier,
status: projectsTable.status,
views: projectsTable.views,
userId: projectsTable.userId
}).from(projectsTable).where(whereClause).orderBy(desc(projectsTable.updatedAt)).limit(limit).offset(offset),
db.select({ count: sql`count(*)` }).from(projectsTable).where(whereClause)
]);
const userIds = [...new Set(projectsList.map((p) => p.userId))];
let users = [];
if (userIds.length > 0) {
users = await db.select({ id: usersTable.id, username: usersTable.username }).from(usersTable).where(inArray(usersTable.id, userIds));
}
const total = Number(countResult[0]?.count || 0);
return {
data: projectsList.map((p) => ({
id: p.id,
name: p.name,
description: p.description.substring(0, 150) + (p.description.length > 150 ? "..." : ""),
image: p.image,
hours: p.hours,
tier: p.tier,
status: p.status,
views: p.views,
username: users.find((u) => u.id === p.userId)?.username || null
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
});
projects.get("/", async ({ headers, query }) => {
const user = await getUserFromSession(headers);
if (!user)
@ -28745,6 +28815,16 @@ projects.post("/:id/submit", async ({ params, headers }) => {
const user = await getUserFromSession(headers);
if (!user)
return { error: "Unauthorized" };
if (user.accessToken) {
const meResponse = await fetchUserIdentity(user.accessToken);
if (meResponse) {
const { identity } = meResponse;
await db.update(usersTable).set({ verificationStatus: identity.verification_status }).where(eq(usersTable.id, user.id));
if (identity.verification_status === "ineligible") {
return { error: "ineligible", redirectTo: "/auth/error?reason=not-eligible" };
}
}
}
const project = await db.select().from(projectsTable).where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id))).limit(1);
if (!project[0])
return { error: "Not found" };
@ -28808,11 +28888,11 @@ var news = new Elysia({
prefix: "/news"
});
news.get("/", async () => {
const items = await db.select().from(newsTable).orderBy(desc(newsTable.createdAt));
const items = await db.select().from(newsTable).where(eq(newsTable.active, true)).orderBy(desc(newsTable.createdAt));
return items;
});
news.get("/latest", async () => {
const items = await db.select().from(newsTable).orderBy(desc(newsTable.createdAt)).limit(1);
const items = await db.select().from(newsTable).where(eq(newsTable.active, true)).orderBy(desc(newsTable.createdAt)).limit(1);
return items[0] || null;
});
var news_default = news;
@ -28929,10 +29009,25 @@ async function canAfford(userId, cost, txOrDb = db) {
return false;
return balance >= cost;
}
// src/schemas/user-emails.ts
var userEmailsTable = pgTable("user_emails", {
id: serial().primaryKey(),
email: varchar().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull()
});
// src/routes/auth.ts
var FRONTEND_URL = config.frontendUrl;
var authRoutes = new Elysia({ prefix: "/auth" });
authRoutes.post("/collect-email", async ({ body }) => {
const { email } = body;
console.log("[AUTH] Collecting email:", email);
await db.insert(userEmailsTable).values({ email });
return { success: true };
}, {
body: t.Object({
email: t.String({ format: "email" })
})
});
authRoutes.get("/login", ({ redirect: redirect2 }) => {
console.log("[AUTH] Login initiated");
return redirect2(getAuthorizationUrl());
@ -28964,10 +29059,20 @@ authRoutes.get("/callback", async ({ query, redirect: redirect2, cookie }) => {
verificationStatus: identity.verification_status
});
const user = await createOrUpdateUser(identity, tokens);
await db.delete(userEmailsTable).where(eq(userEmailsTable.email, identity.primary_email));
console.log("[AUTH] Deleted collected email:", identity.primary_email);
if (user.role === "banned") {
console.log("[AUTH] Banned user attempted login:", { userId: user.id, username: user.username });
return redirect2("https://fraud.land");
}
if (identity.verification_status === "needs_submission") {
console.log("[AUTH] User needs to verify identity:", { userId: user.id });
return redirect2(`${FRONTEND_URL}/auth/error?reason=needs-verification`);
}
if (identity.verification_status === "ineligible") {
console.log("[AUTH] User is ineligible:", { userId: user.id });
return redirect2(`${FRONTEND_URL}/auth/error?reason=not-eligible`);
}
const sessionToken = await createSession(user.id);
console.log("[AUTH] User authenticated successfully:", { userId: user.id, username: user.username });
cookie.session.set({
@ -29058,10 +29163,15 @@ user.post("/complete-tutorial", async ({ headers }) => {
if (userData.tutorialCompleted) {
return { success: true, alreadyCompleted: true };
}
const existingBonus = await db.select({ id: userBonusesTable.id }).from(userBonusesTable).where(and(eq(userBonusesTable.userId, userData.id), eq(userBonusesTable.reason, "tutorial_completion"))).limit(1);
if (existingBonus.length > 0) {
await db.update(usersTable).set({ tutorialCompleted: true, updatedAt: new Date }).where(eq(usersTable.id, userData.id));
return { success: true, alreadyCompleted: true };
}
await db.update(usersTable).set({ tutorialCompleted: true, updatedAt: new Date }).where(eq(usersTable.id, userData.id));
await db.insert(userBonusesTable).values({
userId: userData.id,
type: "tutorial_bonus",
reason: "tutorial_completion",
amount: 10
});
return { success: true, bonusAwarded: 10 };
@ -29805,6 +29915,7 @@ var leaderboard_default = leaderboard;
// src/routes/hackatime.ts
var HACKATIME_API2 = "https://hackatime.hackclub.com/api/v1";
var SCRAPS_START_DATE2 = "2026-02-03";
var hackatime = new Elysia({ prefix: "/hackatime" });
hackatime.get("/projects", async ({ headers }) => {
const user2 = await getUserFromSession(headers);
@ -29814,29 +29925,39 @@ hackatime.get("/projects", async ({ headers }) => {
console.log("[HACKATIME] No slackId found for user:", user2.id);
return { error: "No Slack ID found for user", projects: [] };
}
const url = `${HACKATIME_API2}/users/${encodeURIComponent(user2.slackId)}/projects/details`;
console.log("[HACKATIME] Fetching projects:", { userId: user2.id, slackId: user2.slackId, url });
const statsParams = new URLSearchParams({
features: "projects",
start_date: SCRAPS_START_DATE2
});
const statsUrl = `${HACKATIME_API2}/users/${encodeURIComponent(user2.slackId)}/stats?${statsParams}`;
const detailsUrl = `${HACKATIME_API2}/users/${encodeURIComponent(user2.slackId)}/projects/details`;
console.log("[HACKATIME] Fetching projects:", { userId: user2.id, slackId: user2.slackId, statsUrl });
try {
const response = await fetch(url, {
headers: {
Accept: "application/json"
}
});
if (!response.ok) {
const errorText = await response.text();
console.log("[HACKATIME] API error:", { status: response.status, body: errorText });
const [statsResponse, detailsResponse] = await Promise.all([
fetch(statsUrl, { headers: { Accept: "application/json" } }),
fetch(detailsUrl, { headers: { Accept: "application/json" } })
]);
if (!statsResponse.ok) {
const errorText = await statsResponse.text();
console.log("[HACKATIME] Stats API error:", { status: statsResponse.status, body: errorText });
return { projects: [] };
}
const data = await response.json();
console.log("[HACKATIME] Projects fetched:", data.projects?.length || 0);
const statsData = await statsResponse.json();
const detailsData = detailsResponse.ok ? await detailsResponse.json() : { projects: [] };
const projects2 = statsData.data?.projects || [];
console.log("[HACKATIME] Projects fetched:", projects2.length);
const detailsMap = new Map(detailsData.projects?.map((p) => [p.name, p]) || []);
return {
slackId: user2.slackId,
projects: data.projects.map((p) => ({
name: p.name,
hours: Math.round(p.total_seconds / 3600 * 10) / 10,
repoUrl: p.repo_url,
languages: p.languages
}))
projects: projects2.map((p) => {
const details = detailsMap.get(p.name);
return {
name: p.name,
hours: Math.round(p.total_seconds / 3600 * 10) / 10,
repoUrl: details?.repo_url || null,
languages: details?.languages || []
};
})
};
} catch (error) {
console.error("[HACKATIME] Error fetching projects:", error);
@ -29915,6 +30036,27 @@ async function requireAdmin(headers) {
return null;
return user2;
}
admin.get("/stats", async ({ headers, status: status2 }) => {
const user2 = await requireReviewer(headers);
if (!user2) {
return status2(401, { error: "Unauthorized" });
}
const [usersCount, projectsCount, totalHoursResult] = await Promise.all([
db.select({ count: sql`count(*)` }).from(usersTable),
db.select({ count: sql`count(*)` }).from(projectsTable).where(or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`)),
db.select({ total: sql`COALESCE(SUM(COALESCE(${projectsTable.hoursOverride}, ${projectsTable.hours})), 0)` }).from(projectsTable).where(and(eq(projectsTable.status, "shipped"), or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`)))
]);
const totalUsers = Number(usersCount[0]?.count || 0);
const totalProjects = Number(projectsCount[0]?.count || 0);
const totalHours = Number(totalHoursResult[0]?.total || 0);
const weightedGrants = Math.round(totalHours / 10 * 100) / 100;
return {
totalUsers,
totalProjects,
totalHours: Math.round(totalHours * 10) / 10,
weightedGrants
};
});
admin.get("/users", async ({ headers, query, status: status2 }) => {
try {
const user2 = await requireReviewer(headers);
@ -30208,6 +30350,9 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
const project = await db.select().from(projectsTable).where(eq(projectsTable.id, projectId)).limit(1);
if (!project[0])
return { error: "Project not found" };
if (hoursOverride !== undefined && hoursOverride > (project[0].hours ?? 0)) {
return { error: `Hours override (${hoursOverride}) cannot exceed project hours (${project[0].hours})` };
}
if (project[0].deleted) {
return { error: "Cannot review a deleted project" };
}

View file

@ -0,0 +1,175 @@
CREATE TABLE "activity" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "activity_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"project_id" integer,
"action" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "news" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "news_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"title" varchar NOT NULL,
"content" varchar NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "projects" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "projects_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"name" varchar NOT NULL,
"description" varchar NOT NULL,
"image" text,
"github_url" varchar,
"playable_url" varchar,
"hackatime_project" varchar,
"hours" real DEFAULT 0,
"hours_override" real,
"tier" integer DEFAULT 1 NOT NULL,
"tier_override" integer,
"status" varchar DEFAULT 'in_progress' NOT NULL,
"deleted" integer DEFAULT 0,
"scraps_awarded" integer DEFAULT 0 NOT NULL,
"views" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "refinery_orders" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "refinery_orders_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"shop_item_id" integer NOT NULL,
"cost" integer NOT NULL,
"boost_amount" integer DEFAULT 1 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reviews" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "reviews_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"project_id" integer NOT NULL,
"reviewer_id" integer NOT NULL,
"action" varchar NOT NULL,
"feedback_for_author" text NOT NULL,
"internal_justification" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"token" varchar PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shop_hearts" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "shop_hearts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"shop_item_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "shop_hearts_user_id_shop_item_id_unique" UNIQUE("user_id","shop_item_id")
);
--> statement-breakpoint
CREATE TABLE "shop_items" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "shop_items_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"name" varchar NOT NULL,
"image" varchar NOT NULL,
"description" varchar NOT NULL,
"price" integer NOT NULL,
"category" varchar NOT NULL,
"count" integer DEFAULT 0 NOT NULL,
"base_probability" integer DEFAULT 50 NOT NULL,
"base_upgrade_cost" integer DEFAULT 10 NOT NULL,
"cost_multiplier" integer DEFAULT 115 NOT NULL,
"boost_amount" integer DEFAULT 1 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shop_orders" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "shop_orders_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"shop_item_id" integer NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"price_per_item" integer NOT NULL,
"total_price" integer NOT NULL,
"status" varchar DEFAULT 'pending' NOT NULL,
"order_type" varchar DEFAULT 'purchase' NOT NULL,
"shipping_address" text,
"notes" text,
"is_fulfilled" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shop_penalties" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "shop_penalties_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"shop_item_id" integer NOT NULL,
"probability_multiplier" integer DEFAULT 100 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "shop_penalties_user_id_shop_item_id_unique" UNIQUE("user_id","shop_item_id")
);
--> statement-breakpoint
CREATE TABLE "shop_rolls" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "shop_rolls_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"shop_item_id" integer NOT NULL,
"rolled" integer NOT NULL,
"threshold" integer NOT NULL,
"won" boolean NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_emails" (
"id" serial PRIMARY KEY NOT NULL,
"email" varchar NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "users_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"sub" varchar NOT NULL,
"slack_id" varchar,
"username" varchar,
"email" varchar NOT NULL,
"avatar" varchar,
"access_token" text,
"refresh_token" text,
"id_token" text,
"role" varchar DEFAULT 'member' NOT NULL,
"internal_notes" text,
"tutorial_completed" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_sub_unique" UNIQUE("sub")
);
--> statement-breakpoint
CREATE TABLE "user_bonuses" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_bonuses_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"amount" integer NOT NULL,
"reason" text NOT NULL,
"given_by" integer,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "activity" ADD CONSTRAINT "activity_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "activity" ADD CONSTRAINT "activity_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "refinery_orders" ADD CONSTRAINT "refinery_orders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "refinery_orders" ADD CONSTRAINT "refinery_orders_shop_item_id_shop_items_id_fk" FOREIGN KEY ("shop_item_id") REFERENCES "public"."shop_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_reviewer_id_users_id_fk" FOREIGN KEY ("reviewer_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_hearts" ADD CONSTRAINT "shop_hearts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_hearts" ADD CONSTRAINT "shop_hearts_shop_item_id_shop_items_id_fk" FOREIGN KEY ("shop_item_id") REFERENCES "public"."shop_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_orders" ADD CONSTRAINT "shop_orders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_orders" ADD CONSTRAINT "shop_orders_shop_item_id_shop_items_id_fk" FOREIGN KEY ("shop_item_id") REFERENCES "public"."shop_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_penalties" ADD CONSTRAINT "shop_penalties_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_penalties" ADD CONSTRAINT "shop_penalties_shop_item_id_shop_items_id_fk" FOREIGN KEY ("shop_item_id") REFERENCES "public"."shop_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_rolls" ADD CONSTRAINT "shop_rolls_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_rolls" ADD CONSTRAINT "shop_rolls_shop_item_id_shop_items_id_fk" FOREIGN KEY ("shop_item_id") REFERENCES "public"."shop_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_bonuses" ADD CONSTRAINT "user_bonuses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_bonuses" ADD CONSTRAINT "user_bonuses_given_by_users_id_fk" FOREIGN KEY ("given_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "verification_status" varchar;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1770241110696,
"tag": "0000_giant_colonel_america",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1770241831758,
"tag": "0001_short_domino",
"breakpoints": true
}
]
}

View file

@ -89,7 +89,8 @@ export async function fetchUserIdentity(accessToken: string): Promise<HackClubMe
}
export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OIDCTokenResponse) {
if (!identity.ysws_eligible) {
// Only block if explicitly false (undefined means pending verification)
if (identity.ysws_eligible === false) {
throw new Error("not-eligible")
}
@ -116,7 +117,8 @@ export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OID
avatar: avatarUrl,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token
idToken: tokens.id_token,
verificationStatus: identity.verification_status
})
.onConflictDoUpdate({
target: usersTable.sub,
@ -128,6 +130,7 @@ export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OID
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
verificationStatus: identity.verification_status,
updatedAt: new Date()
}
})

View file

@ -26,6 +26,39 @@ async function requireAdmin(headers: Record<string, string>) {
return user
}
// Get admin stats (info page)
admin.get('/stats', async ({ headers, status }) => {
const user = await requireReviewer(headers as Record<string, string>)
if (!user) {
return status(401, { error: 'Unauthorized' })
}
const [usersCount, projectsCount, totalHoursResult] = await Promise.all([
db.select({ count: sql<number>`count(*)` }).from(usersTable),
db.select({ count: sql<number>`count(*)` })
.from(projectsTable)
.where(or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`)),
db.select({ total: sql<number>`COALESCE(SUM(COALESCE(${projectsTable.hoursOverride}, ${projectsTable.hours})), 0)` })
.from(projectsTable)
.where(and(
eq(projectsTable.status, 'shipped'),
or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`)
))
])
const totalUsers = Number(usersCount[0]?.count || 0)
const totalProjects = Number(projectsCount[0]?.count || 0)
const totalHours = Number(totalHoursResult[0]?.total || 0)
const weightedGrants = Math.round(totalHours / 10 * 100) / 100
return {
totalUsers,
totalProjects,
totalHours: Math.round(totalHours * 10) / 10,
weightedGrants
}
})
// Get all users (reviewers see limited info)
admin.get('/users', async ({ headers, query, status }) => {
try {

View file

@ -1,4 +1,4 @@
import { Elysia } from "elysia"
import { Elysia, t } from "elysia"
import {
getAuthorizationUrl,
exchangeCodeForTokens,
@ -10,11 +10,28 @@ import {
} from "../lib/auth"
import { config } from "../config"
import { getUserScrapsBalance } from "../lib/scraps"
import { db } from "../db"
import { userEmailsTable } from "../schemas"
import { eq } from "drizzle-orm"
const FRONTEND_URL = config.frontendUrl
const authRoutes = new Elysia({ prefix: "/auth" })
// POST /auth/collect-email - Store email before redirecting to auth
authRoutes.post("/collect-email", async ({ body }) => {
const { email } = body as { email: string }
console.log("[AUTH] Collecting email:", email)
await db.insert(userEmailsTable).values({ email })
return { success: true }
}, {
body: t.Object({
email: t.String({ format: 'email' })
})
})
// GET /auth/login - Redirect to Hack Club Auth
authRoutes.get("/login", ({ redirect }) => {
console.log("[AUTH] Login initiated")
@ -53,8 +70,23 @@ authRoutes.get("/callback", async ({ query, redirect, cookie }) => {
verificationStatus: identity.verification_status
})
// Check verification status BEFORE creating user
if (identity.verification_status === 'needs_submission') {
console.log("[AUTH] User needs to verify identity")
return redirect(`${FRONTEND_URL}/auth/error?reason=needs-verification`)
}
if (identity.verification_status === 'ineligible') {
console.log("[AUTH] User is ineligible")
return redirect(`${FRONTEND_URL}/auth/error?reason=not-eligible`)
}
const user = await createOrUpdateUser(identity, tokens)
// Delete the collected email since user completed auth
await db.delete(userEmailsTable).where(eq(userEmailsTable.email, identity.primary_email))
console.log("[AUTH] Deleted collected email:", identity.primary_email)
if (user.role === 'banned') {
console.log("[AUTH] Banned user attempted login:", { userId: user.id, username: user.username })
return redirect('https://fraud.land')

View file

@ -2,19 +2,28 @@ import { Elysia } from 'elysia'
import { getUserFromSession } from '../lib/auth'
const HACKATIME_API = 'https://hackatime.hackclub.com/api/v1'
const SCRAPS_START_DATE = '2026-02-03'
interface HackatimeProject {
interface HackatimeStatsProject {
name: string
total_seconds: number
}
interface HackatimeDetailsProject {
name: string
total_seconds: number
languages: string[]
repo_url: string | null
total_heartbeats: number
first_heartbeat: string
last_heartbeat: string
}
interface HackatimeResponse {
projects: HackatimeProject[]
interface HackatimeStatsResponse {
data: {
projects: HackatimeStatsProject[]
}
}
interface HackatimeDetailsResponse {
projects: HackatimeDetailsProject[]
}
const hackatime = new Elysia({ prefix: '/hackatime' })
@ -28,33 +37,51 @@ hackatime.get('/projects', async ({ headers }) => {
return { error: 'No Slack ID found for user', projects: [] }
}
const url = `${HACKATIME_API}/users/${encodeURIComponent(user.slackId)}/projects/details`
console.log('[HACKATIME] Fetching projects:', { userId: user.id, slackId: user.slackId, url })
const statsParams = new URLSearchParams({
features: 'projects',
start_date: SCRAPS_START_DATE
})
const statsUrl = `${HACKATIME_API}/users/${encodeURIComponent(user.slackId)}/stats?${statsParams}`
const detailsUrl = `${HACKATIME_API}/users/${encodeURIComponent(user.slackId)}/projects/details`
console.log('[HACKATIME] Fetching projects:', { userId: user.id, slackId: user.slackId, statsUrl })
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json'
}
})
// Fetch both stats (for hours after start date) and details (for repo URLs and languages)
const [statsResponse, detailsResponse] = await Promise.all([
fetch(statsUrl, { headers: { 'Accept': 'application/json' } }),
fetch(detailsUrl, { headers: { 'Accept': 'application/json' } })
])
if (!response.ok) {
const errorText = await response.text()
console.log('[HACKATIME] API error:', { status: response.status, body: errorText })
if (!statsResponse.ok) {
const errorText = await statsResponse.text()
console.log('[HACKATIME] Stats API error:', { status: statsResponse.status, body: errorText })
return { projects: [] }
}
const data: HackatimeResponse = await response.json()
console.log('[HACKATIME] Projects fetched:', data.projects?.length || 0)
const statsData: HackatimeStatsResponse = await statsResponse.json()
const detailsData: HackatimeDetailsResponse = detailsResponse.ok
? await detailsResponse.json()
: { projects: [] }
const projects = statsData.data?.projects || []
console.log('[HACKATIME] Projects fetched:', projects.length)
// Create a map of project details for quick lookup
const detailsMap = new Map(
detailsData.projects?.map(p => [p.name, p]) || []
)
return {
slackId: user.slackId,
projects: data.projects.map((p) => ({
name: p.name,
hours: Math.round(p.total_seconds / 3600 * 10) / 10,
repoUrl: p.repo_url,
languages: p.languages
}))
projects: projects.map((p) => {
const details = detailsMap.get(p.name)
return {
name: p.name,
hours: Math.round(p.total_seconds / 3600 * 10) / 10,
repoUrl: details?.repo_url || null,
languages: details?.languages || []
}
})
}
} catch (error) {
console.error('[HACKATIME] Error fetching projects:', error)

View file

@ -5,29 +5,37 @@ import { projectsTable } from '../schemas/projects'
import { reviewsTable } from '../schemas/reviews'
import { usersTable } from '../schemas/users'
import { activityTable } from '../schemas/activity'
import { getUserFromSession } from '../lib/auth'
import { getUserFromSession, fetchUserIdentity } from '../lib/auth'
const HACKATIME_API = 'https://hackatime.hackclub.com/api/v1'
const SCRAPS_START_DATE = '2026-02-03'
interface HackatimeProject {
interface HackatimeStatsProject {
name: string
total_seconds: number
}
interface HackatimeResponse {
projects: HackatimeProject[]
interface HackatimeStatsResponse {
data: {
projects: HackatimeStatsProject[]
}
}
async function fetchHackatimeHours(slackId: string, projectName: string): Promise<number> {
try {
const url = `${HACKATIME_API}/users/${encodeURIComponent(slackId)}/projects/details`
const params = new URLSearchParams({
features: 'projects',
start_date: SCRAPS_START_DATE,
filter_by_project: projectName
})
const url = `${HACKATIME_API}/users/${encodeURIComponent(slackId)}/stats?${params}`
const response = await fetch(url, {
headers: { 'Accept': 'application/json' }
})
if (!response.ok) return 0
const data: HackatimeResponse = await response.json()
const project = data.projects.find(p => p.name === projectName)
const data: HackatimeStatsResponse = await response.json()
const project = data.data?.projects?.find(p => p.name === projectName)
if (!project) return 0
return Math.round(project.total_seconds / 3600 * 10) / 10
@ -443,6 +451,22 @@ projects.post("/:id/submit", async ({ params, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) return { error: "Unauthorized" }
// Check verification status before allowing submission
if (user.accessToken) {
const meResponse = await fetchUserIdentity(user.accessToken)
if (meResponse) {
const { identity } = meResponse
// Update stored verification status
await db.update(usersTable)
.set({ verificationStatus: identity.verification_status })
.where(eq(usersTable.id, user.id))
if (identity.verification_status === 'ineligible') {
return { error: "ineligible", redirectTo: "/auth/error?reason=not-eligible" }
}
}
}
const project = await db
.select()
.from(projectsTable)

View file

@ -5,3 +5,4 @@ export { sessionsTable } from './sessions'
export { shopItemsTable, shopHeartsTable, shopOrdersTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable } from './shop'
export { newsTable } from './news'
export { activityTable } from './activity'
export { userEmailsTable } from './user-emails'

View file

@ -0,0 +1,7 @@
import { pgTable, varchar, timestamp, serial } from 'drizzle-orm/pg-core'
export const userEmailsTable = pgTable('user_emails', {
id: serial().primaryKey(),
email: varchar().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})

View file

@ -21,6 +21,9 @@ export const usersTable = pgTable('users', {
role: varchar().notNull().default('member'),
internalNotes: text('internal_notes'),
// verification status from Hack Club Auth
verificationStatus: varchar('verification_status'),
// tutorial status
tutorialCompleted: boolean('tutorial_completed').notNull().default(false),

View file

@ -17,7 +17,8 @@
ShoppingBag,
Newspaper,
PackageCheck,
Compass
Compass,
BarChart3
} from '@lucide/svelte'
import { logout, getUser, userScrapsStore } from '$lib/auth-client'
@ -169,6 +170,16 @@
<div class="w-16 h-6 bg-gray-200 rounded animate-pulse"></div>
</div>
{:else}
<a
href="/admin"
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 cursor-pointer {currentPath === '/admin'
? 'bg-black text-white border-black'
: 'border-black hover:border-dashed'}"
>
<BarChart3 size={18} />
<span class="text-lg font-bold">info</span>
</a>
<a
href="/admin/reviews"
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 cursor-pointer {currentPath.startsWith('/admin/reviews')
@ -345,7 +356,7 @@
{#if isReviewer}
<a
href={isInAdminSection ? '/dashboard' : '/admin/reviews'}
href={isInAdminSection ? '/dashboard' : '/admin'}
class="fixed bottom-6 left-6 z-50 flex items-center gap-2 px-6 py-3 bg-red-600 text-white rounded-full font-bold hover:bg-red-700 transition-all duration-200 border-4 border-red-800 cursor-pointer"
>
<Shield size={20} />

View file

@ -25,6 +25,8 @@
}
})
let hideNavbar = $derived($page.url.pathname.startsWith('/auth/error'))
// Prefetch data on initial load if user is logged in
onMount(async () => {
user = await getUser()
@ -48,7 +50,9 @@
</svelte:head>
<div class="min-h-dvh flex flex-col">
<Navbar />
{#if !hideNavbar}
<Navbar />
{/if}
<main class="flex-1">
{@render children()}
</main>

View file

@ -5,8 +5,38 @@
import { login } from '$lib/auth-client'
import { API_URL } from '$lib/config'
function handleLogin() {
login()
let email = $state('')
let emailError = $state('')
let isSubmitting = $state(false)
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
async function handleLogin() {
emailError = ''
if (!email.trim()) {
emailError = 'please enter your email'
return
}
if (!isValidEmail(email)) {
emailError = 'please enter a valid email'
return
}
isSubmitting = true
try {
await fetch(`${API_URL}/auth/collect-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
})
login()
} catch {
login()
}
}
interface ShopItem {
@ -138,13 +168,25 @@
</p>
<!-- Auth Section -->
<button
onclick={handleLogin}
class="flex items-center cursor-pointer justify-center gap-2 px-8 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all"
>
<Origami size={18} />
<span>start scrapping</span>
</button>
<div class="flex flex-col gap-2">
<input
type="email"
bind:value={email}
placeholder="your email"
class="px-4 py-3 border-2 border-black rounded-full focus:outline-none focus:border-dashed w-full"
/>
{#if emailError}
<p class="text-red-500 text-sm px-4">{emailError}</p>
{/if}
<button
onclick={handleLogin}
disabled={isSubmitting}
class="flex items-center cursor-pointer justify-center gap-2 px-8 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Origami size={18} />
<span>start scrapping</span>
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { Users, FolderKanban, Clock, Scale } from '@lucide/svelte'
import { getUser } from '$lib/auth-client'
import { API_URL } from '$lib/config'
interface Stats {
totalUsers: number
totalProjects: number
totalHours: number
weightedGrants: number
}
let stats = $state<Stats | null>(null)
let loading = $state(true)
let error = $state<string | null>(null)
onMount(async () => {
const user = await getUser()
if (!user || (user.role !== 'admin' && user.role !== 'reviewer')) {
goto('/dashboard')
return
}
try {
const response = await fetch(`${API_URL}/admin/stats`, {
credentials: 'include'
})
if (!response.ok) throw new Error('Failed to fetch stats')
stats = await response.json()
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load stats'
} finally {
loading = false
}
})
</script>
<svelte:head>
<title>admin info - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">
<h1 class="text-4xl md:text-5xl font-bold mb-8">admin info</h1>
{#if loading}
<div class="text-center py-12 text-gray-500">loading stats...</div>
{:else if error}
<div class="text-center py-12 text-red-600">{error}</div>
{:else if stats}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="border-4 border-black rounded-2xl p-6 flex items-center gap-4">
<div class="w-16 h-16 bg-black text-white rounded-full flex items-center justify-center">
<Users size={32} />
</div>
<div>
<p class="text-sm text-gray-500 font-bold">total users</p>
<p class="text-4xl font-bold">{stats.totalUsers.toLocaleString()}</p>
</div>
</div>
<div class="border-4 border-black rounded-2xl p-6 flex items-center gap-4">
<div class="w-16 h-16 bg-black text-white rounded-full flex items-center justify-center">
<FolderKanban size={32} />
</div>
<div>
<p class="text-sm text-gray-500 font-bold">total projects</p>
<p class="text-4xl font-bold">{stats.totalProjects.toLocaleString()}</p>
</div>
</div>
<div class="border-4 border-black rounded-2xl p-6 flex items-center gap-4">
<div class="w-16 h-16 bg-black text-white rounded-full flex items-center justify-center">
<Clock size={32} />
</div>
<div>
<p class="text-sm text-gray-500 font-bold">total shipped hours</p>
<p class="text-4xl font-bold">{stats.totalHours.toLocaleString()}h</p>
</div>
</div>
<div class="border-4 border-black rounded-2xl p-6 flex items-center gap-4">
<div class="w-16 h-16 bg-black text-white rounded-full flex items-center justify-center">
<Scale size={32} />
</div>
<div>
<p class="text-sm text-gray-500 font-bold">weighted grants</p>
<p class="text-4xl font-bold">{stats.weightedGrants.toLocaleString()}</p>
<p class="text-xs text-gray-400">total hours ÷ 10</p>
</div>
</div>
</div>
{/if}
</div>

View file

@ -4,10 +4,16 @@
let reason = $derived(page.url.searchParams.get('reason') || 'unknown')
const errorMessages: Record<string, { title: string; description: string }> = {
const errorMessages: Record<string, { title: string; description: string; redirectUrl?: string; redirectText?: string }> = {
'needs-verification': {
title: 'verify your identity',
description: 'you need to verify your identity with hack club auth before you can use scraps. click below to complete verification.',
redirectUrl: 'https://auth.hackclub.com',
redirectText: 'verify with hack club auth'
},
'not-eligible': {
title: 'not eligible for ysws',
description: 'your hack club account is not currently eligible for you ship we ship programs. this might be because your account needs to be verified first.'
description: 'your hack club account is not currently eligible for you ship we ship programs. please ask for help in the hack club slack.'
},
'auth-failed': {
title: 'authentication failed',
@ -44,14 +50,25 @@
>
go back home
</a>
<a
href="https://hackclub.com/slack"
target="_blank"
rel="noopener noreferrer"
class="px-6 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all cursor-pointer"
>
get help on slack
</a>
{#if errorInfo.redirectUrl}
<a
href={errorInfo.redirectUrl}
target="_blank"
rel="noopener noreferrer"
class="px-6 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all cursor-pointer"
>
{errorInfo.redirectText}
</a>
{:else}
<a
href="https://hackclub.com/slack"
target="_blank"
rel="noopener noreferrer"
class="px-6 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all cursor-pointer"
>
get help on slack
</a>
{/if}
</div>
</div>
</div>

View file

@ -163,9 +163,15 @@
credentials: 'include'
})
const submitData = await submitResponse.json().catch(() => ({}))
if (submitData.error === 'ineligible' && submitData.redirectTo) {
goto(submitData.redirectTo)
return
}
if (!submitResponse.ok) {
const responseData = await submitResponse.json().catch(() => ({}))
throw new Error(responseData.message || 'Failed to submit project')
throw new Error(submitData.message || 'Failed to submit project')
}
goto(`/projects/${project.id}`)