Skip to content

Commit 0baad60

Browse files
committed
Add CI workflow for YouTube app and enhance environment configuration
- Introduced a GitHub Actions workflow for continuous integration, focusing on linting, type checking, and testing for the YouTube app. - Updated the `.env.example` file to clarify required and optional environment variables. - Enhanced `package.json` with a new type checking script and coverage testing command. - Improved security headers in `vercel.json` for better protection against common vulnerabilities. - Added a new SQL migration for saga corrections and updated the drizzle metadata. - Refactored various API routes to improve error handling and logging. Made-with: Cursor
1 parent 4057c7e commit 0baad60

59 files changed

Lines changed: 1568 additions & 844 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/youtube-ci.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: YouTube App CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths: ['apps/youtube/**', 'packages/**']
7+
pull_request:
8+
branches: [main]
9+
paths: ['apps/youtube/**', 'packages/**']
10+
11+
jobs:
12+
quality:
13+
name: Lint, Type Check & Test
14+
runs-on: ubuntu-latest
15+
defaults:
16+
run:
17+
working-directory: apps/youtube
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: pnpm/action-setup@v4
23+
24+
- uses: actions/setup-node@v4
25+
with:
26+
node-version: 22
27+
cache: pnpm
28+
29+
- name: Install dependencies
30+
run: pnpm install --frozen-lockfile
31+
working-directory: .
32+
33+
- name: Lint
34+
run: pnpm lint
35+
36+
- name: Type check
37+
run: pnpm typecheck
38+
39+
- name: Test
40+
run: pnpm test

apps/youtube/.env.example

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1-
YOUTUBE_API_KEY=
2-
# Optional: place a cookies.txt file in the project root for YouTube auth (avoids bot detection)your_youtube_api_key_here
1+
# YouTube Data API key (required)
2+
YOUTUBE_API_KEY=your_youtube_api_key_here
33

4+
# AI providers (at least one required)
45
GROQ_API_KEY=your_groq_api_key_here
56
GOOGLE_AI_API_KEY=your_google_ai_api_key_here
67

8+
# Analytics (optional)
79
NEXT_PUBLIC_POSTHOG_KEY=your_posthog_key_here
810

11+
# Database - Neon Postgres (required)
912
POSTGRES_URL=your_neon_postgres_connection_string_here
13+
14+
# Internal sync auth (required)
1015
SYNC_SECRET=your_random_secret_for_internal_sync_calls
1116

12-
# Auth (next-auth v5 + Google OAuth)
17+
# Auth - NextAuth v5 + Google OAuth (required)
1318
AUTH_SECRET=your_random_secret_here_run_npx_auth_secret
1419
AUTH_GOOGLE_ID=your_google_oauth_client_id
1520
AUTH_GOOGLE_SECRET=your_google_oauth_client_secret
21+
22+
# Comma-separated list of emails allowed admin access (required for admin features)
1623
ALLOWED_EMAILS=your_email@gmail.com
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE TABLE "saga_corrections" (
2+
"id" text PRIMARY KEY NOT NULL,
3+
"channel_id" text NOT NULL,
4+
"action" text NOT NULL,
5+
"video_id" text NOT NULL,
6+
"video_title" text NOT NULL,
7+
"video_published_at" timestamp with time zone NOT NULL,
8+
"target_saga_id" text,
9+
"target_saga_name" text,
10+
"previous_saga_id" text,
11+
"previous_saga_name" text,
12+
"neighbor_context" jsonb,
13+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
14+
);
15+
--> statement-breakpoint
16+
ALTER TABLE "saga_corrections" ADD CONSTRAINT "saga_corrections_channel_id_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
17+
CREATE INDEX "saga_corrections_channel_idx" ON "saga_corrections" USING btree ("channel_id");

apps/youtube/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
"when": 1773548075734,
99
"tag": "0000_add-saga-reasoning",
1010
"breakpoints": true
11+
},
12+
{
13+
"idx": 1,
14+
"version": "7",
15+
"when": 1773548075735,
16+
"tag": "0001_saga-corrections",
17+
"breakpoints": true
1118
}
1219
]
1320
}

apps/youtube/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
"build": "next build",
1212
"start": "next start -p 3003",
1313
"lint": "eslint .",
14+
"typecheck": "tsc --noEmit",
1415
"test": "vitest run",
1516
"test:watch": "vitest",
17+
"test:coverage": "vitest run --coverage",
1618
"db:push": "drizzle-kit push",
1719
"db:generate": "drizzle-kit generate",
1820
"db:migrate": "drizzle-kit migrate",
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { Metadata } from "next";
22

33
export const metadata: Metadata = {
4-
title: "Admin",
54
robots: { index: false, follow: false },
5+
title: "Admin - YouTube Analyzer",
66
};
77

88
export default function AdminLayout({
99
children,
10-
}: {
10+
}: Readonly<{
1111
children: React.ReactNode;
12-
}) {
12+
}>) {
1313
return children;
1414
}

apps/youtube/src/app/api/admin/bulk/route.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ async function startBulkSync(channelIds: string[], type: "videos" | "transcripts
2828

2929
if (type === "videos") {
3030
syncChannelVideos(channelId, jobId).catch((err) => {
31-
console.error(`[Bulk Sync] Video sync failed for ${channelId}:`, err);
31+
const e = err instanceof Error ? err : new Error(String(err));
32+
console.error("[Admin Bulk] Video sync failed:", { channelId, message: e.message, stack: e.stack });
3233
});
3334
} else if (type === "transcripts") {
3435
syncChannelTranscripts(channelId, jobId).catch((err) => {
35-
console.error(`[Bulk Sync] Transcript sync failed for ${channelId}:`, err);
36+
const e = err instanceof Error ? err : new Error(String(err));
37+
console.error("[Admin Bulk] Transcript sync failed:", { channelId, message: e.message, stack: e.stack });
3638
});
3739
}
3840
started++;
@@ -86,10 +88,12 @@ export async function POST(request: NextRequest) {
8688
);
8789
}
8890
const { action, channelIds } = parsed.data;
91+
console.log("[Admin Bulk] Request", { action, channelCount: channelIds.length });
8992

9093
if (action === "sync-videos" || action === "sync-transcripts") {
9194
const type = action === "sync-videos" ? "videos" : "transcripts";
9295
const started = await startBulkSync(channelIds, type);
96+
console.log("[Admin Bulk] Result", { action, started, total: channelIds.length });
9397
return NextResponse.json({ action, started, total: channelIds.length });
9498
}
9599

@@ -98,9 +102,11 @@ export async function POST(request: NextRequest) {
98102
totalDeleted += await deleteForChannel(action as CleanupAction, channelId);
99103
}
100104

105+
console.log("[Admin Bulk] Result", { action, deleted: totalDeleted, total: channelIds.length });
101106
return NextResponse.json({ action, deleted: totalDeleted, total: channelIds.length });
102107
} catch (error) {
103-
console.error("[Admin Bulk] Error:", error);
108+
const err = error instanceof Error ? error : new Error(String(error));
109+
console.error("[Admin Bulk] Error:", err.message, err.stack);
104110
return handleRouteError(error);
105111
}
106112
}

apps/youtube/src/app/api/admin/cleanup/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export async function POST(request: NextRequest) {
6666
);
6767
}
6868
const { action, channelId } = parsed.data;
69+
console.log("[Admin Cleanup] Request", { action, channelId });
6970

7071
const handler = ACTIONS[action];
7172
if (!handler) {
@@ -79,9 +80,11 @@ export async function POST(request: NextRequest) {
7980
}
8081

8182
const deleted = await fn(channelId);
83+
console.log("[Admin Cleanup] Result", { action, channelId, deleted });
8284
return NextResponse.json({ action, channelId, deleted });
8385
} catch (error) {
84-
console.error("[Admin Cleanup] Error:", error);
86+
const err = error instanceof Error ? error : new Error(String(error));
87+
console.error("[Admin Cleanup] Error:", err.message, err.stack);
8588
return handleRouteError(error);
8689
}
8790
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { db } from "@/db";
2+
import { channels, sagaCorrections } from "@/db/schema";
3+
import { requireAdmin } from "@/lib/admin-auth";
4+
import { validateChannelId } from "@/lib/validation";
5+
import { desc, eq } from "drizzle-orm";
6+
import { NextRequest, NextResponse } from "next/server";
7+
8+
export async function GET(request: NextRequest) {
9+
const forbidden = await requireAdmin();
10+
if (forbidden) return forbidden;
11+
12+
const channelId = request.nextUrl.searchParams.get("channelId");
13+
if (channelId) {
14+
const v = validateChannelId(channelId);
15+
if (!v.valid) return NextResponse.json({ error: v.error }, { status: 400 });
16+
}
17+
const rawLimit = Number(request.nextUrl.searchParams.get("limit") ?? "200");
18+
const limit = Math.min(Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 200, 1000);
19+
20+
const where = channelId ? eq(sagaCorrections.channelId, channelId) : undefined;
21+
22+
const rows = await db
23+
.select({
24+
id: sagaCorrections.id,
25+
channelId: sagaCorrections.channelId,
26+
channelTitle: channels.title,
27+
action: sagaCorrections.action,
28+
videoId: sagaCorrections.videoId,
29+
videoTitle: sagaCorrections.videoTitle,
30+
videoPublishedAt: sagaCorrections.videoPublishedAt,
31+
targetSagaId: sagaCorrections.targetSagaId,
32+
targetSagaName: sagaCorrections.targetSagaName,
33+
previousSagaId: sagaCorrections.previousSagaId,
34+
previousSagaName: sagaCorrections.previousSagaName,
35+
neighborContext: sagaCorrections.neighborContext,
36+
createdAt: sagaCorrections.createdAt,
37+
})
38+
.from(sagaCorrections)
39+
.leftJoin(channels, eq(sagaCorrections.channelId, channels.id))
40+
.where(where)
41+
.orderBy(desc(sagaCorrections.createdAt))
42+
.limit(limit);
43+
44+
return NextResponse.json({ corrections: rows });
45+
}

apps/youtube/src/app/api/admin/stats/route.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ export async function GET() {
77
const forbidden = await requireAdmin();
88
if (forbidden) return forbidden;
99

10+
console.log("[Admin Stats] Request");
11+
1012
try {
13+
const globalStart = Date.now();
1114
const globalResult = await db.execute(sql`
1215
SELECT
1316
(SELECT count(*) FROM channels) AS channels,
@@ -20,7 +23,9 @@ export async function GET() {
2023
(SELECT count(*) FROM videos v LEFT JOIN transcripts t ON t.video_id = v.id WHERE t.video_id IS NULL) AS videos_without_transcripts
2124
`);
2225
const globalRow = globalResult.rows[0];
26+
console.log("[Admin Stats] Global query", { ms: Date.now() - globalStart });
2327

28+
const channelStart = Date.now();
2429
const channelRows = await db.execute(sql`
2530
SELECT
2631
c.id,
@@ -41,6 +46,7 @@ export async function GET() {
4146
GROUP BY c.id, c.title, c.thumbnail_url, c.fetched_at
4247
ORDER BY c.fetched_at DESC
4348
`);
49+
console.log("[Admin Stats] Channel query", { ms: Date.now() - channelStart, channelCount: channelRows.rows.length });
4450

4551
const g = globalRow as Record<string, unknown>;
4652

@@ -58,7 +64,8 @@ export async function GET() {
5864
channels: channelRows.rows,
5965
});
6066
} catch (error) {
61-
console.error("[Admin Stats] Error:", error);
67+
const err = error instanceof Error ? error : new Error(String(error));
68+
console.error("[Admin Stats] Error:", err.message, err.stack);
6269
return NextResponse.json({ error: "Failed to fetch stats" }, { status: 500 });
6370
}
6471
}

0 commit comments

Comments
 (0)