mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 18:35:20 +00:00
add support for updates
This commit is contained in:
parent
f17adfa7e6
commit
02e66b488f
17 changed files with 1791 additions and 211 deletions
120
CLAUDE.md
Normal file
120
CLAUDE.md
Normal file
|
|
@ -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 <file>`) |
|
||||
|
||||
## 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
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onclick={(e) => 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
|
||||
<h2 class="text-2xl font-bold mb-4">{title}</h2>
|
||||
```
|
||||
|
||||
**Message**
|
||||
```html
|
||||
<p class="text-gray-600 mb-6">{message}</p>
|
||||
```
|
||||
|
||||
**Button Row**
|
||||
```html
|
||||
<div class="flex gap-3">
|
||||
<!-- Cancel button (secondary) -->
|
||||
<button class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer">
|
||||
cancel
|
||||
</button>
|
||||
<!-- Confirm button (primary/success/warning/danger) -->
|
||||
<button class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold border-4 border-black hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer">
|
||||
confirm
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**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
|
||||
10
backend/drizzle/0006_add_shipped_at.sql
Normal file
10
backend/drizzle/0006_add_shipped_at.sql
Normal file
|
|
@ -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;
|
||||
4
backend/drizzle/0007_remove_shipped_at.sql
Normal file
4
backend/drizzle/0007_remove_shipped_at.sql
Normal file
|
|
@ -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;
|
||||
1
backend/drizzle/0008_add_reviewer_notes.sql
Normal file
1
backend/drizzle/0008_add_reviewer_notes.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS reviewer_notes TEXT;
|
||||
|
|
@ -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<void> {
|
|||
// Cache userinfo per userId to avoid redundant API calls
|
||||
const userInfoCache: Map<number, Awaited<ReturnType<typeof fetchUserInfo>>> = 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<string>()
|
||||
|
||||
|
|
@ -235,13 +239,19 @@ async function syncProjectsToAirtable(): Promise<void> {
|
|||
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)
|
||||
|
|
|
|||
139
backend/src/lib/effective-hours.ts
Normal file
139
backend/src/lib/effective-hours.ts
Normal file
|
|
@ -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<Map<number, Date>> {
|
||||
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<number, Date>()
|
||||
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<Date | null> {
|
||||
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<boolean> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string>) {
|
|||
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<string, string>)
|
||||
|
|
@ -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<string, string>)
|
||||
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<number>`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<string, string>)
|
||||
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<string, string>)
|
||||
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<string, unknown> = {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -263,6 +263,20 @@
|
|||
<span class="text-lg font-bold">{$t.nav.reviews}</span>
|
||||
</a>
|
||||
|
||||
{#if isAdminOnly}
|
||||
<a
|
||||
href="/admin/second-pass"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath.startsWith(
|
||||
'/admin/second-pass'
|
||||
)
|
||||
? 'border-yellow-500 bg-yellow-500 text-white'
|
||||
: 'border-yellow-500 hover:border-dashed'}"
|
||||
>
|
||||
<ClipboardList size={18} />
|
||||
<span class="text-lg font-bold">2nd pass</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/admin/users"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath.startsWith(
|
||||
|
|
@ -580,6 +594,21 @@
|
|||
<span class="text-lg font-bold">{$t.nav.reviews}</span>
|
||||
</a>
|
||||
|
||||
{#if isAdminOnly}
|
||||
<a
|
||||
href="/admin/second-pass"
|
||||
onclick={handleMobileNavClick}
|
||||
class="flex cursor-pointer items-center gap-3 rounded-full border-4 px-4 py-3 transition-all duration-300 {currentPath.startsWith(
|
||||
'/admin/second-pass'
|
||||
)
|
||||
? 'border-yellow-500 bg-yellow-500 text-white'
|
||||
: 'border-yellow-500 hover:border-dashed'}"
|
||||
>
|
||||
<ClipboardList size={20} />
|
||||
<span class="text-lg font-bold">2nd pass</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/admin/users"
|
||||
onclick={handleMobileNavClick}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
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 @@
|
|||
<title>{$t.nav.reviews} - {$t.nav.admin} - scraps</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
|
||||
<div class="mx-auto max-w-6xl px-6 pt-24 pb-32 md:px-12">
|
||||
<a
|
||||
href="/admin"
|
||||
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
back to admin
|
||||
</a>
|
||||
|
||||
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.admin.reviewQueue}</h1>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<title>review {project?.name || 'project'} - admin - scraps</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
|
||||
<div class="mx-auto max-w-4xl px-6 pt-24 pb-32 md:px-12">
|
||||
{#if loading}
|
||||
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
|
||||
{:else if !project}
|
||||
<div class="py-12 text-center">
|
||||
<p class="mb-4 text-xl text-gray-500">{$t.project.projectNotFound}</p>
|
||||
<a href="/admin/reviews" class="font-bold underline">{$t.project.back}</a>
|
||||
<a
|
||||
href="/admin/reviews"
|
||||
class="inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{$t.project.back}
|
||||
</a>
|
||||
</div>
|
||||
{:else if project.deleted}
|
||||
<div class="py-12 text-center">
|
||||
<p class="mb-2 text-xl text-gray-500">this project has been deleted</p>
|
||||
<p class="mb-4 text-gray-400">status: {project.status}</p>
|
||||
<a href="/projects/{project.id}" class="font-bold underline">view project</a>
|
||||
<a
|
||||
href="/admin/reviews"
|
||||
class="inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
back to reviews
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
href="/admin/reviews"
|
||||
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
back to reviews
|
||||
</a>
|
||||
|
||||
{@const isReviewable = project.status === 'waiting_for_review'}
|
||||
|
||||
<!-- Status Banner (shown when project is not waiting for review) -->
|
||||
|
|
@ -401,6 +424,15 @@
|
|||
<p class="text-purple-700">{project.aiDescription}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if project.reviewerNotes}
|
||||
<div class="mb-4 rounded-lg border-2 border-dashed border-blue-400 bg-blue-50 p-4">
|
||||
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-blue-600">
|
||||
<MessageSquare size={14} />
|
||||
notes from author
|
||||
</p>
|
||||
<p class="text-blue-700">{project.reviewerNotes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
{#if deductedHours > 0}
|
||||
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold text-gray-400 line-through"
|
||||
|
|
@ -563,7 +595,7 @@
|
|||
<div class="mb-6 rounded-2xl border-4 border-black p-6">
|
||||
<h2 class="mb-4 text-xl font-bold">previous reviews</h2>
|
||||
<div class="space-y-4">
|
||||
{#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)}
|
||||
<div
|
||||
class="rounded-lg border-2 border-black p-4 transition-all duration-200 hover:border-dashed"
|
||||
|
|
@ -596,7 +628,7 @@
|
|||
{review.action === 'permanently_rejected' ? 'rejected' : review.action === 'scraps_unawarded' ? 'scraps unawarded' : review.action}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{new Date(review.createdAt).toLocaleDateString()}
|
||||
{new Date(review.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
182
frontend/src/routes/admin/second-pass/+page.svelte
Normal file
182
frontend/src/routes/admin/second-pass/+page.svelte
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
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';
|
||||
import { formatHours } from '$lib/utils';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
userId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string | null;
|
||||
status: string;
|
||||
hours: number;
|
||||
effectiveHours: number;
|
||||
deductedHours: number;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
slackId: string | null;
|
||||
scraps: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let user = $state<User | null>(null);
|
||||
let projects = $state<Project[]>([]);
|
||||
let pagination = $state<Pagination | null>(null);
|
||||
let loading = $state(true);
|
||||
let sortOrder = $state<'oldest' | 'newest'>('oldest');
|
||||
let scraps = $derived(user?.scraps ?? 0);
|
||||
|
||||
async function fetchSecondPass(page = 1) {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/second-pass?page=${page}&limit=12&sort=${sortOrder}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
projects = data.data || [];
|
||||
pagination = data.pagination;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch second-pass reviews:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
user = await getUser();
|
||||
if (!user || user.role !== 'admin') {
|
||||
goto('/dashboard');
|
||||
return;
|
||||
}
|
||||
await fetchSecondPass();
|
||||
});
|
||||
|
||||
function goToPage(page: number) {
|
||||
fetchSecondPass(page);
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
sortOrder = sortOrder === 'oldest' ? 'newest' : 'oldest';
|
||||
fetchSecondPass(1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>second pass reviews - admin - scraps</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-6 pt-24 pb-32 md:px-12">
|
||||
<a
|
||||
href="/admin"
|
||||
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
back to admin
|
||||
</a>
|
||||
|
||||
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 class="mb-2 text-4xl font-bold md:text-5xl">second pass reviews</h1>
|
||||
<p class="text-lg text-gray-600">projects approved by reviewers, awaiting admin confirmation</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={toggleSort}
|
||||
class="flex cursor-pointer items-center gap-2 rounded-full border-2 border-black px-4 py-2 text-sm font-bold transition-all hover:border-dashed"
|
||||
>
|
||||
<ArrowUpDown size={16} />
|
||||
sort: {sortOrder === 'oldest' ? 'oldest first' : 'newest first'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="py-12 text-center text-gray-500">loading...</div>
|
||||
{:else if projects.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<p class="text-xl text-gray-500">no projects pending second-pass review</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each projects as project}
|
||||
<a
|
||||
href="/admin/second-pass/{project.id}"
|
||||
class="flex flex-col overflow-hidden rounded-2xl border-4 border-yellow-500 bg-yellow-50 transition-all hover:border-dashed"
|
||||
>
|
||||
<div class="h-40 overflow-hidden">
|
||||
{#if project.image}
|
||||
<img src={project.image} alt={project.name} class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<ProjectPlaceholder seed={project.id} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="mb-1 text-xl font-bold">{project.name}</h3>
|
||||
<p class="mb-2 line-clamp-2 text-sm text-gray-600">{project.description}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if project.deductedHours > 0}
|
||||
<span class="rounded-full bg-gray-100 px-3 py-1 text-sm font-bold text-gray-400 line-through"
|
||||
>{formatHours(project.hours)}h</span
|
||||
>
|
||||
<span class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-800"
|
||||
>{formatHours(project.effectiveHours)}h</span
|
||||
>
|
||||
{:else}
|
||||
<span class="rounded-full bg-gray-100 px-3 py-1 text-sm font-bold"
|
||||
>{formatHours(project.hours)}h</span
|
||||
>
|
||||
{/if}
|
||||
<span class="rounded-full bg-yellow-200 px-3 py-1 text-xs font-bold text-yellow-800">
|
||||
PENDING APPROVAL
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if pagination && pagination.totalPages > 1}
|
||||
<div class="mt-8 flex items-center justify-center gap-4">
|
||||
<button
|
||||
onclick={() => goToPage(pagination!.page - 1)}
|
||||
disabled={pagination.page <= 1}
|
||||
class="cursor-pointer rounded-full border-2 border-black p-2 transition-all hover:border-dashed disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<span class="font-bold">
|
||||
Page
|
||||
{pagination.page}
|
||||
of
|
||||
{pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => goToPage(pagination!.page + 1)}
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
class="cursor-pointer rounded-full border-2 border-black p-2 transition-all hover:border-dashed disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
634
frontend/src/routes/admin/second-pass/[id]/+page.svelte
Normal file
634
frontend/src/routes/admin/second-pass/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Github,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info,
|
||||
Globe,
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
Bot,
|
||||
MessageSquare
|
||||
} from '@lucide/svelte';
|
||||
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
|
||||
import { getUser } from '$lib/auth-client';
|
||||
import { API_URL } from '$lib/config';
|
||||
import { formatHours } from '$lib/utils';
|
||||
|
||||
interface Review {
|
||||
id: number;
|
||||
action: string;
|
||||
feedbackForAuthor: string;
|
||||
internalJustification: string | null;
|
||||
reviewerName: string | null;
|
||||
reviewerAvatar: string | null;
|
||||
reviewerId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ProjectUser {
|
||||
id: number;
|
||||
username: string | null;
|
||||
avatar: string | null;
|
||||
internalNotes: string | null;
|
||||
}
|
||||
|
||||
interface OverlappingProject {
|
||||
id: number;
|
||||
name: string;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
userId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string | null;
|
||||
githubUrl: string | null;
|
||||
playableUrl: string | null;
|
||||
hackatimeProject: string | null;
|
||||
status: string;
|
||||
hours: number;
|
||||
hoursOverride: number | null;
|
||||
tier: number;
|
||||
tierOverride: number | null;
|
||||
feedbackSource: string | null;
|
||||
feedbackGood: string | null;
|
||||
feedbackImprove: string | null;
|
||||
updateDescription: string | null;
|
||||
aiDescription: string | null;
|
||||
reviewerNotes: string | null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
slackId: string | null;
|
||||
scraps: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let user = $state<User | null>(null);
|
||||
let project = $state<Project | null>(null);
|
||||
let projectUser = $state<ProjectUser | null>(null);
|
||||
let reviews = $state<Review[]>([]);
|
||||
let overlappingProjects = $state<OverlappingProject[]>([]);
|
||||
let loading = $state(true);
|
||||
let submitting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let feedbackForAuthor = $state('');
|
||||
let hoursOverride = $state<number | undefined>(undefined);
|
||||
let confirmAction = $state<'accept' | 'reject' | null>(null);
|
||||
|
||||
let deductedHours = $derived(
|
||||
overlappingProjects.reduce((sum: number, op: OverlappingProject) => sum + op.hours, 0)
|
||||
);
|
||||
let effectiveHours = $derived(
|
||||
project ? Math.max(0, (hoursOverride ?? project.hoursOverride ?? project.hours) - deductedHours) : 0
|
||||
);
|
||||
let hoursOverrideError = $derived(
|
||||
hoursOverride !== undefined && project && hoursOverride > project.hours
|
||||
? `hours override cannot exceed project hours (${formatHours(project.hours)}h)`
|
||||
: null
|
||||
);
|
||||
|
||||
let projectId = $derived(page.params.id);
|
||||
|
||||
// Find the approval review
|
||||
let approvalReview = $derived(reviews.find((r) => r.action === 'approved'));
|
||||
|
||||
onMount(async () => {
|
||||
user = await getUser();
|
||||
if (!user || user.role !== 'admin') {
|
||||
goto('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/second-pass/${projectId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
error = data.error;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
project = data.project;
|
||||
projectUser = data.user;
|
||||
reviews = data.reviews || [];
|
||||
overlappingProjects = data.overlappingProjects || [];
|
||||
if (data.project?.hoursOverride != null) {
|
||||
hoursOverride = data.project.hoursOverride;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch second-pass review:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function requestConfirmation(action: 'accept' | 'reject') {
|
||||
if (action === 'reject' && !feedbackForAuthor.trim()) {
|
||||
error = 'feedback for author is required when rejecting';
|
||||
return;
|
||||
}
|
||||
error = null;
|
||||
confirmAction = action;
|
||||
}
|
||||
|
||||
function cancelConfirmation() {
|
||||
confirmAction = null;
|
||||
}
|
||||
|
||||
async function submitReview() {
|
||||
if (!confirmAction) return;
|
||||
|
||||
if (confirmAction === 'reject' && !feedbackForAuthor.trim()) {
|
||||
error = 'feedback for author is required when rejecting';
|
||||
return;
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/second-pass/${projectId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
action: confirmAction,
|
||||
feedbackForAuthor: confirmAction === 'reject' ? feedbackForAuthor : undefined,
|
||||
hoursOverride: hoursOverride !== undefined ? hoursOverride : undefined
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
goto('/admin/second-pass');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'failed to submit review';
|
||||
} finally {
|
||||
submitting = false;
|
||||
confirmAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getReviewIcon(action: string) {
|
||||
switch (action) {
|
||||
case 'approved':
|
||||
return CheckCircle;
|
||||
case 'denied':
|
||||
return AlertTriangle;
|
||||
case 'permanently_rejected':
|
||||
case 'scraps_unawarded':
|
||||
return XCircle;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
}
|
||||
|
||||
function getReviewIconColor(action: string) {
|
||||
switch (action) {
|
||||
case 'approved':
|
||||
return 'text-green-600';
|
||||
case 'denied':
|
||||
return 'text-yellow-600';
|
||||
case 'permanently_rejected':
|
||||
case 'scraps_unawarded':
|
||||
return 'text-red-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>second pass review - admin - scraps</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-6 pt-24 pb-32 md:px-12">
|
||||
{#if loading}
|
||||
<div class="py-12 text-center text-gray-500">loading...</div>
|
||||
{:else if !project}
|
||||
<div class="py-12 text-center">
|
||||
<p class="mb-4 text-xl text-gray-500">project not found</p>
|
||||
<a
|
||||
href="/admin/second-pass"
|
||||
class="inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
back to second pass reviews
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
href="/admin/second-pass"
|
||||
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
back to second pass reviews
|
||||
</a>
|
||||
|
||||
<!-- Status Banner (yellow alert for pending admin approval) -->
|
||||
<div class="mb-6 rounded-2xl border-4 border-yellow-500 bg-yellow-50 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertTriangle size={20} class="text-yellow-600" />
|
||||
<div>
|
||||
<p class="font-bold text-yellow-800">pending admin approval</p>
|
||||
<p class="text-sm text-yellow-700">
|
||||
this project was approved by a reviewer and awaits your final confirmation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/projects/{project.id}"
|
||||
class="font-bold text-yellow-600 underline hover:text-black"
|
||||
>view project</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Image -->
|
||||
<div class="mb-6 h-64 w-full overflow-hidden rounded-2xl border-4 border-black md:h-80">
|
||||
{#if project.image}
|
||||
<img src={project.image} alt={project.name} class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<ProjectPlaceholder seed={project.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Project Info -->
|
||||
<div class="mb-6 rounded-2xl border-4 border-black p-6">
|
||||
<div class="mb-2 flex items-start justify-between">
|
||||
<h1 class="text-3xl font-bold">{project.name}</h1>
|
||||
<span
|
||||
class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-800"
|
||||
>
|
||||
pending approval
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-4 text-gray-600">{project.description}</p>
|
||||
{#if project.updateDescription}
|
||||
<div class="mb-4 rounded-lg border-2 border-dashed border-gray-400 bg-gray-50 p-4">
|
||||
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-gray-600">
|
||||
<RefreshCw size={14} />
|
||||
what was updated
|
||||
</p>
|
||||
<p class="text-gray-700">{project.updateDescription}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if project.aiDescription}
|
||||
<div class="mb-4 rounded-lg border-2 border-dashed border-purple-400 bg-purple-50 p-4">
|
||||
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-purple-600">
|
||||
<Bot size={14} />
|
||||
ai was used
|
||||
</p>
|
||||
<p class="text-purple-700">{project.aiDescription}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if project.reviewerNotes}
|
||||
<div class="mb-4 rounded-lg border-2 border-dashed border-blue-400 bg-blue-50 p-4">
|
||||
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-blue-600">
|
||||
<MessageSquare size={14} />
|
||||
notes from author
|
||||
</p>
|
||||
<p class="text-blue-700">{project.reviewerNotes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
{#if deductedHours > 0}
|
||||
<span
|
||||
class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold text-gray-400 line-through"
|
||||
>{formatHours(project.hours)}h logged</span
|
||||
>
|
||||
<span
|
||||
class="rounded-full border-2 border-yellow-500 bg-yellow-100 px-3 py-1 font-bold text-yellow-800"
|
||||
>{formatHours(effectiveHours)}h effective</span
|
||||
>
|
||||
{:else}
|
||||
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold"
|
||||
>{formatHours(project.hours)}h logged</span
|
||||
>
|
||||
{/if}
|
||||
{#if project.hackatimeProject}
|
||||
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold"
|
||||
>hackatime: {project.hackatimeProject}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold"
|
||||
>tier {project.tier}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if overlappingProjects.length > 0}
|
||||
<div class="mt-4 rounded-lg border-2 border-dashed border-yellow-500 bg-yellow-50 p-4">
|
||||
<p class="mb-2 flex items-center gap-1.5 text-sm font-bold text-yellow-700">
|
||||
<AlertTriangle size={14} />
|
||||
shared hackatime project — hours will be deducted
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-yellow-700">
|
||||
this project shares a hackatime project with other shipped projects. hours from those
|
||||
projects will be subtracted when calculating scraps.
|
||||
</p>
|
||||
<ul class="mb-3 space-y-1 text-sm text-yellow-800">
|
||||
{#each overlappingProjects as op}
|
||||
<li class="flex items-center gap-2">
|
||||
<span>•</span>
|
||||
<a href="/projects/{op.id}" class="font-bold underline">{op.name}</a>
|
||||
<span>— {formatHours(op.hours)}h</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="flex flex-wrap gap-3 text-sm font-bold">
|
||||
<span
|
||||
class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-yellow-800"
|
||||
>
|
||||
total: {formatHours(project.hoursOverride ?? project.hours)}h
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-yellow-800"
|
||||
>
|
||||
deducted: -{formatHours(deductedHours)}h
|
||||
</span>
|
||||
<span class="rounded-full border-2 border-black bg-yellow-200 px-3 py-1 text-black">
|
||||
effective: {formatHours(effectiveHours)}h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
{#if project.githubUrl}
|
||||
<a
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
|
||||
>
|
||||
<Github size={18} />
|
||||
<span>view on github</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
|
||||
>
|
||||
<Github size={18} />
|
||||
<span>view on github</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if project.playableUrl}
|
||||
<a
|
||||
href={project.playableUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-solid border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
|
||||
>
|
||||
<Globe size={18} />
|
||||
<span>try it out</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
|
||||
>
|
||||
<Globe size={18} />
|
||||
<span>try it out</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User Info (clickable) -->
|
||||
{#if projectUser}
|
||||
<a
|
||||
href="/admin/users/{projectUser.id}"
|
||||
class="-mx-6 mt-6 -mb-6 flex cursor-pointer items-center gap-4 border-t-2 border-gray-200 px-6 pt-6 pb-6 transition-all hover:bg-gray-50"
|
||||
>
|
||||
{#if projectUser.avatar}
|
||||
<img
|
||||
src={projectUser.avatar}
|
||||
alt=""
|
||||
class="h-12 w-12 rounded-full border-2 border-black"
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-12 w-12 rounded-full border-2 border-black bg-gray-200"></div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<p class="font-bold">{projectUser.username || 'unknown'}</p>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">view profile →</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Author Feedback -->
|
||||
{#if project.feedbackSource || project.feedbackGood || project.feedbackImprove}
|
||||
<div class="mb-6 rounded-2xl border-4 border-black bg-white p-6">
|
||||
<h2 class="mb-4 text-xl font-bold">author feedback</h2>
|
||||
<div class="space-y-4">
|
||||
{#if project.feedbackSource}
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-bold text-gray-500">how did you hear about this?</p>
|
||||
<p class="text-gray-700">{project.feedbackSource}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if project.feedbackGood}
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-bold text-gray-500">what are we doing well?</p>
|
||||
<p class="text-gray-700">{project.feedbackGood}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if project.feedbackImprove}
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-bold text-gray-500">how can we improve?</p>
|
||||
<p class="text-gray-700">{project.feedbackImprove}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reviewer's Approval -->
|
||||
{#if approvalReview}
|
||||
<div class="mb-6 rounded-2xl border-4 border-green-500 bg-green-50 p-6">
|
||||
<h2 class="mb-4 text-xl font-bold text-green-800">reviewer approval</h2>
|
||||
<div
|
||||
class="rounded-lg border-2 border-green-600 bg-white p-4 transition-all duration-200"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<a
|
||||
href="/admin/users/{approvalReview.reviewerId}"
|
||||
class="flex cursor-pointer items-center gap-2 transition-all duration-200 hover:opacity-80"
|
||||
>
|
||||
{#if approvalReview.reviewerAvatar}
|
||||
<img
|
||||
src={approvalReview.reviewerAvatar}
|
||||
alt=""
|
||||
class="h-6 w-6 rounded-full border-2 border-black"
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-6 w-6 rounded-full border-2 border-black bg-gray-200"></div>
|
||||
{/if}
|
||||
<CheckCircle size={18} class="text-green-600" />
|
||||
<span class="font-bold">{approvalReview.reviewerName || 'reviewer'}</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded border border-green-600 bg-green-100 px-2 py-1 text-xs font-bold text-green-700">
|
||||
approved
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{new Date(approvalReview.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-2 text-sm text-gray-700">
|
||||
<strong>feedback:</strong>
|
||||
{approvalReview.feedbackForAuthor}
|
||||
</p>
|
||||
{#if approvalReview.internalJustification}
|
||||
<p class="text-sm text-gray-500">
|
||||
<strong>internal:</strong>
|
||||
{approvalReview.internalJustification}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User Internal Notes (read-only) -->
|
||||
{#if projectUser?.internalNotes}
|
||||
<div class="mb-6 rounded-2xl border-4 border-black bg-white p-6">
|
||||
<h2 class="mb-4 text-xl font-bold">user internal notes</h2>
|
||||
<div class="rounded-lg border-2 border-gray-300 bg-gray-50 p-4">
|
||||
<p class="whitespace-pre-wrap text-sm text-gray-700">{projectUser.internalNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mb-6 rounded-lg border-2 border-red-500 bg-red-100 p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Review Form -->
|
||||
<div class="rounded-2xl border-4 border-black p-6">
|
||||
<h2 class="mb-4 text-xl font-bold">admin decision</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-bold">hours override {#if deductedHours > 0}<span class="font-normal text-yellow-600">(effective: {formatHours(effectiveHours)}h after -{formatHours(deductedHours)}h deduction)</span>{/if}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max={project.hours}
|
||||
bind:value={hoursOverride}
|
||||
placeholder="{formatHours(project.hoursOverride ?? project.hours)}h ({formatHours(effectiveHours)}h effective)"
|
||||
class="w-full rounded-lg border-2 px-4 py-2 focus:border-dashed focus:outline-none {hoursOverrideError
|
||||
? 'border-red-500'
|
||||
: 'border-black'}"
|
||||
/>
|
||||
{#if hoursOverrideError}
|
||||
<p class="mt-1 text-sm text-red-500">{hoursOverrideError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-bold">
|
||||
rejection feedback <span class="text-gray-500">(required only if rejecting)</span>
|
||||
</label>
|
||||
<textarea
|
||||
bind:value={feedbackForAuthor}
|
||||
rows="4"
|
||||
placeholder="explain why you're rejecting this approval (user will see this)"
|
||||
class="w-full resize-none rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
onclick={() => requestConfirmation('accept')}
|
||||
disabled={submitting || !!hoursOverrideError}
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black bg-green-600 px-4 py-3 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Check size={20} />
|
||||
<span>accept & ship</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => requestConfirmation('reject')}
|
||||
disabled={submitting || !!hoursOverrideError}
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black bg-red-600 px-4 py-3 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<X size={20} />
|
||||
<span>reject approval</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
{#if confirmAction}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={(e) => e.target === e.currentTarget && cancelConfirmation()}
|
||||
onkeydown={(e) => e.key === 'Escape' && cancelConfirmation()}
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold">
|
||||
confirm {confirmAction === 'accept' ? 'acceptance' : 'rejection'}
|
||||
</h2>
|
||||
<p class="mb-6 text-gray-600">
|
||||
{#if confirmAction === 'accept'}
|
||||
are you sure you want to <strong>accept</strong> this approval and ship the project? the
|
||||
user will be notified and scraps will be awarded.
|
||||
{:else}
|
||||
are you sure you want to <strong>reject</strong> this approval? the original approval review
|
||||
will be deleted and the user will need to resubmit.
|
||||
{/if}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={cancelConfirmation}
|
||||
disabled={submitting}
|
||||
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={submitReview}
|
||||
disabled={submitting}
|
||||
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50 {confirmAction ===
|
||||
'accept'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-red-600 text-white'}"
|
||||
>
|
||||
{submitting
|
||||
? 'processing...'
|
||||
: confirmAction === 'accept'
|
||||
? 'accept & ship'
|
||||
: 'reject approval'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -452,12 +452,13 @@
|
|||
{$t.project.awaitingReview}
|
||||
</span>
|
||||
{:else if project.status === 'shipped'}
|
||||
<span
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-full border-4 border-black bg-gray-200 px-4 py-3 text-center text-sm font-bold text-gray-600 sm:px-6 sm:text-base"
|
||||
<a
|
||||
href="/projects/{project.id}/submit"
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black bg-black px-4 py-3 text-sm font-bold text-white transition-all duration-200 hover:bg-gray-800 sm:px-6 sm:text-base"
|
||||
>
|
||||
<Send size={18} />
|
||||
{$t.project.shipped}
|
||||
</span>
|
||||
<RefreshCw size={18} />
|
||||
ship update
|
||||
</a>
|
||||
{:else if project.status === 'permanently_rejected'}
|
||||
<span
|
||||
class="flex flex-1 cursor-not-allowed items-center justify-center gap-2 rounded-full border-4 border-black bg-red-100 px-4 py-3 text-center text-sm font-bold text-red-600 sm:px-6 sm:text-base"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
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}
|
||||
</div>
|
||||
|
||||
<!-- Notes for Reviewer -->
|
||||
<div class="mt-6">
|
||||
<label for="reviewerNotes" class="mb-2 flex items-center gap-1.5 text-sm font-bold">
|
||||
<MessageSquare size={14} />
|
||||
notes for reviewer
|
||||
<span class="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="reviewerNotes"
|
||||
bind:value={reviewerNotes}
|
||||
rows="3"
|
||||
placeholder="anything you'd like the reviewer to know about this project..."
|
||||
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-8 flex gap-4">
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
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 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>submit {project?.name || 'project'} - scraps</title>
|
||||
<title>{isShippedUpdate ? 'ship update' : 'submit'} {project?.name || 'project'} - scraps</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
|
||||
|
|
@ -307,9 +320,9 @@
|
|||
</div>
|
||||
{:else if project}
|
||||
<div class="rounded-2xl border-4 border-black bg-white p-6">
|
||||
<h1 class="mb-2 text-3xl font-bold">{$t.project.submitForReview}</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold">{isShippedUpdate ? 'ship update' : $t.project.submitForReview}</h1>
|
||||
<p class="mb-6 text-gray-600">
|
||||
{$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}
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
|
|
@ -536,6 +549,76 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Description (required for shipped project updates) -->
|
||||
{#if isShippedUpdate}
|
||||
<div class="mb-6 rounded-lg border-2 border-dashed border-gray-400 bg-gray-50 p-4">
|
||||
<p class="mb-3 flex items-center gap-1.5 text-sm font-bold text-gray-600">
|
||||
<RefreshCw size={14} />
|
||||
this is an update to a shipped project
|
||||
</p>
|
||||
<div>
|
||||
<label for="updateDescription" class="mb-2 block text-sm font-bold"
|
||||
>what did you update? <span class="text-red-500">*</span></label
|
||||
>
|
||||
<textarea
|
||||
id="updateDescription"
|
||||
bind:value={updateDescription}
|
||||
rows="3"
|
||||
placeholder="describe what you changed or added since the last ship..."
|
||||
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
|
||||
></textarea>
|
||||
{#if updateDescription.trim().length === 0}
|
||||
<p class="mt-1 text-xs text-red-500">please describe what you updated</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- AI Usage -->
|
||||
<div class="mb-6 space-y-4">
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={usedAi}
|
||||
class="h-5 w-5 cursor-pointer accent-black"
|
||||
/>
|
||||
<span class="text-sm font-bold">i used ai in this project</span>
|
||||
</label>
|
||||
{#if usedAi}
|
||||
<div class="rounded-lg border-2 border-dashed border-purple-400 bg-purple-50 p-4">
|
||||
<p class="mb-2 flex items-center gap-1.5 text-sm font-bold text-purple-600">
|
||||
<Bot size={14} />
|
||||
how was ai used? <span class="text-red-500">*</span>
|
||||
</p>
|
||||
<textarea
|
||||
bind:value={aiDescription}
|
||||
rows="3"
|
||||
placeholder="describe how you used ai in this project..."
|
||||
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
|
||||
></textarea>
|
||||
{#if aiDescription.trim().length === 0}
|
||||
<p class="mt-1 text-xs text-red-500">please describe how ai was used</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notes for Reviewer -->
|
||||
<div class="mb-6">
|
||||
<label for="reviewerNotes" class="mb-2 flex items-center gap-1.5 text-sm font-bold">
|
||||
<MessageSquare size={14} />
|
||||
notes for reviewer
|
||||
<span class="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="reviewerNotes"
|
||||
bind:value={reviewerNotes}
|
||||
rows="3"
|
||||
placeholder="anything you'd like the reviewer to know about this project..."
|
||||
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Feedback -->
|
||||
<div class="mb-6 space-y-4">
|
||||
<div>
|
||||
|
|
@ -654,6 +737,30 @@
|
|||
>{$t.project.hackatimeProjectSelected}</span
|
||||
>
|
||||
</li>
|
||||
{#if isShippedUpdate}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border-2 border-black {hasUpdateDescription
|
||||
? 'bg-black text-white'
|
||||
: ''}"
|
||||
>
|
||||
{#if hasUpdateDescription}<Check size={12} />{/if}
|
||||
</span>
|
||||
<span class={hasUpdateDescription ? '' : 'text-gray-500'}>update description provided</span>
|
||||
</li>
|
||||
{/if}
|
||||
{#if usedAi}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border-2 border-black {hasAiDescription
|
||||
? 'bg-black text-white'
|
||||
: ''}"
|
||||
>
|
||||
{#if hasAiDescription}<Check size={12} />{/if}
|
||||
</span>
|
||||
<span class={hasAiDescription ? '' : 'text-gray-500'}>ai usage described</span>
|
||||
</li>
|
||||
{/if}
|
||||
{#if !hasSubmittedFeedbackBefore}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
|
|
@ -684,8 +791,12 @@
|
|||
disabled={submitting || !allRequirementsMet}
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full bg-black px-4 py-3 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Send size={18} />
|
||||
{submitting ? $t.project.submitting : $t.project.submitForReview}
|
||||
{#if isShippedUpdate}
|
||||
<RefreshCw size={18} />
|
||||
{:else}
|
||||
<Send size={18} />
|
||||
{/if}
|
||||
{submitting ? $t.project.submitting : isShippedUpdate ? 'ship update' : $t.project.submitForReview}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue