mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 19:45:14 +00:00
before mobile optimize
This commit is contained in:
parent
687735b053
commit
e7374422a0
20 changed files with 3365 additions and 83 deletions
195
backend/dist/index.js
vendored
195
backend/dist/index.js
vendored
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
175
backend/drizzle/0000_giant_colonel_america.sql
Normal file
175
backend/drizzle/0000_giant_colonel_america.sql
Normal 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;
|
||||
1
backend/drizzle/0001_short_domino.sql
Normal file
1
backend/drizzle/0001_short_domino.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "users" ADD COLUMN "verification_status" varchar;
|
||||
1315
backend/drizzle/meta/0000_snapshot.json
Normal file
1315
backend/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1321
backend/drizzle/meta/0001_snapshot.json
Normal file
1321
backend/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
20
backend/drizzle/meta/_journal.json
Normal file
20
backend/drizzle/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
7
backend/src/schemas/user-emails.ts
Normal file
7
backend/src/schemas/user-emails.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
95
frontend/src/routes/admin/+page.svelte
Normal file
95
frontend/src/routes/admin/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue