Skip to content

Commit d77a373

Browse files
Miriaddashboard
andcommitted
fix: address PR review — fail-closed auth, input validation, rendering stage
- All API routes now fail closed (503 if Supabase not configured) - Settings PUT validates and whitelists fields (videosPerWeek, publishDays, contentCategories, rateCardTiers) with type checking - Uses createIfNotExists with deterministic ID for singleton safety - Added missing 'rendering' pipeline stage (8 stages total) - Consolidated GROQ queries (7 parallel calls → 1 query) - Added AbortController cleanup to pipeline polling - Use shared POLL_INTERVAL_MS constant Co-authored-by: dashboard <dashboard@miriad.systems>
1 parent 58af6e2 commit d77a373

File tree

5 files changed

+192
-96
lines changed

5 files changed

+192
-96
lines changed

app/api/dashboard/activity/route.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ export async function GET() {
1010
process.env.NEXT_PUBLIC_SUPABASE_URL &&
1111
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
1212

13-
if (hasSupabase) {
14-
const supabase = await createClient();
15-
const {
16-
data: { user },
17-
} = await supabase.auth.getUser();
18-
if (!user) {
19-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
20-
}
13+
if (!hasSupabase) {
14+
return NextResponse.json({ error: "Auth not configured" }, { status: 503 });
15+
}
16+
17+
const supabase = await createClient();
18+
const {
19+
data: { user },
20+
} = await supabase.auth.getUser();
21+
22+
if (!user) {
23+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
2124
}
2225

2326
try {

app/api/dashboard/metrics/route.ts

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,37 +10,31 @@ export async function GET() {
1010
process.env.NEXT_PUBLIC_SUPABASE_URL &&
1111
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
1212

13-
if (hasSupabase) {
14-
const supabase = await createClient();
15-
const {
16-
data: { user },
17-
} = await supabase.auth.getUser();
18-
if (!user) {
19-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
20-
}
13+
if (!hasSupabase) {
14+
return NextResponse.json({ error: "Auth not configured" }, { status: 503 });
15+
}
16+
17+
const supabase = await createClient();
18+
const {
19+
data: { user },
20+
} = await supabase.auth.getUser();
21+
22+
if (!user) {
23+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
2124
}
2225

2326
try {
24-
const [videosPublished, flaggedVideos, newIdeas, sponsorPipeline] =
25-
await Promise.all([
26-
dashboardQuery<number>(
27-
`count(*[_type == "automatedVideo" && status == "published"])`,
28-
),
29-
dashboardQuery<number>(
30-
`count(*[_type == "automatedVideo" && status == "flagged"])`,
31-
),
32-
dashboardQuery<number>(
33-
`count(*[_type == "contentIdea" && status == "new"])`,
34-
),
35-
dashboardQuery<number>(
36-
`count(*[_type == "sponsorLead" && status != "paid"])`,
37-
),
38-
]);
27+
const counts = await dashboardQuery<Record<string, number>>(`{
28+
"videosPublished": count(*[_type == "automatedVideo" && status == "published"]),
29+
"flaggedVideos": count(*[_type == "automatedVideo" && status == "flagged"]),
30+
"newIdeas": count(*[_type == "contentIdea" && status == "new"]),
31+
"sponsorPipeline": count(*[_type == "sponsorLead" && status != "paid"])
32+
}`);
3933

4034
const metrics: DashboardMetrics = {
41-
videosPublished: videosPublished ?? 0,
42-
flaggedForReview: (flaggedVideos ?? 0) + (newIdeas ?? 0),
43-
sponsorPipeline: sponsorPipeline ?? 0,
35+
videosPublished: counts?.videosPublished ?? 0,
36+
flaggedForReview: (counts?.flaggedVideos ?? 0) + (counts?.newIdeas ?? 0),
37+
sponsorPipeline: counts?.sponsorPipeline ?? 0,
4438
revenue: null,
4539
};
4640

app/api/dashboard/pipeline/route.ts

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,41 @@ import { dashboardQuery } from "@/lib/sanity/dashboard";
55
export const dynamic = "force-dynamic";
66

77
export async function GET() {
8-
const hasSupabase = process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
9-
if (hasSupabase) {
10-
const supabase = await createClient();
11-
const { data: { user } } = await supabase.auth.getUser();
12-
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8+
const hasSupabase =
9+
process.env.NEXT_PUBLIC_SUPABASE_URL &&
10+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
11+
12+
if (!hasSupabase) {
13+
return NextResponse.json({ error: "Auth not configured" }, { status: 503 });
14+
}
15+
16+
const supabase = await createClient();
17+
const {
18+
data: { user },
19+
} = await supabase.auth.getUser();
20+
21+
if (!user) {
22+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
1323
}
1424

1525
try {
16-
const [draft, scriptReady, audioGen, videoGen, flagged, uploading, published] = await Promise.all([
17-
dashboardQuery<number>(`count(*[_type == "automatedVideo" && status == "draft"])`),
18-
dashboardQuery<number>(`count(*[_type == "automatedVideo" && status == "script_ready"])`),
19-
dashboardQuery<number>(`count(*[_type == "automatedVideo" && status == "audio_gen"])`),
20-
dashboardQuery<number>(`count(*[_type == "automatedVideo" && status == "video_gen"])`),
21-
dashboardQuery<number>(`count(*[_type == "automatedVideo" && status == "flagged"])`),
22-
dashboardQuery<number>(`count(*[_type == "automatedVideo" && status == "uploading"])`),
23-
dashboardQuery<number>(`count(*[_type == "automatedVideo" && status == "published"])`),
24-
]);
26+
// Single consolidated GROQ query for all pipeline stages
27+
const counts = await dashboardQuery<Record<string, number>>(`{
28+
"draft": count(*[_type == "automatedVideo" && status == "draft"]),
29+
"scriptReady": count(*[_type == "automatedVideo" && status == "script_ready"]),
30+
"audioGen": count(*[_type == "automatedVideo" && status == "audio_gen"]),
31+
"rendering": count(*[_type == "automatedVideo" && status == "rendering"]),
32+
"videoGen": count(*[_type == "automatedVideo" && status == "video_gen"]),
33+
"flagged": count(*[_type == "automatedVideo" && status == "flagged"]),
34+
"uploading": count(*[_type == "automatedVideo" && status == "uploading"]),
35+
"published": count(*[_type == "automatedVideo" && status == "published"])
36+
}`);
37+
38+
const total = Object.values(counts ?? {}).reduce((sum, n) => sum + (n ?? 0), 0);
2539

2640
return NextResponse.json({
27-
draft: draft ?? 0,
28-
scriptReady: scriptReady ?? 0,
29-
audioGen: audioGen ?? 0,
30-
videoGen: videoGen ?? 0,
31-
flagged: flagged ?? 0,
32-
uploading: uploading ?? 0,
33-
published: published ?? 0,
34-
total: (draft ?? 0) + (scriptReady ?? 0) + (audioGen ?? 0) + (videoGen ?? 0) + (flagged ?? 0) + (uploading ?? 0) + (published ?? 0),
41+
...counts,
42+
total,
3543
});
3644
} catch (error) {
3745
console.error("Failed to fetch pipeline status:", error);

app/api/dashboard/settings/route.ts

Lines changed: 112 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,105 @@ import { dashboardQuery, dashboardClient } from "@/lib/sanity/dashboard";
44

55
export const dynamic = "force-dynamic";
66

7-
export async function GET() {
8-
// Auth check (skip if Supabase not configured)
9-
const hasSupabase = process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
10-
if (hasSupabase) {
11-
const supabase = await createClient();
12-
const { data: { user } } = await supabase.auth.getUser();
13-
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
7+
const SETTINGS_DOC_ID = "dashboardSettings";
8+
9+
const DEFAULT_SETTINGS = {
10+
videosPerWeek: 3,
11+
publishDays: ["Mon", "Wed", "Fri"],
12+
contentCategories: [
13+
"JavaScript", "TypeScript", "React", "Next.js", "Angular",
14+
"Svelte", "Node.js", "CSS", "DevOps", "AI / ML",
15+
"Web Performance", "Tooling",
16+
],
17+
rateCardTiers: [
18+
{ name: "Pre-roll Mention", description: "15-second sponsor mention", price: 200 },
19+
{ name: "Mid-roll Segment", description: "60-second dedicated segment", price: 500 },
20+
{ name: "Dedicated Video", description: "Full sponsored video", price: 1500 },
21+
],
22+
};
23+
24+
async function requireAuth() {
25+
const hasSupabase =
26+
process.env.NEXT_PUBLIC_SUPABASE_URL &&
27+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
28+
29+
if (!hasSupabase) {
30+
return { error: NextResponse.json({ error: "Auth not configured" }, { status: 503 }) };
31+
}
32+
33+
const supabase = await createClient();
34+
const {
35+
data: { user },
36+
} = await supabase.auth.getUser();
37+
38+
if (!user) {
39+
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
40+
}
41+
42+
return { user };
43+
}
44+
45+
const VALID_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
46+
47+
function validateSettings(body: unknown): { valid: boolean; data?: Record<string, unknown>; error?: string } {
48+
if (!body || typeof body !== "object") {
49+
return { valid: false, error: "Invalid request body" };
50+
}
51+
52+
const input = body as Record<string, unknown>;
53+
const sanitized: Record<string, unknown> = {};
54+
55+
if ("videosPerWeek" in input) {
56+
const v = Number(input.videosPerWeek);
57+
if (!Number.isInteger(v) || v < 1 || v > 14) {
58+
return { valid: false, error: "videosPerWeek must be an integer between 1 and 14" };
59+
}
60+
sanitized.videosPerWeek = v;
61+
}
62+
63+
if ("publishDays" in input) {
64+
if (!Array.isArray(input.publishDays) || !input.publishDays.every((d: unknown) => typeof d === "string" && VALID_DAYS.includes(d as string))) {
65+
return { valid: false, error: "publishDays must be an array of valid day abbreviations" };
66+
}
67+
sanitized.publishDays = input.publishDays;
1468
}
1569

70+
if ("contentCategories" in input) {
71+
if (!Array.isArray(input.contentCategories) || !input.contentCategories.every((c: unknown) => typeof c === "string" && (c as string).length <= 50)) {
72+
return { valid: false, error: "contentCategories must be an array of strings (max 50 chars each)" };
73+
}
74+
sanitized.contentCategories = input.contentCategories;
75+
}
76+
77+
if ("rateCardTiers" in input) {
78+
if (!Array.isArray(input.rateCardTiers)) {
79+
return { valid: false, error: "rateCardTiers must be an array" };
80+
}
81+
for (const tier of input.rateCardTiers as Record<string, unknown>[]) {
82+
if (typeof tier.name !== "string" || typeof tier.description !== "string" || typeof tier.price !== "number") {
83+
return { valid: false, error: "Each rate card tier must have name (string), description (string), and price (number)" };
84+
}
85+
}
86+
sanitized.rateCardTiers = (input.rateCardTiers as Record<string, unknown>[]).map((t) => ({
87+
_type: "object",
88+
_key: crypto.randomUUID().slice(0, 8),
89+
name: t.name,
90+
description: t.description,
91+
price: t.price,
92+
}));
93+
}
94+
95+
if (Object.keys(sanitized).length === 0) {
96+
return { valid: false, error: "No valid fields provided" };
97+
}
98+
99+
return { valid: true, data: sanitized };
100+
}
101+
102+
export async function GET() {
103+
const auth = await requireAuth();
104+
if (auth.error) return auth.error;
105+
16106
try {
17107
const settings = await dashboardQuery(
18108
`*[_type == "dashboardSettings"][0] {
@@ -22,49 +112,38 @@ export async function GET() {
22112
rateCardTiers[] { name, description, price }
23113
}`
24114
);
25-
return NextResponse.json(settings ?? {
26-
videosPerWeek: 3,
27-
publishDays: ["Mon", "Wed", "Fri"],
28-
contentCategories: ["JavaScript", "TypeScript", "React", "Next.js", "Angular", "Svelte", "Node.js", "CSS", "DevOps", "AI / ML", "Web Performance", "Tooling"],
29-
rateCardTiers: [
30-
{ name: "Pre-roll Mention", description: "15-second sponsor mention", price: 200 },
31-
{ name: "Mid-roll Segment", description: "60-second dedicated segment", price: 500 },
32-
{ name: "Dedicated Video", description: "Full sponsored video", price: 1500 },
33-
],
34-
});
115+
return NextResponse.json(settings ?? DEFAULT_SETTINGS);
35116
} catch (error) {
36117
console.error("Failed to fetch settings:", error);
37118
return NextResponse.json({ error: "Failed to fetch settings" }, { status: 500 });
38119
}
39120
}
40121

41122
export async function PUT(request: Request) {
42-
const hasSupabase = process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
43-
if (hasSupabase) {
44-
const supabase = await createClient();
45-
const { data: { user } } = await supabase.auth.getUser();
46-
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
47-
}
123+
const auth = await requireAuth();
124+
if (auth.error) return auth.error;
48125

49126
if (!dashboardClient) {
50127
return NextResponse.json({ error: "Sanity client not available" }, { status: 503 });
51128
}
52129

53130
try {
54131
const body = await request.json();
132+
const validation = validateSettings(body);
55133

56-
// Find or create the settings document
57-
const existing = await dashboardQuery(`*[_type == "dashboardSettings"][0]{ _id }`);
58-
59-
if (existing?._id) {
60-
await dashboardClient.patch(existing._id).set(body).commit();
61-
} else {
62-
await dashboardClient.create({
63-
_type: "dashboardSettings",
64-
...body,
65-
});
134+
if (!validation.valid) {
135+
return NextResponse.json({ error: validation.error }, { status: 400 });
66136
}
67137

138+
// Use createIfNotExists with deterministic ID to prevent race conditions
139+
await dashboardClient.createIfNotExists({
140+
_id: SETTINGS_DOC_ID,
141+
_type: "dashboardSettings",
142+
...DEFAULT_SETTINGS,
143+
});
144+
145+
await dashboardClient.patch(SETTINGS_DOC_ID).set(validation.data!).commit();
146+
68147
return NextResponse.json({ success: true });
69148
} catch (error) {
70149
console.error("Failed to update settings:", error);

components/pipeline-status.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
"use client";
22

3-
import { useCallback, useEffect, useState } from "react";
3+
import { useCallback, useEffect, useRef, useState } from "react";
44
import { Loader2 } from "lucide-react";
5-
6-
const POLL_INTERVAL_MS = 30_000;
5+
import { POLL_INTERVAL_MS } from "@/lib/types/dashboard";
76

87
interface PipelineData {
98
draft: number;
109
scriptReady: number;
1110
audioGen: number;
11+
rendering: number;
1212
videoGen: number;
1313
flagged: number;
1414
uploading: number;
@@ -26,6 +26,7 @@ const STAGES: {
2626
{ key: "draft", label: "Draft", color: "text-gray-700 dark:text-gray-300", bg: "bg-gray-200 dark:bg-gray-700", ring: "ring-gray-300 dark:ring-gray-600" },
2727
{ key: "scriptReady", label: "Script", color: "text-yellow-700 dark:text-yellow-300", bg: "bg-yellow-200 dark:bg-yellow-800", ring: "ring-yellow-300 dark:ring-yellow-600" },
2828
{ key: "audioGen", label: "Audio", color: "text-orange-700 dark:text-orange-300", bg: "bg-orange-200 dark:bg-orange-800", ring: "ring-orange-300 dark:ring-orange-600" },
29+
{ key: "rendering", label: "Render", color: "text-cyan-700 dark:text-cyan-300", bg: "bg-cyan-200 dark:bg-cyan-800", ring: "ring-cyan-300 dark:ring-cyan-600" },
2930
{ key: "videoGen", label: "Video", color: "text-blue-700 dark:text-blue-300", bg: "bg-blue-200 dark:bg-blue-800", ring: "ring-blue-300 dark:ring-blue-600" },
3031
{ key: "flagged", label: "Flagged", color: "text-red-700 dark:text-red-300", bg: "bg-red-200 dark:bg-red-800", ring: "ring-red-300 dark:ring-red-600" },
3132
{ key: "uploading", label: "Upload", color: "text-purple-700 dark:text-purple-300", bg: "bg-purple-200 dark:bg-purple-800", ring: "ring-purple-300 dark:ring-purple-600" },
@@ -35,14 +36,22 @@ const STAGES: {
3536
export function PipelineStatus() {
3637
const [data, setData] = useState<PipelineData | null>(null);
3738
const [loading, setLoading] = useState(true);
39+
const abortRef = useRef<AbortController | null>(null);
3840

3941
const fetchPipeline = useCallback(async () => {
42+
abortRef.current?.abort();
43+
const controller = new AbortController();
44+
abortRef.current = controller;
45+
4046
try {
41-
const res = await fetch("/api/dashboard/pipeline");
47+
const res = await fetch("/api/dashboard/pipeline", {
48+
signal: controller.signal,
49+
});
4250
if (res.ok) {
4351
setData(await res.json());
4452
}
45-
} catch {
53+
} catch (error) {
54+
if (error instanceof DOMException && error.name === "AbortError") return;
4655
// Silently fail — will retry on next poll
4756
} finally {
4857
setLoading(false);
@@ -52,7 +61,10 @@ export function PipelineStatus() {
5261
useEffect(() => {
5362
fetchPipeline();
5463
const interval = setInterval(fetchPipeline, POLL_INTERVAL_MS);
55-
return () => clearInterval(interval);
64+
return () => {
65+
clearInterval(interval);
66+
abortRef.current?.abort();
67+
};
5668
}, [fetchPipeline]);
5769

5870
if (loading) {
@@ -111,7 +123,7 @@ export function PipelineStatus() {
111123
<div key={stage.key} className="flex flex-1 items-center">
112124
<div className={`h-0.5 flex-1 ${stage.bg}`} />
113125
{i < STAGES.length - 1 && (
114-
<span className="text-[10px] text-muted-foreground"></span>
126+
<span className="text-[10px] text-muted-foreground">\u2192</span>
115127
)}
116128
</div>
117129
))}

0 commit comments

Comments
 (0)