feat: scope candles/annotations/export APIs by chartId query param

- GET /api/candles accepts ?chartId= with fallback to most recent chart
- GET /api/annotations accepts ?chartId= with fallback to most recent chart
- POST /api/annotations now requires chart_id in request body
- GET /api/export accepts ?chartId= to scope exported annotations
- DELETE /api/annotations supports optional chartId scoping
This commit is contained in:
Marko Djordjevic 2026-02-13 00:14:22 +01:00
parent 98e91b047a
commit 90e1e179cc
4 changed files with 107 additions and 35 deletions

View file

@ -1,12 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { annotations } from '@/lib/db/schema';
import { eq, inArray } from 'drizzle-orm';
import { annotations, charts } from '@/lib/db/schema';
import { eq, inArray, and, desc } from 'drizzle-orm';
// GET all annotations
export async function GET() {
// GET annotations scoped by chartId
export async function GET(request: NextRequest) {
try {
const allAnnotations = await db.select().from(annotations);
const { searchParams } = request.nextUrl;
let chartId = searchParams.get('chartId');
// 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);
if (latest.length === 0) {
return NextResponse.json([]);
}
chartId = String(latest[0].id);
}
const allAnnotations = await db
.select()
.from(annotations)
.where(eq(annotations.chart_id, parseInt(chartId, 10)));
// Parse geometry from JSON string
const parsed = allAnnotations.map((annotation) => ({
@ -23,26 +38,36 @@ export async function GET() {
}
}
// POST create new annotation
// POST create new annotation (requires chart_id)
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { timestamp, label_type, geometry, color } = body;
const { timestamp, label_type, chart_id, geometry, color } = body;
// Validate required fields
if (!timestamp || !label_type) {
if (!timestamp || !label_type || !chart_id) {
return NextResponse.json(
{ error: 'timestamp and label_type are required' },
{ error: 'timestamp, label_type, and chart_id are required' },
{ status: 400 }
);
}
// Verify chart exists
const chartExists = await db.select({ id: charts.id }).from(charts).where(eq(charts.id, chart_id)).limit(1);
if (chartExists.length === 0) {
return NextResponse.json(
{ error: 'Chart not found' },
{ status: 404 }
);
}
// Serialize geometry to JSON string if present
const geometryString = geometry ? JSON.stringify(geometry) : null;
const result = await db
.insert(annotations)
.values({
chart_id,
timestamp,
label_type,
geometry: geometryString,
@ -68,27 +93,36 @@ export async function POST(request: NextRequest) {
}
}
// DELETE annotations with bulk operations
// DELETE annotations with bulk operations (scoped by chartId)
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const type = searchParams.get('type');
const all = searchParams.get('all');
const chartId = searchParams.get('chartId');
let result;
if (all === 'true') {
// Delete all annotations
result = await db.delete(annotations).returning();
if (chartId) {
result = await db.delete(annotations).where(eq(annotations.chart_id, parseInt(chartId, 10))).returning();
} else {
result = await db.delete(annotations).returning();
}
} else if (type) {
// Delete by type(s)
const types = type.split(',').map((t) => t.trim());
result = await db
.delete(annotations)
.where(inArray(annotations.label_type, types))
.returning();
if (chartId) {
result = await db
.delete(annotations)
.where(and(inArray(annotations.label_type, types), eq(annotations.chart_id, parseInt(chartId, 10))))
.returning();
} else {
result = await db
.delete(annotations)
.where(inArray(annotations.label_type, types))
.returning();
}
} else {
// No filter specified
return NextResponse.json(
{ error: 'Specify type or all parameter for bulk delete' },
{ status: 400 }

View file

@ -1,13 +1,32 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { candles } from '@/lib/db/schema';
import { asc } from 'drizzle-orm';
import { candles, charts } from '@/lib/db/schema';
import { asc, desc, eq } from 'drizzle-orm';
export async function GET() {
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
let chartId = searchParams.get('chartId');
// 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);
if (latest.length === 0) {
return NextResponse.json([]);
}
chartId = String(latest[0].id);
}
const allCandles = await db
.select()
.select({
time: candles.time,
open: candles.open,
high: candles.high,
low: candles.low,
close: candles.close,
})
.from(candles)
.where(eq(candles.chart_id, parseInt(chartId, 10)))
.orderBy(asc(candles.time));
return NextResponse.json(allCandles);

View file

@ -1,11 +1,32 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { annotations, candles } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { annotations, candles, charts } from '@/lib/db/schema';
import { eq, and, desc } from 'drizzle-orm';
export async function GET() {
export async function GET(request: NextRequest) {
try {
const allAnnotations = await db.select().from(annotations);
const { searchParams } = request.nextUrl;
let chartId = searchParams.get('chartId');
// 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);
if (latest.length === 0) {
return new NextResponse('timestamp,label_type,price\n', {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="annotations.csv"',
},
});
}
chartId = String(latest[0].id);
}
const chartIdNum = parseInt(chartId, 10);
const allAnnotations = await db
.select()
.from(annotations)
.where(eq(annotations.chart_id, chartIdNum));
// Build CSV content
const csvRows = ['timestamp,label_type,price'];
@ -14,18 +35,16 @@ export async function GET() {
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))
.where(and(eq(candles.chart_id, chartIdNum), 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;
}