feat: Supabase-backed user data (portfolio, favorites, trades, alerts, notifications)
Browse files- Migration: 5 new tables (user_trades, favorites, price_alerts, notifications, portfolio_snapshots)
- Fixed portfolios table: added symbol column, made stock_id nullable
- Auto-create user_profiles on signup trigger
- 6 new API routes: /api/portfolio, /api/favorites, /api/watchlist, /api/trades, /api/alerts, /api/notifications
- Portfolio page: localStorage → Supabase with auto-migration fallback
- Favorites: async Supabase API with localStorage fallback for anonymous
- StockDetail & StockList: use async favorites API
- RLS + per-user policies on all new tables
- nextjs-app/src/app/api/alerts/route.ts +124 -0
- nextjs-app/src/app/api/favorites/route.ts +74 -0
- nextjs-app/src/app/api/notifications/route.ts +59 -0
- nextjs-app/src/app/api/portfolio/route.ts +195 -0
- nextjs-app/src/app/api/trades/route.ts +98 -0
- nextjs-app/src/app/api/watchlist/route.ts +91 -0
- nextjs-app/src/app/favorites/page.tsx +6 -7
- nextjs-app/src/app/portfolio/page.tsx +102 -58
- nextjs-app/src/components/StockDetail.tsx +6 -5
- nextjs-app/src/components/StockList.tsx +6 -5
- nextjs-app/src/lib/favorites.ts +113 -11
- supabase/migrations/20260216_user_features.sql +261 -0
- trading/auto_trader.py +1 -1
nextjs-app/src/app/api/alerts/route.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { createClient } from '@/lib/supabase/server'
|
| 3 |
+
|
| 4 |
+
export const dynamic = 'force-dynamic'
|
| 5 |
+
|
| 6 |
+
// GET /api/alerts — kullanıcının fiyat alarmları
|
| 7 |
+
export async function GET() {
|
| 8 |
+
const supabase = await createClient()
|
| 9 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 10 |
+
if (!user || authErr) {
|
| 11 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const { data, error } = await supabase
|
| 15 |
+
.from('price_alerts')
|
| 16 |
+
.select('*')
|
| 17 |
+
.eq('user_id', user.id)
|
| 18 |
+
.order('created_at', { ascending: false })
|
| 19 |
+
|
| 20 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 21 |
+
return NextResponse.json({ alerts: data ?? [] })
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// POST /api/alerts — yeni alarm kur
|
| 25 |
+
export async function POST(req: NextRequest) {
|
| 26 |
+
const supabase = await createClient()
|
| 27 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 28 |
+
if (!user || authErr) {
|
| 29 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
let body: Record<string, unknown>
|
| 33 |
+
try { body = await req.json() } catch { return NextResponse.json({ error: 'Geçersiz JSON' }, { status: 400 }) }
|
| 34 |
+
|
| 35 |
+
const symbol = String(body.symbol || '').trim().toUpperCase()
|
| 36 |
+
const alertType = String(body.alert_type || '')
|
| 37 |
+
const targetValue = Number(body.target_value || 0)
|
| 38 |
+
|
| 39 |
+
if (!symbol || !['above', 'below', 'change_pct'].includes(alertType) || targetValue <= 0) {
|
| 40 |
+
return NextResponse.json(
|
| 41 |
+
{ error: 'symbol, alert_type (above/below/change_pct), target_value (>0) gerekli' },
|
| 42 |
+
{ status: 400 }
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Limit: max 20 active alerts per user
|
| 47 |
+
const { count } = await supabase
|
| 48 |
+
.from('price_alerts')
|
| 49 |
+
.select('*', { count: 'exact', head: true })
|
| 50 |
+
.eq('user_id', user.id)
|
| 51 |
+
.eq('is_active', true)
|
| 52 |
+
|
| 53 |
+
if ((count ?? 0) >= 20) {
|
| 54 |
+
return NextResponse.json({ error: 'Maksimum 20 aktif alarm kurabilirsiniz' }, { status: 429 })
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const { data, error } = await supabase
|
| 58 |
+
.from('price_alerts')
|
| 59 |
+
.insert({
|
| 60 |
+
user_id: user.id,
|
| 61 |
+
symbol,
|
| 62 |
+
alert_type: alertType,
|
| 63 |
+
target_value: targetValue,
|
| 64 |
+
notes: body.notes || null,
|
| 65 |
+
})
|
| 66 |
+
.select()
|
| 67 |
+
.single()
|
| 68 |
+
|
| 69 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 70 |
+
return NextResponse.json({ alert: data }, { status: 201 })
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// PATCH /api/alerts — alarmı güncelle (deaktive et vb.)
|
| 74 |
+
export async function PATCH(req: NextRequest) {
|
| 75 |
+
const supabase = await createClient()
|
| 76 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 77 |
+
if (!user || authErr) {
|
| 78 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
let body: Record<string, unknown>
|
| 82 |
+
try { body = await req.json() } catch { return NextResponse.json({ error: 'Geçersiz JSON' }, { status: 400 }) }
|
| 83 |
+
|
| 84 |
+
const id = String(body.id || '')
|
| 85 |
+
if (!id) return NextResponse.json({ error: 'id gerekli' }, { status: 400 })
|
| 86 |
+
|
| 87 |
+
const updates: Record<string, unknown> = {}
|
| 88 |
+
if (body.is_active !== undefined) updates.is_active = Boolean(body.is_active)
|
| 89 |
+
if (body.target_value !== undefined) updates.target_value = Number(body.target_value)
|
| 90 |
+
if (body.notes !== undefined) updates.notes = body.notes
|
| 91 |
+
|
| 92 |
+
const { data, error } = await supabase
|
| 93 |
+
.from('price_alerts')
|
| 94 |
+
.update(updates)
|
| 95 |
+
.eq('id', id)
|
| 96 |
+
.eq('user_id', user.id)
|
| 97 |
+
.select()
|
| 98 |
+
.single()
|
| 99 |
+
|
| 100 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 101 |
+
return NextResponse.json({ alert: data })
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// DELETE /api/alerts?id=xxx — alarm sil
|
| 105 |
+
export async function DELETE(req: NextRequest) {
|
| 106 |
+
const supabase = await createClient()
|
| 107 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 108 |
+
if (!user || authErr) {
|
| 109 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const url = new URL(req.url)
|
| 113 |
+
const id = url.searchParams.get('id')
|
| 114 |
+
if (!id) return NextResponse.json({ error: 'id gerekli' }, { status: 400 })
|
| 115 |
+
|
| 116 |
+
const { error } = await supabase
|
| 117 |
+
.from('price_alerts')
|
| 118 |
+
.delete()
|
| 119 |
+
.eq('id', id)
|
| 120 |
+
.eq('user_id', user.id)
|
| 121 |
+
|
| 122 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 123 |
+
return NextResponse.json({ ok: true })
|
| 124 |
+
}
|
nextjs-app/src/app/api/favorites/route.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { createClient } from '@/lib/supabase/server'
|
| 3 |
+
|
| 4 |
+
export const dynamic = 'force-dynamic'
|
| 5 |
+
|
| 6 |
+
// GET /api/favorites — kullanıcının favorileri
|
| 7 |
+
export async function GET() {
|
| 8 |
+
const supabase = await createClient()
|
| 9 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 10 |
+
if (!user || authErr) {
|
| 11 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const { data, error } = await supabase
|
| 15 |
+
.from('favorites')
|
| 16 |
+
.select('*')
|
| 17 |
+
.eq('user_id', user.id)
|
| 18 |
+
.order('created_at', { ascending: false })
|
| 19 |
+
|
| 20 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 21 |
+
return NextResponse.json({ items: data ?? [] })
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// POST /api/favorites — favorilere ekle
|
| 25 |
+
export async function POST(req: NextRequest) {
|
| 26 |
+
const supabase = await createClient()
|
| 27 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 28 |
+
if (!user || authErr) {
|
| 29 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
let body: Record<string, unknown>
|
| 33 |
+
try { body = await req.json() } catch { return NextResponse.json({ error: 'Geçersiz JSON' }, { status: 400 }) }
|
| 34 |
+
|
| 35 |
+
const symbol = String(body.symbol || '').trim().toUpperCase()
|
| 36 |
+
if (!symbol) return NextResponse.json({ error: 'symbol gerekli' }, { status: 400 })
|
| 37 |
+
|
| 38 |
+
const { data, error } = await supabase
|
| 39 |
+
.from('favorites')
|
| 40 |
+
.upsert(
|
| 41 |
+
{ user_id: user.id, symbol, notes: body.notes || null },
|
| 42 |
+
{ onConflict: 'user_id,symbol' }
|
| 43 |
+
)
|
| 44 |
+
.select()
|
| 45 |
+
.single()
|
| 46 |
+
|
| 47 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 48 |
+
return NextResponse.json({ item: data }, { status: 201 })
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// DELETE /api/favorites?symbol=THYAO — favorilerden çıkar
|
| 52 |
+
export async function DELETE(req: NextRequest) {
|
| 53 |
+
const supabase = await createClient()
|
| 54 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 55 |
+
if (!user || authErr) {
|
| 56 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const url = new URL(req.url)
|
| 60 |
+
const symbol = url.searchParams.get('symbol')?.trim().toUpperCase()
|
| 61 |
+
const id = url.searchParams.get('id')
|
| 62 |
+
|
| 63 |
+
if (!symbol && !id) {
|
| 64 |
+
return NextResponse.json({ error: 'symbol veya id gerekli' }, { status: 400 })
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
let query = supabase.from('favorites').delete().eq('user_id', user.id)
|
| 68 |
+
if (id) query = query.eq('id', id)
|
| 69 |
+
else if (symbol) query = query.eq('symbol', symbol)
|
| 70 |
+
|
| 71 |
+
const { error } = await query
|
| 72 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 73 |
+
return NextResponse.json({ ok: true })
|
| 74 |
+
}
|
nextjs-app/src/app/api/notifications/route.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server'
|
| 2 |
+
import { createClient } from '@/lib/supabase/server'
|
| 3 |
+
|
| 4 |
+
export const dynamic = 'force-dynamic'
|
| 5 |
+
|
| 6 |
+
// GET /api/notifications — kullanıcının bildirimleri
|
| 7 |
+
export async function GET() {
|
| 8 |
+
const supabase = await createClient()
|
| 9 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 10 |
+
if (!user || authErr) {
|
| 11 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const { data, error } = await supabase
|
| 15 |
+
.from('notifications')
|
| 16 |
+
.select('*')
|
| 17 |
+
.eq('user_id', user.id)
|
| 18 |
+
.order('created_at', { ascending: false })
|
| 19 |
+
.limit(50)
|
| 20 |
+
|
| 21 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 22 |
+
|
| 23 |
+
const unreadCount = (data ?? []).filter((n) => !n.is_read).length
|
| 24 |
+
|
| 25 |
+
return NextResponse.json({ notifications: data ?? [], unread_count: unreadCount })
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// PATCH /api/notifications — okundu olarak işaretle
|
| 29 |
+
export async function PATCH(req: Request) {
|
| 30 |
+
const supabase = await createClient()
|
| 31 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 32 |
+
if (!user || authErr) {
|
| 33 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
let body: Record<string, unknown>
|
| 37 |
+
try { body = await req.json() } catch { return NextResponse.json({ error: 'Geçersiz JSON' }, { status: 400 }) }
|
| 38 |
+
|
| 39 |
+
// Mark single or all as read
|
| 40 |
+
if (body.id) {
|
| 41 |
+
const { error } = await supabase
|
| 42 |
+
.from('notifications')
|
| 43 |
+
.update({ is_read: true })
|
| 44 |
+
.eq('id', String(body.id))
|
| 45 |
+
.eq('user_id', user.id)
|
| 46 |
+
|
| 47 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 48 |
+
} else if (body.mark_all_read) {
|
| 49 |
+
const { error } = await supabase
|
| 50 |
+
.from('notifications')
|
| 51 |
+
.update({ is_read: true })
|
| 52 |
+
.eq('user_id', user.id)
|
| 53 |
+
.eq('is_read', false)
|
| 54 |
+
|
| 55 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return NextResponse.json({ ok: true })
|
| 59 |
+
}
|
nextjs-app/src/app/api/portfolio/route.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { createClient } from '@/lib/supabase/server'
|
| 3 |
+
|
| 4 |
+
export const dynamic = 'force-dynamic'
|
| 5 |
+
|
| 6 |
+
// GET /api/portfolio — kullanıcının portföyünü getir
|
| 7 |
+
export async function GET() {
|
| 8 |
+
const supabase = await createClient()
|
| 9 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 10 |
+
if (!user || authErr) {
|
| 11 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const { data, error } = await supabase
|
| 15 |
+
.from('portfolios')
|
| 16 |
+
.select('*')
|
| 17 |
+
.eq('user_id', user.id)
|
| 18 |
+
.order('created_at', { ascending: false })
|
| 19 |
+
|
| 20 |
+
if (error) {
|
| 21 |
+
return NextResponse.json({ error: error.message }, { status: 500 })
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
return NextResponse.json({ items: data ?? [] })
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// POST /api/portfolio — yeni pozisyon ekle
|
| 28 |
+
export async function POST(req: NextRequest) {
|
| 29 |
+
const supabase = await createClient()
|
| 30 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 31 |
+
if (!user || authErr) {
|
| 32 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
let body: Record<string, unknown>
|
| 36 |
+
try {
|
| 37 |
+
body = await req.json()
|
| 38 |
+
} catch {
|
| 39 |
+
return NextResponse.json({ error: 'Geçersiz JSON' }, { status: 400 })
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const symbol = String(body.symbol || '').trim().toUpperCase()
|
| 43 |
+
const quantity = Number(body.quantity || body.shares || 0)
|
| 44 |
+
const avgPrice = Number(body.avg_purchase_price || body.average_price || body.price || 0)
|
| 45 |
+
|
| 46 |
+
if (!symbol || quantity <= 0 || avgPrice <= 0) {
|
| 47 |
+
return NextResponse.json(
|
| 48 |
+
{ error: 'symbol, quantity (>0), avg_purchase_price (>0) gerekli' },
|
| 49 |
+
{ status: 400 }
|
| 50 |
+
)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// UPSERT: aynı hisse varsa quantity ve avg_cost güncelle
|
| 54 |
+
const { data: existing } = await supabase
|
| 55 |
+
.from('portfolios')
|
| 56 |
+
.select('*')
|
| 57 |
+
.eq('user_id', user.id)
|
| 58 |
+
.eq('symbol', symbol)
|
| 59 |
+
.maybeSingle()
|
| 60 |
+
|
| 61 |
+
if (existing) {
|
| 62 |
+
const oldQty = Number(existing.quantity) || 0
|
| 63 |
+
const oldAvg = Number(existing.avg_purchase_price) || avgPrice
|
| 64 |
+
const newQty = oldQty + quantity
|
| 65 |
+
const newAvg = (oldAvg * oldQty + avgPrice * quantity) / newQty
|
| 66 |
+
|
| 67 |
+
const { data, error } = await supabase
|
| 68 |
+
.from('portfolios')
|
| 69 |
+
.update({ quantity: newQty, avg_purchase_price: Math.round(newAvg * 100) / 100 })
|
| 70 |
+
.eq('id', existing.id)
|
| 71 |
+
.select()
|
| 72 |
+
.single()
|
| 73 |
+
|
| 74 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 75 |
+
|
| 76 |
+
// Log trade
|
| 77 |
+
await supabase.from('user_trades').insert({
|
| 78 |
+
user_id: user.id,
|
| 79 |
+
symbol,
|
| 80 |
+
side: 'BUY',
|
| 81 |
+
quantity,
|
| 82 |
+
price: avgPrice,
|
| 83 |
+
notes: String(body.notes || ''),
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
return NextResponse.json({ item: data, merged: true })
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const { data, error } = await supabase
|
| 90 |
+
.from('portfolios')
|
| 91 |
+
.insert({
|
| 92 |
+
user_id: user.id,
|
| 93 |
+
symbol,
|
| 94 |
+
quantity,
|
| 95 |
+
avg_purchase_price: avgPrice,
|
| 96 |
+
purchase_date: body.purchase_date || new Date().toISOString().split('T')[0],
|
| 97 |
+
notes: body.notes || null,
|
| 98 |
+
})
|
| 99 |
+
.select()
|
| 100 |
+
.single()
|
| 101 |
+
|
| 102 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 103 |
+
|
| 104 |
+
// Log trade
|
| 105 |
+
await supabase.from('user_trades').insert({
|
| 106 |
+
user_id: user.id,
|
| 107 |
+
symbol,
|
| 108 |
+
side: 'BUY',
|
| 109 |
+
quantity,
|
| 110 |
+
price: avgPrice,
|
| 111 |
+
notes: String(body.notes || ''),
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
return NextResponse.json({ item: data }, { status: 201 })
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// PATCH /api/portfolio — pozisyon güncelle
|
| 118 |
+
export async function PATCH(req: NextRequest) {
|
| 119 |
+
const supabase = await createClient()
|
| 120 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 121 |
+
if (!user || authErr) {
|
| 122 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
let body: Record<string, unknown>
|
| 126 |
+
try {
|
| 127 |
+
body = await req.json()
|
| 128 |
+
} catch {
|
| 129 |
+
return NextResponse.json({ error: 'Geçersiz JSON' }, { status: 400 })
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const id = String(body.id || '')
|
| 133 |
+
if (!id) return NextResponse.json({ error: 'id gerekli' }, { status: 400 })
|
| 134 |
+
|
| 135 |
+
const updates: Record<string, unknown> = {}
|
| 136 |
+
if (body.quantity !== undefined) updates.quantity = Number(body.quantity)
|
| 137 |
+
if (body.avg_purchase_price !== undefined) updates.avg_purchase_price = Number(body.avg_purchase_price)
|
| 138 |
+
if (body.notes !== undefined) updates.notes = body.notes
|
| 139 |
+
|
| 140 |
+
const { data, error } = await supabase
|
| 141 |
+
.from('portfolios')
|
| 142 |
+
.update(updates)
|
| 143 |
+
.eq('id', id)
|
| 144 |
+
.eq('user_id', user.id)
|
| 145 |
+
.select()
|
| 146 |
+
.single()
|
| 147 |
+
|
| 148 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 149 |
+
return NextResponse.json({ item: data })
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// DELETE /api/portfolio — pozisyon sil (satış)
|
| 153 |
+
export async function DELETE(req: NextRequest) {
|
| 154 |
+
const supabase = await createClient()
|
| 155 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 156 |
+
if (!user || authErr) {
|
| 157 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const url = new URL(req.url)
|
| 161 |
+
const id = url.searchParams.get('id')
|
| 162 |
+
const symbol = url.searchParams.get('symbol')
|
| 163 |
+
const sellPrice = Number(url.searchParams.get('sell_price') || 0)
|
| 164 |
+
|
| 165 |
+
if (!id && !symbol) {
|
| 166 |
+
return NextResponse.json({ error: 'id veya symbol gerekli' }, { status: 400 })
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Get position before deleting (for trade log)
|
| 170 |
+
let query = supabase.from('portfolios').select('*').eq('user_id', user.id)
|
| 171 |
+
if (id) query = query.eq('id', id)
|
| 172 |
+
else if (symbol) query = query.eq('symbol', symbol)
|
| 173 |
+
|
| 174 |
+
const { data: position } = await query.maybeSingle()
|
| 175 |
+
|
| 176 |
+
if (position && sellPrice > 0) {
|
| 177 |
+
// Log sell trade
|
| 178 |
+
await supabase.from('user_trades').insert({
|
| 179 |
+
user_id: user.id,
|
| 180 |
+
symbol: position.symbol || symbol,
|
| 181 |
+
side: 'SELL',
|
| 182 |
+
quantity: position.quantity,
|
| 183 |
+
price: sellPrice,
|
| 184 |
+
})
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
let delQuery = supabase.from('portfolios').delete().eq('user_id', user.id)
|
| 188 |
+
if (id) delQuery = delQuery.eq('id', id)
|
| 189 |
+
else if (symbol) delQuery = delQuery.eq('symbol', symbol)
|
| 190 |
+
|
| 191 |
+
const { error } = await delQuery
|
| 192 |
+
|
| 193 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 194 |
+
return NextResponse.json({ ok: true })
|
| 195 |
+
}
|
nextjs-app/src/app/api/trades/route.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { createClient } from '@/lib/supabase/server'
|
| 3 |
+
|
| 4 |
+
export const dynamic = 'force-dynamic'
|
| 5 |
+
|
| 6 |
+
// GET /api/trades — kullanıcının işlem geçmişi
|
| 7 |
+
export async function GET(req: NextRequest) {
|
| 8 |
+
const supabase = await createClient()
|
| 9 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 10 |
+
if (!user || authErr) {
|
| 11 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const url = new URL(req.url)
|
| 15 |
+
const symbol = url.searchParams.get('symbol')
|
| 16 |
+
const limit = Math.min(Number(url.searchParams.get('limit') || 50), 200)
|
| 17 |
+
const offset = Number(url.searchParams.get('offset') || 0)
|
| 18 |
+
|
| 19 |
+
let query = supabase
|
| 20 |
+
.from('user_trades')
|
| 21 |
+
.select('*', { count: 'exact' })
|
| 22 |
+
.eq('user_id', user.id)
|
| 23 |
+
.order('trade_date', { ascending: false })
|
| 24 |
+
.range(offset, offset + limit - 1)
|
| 25 |
+
|
| 26 |
+
if (symbol) {
|
| 27 |
+
query = query.eq('symbol', symbol.toUpperCase())
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const { data, error, count } = await query
|
| 31 |
+
|
| 32 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 33 |
+
return NextResponse.json({ trades: data ?? [], total: count ?? 0 })
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// POST /api/trades — manuel işlem ekle
|
| 37 |
+
export async function POST(req: NextRequest) {
|
| 38 |
+
const supabase = await createClient()
|
| 39 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 40 |
+
if (!user || authErr) {
|
| 41 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
let body: Record<string, unknown>
|
| 45 |
+
try { body = await req.json() } catch { return NextResponse.json({ error: 'Geçersiz JSON' }, { status: 400 }) }
|
| 46 |
+
|
| 47 |
+
const symbol = String(body.symbol || '').trim().toUpperCase()
|
| 48 |
+
const side = String(body.side || '').toUpperCase()
|
| 49 |
+
const quantity = Number(body.quantity || 0)
|
| 50 |
+
const price = Number(body.price || 0)
|
| 51 |
+
|
| 52 |
+
if (!symbol || !['BUY', 'SELL'].includes(side) || quantity <= 0 || price <= 0) {
|
| 53 |
+
return NextResponse.json(
|
| 54 |
+
{ error: 'symbol, side (BUY/SELL), quantity (>0), price (>0) gerekli' },
|
| 55 |
+
{ status: 400 }
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const { data, error } = await supabase
|
| 60 |
+
.from('user_trades')
|
| 61 |
+
.insert({
|
| 62 |
+
user_id: user.id,
|
| 63 |
+
symbol,
|
| 64 |
+
side,
|
| 65 |
+
quantity,
|
| 66 |
+
price,
|
| 67 |
+
commission: Number(body.commission || 0),
|
| 68 |
+
notes: body.notes || null,
|
| 69 |
+
trade_date: body.trade_date || new Date().toISOString(),
|
| 70 |
+
})
|
| 71 |
+
.select()
|
| 72 |
+
.single()
|
| 73 |
+
|
| 74 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 75 |
+
return NextResponse.json({ trade: data }, { status: 201 })
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// DELETE /api/trades?id=xxx — işlem sil
|
| 79 |
+
export async function DELETE(req: NextRequest) {
|
| 80 |
+
const supabase = await createClient()
|
| 81 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 82 |
+
if (!user || authErr) {
|
| 83 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const url = new URL(req.url)
|
| 87 |
+
const id = url.searchParams.get('id')
|
| 88 |
+
if (!id) return NextResponse.json({ error: 'id gerekli' }, { status: 400 })
|
| 89 |
+
|
| 90 |
+
const { error } = await supabase
|
| 91 |
+
.from('user_trades')
|
| 92 |
+
.delete()
|
| 93 |
+
.eq('id', id)
|
| 94 |
+
.eq('user_id', user.id)
|
| 95 |
+
|
| 96 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 97 |
+
return NextResponse.json({ ok: true })
|
| 98 |
+
}
|
nextjs-app/src/app/api/watchlist/route.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { createClient } from '@/lib/supabase/server'
|
| 3 |
+
|
| 4 |
+
export const dynamic = 'force-dynamic'
|
| 5 |
+
|
| 6 |
+
// GET /api/watchlist — kullanıcının takip listesi
|
| 7 |
+
export async function GET() {
|
| 8 |
+
const supabase = await createClient()
|
| 9 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 10 |
+
if (!user || authErr) {
|
| 11 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const { data, error } = await supabase
|
| 15 |
+
.from('watchlists')
|
| 16 |
+
.select('*')
|
| 17 |
+
.eq('user_id', user.id)
|
| 18 |
+
.order('created_at', { ascending: false })
|
| 19 |
+
|
| 20 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 21 |
+
return NextResponse.json({ items: data ?? [] })
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// POST /api/watchlist — takip listesine ekle
|
| 25 |
+
export async function POST(req: NextRequest) {
|
| 26 |
+
const supabase = await createClient()
|
| 27 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 28 |
+
if (!user || authErr) {
|
| 29 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
let body: Record<string, unknown>
|
| 33 |
+
try { body = await req.json() } catch { return NextResponse.json({ error: 'Geçersiz JSON' }, { status: 400 }) }
|
| 34 |
+
|
| 35 |
+
const symbol = String(body.symbol || '').trim().toUpperCase()
|
| 36 |
+
if (!symbol) return NextResponse.json({ error: 'symbol gerekli' }, { status: 400 })
|
| 37 |
+
|
| 38 |
+
// Find stock_id if exists
|
| 39 |
+
const { data: stock } = await supabase
|
| 40 |
+
.from('stocks')
|
| 41 |
+
.select('id')
|
| 42 |
+
.eq('symbol', symbol)
|
| 43 |
+
.maybeSingle()
|
| 44 |
+
|
| 45 |
+
const { data, error } = await supabase
|
| 46 |
+
.from('watchlists')
|
| 47 |
+
.upsert({
|
| 48 |
+
user_id: user.id,
|
| 49 |
+
stock_id: stock?.id || null,
|
| 50 |
+
symbol,
|
| 51 |
+
alert_price_above: body.alert_price_above || null,
|
| 52 |
+
alert_price_below: body.alert_price_below || null,
|
| 53 |
+
alert_on_news: body.alert_on_news || false,
|
| 54 |
+
notes: body.notes || null,
|
| 55 |
+
}, { onConflict: 'user_id,stock_id' })
|
| 56 |
+
.select()
|
| 57 |
+
.single()
|
| 58 |
+
|
| 59 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 60 |
+
return NextResponse.json({ item: data }, { status: 201 })
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// DELETE /api/watchlist?symbol=THYAO — takip listesinden çıkar
|
| 64 |
+
export async function DELETE(req: NextRequest) {
|
| 65 |
+
const supabase = await createClient()
|
| 66 |
+
const { data: { user }, error: authErr } = await supabase.auth.getUser()
|
| 67 |
+
if (!user || authErr) {
|
| 68 |
+
return NextResponse.json({ error: 'Giriş gerekli' }, { status: 401 })
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const url = new URL(req.url)
|
| 72 |
+
const id = url.searchParams.get('id')
|
| 73 |
+
const symbol = url.searchParams.get('symbol')
|
| 74 |
+
|
| 75 |
+
if (!id && !symbol) {
|
| 76 |
+
return NextResponse.json({ error: 'id veya symbol gerekli' }, { status: 400 })
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
let query = supabase.from('watchlists').delete().eq('user_id', user.id)
|
| 80 |
+
if (id) query = query.eq('id', id)
|
| 81 |
+
// For symbol, find via stocks table
|
| 82 |
+
if (symbol && !id) {
|
| 83 |
+
const { data: stock } = await supabase.from('stocks').select('id').eq('symbol', symbol.toUpperCase()).maybeSingle()
|
| 84 |
+
if (stock) query = query.eq('stock_id', stock.id)
|
| 85 |
+
else return NextResponse.json({ error: 'Hisse bulunamadı' }, { status: 404 })
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const { error } = await query
|
| 89 |
+
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
| 90 |
+
return NextResponse.json({ ok: true })
|
| 91 |
+
}
|
nextjs-app/src/app/favorites/page.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
| 4 |
import Link from 'next/link'
|
| 5 |
import { Star, Trash2, Plus, RefreshCw } from 'lucide-react'
|
| 6 |
import { useAuth } from '@/contexts/AuthContext'
|
| 7 |
-
import {
|
| 8 |
import { formatCurrency, formatPercent } from '@/lib/utils'
|
| 9 |
import { toNumber } from '@/lib/api-utils'
|
| 10 |
import { NoFavorites } from '@/components/EmptyState'
|
|
@@ -30,7 +30,7 @@ export default function FavoritesPage() {
|
|
| 30 |
const refresh = useCallback(async () => {
|
| 31 |
try {
|
| 32 |
setLoading(true)
|
| 33 |
-
const favs =
|
| 34 |
setFavorites(favs)
|
| 35 |
|
| 36 |
if (favs.length === 0) {
|
|
@@ -74,18 +74,17 @@ export default function FavoritesPage() {
|
|
| 74 |
refresh()
|
| 75 |
}, [refresh])
|
| 76 |
|
| 77 |
-
const onAdd = () => {
|
| 78 |
const sym = symbolInput.trim().toUpperCase()
|
| 79 |
if (!sym) return
|
| 80 |
-
const next =
|
| 81 |
setFavorites(next)
|
| 82 |
setSymbolInput('')
|
| 83 |
-
// Best-effort: refresh prices
|
| 84 |
refresh()
|
| 85 |
}
|
| 86 |
|
| 87 |
-
const onRemove = (symbol: string) => {
|
| 88 |
-
const next =
|
| 89 |
setFavorites(next)
|
| 90 |
setRows((prev) => prev.filter((r) => r.symbol !== symbol))
|
| 91 |
}
|
|
|
|
| 4 |
import Link from 'next/link'
|
| 5 |
import { Star, Trash2, Plus, RefreshCw } from 'lucide-react'
|
| 6 |
import { useAuth } from '@/contexts/AuthContext'
|
| 7 |
+
import { addFavoriteAsync, loadFavoritesAsync, removeFavoriteAsync, type FavoriteItem } from '@/lib/favorites'
|
| 8 |
import { formatCurrency, formatPercent } from '@/lib/utils'
|
| 9 |
import { toNumber } from '@/lib/api-utils'
|
| 10 |
import { NoFavorites } from '@/components/EmptyState'
|
|
|
|
| 30 |
const refresh = useCallback(async () => {
|
| 31 |
try {
|
| 32 |
setLoading(true)
|
| 33 |
+
const favs = await loadFavoritesAsync(userKey)
|
| 34 |
setFavorites(favs)
|
| 35 |
|
| 36 |
if (favs.length === 0) {
|
|
|
|
| 74 |
refresh()
|
| 75 |
}, [refresh])
|
| 76 |
|
| 77 |
+
const onAdd = async () => {
|
| 78 |
const sym = symbolInput.trim().toUpperCase()
|
| 79 |
if (!sym) return
|
| 80 |
+
const next = await addFavoriteAsync(sym, userKey)
|
| 81 |
setFavorites(next)
|
| 82 |
setSymbolInput('')
|
|
|
|
| 83 |
refresh()
|
| 84 |
}
|
| 85 |
|
| 86 |
+
const onRemove = async (symbol: string) => {
|
| 87 |
+
const next = await removeFavoriteAsync(symbol, userKey)
|
| 88 |
setFavorites(next)
|
| 89 |
setRows((prev) => prev.filter((r) => r.symbol !== symbol))
|
| 90 |
}
|
nextjs-app/src/app/portfolio/page.tsx
CHANGED
|
@@ -38,54 +38,101 @@ function PortfolioContent() {
|
|
| 38 |
const [showAddForm, setShowAddForm] = useState(false);
|
| 39 |
|
| 40 |
const fetchPortfolio = useCallback(async () => {
|
|
|
|
| 41 |
try {
|
| 42 |
-
//
|
| 43 |
-
const
|
| 44 |
-
|
| 45 |
-
const items: PortfolioItem[] = JSON.parse(stored);
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
|
|
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
const hasLivePrice = Number.isFinite(parsedLast) && parsedLast > 0;
|
| 63 |
-
const currentPrice = hasLivePrice ? parsedLast : item.average_price;
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
profit_loss_pct: profitLossPct,
|
| 77 |
-
price_is_fallback: !hasLivePrice,
|
| 78 |
-
};
|
| 79 |
-
});
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
} catch (error) {
|
| 84 |
console.error('Failed to fetch portfolio:', error);
|
| 85 |
} finally {
|
| 86 |
setLoading(false);
|
| 87 |
}
|
| 88 |
-
}, [user
|
| 89 |
|
| 90 |
useEffect(() => {
|
| 91 |
if (user) {
|
|
@@ -93,30 +140,27 @@ function PortfolioContent() {
|
|
| 93 |
}
|
| 94 |
}, [user, fetchPortfolio]);
|
| 95 |
|
| 96 |
-
const addToPortfolio = (symbol: string, shares: number, price: number) => {
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
localStorage.setItem(`portfolio_${user?.id}`, JSON.stringify(items));
|
| 109 |
-
|
| 110 |
-
fetchPortfolio();
|
| 111 |
-
setShowAddForm(false);
|
| 112 |
};
|
| 113 |
|
| 114 |
-
const removeFromPortfolio = (id: string) => {
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
| 120 |
};
|
| 121 |
|
| 122 |
const totalValue = portfolio.reduce((sum, item) => sum + item.total_value, 0);
|
|
|
|
| 38 |
const [showAddForm, setShowAddForm] = useState(false);
|
| 39 |
|
| 40 |
const fetchPortfolio = useCallback(async () => {
|
| 41 |
+
if (!user) return;
|
| 42 |
try {
|
| 43 |
+
// Fetch from Supabase via API
|
| 44 |
+
const resp = await fetch('/api/portfolio');
|
| 45 |
+
const json = await resp.json().catch(() => null);
|
|
|
|
| 46 |
|
| 47 |
+
let items: PortfolioItem[] = [];
|
| 48 |
+
|
| 49 |
+
if (resp.ok && json?.items?.length > 0) {
|
| 50 |
+
items = json.items.map((row: Record<string, unknown>) => ({
|
| 51 |
+
id: String(row.id),
|
| 52 |
+
symbol: String(row.symbol || ''),
|
| 53 |
+
shares: Number(row.quantity || 0),
|
| 54 |
+
average_price: Number(row.avg_purchase_price || 0),
|
| 55 |
+
created_at: String(row.created_at || ''),
|
| 56 |
+
}));
|
| 57 |
+
} else {
|
| 58 |
+
// Fallback: migrate from localStorage if exists
|
| 59 |
+
const stored = localStorage.getItem(`portfolio_${user.id}`);
|
| 60 |
+
if (stored) {
|
| 61 |
+
const localItems: PortfolioItem[] = JSON.parse(stored);
|
| 62 |
+
// Migrate each to Supabase
|
| 63 |
+
for (const li of localItems) {
|
| 64 |
+
try {
|
| 65 |
+
await fetch('/api/portfolio', {
|
| 66 |
+
method: 'POST',
|
| 67 |
+
headers: { 'Content-Type': 'application/json' },
|
| 68 |
+
body: JSON.stringify({
|
| 69 |
+
symbol: li.symbol,
|
| 70 |
+
quantity: li.shares,
|
| 71 |
+
avg_purchase_price: li.average_price,
|
| 72 |
+
}),
|
| 73 |
+
});
|
| 74 |
+
} catch { /* ignore migration errors */ }
|
| 75 |
+
}
|
| 76 |
+
localStorage.removeItem(`portfolio_${user.id}`);
|
| 77 |
+
// Re-fetch from Supabase
|
| 78 |
+
const resp2 = await fetch('/api/portfolio');
|
| 79 |
+
const json2 = await resp2.json().catch(() => null);
|
| 80 |
+
if (json2?.items) {
|
| 81 |
+
items = json2.items.map((row: Record<string, unknown>) => ({
|
| 82 |
+
id: String(row.id),
|
| 83 |
+
symbol: String(row.symbol || ''),
|
| 84 |
+
shares: Number(row.quantity || 0),
|
| 85 |
+
average_price: Number(row.avg_purchase_price || 0),
|
| 86 |
+
created_at: String(row.created_at || ''),
|
| 87 |
+
}));
|
| 88 |
+
}
|
| 89 |
}
|
| 90 |
+
}
|
| 91 |
|
| 92 |
+
if (items.length === 0) {
|
| 93 |
+
setPortfolio([]);
|
| 94 |
+
return;
|
| 95 |
+
}
|
|
|
|
|
|
|
| 96 |
|
| 97 |
+
// Batch fetch current prices
|
| 98 |
+
const symbols = items.map((item) => item.symbol).join(',');
|
| 99 |
+
let batchData: Record<string, unknown> = {};
|
| 100 |
+
try {
|
| 101 |
+
const batchResp = await fetch(`/api/stocks/batch?symbols=${encodeURIComponent(symbols)}`);
|
| 102 |
+
const batchJson = await batchResp.json().catch(() => null);
|
| 103 |
+
batchData = batchJson?.stocks || {};
|
| 104 |
+
} catch { /* price fetch failed, use avg cost */ }
|
| 105 |
|
| 106 |
+
const enriched = items.map((item) => {
|
| 107 |
+
const data = batchData[item.symbol] as Record<string, Record<string, unknown> | undefined> | undefined;
|
| 108 |
+
const rawLast = data?.stock?.last_price;
|
| 109 |
+
const parsedLast = typeof rawLast === 'number' ? rawLast : Number(rawLast);
|
| 110 |
+
const hasLivePrice = Number.isFinite(parsedLast) && parsedLast > 0;
|
| 111 |
+
const currentPrice = hasLivePrice ? parsedLast : item.average_price;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
+
const totalValue = item.shares * currentPrice;
|
| 114 |
+
const totalCost = item.shares * item.average_price;
|
| 115 |
+
const profitLoss = hasLivePrice ? (totalValue - totalCost) : 0;
|
| 116 |
+
const profitLossPct = hasLivePrice && totalCost > 0 ? (profitLoss / totalCost) * 100 : 0;
|
| 117 |
+
|
| 118 |
+
return {
|
| 119 |
+
...item,
|
| 120 |
+
current_price: currentPrice,
|
| 121 |
+
name: String(data?.stock?.name || item.symbol),
|
| 122 |
+
total_value: totalValue,
|
| 123 |
+
profit_loss: profitLoss,
|
| 124 |
+
profit_loss_pct: profitLossPct,
|
| 125 |
+
price_is_fallback: !hasLivePrice,
|
| 126 |
+
};
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
setPortfolio(enriched);
|
| 130 |
} catch (error) {
|
| 131 |
console.error('Failed to fetch portfolio:', error);
|
| 132 |
} finally {
|
| 133 |
setLoading(false);
|
| 134 |
}
|
| 135 |
+
}, [user]);
|
| 136 |
|
| 137 |
useEffect(() => {
|
| 138 |
if (user) {
|
|
|
|
| 140 |
}
|
| 141 |
}, [user, fetchPortfolio]);
|
| 142 |
|
| 143 |
+
const addToPortfolio = async (symbol: string, shares: number, price: number) => {
|
| 144 |
+
try {
|
| 145 |
+
await fetch('/api/portfolio', {
|
| 146 |
+
method: 'POST',
|
| 147 |
+
headers: { 'Content-Type': 'application/json' },
|
| 148 |
+
body: JSON.stringify({ symbol, quantity: shares, avg_purchase_price: price }),
|
| 149 |
+
});
|
| 150 |
+
fetchPortfolio();
|
| 151 |
+
setShowAddForm(false);
|
| 152 |
+
} catch (error) {
|
| 153 |
+
console.error('Failed to add to portfolio:', error);
|
| 154 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
};
|
| 156 |
|
| 157 |
+
const removeFromPortfolio = async (id: string) => {
|
| 158 |
+
try {
|
| 159 |
+
await fetch(`/api/portfolio?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
| 160 |
+
fetchPortfolio();
|
| 161 |
+
} catch (error) {
|
| 162 |
+
console.error('Failed to remove from portfolio:', error);
|
| 163 |
+
}
|
| 164 |
};
|
| 165 |
|
| 166 |
const totalValue = portfolio.reduce((sum, item) => sum + item.total_value, 0);
|
nextjs-app/src/components/StockDetail.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
|
| 8 |
import { TrendingUp, TrendingDown, ArrowLeft, Star, BarChart3, Activity, Brain, ChevronDown, ChevronUp } from 'lucide-react'
|
| 9 |
import Link from 'next/link'
|
| 10 |
import { useAuth } from '@/contexts/AuthContext'
|
| 11 |
-
import {
|
| 12 |
import type { TechnicalIndicator } from '@/types'
|
| 13 |
|
| 14 |
// Lazy load the heavy chart component
|
|
@@ -39,8 +39,9 @@ export function StockDetail({ symbol }: { symbol: string }) {
|
|
| 39 |
const userKey = user?.id
|
| 40 |
|
| 41 |
useEffect(() => {
|
| 42 |
-
|
| 43 |
-
|
|
|
|
| 44 |
}, [symbol, userKey])
|
| 45 |
|
| 46 |
useEffect(() => {
|
|
@@ -113,8 +114,8 @@ export function StockDetail({ symbol }: { symbol: string }) {
|
|
| 113 |
<h1 className="text-2xl font-bold text-white">{stock.symbol}</h1>
|
| 114 |
<button
|
| 115 |
type="button"
|
| 116 |
-
onClick={() => {
|
| 117 |
-
const res =
|
| 118 |
setIsFav(res.nowFavorite)
|
| 119 |
}}
|
| 120 |
className="p-1.5 rounded-lg hover:bg-gray-800 transition-colors"
|
|
|
|
| 8 |
import { TrendingUp, TrendingDown, ArrowLeft, Star, BarChart3, Activity, Brain, ChevronDown, ChevronUp } from 'lucide-react'
|
| 9 |
import Link from 'next/link'
|
| 10 |
import { useAuth } from '@/contexts/AuthContext'
|
| 11 |
+
import { loadFavoritesAsync, toggleFavoriteAsync } from '@/lib/favorites'
|
| 12 |
import type { TechnicalIndicator } from '@/types'
|
| 13 |
|
| 14 |
// Lazy load the heavy chart component
|
|
|
|
| 39 |
const userKey = user?.id
|
| 40 |
|
| 41 |
useEffect(() => {
|
| 42 |
+
loadFavoritesAsync(userKey).then(favs => {
|
| 43 |
+
setIsFav(favs.some((f) => f.symbol === String(symbol).toUpperCase()))
|
| 44 |
+
})
|
| 45 |
}, [symbol, userKey])
|
| 46 |
|
| 47 |
useEffect(() => {
|
|
|
|
| 114 |
<h1 className="text-2xl font-bold text-white">{stock.symbol}</h1>
|
| 115 |
<button
|
| 116 |
type="button"
|
| 117 |
+
onClick={async () => {
|
| 118 |
+
const res = await toggleFavoriteAsync(stock.symbol, userKey)
|
| 119 |
setIsFav(res.nowFavorite)
|
| 120 |
}}
|
| 121 |
className="p-1.5 rounded-lg hover:bg-gray-800 transition-colors"
|
nextjs-app/src/components/StockList.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { Star, TrendingUp, TrendingDown } from 'lucide-react'
|
|
| 9 |
|
| 10 |
import { fetchJson } from '@/lib/http'
|
| 11 |
import { useAuth } from '@/contexts/AuthContext'
|
| 12 |
-
import {
|
| 13 |
import { toNumber } from '@/lib/api-utils'
|
| 14 |
|
| 15 |
type StockRow = Partial<StockSummary> & {
|
|
@@ -45,8 +45,9 @@ export function StockList() {
|
|
| 45 |
const userKey = user?.id
|
| 46 |
|
| 47 |
useEffect(() => {
|
| 48 |
-
|
| 49 |
-
|
|
|
|
| 50 |
}, [userKey])
|
| 51 |
|
| 52 |
useEffect(() => {
|
|
@@ -133,8 +134,8 @@ export function StockList() {
|
|
| 133 |
<div className="flex items-center gap-2">
|
| 134 |
<button
|
| 135 |
type="button"
|
| 136 |
-
onClick={() => {
|
| 137 |
-
const { next } =
|
| 138 |
setFavoriteSymbols(new Set(next.map((f) => f.symbol)))
|
| 139 |
}}
|
| 140 |
className="p-1 rounded hover:bg-gray-100"
|
|
|
|
| 9 |
|
| 10 |
import { fetchJson } from '@/lib/http'
|
| 11 |
import { useAuth } from '@/contexts/AuthContext'
|
| 12 |
+
import { loadFavoritesAsync, toggleFavoriteAsync } from '@/lib/favorites'
|
| 13 |
import { toNumber } from '@/lib/api-utils'
|
| 14 |
|
| 15 |
type StockRow = Partial<StockSummary> & {
|
|
|
|
| 45 |
const userKey = user?.id
|
| 46 |
|
| 47 |
useEffect(() => {
|
| 48 |
+
loadFavoritesAsync(userKey).then(favs => {
|
| 49 |
+
setFavoriteSymbols(new Set(favs.map((f) => f.symbol)))
|
| 50 |
+
})
|
| 51 |
}, [userKey])
|
| 52 |
|
| 53 |
useEffect(() => {
|
|
|
|
| 134 |
<div className="flex items-center gap-2">
|
| 135 |
<button
|
| 136 |
type="button"
|
| 137 |
+
onClick={async () => {
|
| 138 |
+
const { next } = await toggleFavoriteAsync(stock.symbol, userKey)
|
| 139 |
setFavoriteSymbols(new Set(next.map((f) => f.symbol)))
|
| 140 |
}}
|
| 141 |
className="p-1 rounded hover:bg-gray-100"
|
nextjs-app/src/lib/favorites.ts
CHANGED
|
@@ -5,17 +5,122 @@ export interface FavoriteItem {
|
|
| 5 |
|
| 6 |
const FAVORITES_PREFIX = 'borsa.favorites.'
|
| 7 |
|
| 8 |
-
function
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
try {
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
-
function
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
function normalizeItem(input: unknown): FavoriteItem | null {
|
|
@@ -37,9 +142,7 @@ export function loadFavorites(userId?: string | null): FavoriteItem[] {
|
|
| 37 |
const parsed = safeParseJson(window.localStorage.getItem(key))
|
| 38 |
if (!Array.isArray(parsed)) return []
|
| 39 |
const items = parsed.map(normalizeItem).filter(Boolean) as FavoriteItem[]
|
| 40 |
-
// Newest first
|
| 41 |
items.sort((a, b) => (a.added_at < b.added_at ? 1 : a.added_at > b.added_at ? -1 : 0))
|
| 42 |
-
// Dedupe by symbol
|
| 43 |
const seen = new Set<string>()
|
| 44 |
const deduped: FavoriteItem[] = []
|
| 45 |
for (const it of items) {
|
|
@@ -59,8 +162,7 @@ export function saveFavorites(items: FavoriteItem[], userId?: string | null): vo
|
|
| 59 |
export function isFavorite(symbol: string, userId?: string | null): boolean {
|
| 60 |
const sym = normalizeSymbol(symbol)
|
| 61 |
if (!sym) return false
|
| 62 |
-
|
| 63 |
-
return items.some((i) => i.symbol === sym)
|
| 64 |
}
|
| 65 |
|
| 66 |
export function addFavorite(symbol: string, userId?: string | null): FavoriteItem[] {
|
|
|
|
| 5 |
|
| 6 |
const FAVORITES_PREFIX = 'borsa.favorites.'
|
| 7 |
|
| 8 |
+
function normalizeSymbol(value: unknown): string {
|
| 9 |
+
return String(value || '').trim().toUpperCase()
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/** Cached in-memory store to avoid repeated fetches within same page */
|
| 13 |
+
let _cache: FavoriteItem[] | null = null
|
| 14 |
+
let _cacheUserId: string | null = null
|
| 15 |
+
|
| 16 |
+
function invalidateCache() {
|
| 17 |
+
_cache = null
|
| 18 |
+
_cacheUserId = null
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// --------------- Async (Supabase-backed) API ---------------
|
| 22 |
+
|
| 23 |
+
export async function loadFavoritesAsync(userId?: string | null): Promise<FavoriteItem[]> {
|
| 24 |
+
if (!userId) return []
|
| 25 |
+
if (_cache && _cacheUserId === userId) return _cache
|
| 26 |
+
|
| 27 |
try {
|
| 28 |
+
const resp = await fetch('/api/favorites')
|
| 29 |
+
const json = await resp.json().catch(() => null)
|
| 30 |
+
if (resp.ok && json?.items) {
|
| 31 |
+
const items: FavoriteItem[] = (json.items as Record<string, unknown>[]).map((r) => ({
|
| 32 |
+
symbol: String(r.symbol || ''),
|
| 33 |
+
added_at: String(r.created_at || r.added_at || new Date().toISOString()),
|
| 34 |
+
}))
|
| 35 |
+
items.sort((a, b) => (a.added_at < b.added_at ? 1 : -1))
|
| 36 |
+
_cache = items
|
| 37 |
+
_cacheUserId = userId
|
| 38 |
+
// Migrate from localStorage if Supabase was empty but localStorage has data
|
| 39 |
+
if (items.length === 0 && typeof window !== 'undefined') {
|
| 40 |
+
const key = `${FAVORITES_PREFIX}${userId}`
|
| 41 |
+
const stored = window.localStorage.getItem(key)
|
| 42 |
+
if (stored) {
|
| 43 |
+
try {
|
| 44 |
+
const local = JSON.parse(stored) as { symbol?: string; added_at?: string }[]
|
| 45 |
+
if (Array.isArray(local) && local.length > 0) {
|
| 46 |
+
for (const li of local) {
|
| 47 |
+
const sym = normalizeSymbol(li.symbol)
|
| 48 |
+
if (sym) {
|
| 49 |
+
await fetch('/api/favorites', {
|
| 50 |
+
method: 'POST',
|
| 51 |
+
headers: { 'Content-Type': 'application/json' },
|
| 52 |
+
body: JSON.stringify({ symbol: sym }),
|
| 53 |
+
})
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
window.localStorage.removeItem(key)
|
| 57 |
+
invalidateCache()
|
| 58 |
+
return loadFavoritesAsync(userId)
|
| 59 |
+
}
|
| 60 |
+
} catch { /* ignore */ }
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
return items
|
| 64 |
+
}
|
| 65 |
+
} catch { /* network error */ }
|
| 66 |
+
|
| 67 |
+
// Fallback to localStorage
|
| 68 |
+
return loadFavorites(userId)
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export async function addFavoriteAsync(symbol: string, _userId?: string | null): Promise<FavoriteItem[]> {
|
| 72 |
+
const sym = normalizeSymbol(symbol)
|
| 73 |
+
if (!sym) return _cache || []
|
| 74 |
+
try {
|
| 75 |
+
await fetch('/api/favorites', {
|
| 76 |
+
method: 'POST',
|
| 77 |
+
headers: { 'Content-Type': 'application/json' },
|
| 78 |
+
body: JSON.stringify({ symbol: sym }),
|
| 79 |
+
})
|
| 80 |
+
invalidateCache()
|
| 81 |
+
} catch { /* fallback handled below */ }
|
| 82 |
+
return loadFavoritesAsync(_userId)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export async function removeFavoriteAsync(symbol: string, _userId?: string | null): Promise<FavoriteItem[]> {
|
| 86 |
+
const sym = normalizeSymbol(symbol)
|
| 87 |
+
if (!sym) return _cache || []
|
| 88 |
+
try {
|
| 89 |
+
await fetch(`/api/favorites?symbol=${encodeURIComponent(sym)}`, { method: 'DELETE' })
|
| 90 |
+
invalidateCache()
|
| 91 |
+
} catch { /* fallback */ }
|
| 92 |
+
return loadFavoritesAsync(_userId)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export async function toggleFavoriteAsync(
|
| 96 |
+
symbol: string,
|
| 97 |
+
userId?: string | null
|
| 98 |
+
): Promise<{ next: FavoriteItem[]; nowFavorite: boolean }> {
|
| 99 |
+
const sym = normalizeSymbol(symbol)
|
| 100 |
+
if (!sym) return { next: [], nowFavorite: false }
|
| 101 |
+
const items = await loadFavoritesAsync(userId)
|
| 102 |
+
const exists = items.some((i) => i.symbol === sym)
|
| 103 |
+
if (exists) {
|
| 104 |
+
const next = await removeFavoriteAsync(sym, userId)
|
| 105 |
+
return { next, nowFavorite: false }
|
| 106 |
}
|
| 107 |
+
const next = await addFavoriteAsync(sym, userId)
|
| 108 |
+
return { next, nowFavorite: true }
|
| 109 |
}
|
| 110 |
|
| 111 |
+
export async function isFavoriteAsync(symbol: string, userId?: string | null): Promise<boolean> {
|
| 112 |
+
const sym = normalizeSymbol(symbol)
|
| 113 |
+
if (!sym) return false
|
| 114 |
+
const items = await loadFavoritesAsync(userId)
|
| 115 |
+
return items.some((i) => i.symbol === sym)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// --------------- Synchronous (localStorage) fallback API ---------------
|
| 119 |
+
// Kept for SSR and anonymous users
|
| 120 |
+
|
| 121 |
+
function safeParseJson(value: string | null): unknown {
|
| 122 |
+
if (!value) return null
|
| 123 |
+
try { return JSON.parse(value) } catch { return null }
|
| 124 |
}
|
| 125 |
|
| 126 |
function normalizeItem(input: unknown): FavoriteItem | null {
|
|
|
|
| 142 |
const parsed = safeParseJson(window.localStorage.getItem(key))
|
| 143 |
if (!Array.isArray(parsed)) return []
|
| 144 |
const items = parsed.map(normalizeItem).filter(Boolean) as FavoriteItem[]
|
|
|
|
| 145 |
items.sort((a, b) => (a.added_at < b.added_at ? 1 : a.added_at > b.added_at ? -1 : 0))
|
|
|
|
| 146 |
const seen = new Set<string>()
|
| 147 |
const deduped: FavoriteItem[] = []
|
| 148 |
for (const it of items) {
|
|
|
|
| 162 |
export function isFavorite(symbol: string, userId?: string | null): boolean {
|
| 163 |
const sym = normalizeSymbol(symbol)
|
| 164 |
if (!sym) return false
|
| 165 |
+
return loadFavorites(userId).some((i) => i.symbol === sym)
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
export function addFavorite(symbol: string, userId?: string | null): FavoriteItem[] {
|
supabase/migrations/20260216_user_features.sql
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ============================================
|
| 2 |
+
-- USER FEATURES MIGRATION
|
| 3 |
+
-- Adds: user_trades, price_alerts, notifications,
|
| 4 |
+
-- favorites table + fixes portfolios for real use
|
| 5 |
+
-- Date: 16 Şubat 2026
|
| 6 |
+
-- ============================================
|
| 7 |
+
|
| 8 |
+
-- ============================================
|
| 9 |
+
-- 1. USER TRADES (İşlem Geçmişi)
|
| 10 |
+
-- Her kullanıcının al/sat geçmişi
|
| 11 |
+
-- ============================================
|
| 12 |
+
CREATE TABLE IF NOT EXISTS user_trades (
|
| 13 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 14 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 15 |
+
symbol TEXT NOT NULL,
|
| 16 |
+
side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')),
|
| 17 |
+
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
| 18 |
+
price DECIMAL(12,2) NOT NULL CHECK (price > 0),
|
| 19 |
+
commission DECIMAL(10,2) DEFAULT 0,
|
| 20 |
+
total_cost DECIMAL(14,2) GENERATED ALWAYS AS (quantity * price + commission) STORED,
|
| 21 |
+
notes TEXT,
|
| 22 |
+
trade_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 23 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
CREATE INDEX idx_user_trades_user ON user_trades(user_id, trade_date DESC);
|
| 27 |
+
CREATE INDEX idx_user_trades_symbol ON user_trades(user_id, symbol, trade_date DESC);
|
| 28 |
+
|
| 29 |
+
COMMENT ON TABLE user_trades IS 'Kullanıcı işlem geçmişi — her al/sat kaydı';
|
| 30 |
+
|
| 31 |
+
-- RLS
|
| 32 |
+
ALTER TABLE user_trades ENABLE ROW LEVEL SECURITY;
|
| 33 |
+
CREATE POLICY "Users can view own trades" ON user_trades FOR SELECT USING (auth.uid() = user_id);
|
| 34 |
+
CREATE POLICY "Users can insert own trades" ON user_trades FOR INSERT WITH CHECK (auth.uid() = user_id);
|
| 35 |
+
CREATE POLICY "Users can update own trades" ON user_trades FOR UPDATE USING (auth.uid() = user_id);
|
| 36 |
+
CREATE POLICY "Users can delete own trades" ON user_trades FOR DELETE USING (auth.uid() = user_id);
|
| 37 |
+
|
| 38 |
+
GRANT SELECT, INSERT, UPDATE, DELETE ON user_trades TO authenticated;
|
| 39 |
+
|
| 40 |
+
-- ============================================
|
| 41 |
+
-- 2. FAVORITES TABLE (Favoriler)
|
| 42 |
+
-- Kullanıcının favori hisseleri — localStorage'dan Supabase'e taşınıyor
|
| 43 |
+
-- ============================================
|
| 44 |
+
CREATE TABLE IF NOT EXISTS favorites (
|
| 45 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 46 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 47 |
+
symbol TEXT NOT NULL,
|
| 48 |
+
notes TEXT,
|
| 49 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 50 |
+
UNIQUE(user_id, symbol)
|
| 51 |
+
);
|
| 52 |
+
|
| 53 |
+
CREATE INDEX idx_favorites_user ON favorites(user_id, created_at DESC);
|
| 54 |
+
|
| 55 |
+
COMMENT ON TABLE favorites IS 'Kullanıcı favori hisseleri';
|
| 56 |
+
|
| 57 |
+
-- RLS
|
| 58 |
+
ALTER TABLE favorites ENABLE ROW LEVEL SECURITY;
|
| 59 |
+
CREATE POLICY "Users can view own favorites" ON favorites FOR SELECT USING (auth.uid() = user_id);
|
| 60 |
+
CREATE POLICY "Users can insert own favorites" ON favorites FOR INSERT WITH CHECK (auth.uid() = user_id);
|
| 61 |
+
CREATE POLICY "Users can delete own favorites" ON favorites FOR DELETE USING (auth.uid() = user_id);
|
| 62 |
+
|
| 63 |
+
GRANT SELECT, INSERT, DELETE ON favorites TO authenticated;
|
| 64 |
+
|
| 65 |
+
-- ============================================
|
| 66 |
+
-- 3. PRICE ALERTS (Fiyat Alarmları)
|
| 67 |
+
-- Hisse belirli fiyata gelince bildirim
|
| 68 |
+
-- ============================================
|
| 69 |
+
CREATE TABLE IF NOT EXISTS price_alerts (
|
| 70 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 71 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 72 |
+
symbol TEXT NOT NULL,
|
| 73 |
+
alert_type TEXT NOT NULL CHECK (alert_type IN ('above', 'below', 'change_pct')),
|
| 74 |
+
target_value DECIMAL(12,2) NOT NULL,
|
| 75 |
+
is_triggered BOOLEAN DEFAULT FALSE,
|
| 76 |
+
triggered_at TIMESTAMP WITH TIME ZONE,
|
| 77 |
+
triggered_price DECIMAL(12,2),
|
| 78 |
+
is_active BOOLEAN DEFAULT TRUE,
|
| 79 |
+
notes TEXT,
|
| 80 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
CREATE INDEX idx_price_alerts_user ON price_alerts(user_id, is_active);
|
| 84 |
+
CREATE INDEX idx_price_alerts_active ON price_alerts(symbol, is_active) WHERE is_active = TRUE;
|
| 85 |
+
|
| 86 |
+
COMMENT ON TABLE price_alerts IS 'Fiyat alarmları — hedefe ulaşınca tetiklenir';
|
| 87 |
+
|
| 88 |
+
-- RLS
|
| 89 |
+
ALTER TABLE price_alerts ENABLE ROW LEVEL SECURITY;
|
| 90 |
+
CREATE POLICY "Users can view own alerts" ON price_alerts FOR SELECT USING (auth.uid() = user_id);
|
| 91 |
+
CREATE POLICY "Users can insert own alerts" ON price_alerts FOR INSERT WITH CHECK (auth.uid() = user_id);
|
| 92 |
+
CREATE POLICY "Users can update own alerts" ON price_alerts FOR UPDATE USING (auth.uid() = user_id);
|
| 93 |
+
CREATE POLICY "Users can delete own alerts" ON price_alerts FOR DELETE USING (auth.uid() = user_id);
|
| 94 |
+
|
| 95 |
+
GRANT SELECT, INSERT, UPDATE, DELETE ON price_alerts TO authenticated;
|
| 96 |
+
|
| 97 |
+
-- ============================================
|
| 98 |
+
-- 4. NOTIFICATIONS (Bildirimler)
|
| 99 |
+
-- Kullanıcıya gösterilen bildirimler
|
| 100 |
+
-- ============================================
|
| 101 |
+
CREATE TABLE IF NOT EXISTS notifications (
|
| 102 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 103 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 104 |
+
type TEXT NOT NULL CHECK (type IN ('alert', 'trade', 'system', 'news', 'ml_signal')),
|
| 105 |
+
title TEXT NOT NULL,
|
| 106 |
+
message TEXT,
|
| 107 |
+
data JSONB,
|
| 108 |
+
is_read BOOLEAN DEFAULT FALSE,
|
| 109 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 110 |
+
);
|
| 111 |
+
|
| 112 |
+
CREATE INDEX idx_notifications_user ON notifications(user_id, is_read, created_at DESC);
|
| 113 |
+
CREATE INDEX idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE;
|
| 114 |
+
|
| 115 |
+
COMMENT ON TABLE notifications IS 'Kullanıcı bildirimleri';
|
| 116 |
+
|
| 117 |
+
-- RLS
|
| 118 |
+
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
|
| 119 |
+
CREATE POLICY "Users can view own notifications" ON notifications FOR SELECT USING (auth.uid() = user_id);
|
| 120 |
+
CREATE POLICY "Users can update own notifications" ON notifications FOR UPDATE USING (auth.uid() = user_id);
|
| 121 |
+
CREATE POLICY "Users can delete own notifications" ON notifications FOR DELETE USING (auth.uid() = user_id);
|
| 122 |
+
-- System can insert for any user (via service role)
|
| 123 |
+
CREATE POLICY "Service can insert notifications" ON notifications FOR INSERT WITH CHECK (true);
|
| 124 |
+
|
| 125 |
+
GRANT SELECT, UPDATE, DELETE ON notifications TO authenticated;
|
| 126 |
+
-- INSERT requires service_role (backend inserts notifications)
|
| 127 |
+
|
| 128 |
+
-- ============================================
|
| 129 |
+
-- 5. FIX PORTFOLIOS TABLE
|
| 130 |
+
-- Mevcut tablo stock_id FK gerektiriyor ama frontend symbol string kullanıyor
|
| 131 |
+
-- Alternatif: symbol-based portfolio column ekliyoruz
|
| 132 |
+
-- ============================================
|
| 133 |
+
ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS symbol TEXT;
|
| 134 |
+
ALTER TABLE portfolios ALTER COLUMN stock_id DROP NOT NULL;
|
| 135 |
+
|
| 136 |
+
-- Symbol-based unique constraint (stock_id artık zorunlu değil)
|
| 137 |
+
-- Eski constraint kaldırma güvenli — zaten veri yok
|
| 138 |
+
DO $$
|
| 139 |
+
BEGIN
|
| 140 |
+
-- Drop old unique if exists
|
| 141 |
+
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'portfolios_user_id_stock_id_key') THEN
|
| 142 |
+
ALTER TABLE portfolios DROP CONSTRAINT portfolios_user_id_stock_id_key;
|
| 143 |
+
END IF;
|
| 144 |
+
EXCEPTION WHEN OTHERS THEN NULL;
|
| 145 |
+
END $$;
|
| 146 |
+
|
| 147 |
+
-- Yeni unique: user_id + symbol
|
| 148 |
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_portfolios_user_symbol ON portfolios(user_id, symbol) WHERE symbol IS NOT NULL;
|
| 149 |
+
CREATE INDEX IF NOT EXISTS idx_portfolios_symbol ON portfolios(symbol);
|
| 150 |
+
|
| 151 |
+
-- ============================================
|
| 152 |
+
-- 6. PORTFOLIO SNAPSHOTS (Günlük portföy değeri)
|
| 153 |
+
-- Equity curve çizmek için
|
| 154 |
+
-- ============================================
|
| 155 |
+
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
|
| 156 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 157 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 158 |
+
date DATE NOT NULL,
|
| 159 |
+
total_value DECIMAL(14,2) NOT NULL,
|
| 160 |
+
total_cost DECIMAL(14,2) NOT NULL,
|
| 161 |
+
cash_balance DECIMAL(14,2) DEFAULT 0,
|
| 162 |
+
positions_count INTEGER DEFAULT 0,
|
| 163 |
+
daily_pnl DECIMAL(12,2),
|
| 164 |
+
daily_pnl_pct DECIMAL(6,2),
|
| 165 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 166 |
+
UNIQUE(user_id, date)
|
| 167 |
+
);
|
| 168 |
+
|
| 169 |
+
CREATE INDEX idx_portfolio_snapshots_user ON portfolio_snapshots(user_id, date DESC);
|
| 170 |
+
|
| 171 |
+
COMMENT ON TABLE portfolio_snapshots IS 'Günlük portföy snapshots — equity curve için';
|
| 172 |
+
|
| 173 |
+
-- RLS
|
| 174 |
+
ALTER TABLE portfolio_snapshots ENABLE ROW LEVEL SECURITY;
|
| 175 |
+
CREATE POLICY "Users can view own snapshots" ON portfolio_snapshots FOR SELECT USING (auth.uid() = user_id);
|
| 176 |
+
CREATE POLICY "Users can insert own snapshots" ON portfolio_snapshots FOR INSERT WITH CHECK (auth.uid() = user_id);
|
| 177 |
+
|
| 178 |
+
GRANT SELECT, INSERT ON portfolio_snapshots TO authenticated;
|
| 179 |
+
|
| 180 |
+
-- ============================================
|
| 181 |
+
-- 7. USEFUL FUNCTIONS
|
| 182 |
+
-- ============================================
|
| 183 |
+
|
| 184 |
+
-- Get user portfolio summary
|
| 185 |
+
CREATE OR REPLACE FUNCTION get_user_portfolio_summary(p_user_id UUID)
|
| 186 |
+
RETURNS JSONB AS $$
|
| 187 |
+
DECLARE
|
| 188 |
+
result JSONB;
|
| 189 |
+
BEGIN
|
| 190 |
+
SELECT jsonb_build_object(
|
| 191 |
+
'positions_count', COUNT(*),
|
| 192 |
+
'total_cost', COALESCE(SUM(quantity * avg_purchase_price), 0),
|
| 193 |
+
'symbols', COALESCE(jsonb_agg(DISTINCT symbol), '[]'::jsonb)
|
| 194 |
+
) INTO result
|
| 195 |
+
FROM portfolios
|
| 196 |
+
WHERE user_id = p_user_id AND quantity > 0;
|
| 197 |
+
|
| 198 |
+
RETURN COALESCE(result, '{}'::jsonb);
|
| 199 |
+
END;
|
| 200 |
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
| 201 |
+
|
| 202 |
+
-- Get user trade stats
|
| 203 |
+
CREATE OR REPLACE FUNCTION get_user_trade_stats(p_user_id UUID)
|
| 204 |
+
RETURNS JSONB AS $$
|
| 205 |
+
DECLARE
|
| 206 |
+
result JSONB;
|
| 207 |
+
BEGIN
|
| 208 |
+
SELECT jsonb_build_object(
|
| 209 |
+
'total_trades', COUNT(*),
|
| 210 |
+
'buy_count', COUNT(*) FILTER (WHERE side = 'BUY'),
|
| 211 |
+
'sell_count', COUNT(*) FILTER (WHERE side = 'SELL'),
|
| 212 |
+
'total_volume', COALESCE(SUM(quantity * price), 0),
|
| 213 |
+
'first_trade', MIN(trade_date),
|
| 214 |
+
'last_trade', MAX(trade_date)
|
| 215 |
+
) INTO result
|
| 216 |
+
FROM user_trades
|
| 217 |
+
WHERE user_id = p_user_id;
|
| 218 |
+
|
| 219 |
+
RETURN COALESCE(result, '{}'::jsonb);
|
| 220 |
+
END;
|
| 221 |
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
| 222 |
+
|
| 223 |
+
-- ============================================
|
| 224 |
+
-- 8. AUTO-CREATE PROFILE ON SIGNUP
|
| 225 |
+
-- Trigger: auth.users INSERT → user_profiles INSERT
|
| 226 |
+
-- ============================================
|
| 227 |
+
CREATE OR REPLACE FUNCTION handle_new_user()
|
| 228 |
+
RETURNS TRIGGER AS $$
|
| 229 |
+
BEGIN
|
| 230 |
+
INSERT INTO user_profiles (id, display_name, settings)
|
| 231 |
+
VALUES (
|
| 232 |
+
NEW.id,
|
| 233 |
+
COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email, '@', 1)),
|
| 234 |
+
jsonb_build_object(
|
| 235 |
+
'theme', 'light',
|
| 236 |
+
'language', 'tr',
|
| 237 |
+
'notifications', true,
|
| 238 |
+
'default_period', '1M'
|
| 239 |
+
)
|
| 240 |
+
)
|
| 241 |
+
ON CONFLICT (id) DO NOTHING;
|
| 242 |
+
RETURN NEW;
|
| 243 |
+
END;
|
| 244 |
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
| 245 |
+
|
| 246 |
+
-- Drop old trigger if exists, then create
|
| 247 |
+
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
| 248 |
+
CREATE TRIGGER on_auth_user_created
|
| 249 |
+
AFTER INSERT ON auth.users
|
| 250 |
+
FOR EACH ROW
|
| 251 |
+
EXECUTE FUNCTION handle_new_user();
|
| 252 |
+
|
| 253 |
+
-- ============================================
|
| 254 |
+
-- DONE
|
| 255 |
+
-- ============================================
|
| 256 |
+
DO $$ BEGIN
|
| 257 |
+
RAISE NOTICE '✅ User features migration completed!';
|
| 258 |
+
RAISE NOTICE 'New tables: user_trades, favorites, price_alerts, notifications, portfolio_snapshots';
|
| 259 |
+
RAISE NOTICE 'Fixed: portfolios now supports symbol-based (not only stock_id FK)';
|
| 260 |
+
RAISE NOTICE 'Added: auto-create user_profiles on signup trigger';
|
| 261 |
+
END $$;
|
trading/auto_trader.py
CHANGED
|
@@ -54,7 +54,6 @@ from trading.risk_gate import RiskGate, RiskLimits
|
|
| 54 |
from trading.circuit_breaker import CircuitBreaker
|
| 55 |
from trading.db_store import TradingStore
|
| 56 |
from trading.model_risk import ModelRiskManager
|
| 57 |
-
from data.stock_data_api import get_stock_data_for_api
|
| 58 |
|
| 59 |
logger = logging.getLogger("trading.auto_trader")
|
| 60 |
|
|
@@ -337,6 +336,7 @@ def run_trading_cycle(
|
|
| 337 |
logger.info("Step 2: Checking existing positions for SL/TP/expiry...")
|
| 338 |
for sym, pos in list(broker._positions.items()):
|
| 339 |
try:
|
|
|
|
| 340 |
ticker = sym if sym.endswith(".IS") else f"{sym}.IS"
|
| 341 |
df_px = get_stock_data_for_api(ticker, period="5d", interval="1d")
|
| 342 |
if df_px is None or df_px.empty:
|
|
|
|
| 54 |
from trading.circuit_breaker import CircuitBreaker
|
| 55 |
from trading.db_store import TradingStore
|
| 56 |
from trading.model_risk import ModelRiskManager
|
|
|
|
| 57 |
|
| 58 |
logger = logging.getLogger("trading.auto_trader")
|
| 59 |
|
|
|
|
| 336 |
logger.info("Step 2: Checking existing positions for SL/TP/expiry...")
|
| 337 |
for sym, pos in list(broker._positions.items()):
|
| 338 |
try:
|
| 339 |
+
from data.stock_data_api import get_stock_data_for_api
|
| 340 |
ticker = sym if sym.endswith(".IS") else f"{sym}.IS"
|
| 341 |
df_px = get_stock_data_for_api(ticker, period="5d", interval="1d")
|
| 342 |
if df_px is None or df_px.empty:
|