From 02e66b488f7602b7b12e20d47911807af8a1a9cf Mon Sep 17 00:00:00 2001 From: Nathan <70660308+NotARoomba@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:34:21 -0500 Subject: [PATCH] add support for updates --- CLAUDE.md | 120 ++++ backend/drizzle/0006_add_shipped_at.sql | 10 + backend/drizzle/0007_remove_shipped_at.sql | 4 + backend/drizzle/0008_add_reviewer_notes.sql | 1 + backend/src/lib/airtable-sync.ts | 12 +- backend/src/lib/effective-hours.ts | 139 ++++ backend/src/routes/admin.ts | 565 ++++++++++++---- backend/src/routes/projects.ts | 85 +-- backend/src/schemas/projects.ts | 3 + frontend/src/lib/components/Navbar.svelte | 29 + .../src/routes/admin/reviews/+page.svelte | 12 +- .../routes/admin/reviews/[id]/+page.svelte | 44 +- .../src/routes/admin/second-pass/+page.svelte | 182 +++++ .../admin/second-pass/[id]/+page.svelte | 634 ++++++++++++++++++ .../src/routes/projects/[id]/+page.svelte | 11 +- .../routes/projects/[id]/edit/+page.svelte | 24 +- .../routes/projects/[id]/submit/+page.svelte | 127 +++- 17 files changed, 1791 insertions(+), 211 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend/drizzle/0006_add_shipped_at.sql create mode 100644 backend/drizzle/0007_remove_shipped_at.sql create mode 100644 backend/drizzle/0008_add_reviewer_notes.sql create mode 100644 backend/src/lib/effective-hours.ts create mode 100644 frontend/src/routes/admin/second-pass/+page.svelte create mode 100644 frontend/src/routes/admin/second-pass/[id]/+page.svelte diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ea1e634 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +## Architecture +- **frontend/**: SvelteKit 2 + Svelte 5 app with Tailwind CSS v4, static adapter +- **backend/**: Bun + Elysia API server with Drizzle ORM + PostgreSQL + +## Commands +| Task | Frontend (`cd frontend`) | Backend (`cd backend`) | +|------|--------------------------|------------------------| +| Dev | `bun run dev` | `bun --watch src/index.ts` | +| Build | `bun run build` | `bun build src/index.ts --target bun --outdir ./dist` | +| Lint | `bun run lint` | — | +| Format | `bun run format` | — | +| Typecheck | `bun run check` | — | +| Test | — | `bun test` (single: `bun test `) | + +## Code Style +- Use tabs, single quotes, no trailing commas (Prettier configured in frontend) +- Frontend: Svelte 5 runes, TypeScript strict, Lucide icons +- Backend: Drizzle schemas in `schemas/`, snake_case for DB columns, camelCase in TS +- No comments unless complex; no `// @ts-expect-error` or `as any` + +## UI Style Guide + +### Buttons +All buttons should follow these patterns: + +**Primary Button (filled)** +```html +class="px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 cursor-pointer" +``` + +**Secondary Button (outlined) - Navigation/Action buttons** +```html +class="px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer" +``` + +**Toggle/Filter Button (selected state)** +```html +class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {isSelected + ? 'bg-black text-white' + : 'hover:border-dashed'}" +``` + +### Cards & Containers +```html +class="border-4 border-black rounded-2xl p-6 hover:border-dashed transition-all" +``` + +### Inputs +```html +class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed" +``` + +### Modals +```html +class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto" +``` + +### Confirmation Modals +Use the `ConfirmModal` component from `$lib/components/ConfirmModal.svelte` or follow this pattern: + +**Backdrop** +```html +
e.target === e.currentTarget && onCancel()} + onkeydown={(e) => e.key === 'Escape' && onCancel()} + role="dialog" + tabindex="-1" +> +``` + +**Modal Container** +```html +class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black" +``` + +**Title** +```html +

{title}

+``` + +**Message** +```html +

{message}

+``` + +**Button Row** +```html +
+ + + + +
+``` + +**Confirm Button Variants** +- Primary: `bg-black text-white` +- Success: `bg-green-600 text-white` +- Warning: `bg-yellow-500 text-white` +- Danger: `bg-red-600 text-white` + +**Do NOT use browser `alert()` or `confirm()`. Always use styled modals.** + +### Key Patterns +- **Border style**: `border-4` for buttons, `border-2` for inputs, `border-4` for cards/containers +- **Rounding**: `rounded-full` for buttons, `rounded-2xl` for cards, `rounded-lg` for inputs +- **Hover state**: `hover:border-dashed` for outlined elements +- **Focus state**: `focus:border-dashed` for inputs +- **Selected state**: `bg-black text-white` (filled) +- **Animation**: Always include `transition-all duration-200` +- **Colors**: Black borders, white backgrounds, no colors except for errors (red) +- **Cursor**: Always include `cursor-pointer` on clickable elements (buttons, links, interactive cards) +- **Disabled cursor**: Always include `disabled:cursor-not-allowed` on buttons that can be disabled diff --git a/backend/drizzle/0006_add_shipped_at.sql b/backend/drizzle/0006_add_shipped_at.sql new file mode 100644 index 0000000..59e074c --- /dev/null +++ b/backend/drizzle/0006_add_shipped_at.sql @@ -0,0 +1,10 @@ +-- Add shipped_at timestamp to track when projects were shipped +ALTER TABLE projects ADD COLUMN shipped_at timestamp; + +-- Backfill shipped_at for existing shipped projects using updatedAt as approximation +UPDATE projects +SET shipped_at = updated_at +WHERE status = 'shipped' AND shipped_at IS NULL; + +-- Create index for efficient ordering queries +CREATE INDEX idx_projects_shipped_at ON projects(shipped_at) WHERE shipped_at IS NOT NULL; diff --git a/backend/drizzle/0007_remove_shipped_at.sql b/backend/drizzle/0007_remove_shipped_at.sql new file mode 100644 index 0000000..d1cf0b7 --- /dev/null +++ b/backend/drizzle/0007_remove_shipped_at.sql @@ -0,0 +1,4 @@ +-- Remove shipped_at column from projects table +-- Ship dates are now derived from the project_activity table (project_shipped action) +DROP INDEX IF EXISTS idx_projects_shipped_at; +ALTER TABLE projects DROP COLUMN IF EXISTS shipped_at; diff --git a/backend/drizzle/0008_add_reviewer_notes.sql b/backend/drizzle/0008_add_reviewer_notes.sql new file mode 100644 index 0000000..dea9424 --- /dev/null +++ b/backend/drizzle/0008_add_reviewer_notes.sql @@ -0,0 +1 @@ +ALTER TABLE projects ADD COLUMN IF NOT EXISTS reviewer_notes TEXT; diff --git a/backend/src/lib/airtable-sync.ts b/backend/src/lib/airtable-sync.ts index 9250304..b367658 100644 --- a/backend/src/lib/airtable-sync.ts +++ b/backend/src/lib/airtable-sync.ts @@ -8,6 +8,7 @@ import { reviewsTable } from '../schemas/reviews' import { config } from '../config' import { eq, and, or, isNull, min, sql, inArray } from 'drizzle-orm' import { fetchUserInfo } from './auth' +import { getProjectShippedDates } from './effective-hours' const SYNC_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes @@ -209,6 +210,9 @@ async function syncProjectsToAirtable(): Promise { // Cache userinfo per userId to avoid redundant API calls const userInfoCache: Map>> = new Map() + // Batch-fetch first shipped dates from project_activity for all projects + const shippedDates = await getProjectShippedDates(projects.map(p => p.id)) + // Track which Code URLs we've already seen to detect duplicates const seenCodeUrls = new Set() @@ -235,13 +239,19 @@ async function syncProjectsToAirtable(): Promise { const userInfo = userInfoCache.get(project.userId) // Compute effective hours by deducting overlapping shipped project hours + // Only deduct from projects that were shipped BEFORE this one (using activity-derived dates) + const projectShippedDate = shippedDates.get(project.id) let effectiveHours = project.hoursOverride ?? project.hours ?? 0 - if (project.hackatimeProject) { + if (project.hackatimeProject && projectShippedDate) { const hackatimeNames = project.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0) if (hackatimeNames.length > 0) { for (const op of projects) { if (op.id === project.id || op.userId !== project.userId) continue if (!op.hackatimeProject) continue + const opShippedDate = shippedDates.get(op.id) + if (!opShippedDate) continue + // Only deduct from projects shipped before this one + if (opShippedDate >= projectShippedDate) continue const opNames = op.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0) if (opNames.some(name => hackatimeNames.includes(name))) { effectiveHours -= (op.hoursOverride ?? op.hours ?? 0) diff --git a/backend/src/lib/effective-hours.ts b/backend/src/lib/effective-hours.ts new file mode 100644 index 0000000..f29c317 --- /dev/null +++ b/backend/src/lib/effective-hours.ts @@ -0,0 +1,139 @@ +import { db } from '../db' +import { projectActivityTable } from '../schemas/activity' +import { projectsTable } from '../schemas/projects' +import { eq, and, or, isNull, sql, inArray, min } from 'drizzle-orm' + +/** + * Get the first shipped date for each project from the project_activity table. + * Returns a Map of projectId -> first shipped Date. + */ +export async function getProjectShippedDates(projectIds: number[]): Promise> { + if (projectIds.length === 0) return new Map() + + const rows = await db + .select({ + projectId: projectActivityTable.projectId, + firstShipped: min(projectActivityTable.createdAt) + }) + .from(projectActivityTable) + .where(and( + inArray(projectActivityTable.projectId, projectIds), + eq(projectActivityTable.action, 'project_shipped') + )) + .groupBy(projectActivityTable.projectId) + + const map = new Map() + for (const row of rows) { + if (row.projectId != null && row.firstShipped != null) { + map.set(row.projectId, row.firstShipped) + } + } + return map +} + +/** + * Get the first shipped date for a single project from the project_activity table. + */ +export async function getProjectShippedDate(projectId: number): Promise { + const map = await getProjectShippedDates([projectId]) + return map.get(projectId) ?? null +} + +/** + * Check if a project has ever been shipped (has a project_shipped activity entry). + */ +export async function hasProjectBeenShipped(projectId: number): Promise { + const date = await getProjectShippedDate(projectId) + return date !== null +} + +/** + * Compute effective hours for a project by subtracting overlapping shipped hours. + * Only deducts from projects that were shipped BEFORE the current project. + * + * @param project - The project to compute effective hours for + * @param allShipped - All shipped projects (must include shippedDate from activity table) + */ +export function computeEffectiveHours( + project: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null; shippedDate: Date | null }, + allShipped: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null; shippedDate: Date | null }[] +): number { + const hours = project.hoursOverride ?? project.hours ?? 0 + if (!project.hackatimeProject || !project.shippedDate) return hours + + const hackatimeNames = project.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0) + if (hackatimeNames.length === 0) return hours + + let deducted = 0 + for (const op of allShipped) { + if (op.id === project.id || op.userId !== project.userId) continue + if (!op.hackatimeProject || !op.shippedDate) continue + if (op.shippedDate >= project.shippedDate) continue + const opNames = op.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0) + if (opNames.some(name => hackatimeNames.includes(name))) { + deducted += op.hoursOverride ?? op.hours ?? 0 + } + } + + return Math.max(0, hours - deducted) +} + +/** + * Compute effective hours with deduction details for a single project. + * Fetches overlapping shipped projects from the DB and the shipped dates from activity. + * Used in review detail pages. + */ +export async function computeEffectiveHoursForProject( + project: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null } +): Promise<{ overlappingProjects: { id: number; name: string; hours: number }[]; deductedHours: number; effectiveHours: number }> { + const projectHours = project.hoursOverride ?? project.hours ?? 0 + if (!project.hackatimeProject) return { overlappingProjects: [], deductedHours: 0, effectiveHours: projectHours } + + const hackatimeNames = project.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) + if (hackatimeNames.length === 0) return { overlappingProjects: [], deductedHours: 0, effectiveHours: projectHours } + + // Get this project's first shipped date + const projectShippedDate = await getProjectShippedDate(project.id) + const deductBeforeDate = projectShippedDate || new Date() + + // Get all shipped projects by same user (excluding this one) + const shipped = await db + .select({ + id: projectsTable.id, + name: projectsTable.name, + hours: projectsTable.hours, + hoursOverride: projectsTable.hoursOverride, + hackatimeProject: projectsTable.hackatimeProject + }) + .from(projectsTable) + .where(and( + eq(projectsTable.userId, project.userId), + eq(projectsTable.status, 'shipped'), + or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), + sql`${projectsTable.id} != ${project.id}` + )) + + if (shipped.length === 0) return { overlappingProjects: [], deductedHours: 0, effectiveHours: projectHours } + + // Get shipped dates for all these projects + const shippedDates = await getProjectShippedDates(shipped.map(s => s.id)) + + const overlapping = shipped.filter(op => { + if (!op.hackatimeProject) return false + const opShippedDate = shippedDates.get(op.id) + if (!opShippedDate || opShippedDate >= deductBeforeDate) return false + const opNames = op.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) + return opNames.some((name: string) => hackatimeNames.includes(name)) + }).map(op => ({ + id: op.id, + name: op.name, + hours: op.hoursOverride ?? op.hours ?? 0 + })) + + const deductedHours = overlapping.reduce((sum, op) => sum + op.hours, 0) + return { + overlappingProjects: overlapping, + deductedHours, + effectiveHours: Math.max(0, projectHours - deductedHours) + } +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 6ecf26a..4f7cbc6 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -13,6 +13,7 @@ import { payoutPendingScraps, getNextPayoutDate } from '../lib/scraps-payout' import { syncSingleProject } from '../lib/hackatime-sync' import { notifyProjectReview } from '../lib/slack' import { config } from '../config' +import { computeEffectiveHours, getProjectShippedDates, hasProjectBeenShipped, computeEffectiveHoursForProject } from '../lib/effective-hours' const admin = new Elysia({ prefix: '/admin' }) @@ -30,30 +31,6 @@ async function requireAdmin(headers: Record) { return user } -// Helper: compute effective hours for a project by subtracting overlapping shipped hours -function computeEffectiveHours( - project: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null }, - allShipped: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null }[] -): number { - const hours = project.hoursOverride ?? project.hours ?? 0 - if (!project.hackatimeProject) return hours - - const hackatimeNames = project.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0) - if (hackatimeNames.length === 0) return hours - - let deducted = 0 - for (const op of allShipped) { - if (op.id === project.id || op.userId !== project.userId) continue - if (!op.hackatimeProject) continue - const opNames = op.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0) - if (opNames.some(name => hackatimeNames.includes(name))) { - deducted += op.hoursOverride ?? op.hours ?? 0 - } - } - - return Math.max(0, hours - deducted) -} - // Get admin stats (info page) admin.get('/stats', async ({ headers, status }) => { const user = await requireReviewer(headers as Record) @@ -82,10 +59,19 @@ admin.get('/stats', async ({ headers, status }) => { const pending = allProjects.filter(p => p.status === 'waiting_for_review') const inProgress = allProjects.filter(p => p.status === 'in_progress') + // Get shipped dates from activity table for all projects + const allProjectIds = allProjects.map(p => p.id) + const shippedDates = await getProjectShippedDates(allProjectIds) + + // Attach shippedDate to each project for computeEffectiveHours + const shippedWithDates = shipped.map(p => ({ ...p, shippedDate: shippedDates.get(p.id) ?? null })) + const pendingWithDates = pending.map(p => ({ ...p, shippedDate: shippedDates.get(p.id) ?? null })) + const inProgressWithDates = inProgress.map(p => ({ ...p, shippedDate: shippedDates.get(p.id) ?? null })) + // Compute effective hours for each category (deducting overlapping shipped project hours) - const totalHours = shipped.reduce((sum, p) => sum + computeEffectiveHours(p, shipped), 0) - const pendingHours = pending.reduce((sum, p) => sum + computeEffectiveHours(p, shipped), 0) - const inProgressHours = inProgress.reduce((sum, p) => sum + computeEffectiveHours(p, shipped), 0) + const totalHours = shippedWithDates.reduce((sum, p) => sum + computeEffectiveHours(p, shippedWithDates), 0) + const pendingHours = pendingWithDates.reduce((sum, p) => sum + computeEffectiveHours(p, shippedWithDates), 0) + const inProgressHours = inProgressWithDates.reduce((sum, p) => sum + computeEffectiveHours(p, shippedWithDates), 0) const totalUsers = Number(usersCount[0]?.count || 0) const totalProjects = Number(projectsCount[0]?.count || 0) @@ -421,7 +407,32 @@ admin.get('/reviews', async ({ headers, query }) => { : asc(projectsTable.updatedAt) const [projects, countResult] = await Promise.all([ - db.select().from(projectsTable) + db.select({ + id: projectsTable.id, + userId: projectsTable.userId, + name: projectsTable.name, + description: projectsTable.description, + image: projectsTable.image, + githubUrl: projectsTable.githubUrl, + playableUrl: projectsTable.playableUrl, + hours: projectsTable.hours, + hoursOverride: projectsTable.hoursOverride, + hackatimeProject: projectsTable.hackatimeProject, + tier: projectsTable.tier, + tierOverride: projectsTable.tierOverride, + status: projectsTable.status, + deleted: projectsTable.deleted, + scrapsAwarded: projectsTable.scrapsAwarded, + scrapsPaidAt: projectsTable.scrapsPaidAt, + views: projectsTable.views, + updateDescription: projectsTable.updateDescription, + aiDescription: projectsTable.aiDescription, + feedbackSource: projectsTable.feedbackSource, + feedbackGood: projectsTable.feedbackGood, + feedbackImprove: projectsTable.feedbackImprove, + createdAt: projectsTable.createdAt, + updatedAt: projectsTable.updatedAt + }).from(projectsTable) .where(eq(projectsTable.status, 'waiting_for_review')) .orderBy(orderClause) .limit(limit) @@ -434,36 +445,8 @@ admin.get('/reviews', async ({ headers, query }) => { // Compute effective hours for each project (subtract overlapping shipped hours) const projectsWithEffective = await Promise.all(projects.map(async (p) => { - const hours = p.hoursOverride ?? p.hours ?? 0 - if (!p.hackatimeProject) return { ...p, effectiveHours: hours, deductedHours: 0 } - - const hackatimeNames = p.hackatimeProject.split(',').map((n: string) => n.trim()).filter((n: string) => n.length > 0) - if (hackatimeNames.length === 0) return { ...p, effectiveHours: hours, deductedHours: 0 } - - const shipped = await db - .select({ - hours: projectsTable.hours, - hoursOverride: projectsTable.hoursOverride, - hackatimeProject: projectsTable.hackatimeProject - }) - .from(projectsTable) - .where(and( - eq(projectsTable.userId, p.userId), - eq(projectsTable.status, 'shipped'), - or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), - sql`${projectsTable.id} != ${p.id}` - )) - - let deductedHours = 0 - for (const op of shipped) { - if (!op.hackatimeProject) continue - const opNames = op.hackatimeProject.split(',').map((n: string) => n.trim()).filter((n: string) => n.length > 0) - if (opNames.some((name: string) => hackatimeNames.includes(name))) { - deductedHours += op.hoursOverride ?? op.hours ?? 0 - } - } - - return { ...p, effectiveHours: Math.max(0, hours - deductedHours), deductedHours } + const result = await computeEffectiveHoursForProject(p) + return { ...p, effectiveHours: result.effectiveHours, deductedHours: result.deductedHours } })) return { @@ -520,15 +503,26 @@ admin.get('/reviews/:id', async ({ params, headers }) => { .where(inArray(usersTable.id, reviewerIds)) } + const isAdmin = user.role === 'admin' + // Hide pending_admin_approval from non-admin reviewers + const maskedProject = (!isAdmin && project[0].status === 'pending_admin_approval') + ? { ...project[0], status: 'waiting_for_review' } + : project[0] + + // Hide approval reviews from non-admin reviewers when project is pending admin approval + const visibleReviews = (!isAdmin && project[0].status === 'pending_admin_approval') + ? reviews.filter(r => r.action !== 'approved') + : reviews + return { - project: project[0], + project: maskedProject, user: projectUser[0] ? { id: projectUser[0].id, username: projectUser[0].username, avatar: projectUser[0].avatar, internalNotes: projectUser[0].internalNotes } : null, - reviews: reviews.map(r => { + reviews: visibleReviews.map(r => { const reviewer = reviewers.find(rv => rv.id === r.reviewerId) return { ...r, @@ -537,46 +531,7 @@ admin.get('/reviews/:id', async ({ params, headers }) => { reviewerId: r.reviewerId } }), - ...await (async () => { - if (!project[0].hackatimeProject) return { overlappingProjects: [], deductedHours: 0, effectiveHours: project[0].hoursOverride ?? project[0].hours ?? 0 } - const hackatimeNames = project[0].hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) - if (hackatimeNames.length === 0) return { overlappingProjects: [], deductedHours: 0, effectiveHours: project[0].hoursOverride ?? project[0].hours ?? 0 } - - const shipped = await db - .select({ - id: projectsTable.id, - name: projectsTable.name, - hours: projectsTable.hours, - hoursOverride: projectsTable.hoursOverride, - hackatimeProject: projectsTable.hackatimeProject, - status: projectsTable.status - }) - .from(projectsTable) - .where(and( - eq(projectsTable.userId, project[0].userId), - eq(projectsTable.status, 'shipped'), - or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), - sql`${projectsTable.id} != ${project[0].id}` - )) - - const overlapping = shipped.filter(op => { - if (!op.hackatimeProject) return false - const opNames = op.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) - return opNames.some((name: string) => hackatimeNames.includes(name)) - }).map(op => ({ - id: op.id, - name: op.name, - hours: op.hoursOverride ?? op.hours ?? 0 - })) - - const deductedHours = overlapping.reduce((sum, op) => sum + op.hours, 0) - const projectHours = project[0].hoursOverride ?? project[0].hours ?? 0 - return { - overlappingProjects: overlapping, - deductedHours, - effectiveHours: Math.max(0, projectHours - deductedHours) - } - })() + ...await computeEffectiveHoursForProject(project[0]) } } catch (err) { console.error(err); @@ -659,10 +614,13 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => { // Update project status let newStatus = 'in_progress' + const isAdmin = user.role === 'admin' switch (action) { case "approved": - newStatus = "shipped"; + // If reviewer (not admin) approves, send to second-pass review + // If admin approves, ship directly + newStatus = isAdmin ? "shipped" : "pending_admin_approval"; break; case "denied": newStatus = "in_progress"; @@ -692,40 +650,27 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => { const hours = hoursOverride ?? project[0].hours ?? 0 const tier = tierOverride ?? project[0].tier ?? 1 - // Subtract hours from previously shipped projects that share the same hackatime project - let deductedHours = 0 - if (project[0].hackatimeProject) { - const hackatimeNames = project[0].hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) - if (hackatimeNames.length > 0) { - const overlapping = await db - .select({ - id: projectsTable.id, - hours: projectsTable.hours, - hoursOverride: projectsTable.hoursOverride, - hackatimeProject: projectsTable.hackatimeProject - }) - .from(projectsTable) - .where(and( - eq(projectsTable.userId, project[0].userId), - eq(projectsTable.status, 'shipped'), - or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), - sql`${projectsTable.id} != ${projectId}` - )) + // Compute effective hours using activity-derived shipped dates + const { effectiveHours } = await computeEffectiveHoursForProject({ + ...project[0], + hoursOverride: hoursOverride ?? project[0].hoursOverride + }) + const newScrapsAwarded = calculateScrapsFromHours(effectiveHours, tier) - for (const op of overlapping) { - if (!op.hackatimeProject) continue - const opNames = op.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) - const hasOverlap = opNames.some((name: string) => hackatimeNames.includes(name)) - if (hasOverlap) { - deductedHours += op.hoursOverride ?? op.hours ?? 0 - } - } + // Only set scrapsAwarded if admin is approving + // Reviewer approvals just go to pending_admin_approval without awarding scraps yet + if (isAdmin) { + const previouslyShipped = await hasProjectBeenShipped(projectId) + // If this is an update to an already-shipped project, calculate ADDITIONAL scraps + // (difference between new award and previous award) + if (previouslyShipped && project[0].scrapsAwarded > 0) { + scrapsAwarded = Math.max(0, newScrapsAwarded - project[0].scrapsAwarded) + } else { + scrapsAwarded = newScrapsAwarded } - } - const effectiveHours = Math.max(0, hours - deductedHours) - scrapsAwarded = calculateScrapsFromHours(effectiveHours, tier) - updateData.scrapsAwarded = scrapsAwarded + updateData.scrapsAwarded = newScrapsAwarded + } } await db @@ -733,20 +678,25 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => { .set(updateData) .where(eq(projectsTable.id, projectId)) - if (action === 'approved' && scrapsAwarded > 0) { - await db.insert(projectActivityTable).values({ - userId: project[0].userId, - projectId, - action: `earned ${scrapsAwarded} scraps` - }) - } - if (action === 'approved') { - await db.insert(projectActivityTable).values({ - userId: project[0].userId, - projectId, - action: 'project_shipped' - }) + const previouslyShipped = await hasProjectBeenShipped(projectId) + + if (scrapsAwarded > 0) { + await db.insert(projectActivityTable).values({ + userId: project[0].userId, + projectId, + action: previouslyShipped ? `earned ${scrapsAwarded} additional scraps (update)` : `earned ${scrapsAwarded} scraps` + }) + } + + // Only log shipping activity if admin approved (not pending second-pass) + if (isAdmin) { + await db.insert(projectActivityTable).values({ + userId: project[0].userId, + projectId, + action: previouslyShipped ? 'project_updated' : 'project_shipped' + }) + } } // Update user internal notes if provided @@ -760,7 +710,10 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => { } // Send Slack DM notification to the project author - if (config.slackBotToken) { + // Skip notification when a non-admin reviewer approves (goes to pending_admin_approval) + // The second-pass flow sends its own notification when an admin accepts/rejects + const shouldNotify = isAdmin || action !== 'approved' + if (config.slackBotToken && shouldNotify) { try { // Get the project author's Slack ID const projectAuthor = await db @@ -812,6 +765,326 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => { } }) +// Second-pass review endpoints (admin only) +// Get projects pending admin approval (reviewer-approved projects) +admin.get('/second-pass', async ({ headers, query }) => { + try { + const user = await requireAdmin(headers as Record) + if (!user) return { error: 'Unauthorized' } + + const page = parseInt(query.page as string) || 1 + const limit = Math.min(parseInt(query.limit as string) || 20, 100) + const offset = (page - 1) * limit + const sort = (query.sort as string) || 'oldest' + + const orderClause = sort === 'newest' + ? desc(projectsTable.updatedAt) + : asc(projectsTable.updatedAt) + + const [projects, countResult] = await Promise.all([ + db.select({ + id: projectsTable.id, + userId: projectsTable.userId, + name: projectsTable.name, + description: projectsTable.description, + image: projectsTable.image, + githubUrl: projectsTable.githubUrl, + playableUrl: projectsTable.playableUrl, + hours: projectsTable.hours, + hoursOverride: projectsTable.hoursOverride, + hackatimeProject: projectsTable.hackatimeProject, + tier: projectsTable.tier, + tierOverride: projectsTable.tierOverride, + status: projectsTable.status, + deleted: projectsTable.deleted, + scrapsAwarded: projectsTable.scrapsAwarded, + scrapsPaidAt: projectsTable.scrapsPaidAt, + views: projectsTable.views, + updateDescription: projectsTable.updateDescription, + aiDescription: projectsTable.aiDescription, + feedbackSource: projectsTable.feedbackSource, + feedbackGood: projectsTable.feedbackGood, + feedbackImprove: projectsTable.feedbackImprove, + createdAt: projectsTable.createdAt, + updatedAt: projectsTable.updatedAt + }).from(projectsTable) + .where(eq(projectsTable.status, 'pending_admin_approval')) + .orderBy(orderClause) + .limit(limit) + .offset(offset), + db.select({ count: sql`count(*)` }).from(projectsTable) + .where(eq(projectsTable.status, 'pending_admin_approval')) + ]) + + const total = Number(countResult[0]?.count || 0) + + // Compute effective hours for each project + const projectsWithEffective = await Promise.all(projects.map(async (p) => { + const result = await computeEffectiveHoursForProject(p) + return { ...p, effectiveHours: result.effectiveHours, deductedHours: result.deductedHours } + })) + + return { + data: projectsWithEffective, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) + } + } + } catch (err) { + console.error(err) + return { error: 'Failed to fetch second-pass reviews' } + } +}) + +// Get single project for second-pass review +admin.get('/second-pass/:id', async ({ params, headers }) => { + const user = await requireAdmin(headers as Record) + if (!user) return { error: 'Unauthorized' } + + try { + const project = await db + .select() + .from(projectsTable) + .where(eq(projectsTable.id, parseInt(params.id))) + .limit(1) + + if (project.length <= 0) return { error: "Project not found!" } + if (project[0].status !== 'pending_admin_approval') { + return { error: "Project is not pending admin approval" } + } + + const projectUser = await db + .select({ + id: usersTable.id, + username: usersTable.username, + avatar: usersTable.avatar, + internalNotes: usersTable.internalNotes + }) + .from(usersTable) + .where(eq(usersTable.id, project[0].userId)) + .limit(1) + + const reviews = await db + .select() + .from(reviewsTable) + .where(eq(reviewsTable.projectId, parseInt(params.id))) + + const reviewerIds = reviews.map(r => r.reviewerId) + let reviewers: { id: number; username: string | null; avatar: string | null }[] = [] + if (reviewerIds.length > 0) { + reviewers = await db + .select({ id: usersTable.id, username: usersTable.username, avatar: usersTable.avatar }) + .from(usersTable) + .where(inArray(usersTable.id, reviewerIds)) + } + + // Calculate effective hours and overlapping projects + const effectiveHoursData = await computeEffectiveHoursForProject(project[0]) + + return { + project: project[0], + user: projectUser[0] ? { + id: projectUser[0].id, + username: projectUser[0].username, + avatar: projectUser[0].avatar, + internalNotes: projectUser[0].internalNotes + } : null, + reviews: reviews.map(r => { + const reviewer = reviewers.find(rv => rv.id === r.reviewerId) + return { + ...r, + reviewerName: reviewer?.username, + reviewerAvatar: reviewer?.avatar, + reviewerId: r.reviewerId + } + }), + ...effectiveHoursData + } + } catch (err) { + console.error(err) + return { error: "Something went wrong while trying to get project" } + } +}) + +// Accept or reject a second-pass review +admin.post('/second-pass/:id', async ({ params, body, headers }) => { + try { + const user = await requireAdmin(headers as Record) + if (!user) return { error: 'Unauthorized' } + + const { action, feedbackForAuthor, hoursOverride } = body as { + action: 'accept' | 'reject' + feedbackForAuthor?: string + hoursOverride?: number + } + + if (!['accept', 'reject'].includes(action)) { + return { error: 'Invalid action. Must be "accept" or "reject"' } + } + + const projectId = parseInt(params.id) + + // Get project + const project = await db + .select() + .from(projectsTable) + .where(eq(projectsTable.id, projectId)) + .limit(1) + + if (!project[0]) return { error: 'Project not found' } + if (project[0].status !== 'pending_admin_approval') { + return { error: 'Project is not pending admin approval' } + } + + if (action === 'accept') { + // Accept the reviewer's approval and ship the project + const tier = project[0].tierOverride ?? project[0].tier ?? 1 + + // Apply hours override if provided + if (hoursOverride !== undefined) { + await db + .update(projectsTable) + .set({ hoursOverride }) + .where(eq(projectsTable.id, projectId)) + project[0].hoursOverride = hoursOverride + } + + // Compute effective hours using activity-derived shipped dates + const { effectiveHours } = await computeEffectiveHoursForProject(project[0]) + const newScrapsAwarded = calculateScrapsFromHours(effectiveHours, tier) + + const previouslyShipped = await hasProjectBeenShipped(projectId) + + let scrapsAwarded = 0 + const updateData: Record = { + status: 'shipped', + updatedAt: new Date() + } + + // Calculate scraps + if (previouslyShipped && project[0].scrapsAwarded > 0) { + scrapsAwarded = Math.max(0, newScrapsAwarded - project[0].scrapsAwarded) + } else { + scrapsAwarded = newScrapsAwarded + } + + updateData.scrapsAwarded = newScrapsAwarded + + // Update project + await db + .update(projectsTable) + .set(updateData) + .where(eq(projectsTable.id, projectId)) + + // Log scraps earned + if (scrapsAwarded > 0) { + await db.insert(projectActivityTable).values({ + userId: project[0].userId, + projectId, + action: previouslyShipped ? `earned ${scrapsAwarded} additional scraps (update)` : `earned ${scrapsAwarded} scraps` + }) + } + + // Log project shipped + await db.insert(projectActivityTable).values({ + userId: project[0].userId, + projectId, + action: previouslyShipped ? 'project_updated' : 'project_shipped' + }) + + // Send notification to project author + if (config.slackBotToken) { + try { + const projectAuthor = await db + .select({ slackId: usersTable.slackId }) + .from(usersTable) + .where(eq(usersTable.id, project[0].userId)) + .limit(1) + + if (projectAuthor[0]?.slackId) { + await notifyProjectReview({ + userSlackId: projectAuthor[0].slackId, + projectName: project[0].name, + projectId, + action: 'approved', + feedbackForAuthor: 'Your project has been approved and shipped!', + reviewerSlackId: user.slackId ?? null, + adminSlackIds: [], + scrapsAwarded, + frontendUrl: config.frontendUrl, + token: config.slackBotToken + }) + } + } catch (slackErr) { + console.error('Failed to send Slack notification:', slackErr) + } + } + + return { success: true, scrapsAwarded } + } else { + // Reject: Delete the approval review and add a denial review + // Find and delete the approval review + await db + .delete(reviewsTable) + .where(and( + eq(reviewsTable.projectId, projectId), + eq(reviewsTable.action, 'approved') + )) + + // Add a denial review from the admin + await db.insert(reviewsTable).values({ + projectId, + reviewerId: user.id, + action: 'denied', + feedbackForAuthor: feedbackForAuthor || 'The admin has rejected the initial approval. Please make improvements and resubmit.', + internalJustification: 'Second-pass rejection' + }) + + // Set project back to in_progress + await db + .update(projectsTable) + .set({ status: 'in_progress', updatedAt: new Date() }) + .where(eq(projectsTable.id, projectId)) + + // Send notification to project author + if (config.slackBotToken) { + try { + const projectAuthor = await db + .select({ slackId: usersTable.slackId }) + .from(usersTable) + .where(eq(usersTable.id, project[0].userId)) + .limit(1) + + if (projectAuthor[0]?.slackId) { + await notifyProjectReview({ + userSlackId: projectAuthor[0].slackId, + projectName: project[0].name, + projectId, + action: 'denied', + feedbackForAuthor: feedbackForAuthor || 'The admin has rejected the initial approval. Please make improvements and resubmit.', + reviewerSlackId: user.slackId ?? null, + adminSlackIds: [], + scrapsAwarded: 0, + frontendUrl: config.frontendUrl, + token: config.slackBotToken + }) + } + } catch (slackErr) { + console.error('Failed to send Slack notification:', slackErr) + } + } + + return { success: true } + } + } catch (err) { + console.error(err) + return { error: 'Failed to process second-pass review' } + } +}) + // Get pending scraps payout info (admin only) admin.get('/scraps-payout', async ({ headers }) => { try { diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index b9eae59..fb51600 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -9,6 +9,7 @@ import { getUserFromSession, fetchUserIdentity } from '../lib/auth' import { syncSingleProject } from '../lib/hackatime-sync' import { notifyProjectSubmitted } from '../lib/slack' import { config } from '../config' +import { computeEffectiveHoursForProject } from '../lib/effective-hours' const ALLOWED_IMAGE_DOMAIN = 'cdn.hackclub.com' @@ -74,7 +75,7 @@ projects.get('/explore', async ({ query }) => { const conditions = [ or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), - or(eq(projectsTable.status, 'shipped'), eq(projectsTable.status, 'in_progress'), eq(projectsTable.status, 'waiting_for_review')) + or(eq(projectsTable.status, 'shipped'), eq(projectsTable.status, 'in_progress'), eq(projectsTable.status, 'waiting_for_review'), eq(projectsTable.status, 'pending_admin_approval')) ] if (search) { @@ -90,9 +91,11 @@ projects.get('/explore', async ({ query }) => { conditions.push(eq(projectsTable.tier, tier)) } - if (status === 'shipped' || status === 'in_progress' || status === 'waiting_for_review') { - // Replace the default status condition with specific one + if (status === 'shipped' || status === 'in_progress') { conditions[1] = eq(projectsTable.status, status) + } else if (status === 'waiting_for_review') { + // Include pending_admin_approval since it appears as waiting_for_review externally + conditions[1] = or(eq(projectsTable.status, 'waiting_for_review'), eq(projectsTable.status, 'pending_admin_approval'))! } const whereClause = and(...conditions) @@ -150,7 +153,7 @@ projects.get('/explore', async ({ query }) => { image: p.image, hours: p.hoursOverride ?? p.hours, tier: p.tier, - status: p.status, + status: p.status === 'pending_admin_approval' ? 'waiting_for_review' : p.status, views: p.views, username: users.find(u => u.id === p.userId)?.username || null })), @@ -190,7 +193,10 @@ projects.get('/', async ({ headers, query }) => { const total = Number(countResult[0]?.count || 0) return { - data: projectsList, + data: projectsList.map(p => ({ + ...p, + status: p.status === 'pending_admin_approval' ? 'waiting_for_review' : p.status + })), pagination: { page, limit, @@ -214,8 +220,8 @@ projects.get('/:id', async ({ params, headers }) => { const isOwner = project[0].userId === user.id - // If not owner, only show shipped, in_progress, or waiting_for_review projects - if (!isOwner && project[0].status !== 'shipped' && project[0].status !== 'in_progress' && project[0].status !== 'waiting_for_review') { + // If not owner, only show shipped, in_progress, waiting_for_review, or pending_admin_approval projects + if (!isOwner && project[0].status !== 'shipped' && project[0].status !== 'in_progress' && project[0].status !== 'waiting_for_review' && project[0].status !== 'pending_admin_approval') { return { error: 'Not found' } } @@ -264,7 +270,10 @@ projects.get('/:id', async ({ params, headers }) => { } // Add review entries + // Hide approval reviews when project is pending admin approval (internal status) + const isPendingAdmin = project[0].status === 'pending_admin_approval' for (const r of reviews) { + if (isPendingAdmin && r.action === 'approved') continue activity.push({ type: 'review', action: r.action, @@ -343,35 +352,11 @@ projects.get('/:id', async ({ params, headers }) => { } // Calculate effective hours (subtract overlapping shipped project hours) + // Uses activity-derived shipped dates for ordering (consistent with Airtable sync and admin review) const projectHours = project[0].hoursOverride ?? project[0].hours ?? 0 - let deductedHours = 0 - if (project[0].hackatimeProject) { - const hackatimeNames = project[0].hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) - if (hackatimeNames.length > 0) { - const shipped = await db - .select({ - hours: projectsTable.hours, - hoursOverride: projectsTable.hoursOverride, - hackatimeProject: projectsTable.hackatimeProject - }) - .from(projectsTable) - .where(and( - eq(projectsTable.userId, project[0].userId), - eq(projectsTable.status, 'shipped'), - or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), - sql`${projectsTable.id} != ${project[0].id}` - )) - - for (const op of shipped) { - if (!op.hackatimeProject) continue - const opNames = op.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) - if (opNames.some((name: string) => hackatimeNames.includes(name))) { - deductedHours += op.hoursOverride ?? op.hours ?? 0 - } - } - } - } - const effectiveHours = Math.max(0, projectHours - deductedHours) + const effectiveHoursResult = await computeEffectiveHoursForProject(project[0]) + const effectiveHours = effectiveHoursResult.effectiveHours + const deductedHours = effectiveHoursResult.deductedHours return { project: { @@ -386,11 +371,12 @@ projects.get('/:id', async ({ params, headers }) => { hoursOverride: isOwner ? project[0].hoursOverride : undefined, tier: project[0].tier, tierOverride: isOwner ? project[0].tierOverride : undefined, - status: project[0].status, + status: project[0].status === 'pending_admin_approval' ? 'waiting_for_review' : project[0].status, scrapsAwarded: project[0].scrapsAwarded, views: project[0].views, updateDescription: project[0].updateDescription, aiDescription: isOwner ? project[0].aiDescription : undefined, + reviewerNotes: isOwner ? project[0].reviewerNotes : undefined, usedAi: !!project[0].aiDescription, effectiveHours, deductedHours, @@ -470,8 +456,8 @@ projects.put('/:id', async ({ params, body, headers }) => { if (!existing[0]) return { error: 'Not found' } - // Reject edits while waiting for review - if (existing[0].status === 'waiting_for_review') { + // Reject edits while waiting for review or pending admin approval + if (existing[0].status === 'waiting_for_review' || existing[0].status === 'pending_admin_approval') { return { error: 'Cannot edit project while waiting for review' } } @@ -485,6 +471,7 @@ projects.put('/:id', async ({ params, body, headers }) => { tier?: number updateDescription?: string | null aiDescription?: string | null + reviewerNotes?: string | null } if (!validateImageUrl(data.image)) { @@ -511,6 +498,7 @@ projects.put('/:id', async ({ params, body, headers }) => { tier, updateDescription: data.updateDescription !== undefined ? (data.updateDescription || null) : undefined, aiDescription: data.aiDescription !== undefined ? (data.aiDescription || null) : undefined, + reviewerNotes: data.reviewerNotes !== undefined ? (data.reviewerNotes || null) : undefined, updatedAt: new Date() }) .where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id))) @@ -561,10 +549,20 @@ projects.post("/:id/unsubmit", async ({ params, headers }) => { if (!project[0]) return { error: "Not found" } - if (project[0].status !== 'waiting_for_review') { + if (project[0].status !== 'waiting_for_review' && project[0].status !== 'pending_admin_approval') { return { error: "Project can only be unsubmitted while waiting for review" } } + // If project was pending admin approval, clean up the reviewer's approval review + if (project[0].status === 'pending_admin_approval') { + await db + .delete(reviewsTable) + .where(and( + eq(reviewsTable.projectId, parseInt(params.id)), + eq(reviewsTable.action, 'approved') + )) + } + const updated = await db .update(projectsTable) .set({ @@ -618,7 +616,7 @@ projects.post("/:id/submit", async ({ params, headers, body }) => { if (!project[0]) return { error: "Not found" } - if (project[0].status !== 'in_progress') { + if (project[0].status !== 'in_progress' && project[0].status !== 'shipped') { return { error: "Project cannot be submitted in current status" } } @@ -696,7 +694,12 @@ projects.get("/:id/reviews", async ({ params, headers }) => { .where(inArray(usersTable.id, reviewerIds)) } - return reviews.map(r => ({ + // Hide approval reviews when project is pending admin approval (internal status) + const filteredReviews = project[0].status === 'pending_admin_approval' + ? reviews.filter(r => r.action !== 'approved') + : reviews + + return filteredReviews.map(r => ({ id: r.id, action: r.action, feedbackForAuthor: r.feedbackForAuthor, diff --git a/backend/src/schemas/projects.ts b/backend/src/schemas/projects.ts index 2a3a7bf..c89e049 100644 --- a/backend/src/schemas/projects.ts +++ b/backend/src/schemas/projects.ts @@ -29,6 +29,9 @@ export const projectsTable = pgTable('projects', { // AI usage fields aiDescription: text('ai_description'), + // Notes for reviewer + reviewerNotes: text('reviewer_notes'), + // Feedback fields (filled on submission) feedbackSource: text('feedback_source'), feedbackGood: text('feedback_good'), diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 8a1c196..0ebb421 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -263,6 +263,20 @@ {$t.nav.reviews} + {#if isAdminOnly} + + + 2nd pass + + {/if} + {$t.nav.reviews} + {#if isAdminOnly} + + + 2nd pass + + {/if} + import { onMount } from 'svelte'; import { goto } from '$app/navigation'; - import { ChevronLeft, ChevronRight, ArrowUpDown } from '@lucide/svelte'; + import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowLeft } from '@lucide/svelte'; import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte'; import { getUser } from '$lib/auth-client'; import { API_URL } from '$lib/config'; @@ -85,7 +85,15 @@ {$t.nav.reviews} - {$t.nav.admin} - scraps -
+
+ + + back to admin + +

{$t.admin.reviewQueue}

diff --git a/frontend/src/routes/admin/reviews/[id]/+page.svelte b/frontend/src/routes/admin/reviews/[id]/+page.svelte index dff0e9a..a4918c5 100644 --- a/frontend/src/routes/admin/reviews/[id]/+page.svelte +++ b/frontend/src/routes/admin/reviews/[id]/+page.svelte @@ -14,7 +14,9 @@ Globe, RefreshCw, Bot, - Loader + Loader, + ArrowLeft, + MessageSquare } from '@lucide/svelte'; import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte'; import { getUser } from '$lib/auth-client'; @@ -67,6 +69,7 @@ feedbackImprove: string | null; updateDescription: string | null; aiDescription: string | null; + reviewerNotes: string | null; } interface User { @@ -330,21 +333,41 @@ review {project?.name || 'project'} - admin - scraps -
+
{#if loading}
{$t.common.loading}
{:else if !project}

{$t.project.projectNotFound}

- {$t.project.back} + + + {$t.project.back} +
{:else if project.deleted}

this project has been deleted

status: {project.status}

- view project + + + back to reviews +
{:else} + + + back to reviews + + {@const isReviewable = project.status === 'waiting_for_review'} @@ -401,6 +424,15 @@

{project.aiDescription}

{/if} + {#if project.reviewerNotes} +
+

+ + notes from author +

+

{project.reviewerNotes}

+
+ {/if}
{#if deductedHours > 0}

previous reviews

- {#each reviews as review} + {#each [...reviews].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) as review} {@const ReviewIcon = getReviewIcon(review.action)}
- {new Date(review.createdAt).toLocaleDateString()} + {new Date(review.createdAt).toLocaleString()}
diff --git a/frontend/src/routes/admin/second-pass/+page.svelte b/frontend/src/routes/admin/second-pass/+page.svelte new file mode 100644 index 0000000..0c63d39 --- /dev/null +++ b/frontend/src/routes/admin/second-pass/+page.svelte @@ -0,0 +1,182 @@ + + + + second pass reviews - admin - scraps + + +
+ + + back to admin + + +
+
+

second pass reviews

+

projects approved by reviewers, awaiting admin confirmation

+
+ +
+ + {#if loading} +
loading...
+ {:else if projects.length === 0} +
+

no projects pending second-pass review

+
+ {:else} + + + + {#if pagination && pagination.totalPages > 1} +
+ + + Page + {pagination.page} + of + {pagination.totalPages} + + +
+ {/if} + {/if} +
diff --git a/frontend/src/routes/admin/second-pass/[id]/+page.svelte b/frontend/src/routes/admin/second-pass/[id]/+page.svelte new file mode 100644 index 0000000..9128ce5 --- /dev/null +++ b/frontend/src/routes/admin/second-pass/[id]/+page.svelte @@ -0,0 +1,634 @@ + + + + second pass review - admin - scraps + + +
+ {#if loading} +
loading...
+ {:else if !project} +
+

project not found

+ + + back to second pass reviews + +
+ {:else} + + + back to second pass reviews + + + +
+
+
+ +
+

pending admin approval

+

+ this project was approved by a reviewer and awaits your final confirmation +

+
+
+ view project +
+
+ + +
+ {#if project.image} + {project.name} + {:else} + + {/if} +
+ + +
+
+

{project.name}

+ + pending approval + +
+

{project.description}

+ {#if project.updateDescription} +
+

+ + what was updated +

+

{project.updateDescription}

+
+ {/if} + {#if project.aiDescription} +
+

+ + ai was used +

+

{project.aiDescription}

+
+ {/if} + {#if project.reviewerNotes} +
+

+ + notes from author +

+

{project.reviewerNotes}

+
+ {/if} +
+ {#if deductedHours > 0} + {formatHours(project.hours)}h logged + {formatHours(effectiveHours)}h effective + {:else} + {formatHours(project.hours)}h logged + {/if} + {#if project.hackatimeProject} + hackatime: {project.hackatimeProject} + {/if} + tier {project.tier} +
+ + {#if overlappingProjects.length > 0} +
+

+ + shared hackatime project — hours will be deducted +

+

+ this project shares a hackatime project with other shipped projects. hours from those + projects will be subtracted when calculating scraps. +

+
    + {#each overlappingProjects as op} +
  • + + {op.name} + — {formatHours(op.hours)}h +
  • + {/each} +
+
+ + total: {formatHours(project.hoursOverride ?? project.hours)}h + + + deducted: -{formatHours(deductedHours)}h + + + effective: {formatHours(effectiveHours)}h + +
+
+ {/if} + +
+ {#if project.githubUrl} + + + view on github + + {:else} + + + view on github + + {/if} + {#if project.playableUrl} + + + try it out + + {:else} + + + try it out + + {/if} +
+ + + {#if projectUser} + + {#if projectUser.avatar} + + {:else} +
+ {/if} +
+

{projectUser.username || 'unknown'}

+
+ view profile → +
+ {/if} +
+ + + {#if project.feedbackSource || project.feedbackGood || project.feedbackImprove} +
+

author feedback

+
+ {#if project.feedbackSource} +
+

how did you hear about this?

+

{project.feedbackSource}

+
+ {/if} + {#if project.feedbackGood} +
+

what are we doing well?

+

{project.feedbackGood}

+
+ {/if} + {#if project.feedbackImprove} +
+

how can we improve?

+

{project.feedbackImprove}

+
+ {/if} +
+
+ {/if} + + + {#if approvalReview} +
+

reviewer approval

+
+
+ + {#if approvalReview.reviewerAvatar} + + {:else} +
+ {/if} + + {approvalReview.reviewerName || 'reviewer'} +
+
+ + approved + + + {new Date(approvalReview.createdAt).toLocaleDateString()} + +
+
+

+ feedback: + {approvalReview.feedbackForAuthor} +

+ {#if approvalReview.internalJustification} +

+ internal: + {approvalReview.internalJustification} +

+ {/if} +
+
+ {/if} + + + {#if projectUser?.internalNotes} +
+

user internal notes

+
+

{projectUser.internalNotes}

+
+
+ {/if} + + {#if error} +
+ {error} +
+ {/if} + + +
+

admin decision

+
+
+ + + {#if hoursOverrideError} +

{hoursOverrideError}

+ {/if} +
+ +
+ + +
+ +
+ + +
+
+
+ {/if} +
+ + +{#if confirmAction} +
e.target === e.currentTarget && cancelConfirmation()} + onkeydown={(e) => e.key === 'Escape' && cancelConfirmation()} + role="dialog" + tabindex="-1" + > +
+

+ confirm {confirmAction === 'accept' ? 'acceptance' : 'rejection'} +

+

+ {#if confirmAction === 'accept'} + are you sure you want to accept this approval and ship the project? the + user will be notified and scraps will be awarded. + {:else} + are you sure you want to reject this approval? the original approval review + will be deleted and the user will need to resubmit. + {/if} +

+
+ + +
+
+
+{/if} diff --git a/frontend/src/routes/projects/[id]/+page.svelte b/frontend/src/routes/projects/[id]/+page.svelte index 557c43d..28ead92 100644 --- a/frontend/src/routes/projects/[id]/+page.svelte +++ b/frontend/src/routes/projects/[id]/+page.svelte @@ -452,12 +452,13 @@ {$t.project.awaitingReview}
{:else if project.status === 'shipped'} - - - {$t.project.shipped} - + + ship update + {:else if project.status === 'permanently_rejected'} import { onMount } from 'svelte'; import { goto } from '$app/navigation'; - import { ArrowLeft, ChevronDown, Upload, X, Save, Check, Trash2 } from '@lucide/svelte'; + import { ArrowLeft, ChevronDown, Upload, X, Save, Check, Trash2, MessageSquare } from '@lucide/svelte'; import { getUser } from '$lib/auth-client'; import { API_URL } from '$lib/config'; import { formatHours, validateGithubUrl, validatePlayableUrl } from '$lib/utils'; @@ -24,6 +24,7 @@ status: string; updateDescription: string | null; aiDescription: string | null; + reviewerNotes: string | null; } const TIERS = [ @@ -58,6 +59,7 @@ let updateDescription = $state(''); let usedAi = $state(false); let aiDescription = $state(''); + let reviewerNotes = $state(''); const NAME_MAX = 50; const DESC_MIN = 20; @@ -101,6 +103,7 @@ updateDescription = project?.updateDescription || ''; usedAi = !!(project?.aiDescription); aiDescription = project?.aiDescription || ''; + reviewerNotes = project?.reviewerNotes || ''; if (project?.hackatimeProject) { selectedHackatimeNames = project.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0); } @@ -230,7 +233,8 @@ hackatimeProject: hackatimeValue, tier: selectedTier, updateDescription: isUpdate ? updateDescription : null, - aiDescription: usedAi ? aiDescription : null + aiDescription: usedAi ? aiDescription : null, + reviewerNotes: reviewerNotes.trim() || null }) }); @@ -581,6 +585,22 @@ {/if}
+ +
+ + +
+
import { onMount } from 'svelte'; import { goto } from '$app/navigation'; - import { ArrowLeft, Send, Check, ChevronDown, Upload, X } from '@lucide/svelte'; + import { ArrowLeft, Send, Check, ChevronDown, Upload, X, RefreshCw, Bot, MessageSquare } from '@lucide/svelte'; import { getUser } from '$lib/auth-client'; import { API_URL } from '$lib/config'; import { formatHours, validateGithubUrl, validatePlayableUrl } from '$lib/utils'; @@ -21,6 +21,7 @@ hours: number; status: string; tier: number; + reviewerNotes: string | null; } const TIERS = [ @@ -53,6 +54,11 @@ let feedbackGood = $state(''); let feedbackImprove = $state(''); let hasSubmittedFeedbackBefore = $state(false); + let isShippedUpdate = $state(false); + let updateDescription = $state(''); + let usedAi = $state(false); + let aiDescription = $state(''); + let reviewerNotes = $state(''); const NAME_MAX = 50; const DESC_MIN = 20; @@ -77,8 +83,10 @@ feedbackGood.trim().length > 0 && feedbackImprove.trim().length > 0) ); + let hasUpdateDescription = $derived(!isShippedUpdate || updateDescription.trim().length > 0); + let hasAiDescription = $derived(!usedAi || aiDescription.trim().length > 0); let allRequirementsMet = $derived( - hasImage && hasHackatime && hasGithub && hasPlayableUrl && hasDescription && hasName && hasFeedback + hasImage && hasHackatime && hasGithub && hasPlayableUrl && hasDescription && hasName && hasFeedback && hasUpdateDescription && hasAiDescription ); onMount(async () => { @@ -105,6 +113,8 @@ project = responseData.project; imagePreview = project?.image || null; hasSubmittedFeedbackBefore = responseData.hasSubmittedFeedback ?? false; + isShippedUpdate = project?.status === 'shipped'; + reviewerNotes = project?.reviewerNotes || ''; if (project?.hackatimeProject) { selectedHackatimeNames = project.hackatimeProject.split(',').map((p: string) => { const trimmed = p.trim(); @@ -240,7 +250,10 @@ githubUrl: project.githubUrl, playableUrl: project.playableUrl, hackatimeProject: hackatimeValue, - tier: selectedTier + tier: selectedTier, + updateDescription: isShippedUpdate ? updateDescription : null, + aiDescription: usedAi ? aiDescription : null, + reviewerNotes: reviewerNotes.trim() || null }) }); @@ -284,7 +297,7 @@ - submit {project?.name || 'project'} - scraps + {isShippedUpdate ? 'ship update' : 'submit'} {project?.name || 'project'} - scraps
@@ -307,9 +320,9 @@
{:else if project}
-

{$t.project.submitForReview}

+

{isShippedUpdate ? 'ship update' : $t.project.submitForReview}

- {$t.project.submitRequirementsHint} + {isShippedUpdate ? 'submit your updated project for review. you\'ll earn the difference in scraps based on your new hours.' : $t.project.submitRequirementsHint}

{#if error} @@ -536,6 +549,76 @@
+ + {#if isShippedUpdate} +
+

+ + this is an update to a shipped project +

+
+ + + {#if updateDescription.trim().length === 0} +

please describe what you updated

+ {/if} +
+
+ {/if} + + +
+ + {#if usedAi} +
+

+ + how was ai used? * +

+ + {#if aiDescription.trim().length === 0} +

please describe how ai was used

+ {/if} +
+ {/if} +
+ + +
+ + +
+
@@ -654,6 +737,30 @@ >{$t.project.hackatimeProjectSelected} + {#if isShippedUpdate} +
  • + + {#if hasUpdateDescription}{/if} + + update description provided +
  • + {/if} + {#if usedAi} +
  • + + {#if hasAiDescription}{/if} + + ai usage described +
  • + {/if} {#if !hasSubmittedFeedbackBefore}
  • - - {submitting ? $t.project.submitting : $t.project.submitForReview} + {#if isShippedUpdate} + + {:else} + + {/if} + {submitting ? $t.project.submitting : isShippedUpdate ? 'ship update' : $t.project.submitForReview}