Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,4 @@ vite.config.ts.timestamp-*
# Next JS Typescript Env file
# see https://nextjs.org/docs/app/api-reference/config/typescript#next-envdts for additional reference
next-env.d.ts
.playwright-mcp/page-2026-08-25T17-28-50-591Z.yml
334 changes: 334 additions & 0 deletions app/(authenticated)/admin/verification-requests/page.tsx

Large diffs are not rendered by default.

134 changes: 120 additions & 14 deletions app/(authenticated)/dashboard/page.tsx

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions app/(public)/tools/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";

import "github-markdown-css/github-markdown-light.css";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import "github-markdown-css/github-markdown-light.css";

import { Container } from "@/components/Container";
import { VerifiedCheckmark } from "@/components/VerifiedCheckmark";
import { FadeIn } from "@/components/animations";

interface Tool {
Expand All @@ -29,6 +30,7 @@ interface Tool {
lastUpdated?: string;
repository?: string;
website?: string;
tool_maturity?: { status: "unverified" | "verified" };
}

// Mock data for a tool (will be replaced with Supabase data)
Expand Down Expand Up @@ -166,6 +168,7 @@ export default function ToolDetailsPage() {
mau: (toolData.tool_analytics as any)?.mau || 0,
repository: (toolData as any).repository,
website: (toolData as any).website,
tool_maturity: toolData.tool_maturity || { status: "unverified" },
});
} else if (mockTools[toolId]) {
setTool(mockTools[toolId]);
Expand Down Expand Up @@ -245,7 +248,10 @@ export default function ToolDetailsPage() {
<span className="text-4xl sm:text-5xl">{tool.icon || "📦"}</span>
)}
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">{tool.name}</h1>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">{tool.name}</h1>
{tool.tool_maturity?.status === "verified" && <VerifiedCheckmark className="h-6 w-6 sm:h-7 sm:w-7" />}
</div>
</div>
<div className="flex flex-wrap gap-2 sm:gap-3">
{tool.categories.map((category) => (
Expand Down
13 changes: 10 additions & 3 deletions app/(public)/tools/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { Container } from "@/components/Container";
import { VerifiedCheckmark } from "@/components/VerifiedCheckmark";
import { FadeIn, SlideIn } from "@/components/animations";
import Image from "next/image";
import Link from "next/link";
Expand All @@ -16,6 +17,7 @@ interface Tool {
rating: number;
contributors: string[];
mau?: number;
tool_maturity?: { status: "unverified" | "verified" };
}

// Mock data for tools (fallback if API fails)
Expand Down Expand Up @@ -110,6 +112,7 @@ export default function ToolsPage() {
tool_analytics?: unknown;
tool_categories?: Array<{ categories: unknown }>;
tool_contributors?: Array<{ contributors: unknown }>;
tool_maturity?: { status: "unverified" | "verified" };
}) => ({
id: tool.id,
name: tool.name,
Expand All @@ -120,6 +123,7 @@ export default function ToolsPage() {
downloads: (tool.tool_analytics as { downloads: number; rating: number; mau?: number })?.downloads || 0,
rating: (tool.tool_analytics as { downloads: number; rating: number; mau?: number })?.rating || 0,
mau: (tool.tool_analytics as { downloads: number; rating: number; mau?: number })?.mau || 0,
tool_maturity: tool.tool_maturity || { status: "unverified" },
}),
);
setTools(transformed);
Expand Down Expand Up @@ -266,9 +270,12 @@ export default function ToolsPage() {
)}
</div>
<div className="flex-1 flex flex-col justify-between">
<p className="text-lg font-semibold text-slate-900 group-hover:text-gradient transition-colors line-clamp-2" title={tool.name}>
{tool.name}
</p>
<div className="flex items-start gap-1.5">
<p className="text-lg font-semibold text-slate-900 group-hover:text-gradient transition-colors line-clamp-2" title={tool.name}>
{tool.name}
</p>
{tool.tool_maturity?.status === "verified" && <VerifiedCheckmark className="mt-1 h-4 w-4" />}
</div>
<p className="text-xs text-slate-600">
by {tool.contributors.slice(0, 2).join(", ")}
{tool.contributors.length > 2 && ` +${tool.contributors.length - 2}`}
Expand Down
258 changes: 258 additions & 0 deletions app/api/admin/verification-requests/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { sendEmail } from "@/lib/resend";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";

function getSupabaseClient() {
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
return supabaseUrl && supabaseServiceKey ? createClient(supabaseUrl, supabaseServiceKey) : null;
}

async function authenticateAdmin(request: NextRequest, supabase: SupabaseClient) {
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) return null;

const {
data: { user },
error,
} = await supabase.auth.getUser(authHeader.slice(7));
if (error || !user) return null;

const { data: role } = await supabase.from("user_roles").select("role").eq("user_id", user.id).eq("role", "admin").maybeSingle();
return role ? user : null;
}

export async function GET(request: NextRequest) {
try {
const supabase = getSupabaseClient();
if (!supabase) return NextResponse.json({ error: "Database connection not configured" }, { status: 500 });

const admin = await authenticateAdmin(request, supabase);
if (!admin) return NextResponse.json({ error: "Unauthorized. Admin access required." }, { status: 403 });

const [{ data: requests, error: requestsError }, { data: criteria, error: criteriaError }] = await Promise.all([
supabase
.from("tool_verification_requests")
.select("id, tool_id, developer_id, status, submitted_at, reviewed_by")
.in("status", ["queued", "in_review"])
.order("submitted_at", { ascending: true }),
supabase.from("tool_verification_criteria").select("key, category, label, reviewer_guidance, required, sort_order").order("sort_order", { ascending: true }),
]);
if (requestsError || criteriaError) throw requestsError || criteriaError;

const toolIds = [...new Set((requests || []).map((item) => item.tool_id))];
if (toolIds.length === 0) return NextResponse.json({ requests: [], criteria: criteria || [] });

const [{ data: tools, error: toolsError }, { data: ratings, error: ratingsError }] = await Promise.all([
supabase.from("tools").select("id, name, version, repository, csp_exceptions, tool_analytics(downloads, rating, mau)").in("id", toolIds),
supabase.from("ratings").select("tool_id, rating").in("tool_id", toolIds).gte("rating", 3),
]);
if (toolsError || ratingsError) throw toolsError || ratingsError;

const toolById = new Map((tools || []).map((tool) => [tool.id, tool]));
const qualifyingReviewCount = new Map<string, number>();
ratings?.forEach((rating) => qualifyingReviewCount.set(rating.tool_id, (qualifyingReviewCount.get(rating.tool_id) || 0) + 1));

const enrichedRequests = (requests || []).map((verificationRequest) => {
const tool = toolById.get(verificationRequest.tool_id) as any;
const analytics = Array.isArray(tool?.tool_analytics) ? tool.tool_analytics[0] : tool?.tool_analytics;
const metrics = {
mau: analytics?.mau || 0,
downloads: analytics?.downloads || 0,
qualifyingReviews: qualifyingReviewCount.get(verificationRequest.tool_id) || 0,
};
return {
...verificationRequest,
tool: tool ? { id: tool.id, name: tool.name, version: tool.version, repository: tool.repository } : null,
usageMetrics: metrics,
usageMetricsMet: Number(metrics.mau >= 10) + Number(metrics.downloads >= 50) + Number(metrics.qualifyingReviews >= 1),
};
});

return NextResponse.json({ requests: enrichedRequests, criteria: criteria || [] });
} catch (error) {
console.error("[maturity-admin] Failed to load verification queue", error);
return NextResponse.json({ error: "Failed to load verification queue" }, { status: 500 });
}
}

interface ChecklistResultInput {
criterionKey: string;
passed: boolean;
waived?: boolean;
comment?: string;
}

export async function POST(request: NextRequest) {
try {
const supabase = getSupabaseClient();
if (!supabase) return NextResponse.json({ error: "Database connection not configured" }, { status: 500 });

const admin = await authenticateAdmin(request, supabase);
if (!admin) return NextResponse.json({ error: "Unauthorized. Admin access required." }, { status: 403 });

const body = await request.json();
if (!body.requestId || !["claim", "decide"].includes(body.action)) {
return NextResponse.json({ error: "requestId and a valid action are required" }, { status: 400 });
}

const { data: verificationRequest, error: requestError } = await supabase
.from("tool_verification_requests")
.select("id, tool_id, developer_id, status, reviewed_by")
.eq("id", body.requestId)
.maybeSingle();
if (requestError) throw requestError;
if (!verificationRequest) return NextResponse.json({ error: "Verification request not found" }, { status: 404 });

if (body.action === "claim") {
if (verificationRequest.status === "in_review") {
if (verificationRequest.reviewed_by !== admin.id) {
return NextResponse.json({ error: "This request is already being reviewed by another admin" }, { status: 409 });
}
return NextResponse.json({ success: true, request: verificationRequest });
}
if (verificationRequest.status !== "queued") {
return NextResponse.json({ error: "Only queued requests can be opened" }, { status: 409 });
}

const { data: claimed, error: claimError } = await supabase
.from("tool_verification_requests")
.update({ status: "in_review", reviewed_by: admin.id, updated_at: new Date().toISOString() })
.eq("id", body.requestId)
.eq("status", "queued")
.select("id, tool_id, developer_id, status, reviewed_by")
.maybeSingle();
if (claimError) throw claimError;
if (!claimed) return NextResponse.json({ error: "This request was claimed by another admin" }, { status: 409 });
return NextResponse.json({ success: true, request: claimed });
}

if (verificationRequest.status !== "in_review" || verificationRequest.reviewed_by !== admin.id) {
return NextResponse.json({ error: "Open and claim this request before deciding it" }, { status: 409 });
}
if (!["approve", "reject"].includes(body.decision) || !Array.isArray(body.results)) {
return NextResponse.json({ error: "A valid decision and checklist results are required" }, { status: 400 });
}

const { data: criteria, error: criteriaError } = await supabase.from("tool_verification_criteria").select("key, label, required, sort_order").order("sort_order", { ascending: true });
if (criteriaError) throw criteriaError;

const results = body.results as ChecklistResultInput[];
const resultByKey = new Map(results.map((result) => [result.criterionKey, result]));
if (!criteria || results.length !== criteria.length || criteria.some((criterion) => !resultByKey.has(criterion.key))) {
return NextResponse.json({ error: "Every checklist criterion must have one result" }, { status: 400 });
}
if (results.some((result) => typeof result.passed !== "boolean" || (result.waived && result.criterionKey !== "usage_thresholds"))) {
return NextResponse.json({ error: "Checklist results or waiver are invalid" }, { status: 400 });
}

const { data: tool, error: toolError } = await supabase.from("tools").select("id, name, csp_exceptions, tool_analytics(downloads, mau)").eq("id", verificationRequest.tool_id).single();
if (toolError) throw toolError;

const usageResult = resultByKey.get("usage_thresholds");
const analytics = Array.isArray(tool.tool_analytics) ? tool.tool_analytics[0] : tool.tool_analytics;
const { count: qualifyingReviews, error: ratingCountError } = await supabase.from("ratings").select("id", { count: "exact", head: true }).eq("tool_id", tool.id).gte("rating", 3);
if (ratingCountError) throw ratingCountError;

const usageMetricsMet = Number((analytics?.mau || 0) >= 10) + Number((analytics?.downloads || 0) >= 50) + Number((qualifyingReviews || 0) >= 1);
if (usageResult && !usageResult.waived && usageResult.passed !== usageMetricsMet >= 2) {
return NextResponse.json({ error: `Usage result must reflect the measured thresholds (${usageMetricsMet}/3 met)` }, { status: 400 });
}

if (usageResult?.waived) {
const allOtherCriteriaPass = criteria.every((criterion) => criterion.key === "usage_thresholds" || resultByKey.get(criterion.key)?.passed === true);
const hasNoUsageHistory = (analytics?.mau || 0) === 0 && (analytics?.downloads || 0) === 0 && (qualifyingReviews || 0) === 0;
if (!allOtherCriteriaPass || !hasNoUsageHistory) {
return NextResponse.json({ error: "Usage can only be waived when every other criterion passes and the tool has no prior usage history" }, { status: 400 });
}
}

const failedRequiredCriteria = criteria.filter((criterion) => {
if (!criterion.required) return false;
const result = resultByKey.get(criterion.key)!;
return !result.passed && !(criterion.key === "usage_thresholds" && result.waived);
});
const expectedDecision = failedRequiredCriteria.length === 0 ? "approve" : "reject";
if (body.decision !== expectedDecision) {
return NextResponse.json(
{ error: expectedDecision === "approve" ? "All required criteria pass; this request must be approved" : "A required criterion failed; this request must be rejected" },
{ status: 400 },
);
}

const checklistRows = criteria.map((criterion) => {
const result = resultByKey.get(criterion.key)!;
return {
request_id: verificationRequest.id,
criterion_key: criterion.key,
passed: result.passed,
waived: criterion.key === "usage_thresholds" && Boolean(result.waived),
comment: result.comment?.trim() || null,
reviewed_by: admin.id,
updated_at: new Date().toISOString(),
};
});
const { error: checklistError } = await supabase.from("tool_verification_checklist_results").upsert(checklistRows, { onConflict: "request_id,criterion_key" });
if (checklistError) throw checklistError;

const decidedAt = new Date().toISOString();
if (body.decision === "approve") {
const { error: maturityError } = await supabase.from("tool_maturity").upsert(
{
tool_id: tool.id,
status: "verified",
verified_at: decidedAt,
verified_request_id: verificationRequest.id,
verified_csp_exceptions_snapshot: tool.csp_exceptions,
last_change_reason: "initial_approval",
last_changed_at: decidedAt,
updated_at: decidedAt,
},
{ onConflict: "tool_id" },
);
if (maturityError) throw maturityError;
}

const finalStatus = body.decision === "approve" ? "approved" : "rejected";
const { data: decidedRequest, error: decisionError } = await supabase
.from("tool_verification_requests")
.update({ status: finalStatus, decided_at: decidedAt, updated_at: decidedAt })
.eq("id", verificationRequest.id)
.eq("status", "in_review")
.eq("reviewed_by", admin.id)
.select("id, status, decided_at")
.maybeSingle();
if (decisionError || !decidedRequest) {
if (body.decision === "approve") {
await supabase.from("tool_maturity").update({ status: "unverified", verified_at: null, verified_request_id: null, updated_at: decidedAt }).eq("tool_id", tool.id);
}
throw decisionError || new Error("Verification request changed before the decision was saved");
}

const emailResult =
body.decision === "approve"
? await sendEmail({
type: "verification-approved",
supabase,
data: { developerId: verificationRequest.developer_id, toolName: tool.name },
})
: await sendEmail({
type: "verification-rejected",
supabase,
data: { developerId: verificationRequest.developer_id, toolName: tool.name, failedCriteria: failedRequiredCriteria.map((criterion) => criterion.label) },
});
if (!emailResult.success) console.warn(`[maturity-admin] Decision email was not sent for ${verificationRequest.id}: ${emailResult.error}`);

return NextResponse.json({
success: true,
decision: finalStatus,
request: decidedRequest,
notificationSent: emailResult.success,
notificationError: emailResult.success ? null : "Decision saved, but the developer email could not be sent",
});
} catch (error) {
console.error("[maturity-admin] Failed to process verification request", error);
return NextResponse.json({ error: "Failed to process verification request" }, { status: 500 });
}
}
Loading
Loading