From 096a80b229d00ecbb9c371ea580a7680ae3b78b9 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Thu, 12 Feb 2026 10:24:03 +0100 Subject: [PATCH] feat: implement backend API endpoints - CSV upload with papaparse (handles date strings and Unix timestamps) - Annotations CRUD (GET, POST, DELETE) - Candles GET endpoint - Export annotations as CSV --- src/app/api/annotations/[id]/route.ts | 39 ++++++++ src/app/api/annotations/route.ts | 67 +++++++++++++ src/app/api/candles/route.ts | 20 ++++ src/app/api/export/route.ts | 52 ++++++++++ src/app/api/upload/route.ts | 134 ++++++++++++++++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 src/app/api/annotations/[id]/route.ts create mode 100644 src/app/api/annotations/route.ts create mode 100644 src/app/api/candles/route.ts create mode 100644 src/app/api/export/route.ts create mode 100644 src/app/api/upload/route.ts diff --git a/src/app/api/annotations/[id]/route.ts b/src/app/api/annotations/[id]/route.ts new file mode 100644 index 0000000..c23aa6c --- /dev/null +++ b/src/app/api/annotations/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { annotations } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = parseInt(params.id); + + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid annotation ID' }, + { status: 400 } + ); + } + + const result = await db + .delete(annotations) + .where(eq(annotations.id, id)) + .returning(); + + if (result.length === 0) { + return NextResponse.json( + { error: 'Annotation not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Failed to delete annotation' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/annotations/route.ts b/src/app/api/annotations/route.ts new file mode 100644 index 0000000..507a488 --- /dev/null +++ b/src/app/api/annotations/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { annotations } from '@/lib/db/schema'; + +// GET all annotations +export async function GET() { + try { + const allAnnotations = await db.select().from(annotations); + + // Parse geometry from JSON string + const parsed = allAnnotations.map((annotation) => ({ + ...annotation, + geometry: annotation.geometry ? JSON.parse(annotation.geometry) : null, + })); + + return NextResponse.json(parsed); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Failed to fetch annotations' }, + { status: 500 } + ); + } +} + +// POST create new annotation +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { timestamp, label_type, geometry } = body; + + // Validate required fields + if (!timestamp || !label_type) { + return NextResponse.json( + { error: 'timestamp and label_type are required' }, + { status: 400 } + ); + } + + // Serialize geometry to JSON string if present + const geometryString = geometry ? JSON.stringify(geometry) : null; + + const result = await db + .insert(annotations) + .values({ + timestamp, + label_type, + geometry: geometryString, + created_at: Math.floor(Date.now() / 1000), + }) + .returning(); + + const created = result[0]; + + return NextResponse.json( + { + ...created, + geometry: created.geometry ? JSON.parse(created.geometry) : null, + }, + { status: 201 } + ); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Failed to create annotation' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/candles/route.ts b/src/app/api/candles/route.ts new file mode 100644 index 0000000..cd90f6b --- /dev/null +++ b/src/app/api/candles/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { candles } from '@/lib/db/schema'; +import { asc } from 'drizzle-orm'; + +export async function GET() { + try { + const allCandles = await db + .select() + .from(candles) + .orderBy(asc(candles.time)); + + return NextResponse.json(allCandles); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Failed to fetch candles' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts new file mode 100644 index 0000000..075b504 --- /dev/null +++ b/src/app/api/export/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { annotations, candles } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +export async function GET() { + try { + const allAnnotations = await db.select().from(annotations); + + // Build CSV content + const csvRows = ['timestamp,label_type,price']; + + for (const annotation of allAnnotations) { + let price: number | null = null; + + if (annotation.label_type === 'break_up' || annotation.label_type === 'break_down') { + // For marker annotations, look up the candle's close price + const candleResult = await db + .select() + .from(candles) + .where(eq(candles.time, annotation.timestamp)) + .limit(1); + + if (candleResult.length > 0) { + price = candleResult[0].close; + } + } else if (annotation.label_type === 'line' && annotation.geometry) { + // For line annotations, use startPrice from geometry + const geometry = JSON.parse(annotation.geometry); + price = geometry.startPrice || null; + } + + csvRows.push( + `${annotation.timestamp},${annotation.label_type},${price !== null ? price : ''}` + ); + } + + const csvContent = csvRows.join('\n'); + + return new NextResponse(csvContent, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': 'attachment; filename="annotations.csv"', + }, + }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Failed to export annotations' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..a3d7632 --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Papa from 'papaparse'; +import { db } from '@/lib/db'; +import { candles } from '@/lib/db/schema'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json( + { error: 'No file provided' }, + { status: 400 } + ); + } + + const text = await file.text(); + + return new Promise((resolve) => { + Papa.parse(text, { + header: true, + dynamicTyping: true, + skipEmptyLines: true, + complete: async (results) => { + try { + const rows = results.data as any[]; + + // Validate headers + if (rows.length === 0) { + resolve( + NextResponse.json( + { error: 'CSV file is empty' }, + { status: 400 } + ) + ); + return; + } + + const firstRow = rows[0]; + const requiredHeaders = ['time', 'open', 'high', 'low', 'close']; + const missingHeaders = requiredHeaders.filter( + (header) => !(header in firstRow) + ); + + if (missingHeaders.length > 0) { + resolve( + NextResponse.json( + { + error: `Missing required headers: ${missingHeaders.join(', ')}`, + }, + { status: 400 } + ) + ); + return; + } + + // Parse and prepare candle data + const candleData = rows.map((row) => { + let timestamp: number; + + // Handle both date strings and Unix timestamps + if (typeof row.time === 'string') { + // Try parsing as date string + const date = new Date(row.time); + if (isNaN(date.getTime())) { + throw new Error(`Invalid date format: ${row.time}`); + } + timestamp = Math.floor(date.getTime() / 1000); + } else if (typeof row.time === 'number') { + timestamp = row.time; + } else { + throw new Error(`Invalid time value: ${row.time}`); + } + + return { + time: timestamp, + open: Number(row.open), + high: Number(row.high), + low: Number(row.low), + close: Number(row.close), + }; + }); + + // Insert with upsert behavior (replace on conflict) + let count = 0; + for (const candle of candleData) { + await db + .insert(candles) + .values(candle) + .onConflictDoUpdate({ + target: candles.time, + set: { + open: candle.open, + high: candle.high, + low: candle.low, + close: candle.close, + }, + }); + count++; + } + + resolve( + NextResponse.json({ + success: true, + count, + }) + ); + } catch (error: any) { + resolve( + NextResponse.json( + { error: error.message || 'Failed to process CSV data' }, + { status: 400 } + ) + ); + } + }, + error: (error) => { + resolve( + NextResponse.json( + { error: `CSV parsing error: ${error.message}` }, + { status: 400 } + ) + ); + }, + }); + }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: 500 } + ); + } +}