diff --git a/openspec/changes/user-accounts/tasks.md b/openspec/changes/user-accounts/tasks.md index de7466d..740d6cb 100644 --- a/openspec/changes/user-accounts/tasks.md +++ b/openspec/changes/user-accounts/tasks.md @@ -38,7 +38,7 @@ ## 7. Update Existing API Routes - [x] 7.1 `[sonnet]` Add `getAuthUser()` check to all data API routes: `/api/upload`, `/api/candles`, `/api/charts`, `/api/annotations`, `/api/annotation-types`, `/api/span-annotations`, `/api/span-label-types`, `/api/export` -- [ ] 7.2 `[opus]` Update all Drizzle queries to filter by `user_id` from authenticated session (SELECT, INSERT, DELETE) +- [x] 7.2 `[opus]` Update all Drizzle queries to filter by `user_id` from authenticated session (SELECT, INSERT, DELETE) - [ ] 7.3 `[sonnet]` Add `getAuthUser()` check to all proxy API routes: `/api/predict`, `/api/predict/batch`, `/api/model/info`, `/api/model/load`, `/api/patterns/detect`, `/api/patterns/available`, `/api/training/start`, `/api/training/runs` - [ ] 7.4 `[haiku]` Add `X-User-ID` header to all fetch calls from proxy routes to the FastAPI ML service diff --git a/src/app/api/annotation-types/[id]/route.ts b/src/app/api/annotation-types/[id]/route.ts index f40524c..019d518 100644 --- a/src/app/api/annotation-types/[id]/route.ts +++ b/src/app/api/annotation-types/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { annotationTypes } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; type RouteContext = { @@ -51,7 +51,7 @@ export async function PATCH( const result = await db .update(annotationTypes) .set(updateData) - .where(eq(annotationTypes.id, idNum)) + .where(and(eq(annotationTypes.id, idNum), eq(annotationTypes.user_id, user.id))) .returning(); if (result.length === 0) { diff --git a/src/app/api/annotation-types/route.ts b/src/app/api/annotation-types/route.ts index 396391e..f18716a 100644 --- a/src/app/api/annotation-types/route.ts +++ b/src/app/api/annotation-types/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { annotationTypes, annotations } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; // GET - List all annotation types @@ -12,7 +12,7 @@ export async function GET() { } try { - const types = await db.select().from(annotationTypes); + const types = await db.select().from(annotationTypes).where(eq(annotationTypes.user_id, user.id)); return NextResponse.json(types); } catch (error) { console.error('Error fetching annotation types:', error); @@ -35,8 +35,8 @@ export async function POST(request: NextRequest) { // Special case: seed default types if (body.action === 'seed') { - const existing = await db.select().from(annotationTypes); - + const existing = await db.select().from(annotationTypes).where(eq(annotationTypes.user_id, user.id)); + if (existing.length > 0) { return NextResponse.json({ message: 'Types already seeded' }); } @@ -49,6 +49,7 @@ export async function POST(request: NextRequest) { category: 'marker', icon: 'arrowUp', is_active: true, + user_id: user.id, }, { name: 'break_down', @@ -57,6 +58,7 @@ export async function POST(request: NextRequest) { category: 'marker', icon: 'arrowDown', is_active: true, + user_id: user.id, }, { name: 'line', @@ -65,6 +67,7 @@ export async function POST(request: NextRequest) { category: 'line', icon: 'line', is_active: true, + user_id: user.id, }, ]; @@ -91,6 +94,7 @@ export async function POST(request: NextRequest) { category, icon: icon || null, is_active: true, + user_id: user.id, }) .returning(); @@ -130,11 +134,11 @@ export async function DELETE(request: NextRequest) { ); } - // Check if the type exists + // Check if the type exists and belongs to user const type = await db .select() .from(annotationTypes) - .where(eq(annotationTypes.id, parseInt(id))) + .where(and(eq(annotationTypes.id, parseInt(id)), eq(annotationTypes.user_id, user.id))) .limit(1); if (type.length === 0) { @@ -144,11 +148,11 @@ export async function DELETE(request: NextRequest) { ); } - // Check if any annotations use this type + // Check if any of this user's annotations use this type const existingAnnotations = await db .select() .from(annotations) - .where(eq(annotations.label_type, type[0].name)) + .where(and(eq(annotations.label_type, type[0].name), eq(annotations.user_id, user.id))) .limit(1); if (existingAnnotations.length > 0) { @@ -158,7 +162,7 @@ export async function DELETE(request: NextRequest) { ); } - await db.delete(annotationTypes).where(eq(annotationTypes.id, parseInt(id))); + await db.delete(annotationTypes).where(and(eq(annotationTypes.id, parseInt(id)), eq(annotationTypes.user_id, user.id))); return NextResponse.json({ message: 'Annotation type deleted successfully' }); } catch (error) { diff --git a/src/app/api/annotations/[id]/route.ts b/src/app/api/annotations/[id]/route.ts index 5db144b..0f74ddc 100644 --- a/src/app/api/annotations/[id]/route.ts +++ b/src/app/api/annotations/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { annotations } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; export async function PATCH( @@ -37,7 +37,7 @@ export async function PATCH( const result = await db .update(annotations) .set({ geometry }) - .where(eq(annotations.id, id)) + .where(and(eq(annotations.id, id), eq(annotations.user_id, user.id))) .returning(); if (result.length === 0) { @@ -79,7 +79,7 @@ export async function DELETE( const result = await db .delete(annotations) - .where(eq(annotations.id, id)) + .where(and(eq(annotations.id, id), eq(annotations.user_id, user.id))) .returning(); if (result.length === 0) { diff --git a/src/app/api/annotations/route.ts b/src/app/api/annotations/route.ts index 514abc7..85bc978 100644 --- a/src/app/api/annotations/route.ts +++ b/src/app/api/annotations/route.ts @@ -17,7 +17,7 @@ export async function GET(request: NextRequest) { // Fall back to most recent chart if no chartId provided if (!chartId) { - const latest = await db.select({ id: charts.id }).from(charts).orderBy(desc(charts.created_at)).limit(1); + const latest = await db.select({ id: charts.id }).from(charts).where(eq(charts.user_id, user.id)).orderBy(desc(charts.created_at)).limit(1); if (latest.length === 0) { return NextResponse.json([]); } @@ -35,7 +35,7 @@ export async function GET(request: NextRequest) { const allAnnotations = await db .select() .from(annotations) - .where(eq(annotations.chart_id, chartIdNum)); + .where(and(eq(annotations.chart_id, chartIdNum), eq(annotations.user_id, user.id))); const normalized = allAnnotations.map((a) => ({ ...a, @@ -71,8 +71,8 @@ export async function POST(request: NextRequest) { ); } - // Verify chart exists - const chartExists = await db.select({ id: charts.id }).from(charts).where(eq(charts.id, chart_id)).limit(1); + // Verify chart exists and belongs to user + const chartExists = await db.select({ id: charts.id }).from(charts).where(and(eq(charts.id, chart_id), eq(charts.user_id, user.id))).limit(1); if (chartExists.length === 0) { return NextResponse.json( { error: 'Chart not found' }, @@ -89,6 +89,7 @@ export async function POST(request: NextRequest) { .insert(annotations) .values({ chart_id, + user_id: user.id, timestamp: timestampDate, label_type, geometry: geometry || null, @@ -139,7 +140,7 @@ export async function DELETE(request: NextRequest) { { status: 400 } ); } - result = await db.delete(annotations).where(eq(annotations.chart_id, chartIdNum)).returning(); + result = await db.delete(annotations).where(and(eq(annotations.chart_id, chartIdNum), eq(annotations.user_id, user.id))).returning(); } else if (type) { const types = type.split(',').map((t) => t.trim()); if (chartId) { @@ -152,12 +153,12 @@ export async function DELETE(request: NextRequest) { } result = await db .delete(annotations) - .where(and(inArray(annotations.label_type, types), eq(annotations.chart_id, chartIdNum))) + .where(and(inArray(annotations.label_type, types), eq(annotations.chart_id, chartIdNum), eq(annotations.user_id, user.id))) .returning(); } else { result = await db .delete(annotations) - .where(inArray(annotations.label_type, types)) + .where(and(inArray(annotations.label_type, types), eq(annotations.user_id, user.id))) .returning(); } } else { diff --git a/src/app/api/candles/route.ts b/src/app/api/candles/route.ts index 34be8b0..533ffeb 100644 --- a/src/app/api/candles/route.ts +++ b/src/app/api/candles/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { candles, charts } from '@/lib/db/schema'; -import { asc, desc, eq } from 'drizzle-orm'; +import { asc, desc, eq, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; export async function GET(request: NextRequest) { @@ -16,7 +16,7 @@ export async function GET(request: NextRequest) { // Fall back to most recent chart if no chartId provided if (!chartId) { - const latest = await db.select({ id: charts.id }).from(charts).orderBy(desc(charts.created_at)).limit(1); + const latest = await db.select({ id: charts.id }).from(charts).where(eq(charts.user_id, user.id)).orderBy(desc(charts.created_at)).limit(1); if (latest.length === 0) { return NextResponse.json([]); } @@ -31,6 +31,12 @@ export async function GET(request: NextRequest) { ); } + // Verify chart belongs to user + const chartOwner = await db.select({ id: charts.id }).from(charts).where(and(eq(charts.id, chartIdNum), eq(charts.user_id, user.id))).limit(1); + if (chartOwner.length === 0) { + return NextResponse.json({ error: 'Chart not found' }, { status: 404 }); + } + const allCandles = await db .select({ time: candles.time, diff --git a/src/app/api/charts/[id]/route.ts b/src/app/api/charts/[id]/route.ts index 8e24982..109bac6 100644 --- a/src/app/api/charts/[id]/route.ts +++ b/src/app/api/charts/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { charts, candles, annotations, spanAnnotations } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; export async function GET( @@ -20,7 +20,7 @@ export async function GET( return NextResponse.json({ error: 'Invalid chart ID' }, { status: 400 }); } - const result = await db.select().from(charts).where(eq(charts.id, chartId)).limit(1); + const result = await db.select().from(charts).where(and(eq(charts.id, chartId), eq(charts.user_id, user.id))).limit(1); if (result.length === 0) { return NextResponse.json({ error: 'Chart not found' }, { status: 404 }); @@ -45,7 +45,7 @@ export async function DELETE( return NextResponse.json({ error: 'Invalid chart ID' }, { status: 400 }); } - const existing = await db.select().from(charts).where(eq(charts.id, chartId)).limit(1); + const existing = await db.select().from(charts).where(and(eq(charts.id, chartId), eq(charts.user_id, user.id))).limit(1); if (existing.length === 0) { return NextResponse.json({ error: 'Chart not found' }, { status: 404 }); } diff --git a/src/app/api/charts/route.ts b/src/app/api/charts/route.ts index 65bed5d..136daf5 100644 --- a/src/app/api/charts/route.ts +++ b/src/app/api/charts/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { charts } from '@/lib/db/schema'; -import { desc } from 'drizzle-orm'; +import { desc, eq } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; export async function GET() { @@ -10,6 +10,6 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const allCharts = await db.select().from(charts).orderBy(desc(charts.created_at)); + const allCharts = await db.select().from(charts).where(eq(charts.user_id, user.id)).orderBy(desc(charts.created_at)); return NextResponse.json(allCharts); } diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index d00387c..e7ad1da 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -23,7 +23,7 @@ export async function GET(request: NextRequest) { // Fall back to most recent chart if no chartId provided if (!chartId) { - const latest = await db.select({ id: charts.id }).from(charts).orderBy(desc(charts.created_at)).limit(1); + const latest = await db.select({ id: charts.id }).from(charts).where(eq(charts.user_id, user.id)).orderBy(desc(charts.created_at)).limit(1); if (latest.length === 0) { return new NextResponse('timestamp,label_type,price\n', { headers: { @@ -46,7 +46,7 @@ export async function GET(request: NextRequest) { const allAnnotations = await db .select() .from(annotations) - .where(eq(annotations.chart_id, chartIdNum)); + .where(and(eq(annotations.chart_id, chartIdNum), eq(annotations.user_id, user.id))); // Build CSV content const csvRows = ['timestamp,label_type,price']; diff --git a/src/app/api/span-annotations/[id]/route.ts b/src/app/api/span-annotations/[id]/route.ts index e77201a..a4a7d25 100644 --- a/src/app/api/span-annotations/[id]/route.ts +++ b/src/app/api/span-annotations/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { spanAnnotations } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; // PATCH - Update span annotation @@ -29,11 +29,11 @@ export async function PATCH( const { label, confidence, outcome, notes, sub_spans } = body; - // Check if the span exists + // Check if the span exists and belongs to user const existing = await db .select() .from(spanAnnotations) - .where(eq(spanAnnotations.id, idNum)) + .where(and(eq(spanAnnotations.id, idNum), eq(spanAnnotations.user_id, user.id))) .limit(1); if (existing.length === 0) { @@ -54,7 +54,7 @@ export async function PATCH( const result = await db .update(spanAnnotations) .set(updates) - .where(eq(spanAnnotations.id, idNum)) + .where(and(eq(spanAnnotations.id, idNum), eq(spanAnnotations.user_id, user.id))) .returning(); const s = result[0]; @@ -94,11 +94,11 @@ export async function DELETE( ); } - // Check if the span exists + // Check if the span exists and belongs to user const existing = await db .select() .from(spanAnnotations) - .where(eq(spanAnnotations.id, idNum)) + .where(and(eq(spanAnnotations.id, idNum), eq(spanAnnotations.user_id, user.id))) .limit(1); if (existing.length === 0) { @@ -108,7 +108,7 @@ export async function DELETE( ); } - await db.delete(spanAnnotations).where(eq(spanAnnotations.id, idNum)); + await db.delete(spanAnnotations).where(and(eq(spanAnnotations.id, idNum), eq(spanAnnotations.user_id, user.id))); return NextResponse.json({ message: 'Span annotation deleted successfully' }); } catch (error) { diff --git a/src/app/api/span-annotations/export/route.ts b/src/app/api/span-annotations/export/route.ts index 2b6b5ae..3524169 100644 --- a/src/app/api/span-annotations/export/route.ts +++ b/src/app/api/span-annotations/export/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { spanAnnotations, charts } from '@/lib/db/schema'; -import { eq, desc } from 'drizzle-orm'; +import { eq, desc, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; function sanitizeCsvCell(value: string): string { @@ -53,6 +53,7 @@ export async function GET(request: NextRequest) { const latest = await db .select({ id: charts.id }) .from(charts) + .where(eq(charts.user_id, user.id)) .orderBy(desc(charts.created_at)) .limit(1); @@ -78,7 +79,7 @@ export async function GET(request: NextRequest) { const spans = await db .select() .from(spanAnnotations) - .where(eq(spanAnnotations.chart_id, chartIdNum)) + .where(and(eq(spanAnnotations.chart_id, chartIdNum), eq(spanAnnotations.user_id, user.id))) .orderBy(spanAnnotations.start_time); if (format === 'json') { diff --git a/src/app/api/span-annotations/route.ts b/src/app/api/span-annotations/route.ts index 2faf191..8ca2b4d 100644 --- a/src/app/api/span-annotations/route.ts +++ b/src/app/api/span-annotations/route.ts @@ -33,7 +33,7 @@ export async function GET(request: NextRequest) { const spans = await db .select() .from(spanAnnotations) - .where(eq(spanAnnotations.chart_id, chartIdNum)) + .where(and(eq(spanAnnotations.chart_id, chartIdNum), eq(spanAnnotations.user_id, user.id))) .orderBy(desc(spanAnnotations.start_time)); const normalized = spans.map((s) => ({ @@ -91,6 +91,7 @@ export async function POST(request: NextRequest) { .insert(spanAnnotations) .values({ chart_id, + user_id: user.id, start_time: new Date(actualStartTime * 1000), end_time: new Date(actualEndTime * 1000), label, @@ -148,8 +149,8 @@ export async function DELETE(request: NextRequest) { ); } - // Build filter conditions - const conditions = [eq(spanAnnotations.chart_id, chartIdInt)]; + // Build filter conditions (always scoped to user) + const conditions = [eq(spanAnnotations.chart_id, chartIdInt), eq(spanAnnotations.user_id, user.id)]; if (source) conditions.push(eq(spanAnnotations.source, source)); if (label) conditions.push(eq(spanAnnotations.label, label)); diff --git a/src/app/api/span-label-types/[id]/route.ts b/src/app/api/span-label-types/[id]/route.ts index 2f1bd78..023e749 100644 --- a/src/app/api/span-label-types/[id]/route.ts +++ b/src/app/api/span-label-types/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { spanLabelTypes, spanAnnotations } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; // PATCH - Update span label type @@ -29,11 +29,11 @@ export async function PATCH( const { name, display_name, color, hotkey, is_active, sort_order } = body; - // Check if the type exists + // Check if the type exists and belongs to user const existing = await db .select() .from(spanLabelTypes) - .where(eq(spanLabelTypes.id, idNum)) + .where(and(eq(spanLabelTypes.id, idNum), eq(spanLabelTypes.user_id, user.id))) .limit(1); if (existing.length === 0) { @@ -55,7 +55,7 @@ export async function PATCH( const result = await db .update(spanLabelTypes) .set(updates) - .where(eq(spanLabelTypes.id, idNum)) + .where(and(eq(spanLabelTypes.id, idNum), eq(spanLabelTypes.user_id, user.id))) .returning(); return NextResponse.json(result[0]); @@ -97,11 +97,11 @@ export async function DELETE( ); } - // Check if the type exists + // Check if the type exists and belongs to user const type = await db .select() .from(spanLabelTypes) - .where(eq(spanLabelTypes.id, idNum)) + .where(and(eq(spanLabelTypes.id, idNum), eq(spanLabelTypes.user_id, user.id))) .limit(1); if (type.length === 0) { @@ -111,11 +111,11 @@ export async function DELETE( ); } - // Check if any span annotations use this label + // Check if any of this user's span annotations use this label const existingSpans = await db .select() .from(spanAnnotations) - .where(eq(spanAnnotations.label, type[0].name)) + .where(and(eq(spanAnnotations.label, type[0].name), eq(spanAnnotations.user_id, user.id))) .limit(1); if (existingSpans.length > 0) { @@ -125,7 +125,7 @@ export async function DELETE( ); } - await db.delete(spanLabelTypes).where(eq(spanLabelTypes.id, idNum)); + await db.delete(spanLabelTypes).where(and(eq(spanLabelTypes.id, idNum), eq(spanLabelTypes.user_id, user.id))); return NextResponse.json({ message: 'Span label type deleted successfully' }); } catch (error) { diff --git a/src/app/api/span-label-types/route.ts b/src/app/api/span-label-types/route.ts index 96b9483..0c73616 100644 --- a/src/app/api/span-label-types/route.ts +++ b/src/app/api/span-label-types/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { spanLabelTypes } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; // GET - List all span label types (active only, sorted by sort_order) @@ -15,7 +15,7 @@ export async function GET() { const types = await db .select() .from(spanLabelTypes) - .where(eq(spanLabelTypes.is_active, true)) + .where(and(eq(spanLabelTypes.is_active, true), eq(spanLabelTypes.user_id, user.id))) .orderBy(spanLabelTypes.sort_order); return NextResponse.json(types); @@ -55,6 +55,7 @@ export async function POST(request: NextRequest) { hotkey: hotkey || null, is_active: true, sort_order: sort_order ?? 0, + user_id: user.id, }) .returning(); diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 4990fde..a452b92 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -2,17 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'; import Papa from 'papaparse'; import { db } from '@/lib/db'; import { candles, charts } from '@/lib/db/schema'; -import { eq, like } from 'drizzle-orm'; +import { eq, like, and } from 'drizzle-orm'; import { getAuthUser } from '@/lib/auth'; -async function getUniqueChartName(baseName: string): Promise { - // Check if the base name is already taken - const existing = await db.select().from(charts).where(eq(charts.name, baseName)).limit(1); +async function getUniqueChartName(baseName: string, userId: string): Promise { + // Check if the base name is already taken for this user + const existing = await db.select().from(charts).where(and(eq(charts.user_id, userId), eq(charts.name, baseName))).limit(1); if (existing.length === 0) return baseName; // Find existing charts with this base name pattern (e.g., "btc-daily-2", "btc-daily-3") const pattern = `${baseName}-%`; - const suffixed = await db.select({ name: charts.name }).from(charts).where(like(charts.name, pattern)); + const suffixed = await db.select({ name: charts.name }).from(charts).where(and(eq(charts.user_id, userId), like(charts.name, pattern))); let maxSuffix = 1; for (const row of suffixed) { @@ -124,11 +124,12 @@ export async function POST(request: NextRequest): Promise { } // Get unique chart name (handle duplicates) - const chartName = await getUniqueChartName(baseName); + const chartName = await getUniqueChartName(baseName, user.id); // Create the chart const [newChart] = await db.insert(charts).values({ name: chartName, + user_id: user.id, }).returning(); // Parse and prepare candle data