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
This commit is contained in:
parent
d04b673cfa
commit
096a80b229
5 changed files with 312 additions and 0 deletions
39
src/app/api/annotations/[id]/route.ts
Normal file
39
src/app/api/annotations/[id]/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/annotations/route.ts
Normal file
67
src/app/api/annotations/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/api/candles/route.ts
Normal file
20
src/app/api/candles/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/api/export/route.ts
Normal file
52
src/app/api/export/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app/api/upload/route.ts
Normal file
134
src/app/api/upload/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue