veteroner commited on
Commit
6cb84a6
·
1 Parent(s): b9078c5

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 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 { addFavorite, loadFavorites, removeFavorite, 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,7 +30,7 @@ export default function FavoritesPage() {
30
  const refresh = useCallback(async () => {
31
  try {
32
  setLoading(true)
33
- const favs = loadFavorites(userKey)
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 = addFavorite(sym, userKey)
81
  setFavorites(next)
82
  setSymbolInput('')
83
- // Best-effort: refresh prices
84
  refresh()
85
  }
86
 
87
- const onRemove = (symbol: string) => {
88
- const next = removeFavorite(symbol, userKey)
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
- // For now, we'll use localStorage as Supabase portfolio table needs to be created
43
- const stored = localStorage.getItem(`portfolio_${user?.id}`);
44
- if (stored) {
45
- const items: PortfolioItem[] = JSON.parse(stored);
46
 
47
- // Batch fetch current prices (single request instead of N+1)
48
- const symbols = items.map((item) => item.symbol).join(',');
49
- let batchData: Record<string, unknown> = {};
50
- try {
51
- const batchResp = await fetch(`/api/stocks/batch?symbols=${encodeURIComponent(symbols)}`);
52
- const batchJson = await batchResp.json().catch(() => null);
53
- batchData = batchJson?.stocks || {};
54
- } catch {
55
- // Fall back to empty, each stock will use average_price
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
 
57
 
58
- const enriched = items.map((item) => {
59
- const data = batchData[item.symbol] as Record<string, Record<string, unknown> | undefined> | undefined;
60
- const rawLast = data?.stock?.last_price;
61
- const parsedLast = typeof rawLast === 'number' ? rawLast : Number(rawLast);
62
- const hasLivePrice = Number.isFinite(parsedLast) && parsedLast > 0;
63
- const currentPrice = hasLivePrice ? parsedLast : item.average_price;
64
 
65
- const totalValue = item.shares * currentPrice;
66
- const totalCost = item.shares * item.average_price;
67
- const profitLoss = hasLivePrice ? (totalValue - totalCost) : 0;
68
- const profitLossPct = hasLivePrice && totalCost > 0 ? (profitLoss / totalCost) * 100 : 0;
 
 
 
 
69
 
70
- return {
71
- ...item,
72
- current_price: currentPrice,
73
- name: String(data?.stock?.name || item.symbol),
74
- total_value: totalValue,
75
- profit_loss: profitLoss,
76
- profit_loss_pct: profitLossPct,
77
- price_is_fallback: !hasLivePrice,
78
- };
79
- });
80
 
81
- setPortfolio(enriched);
82
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  } catch (error) {
84
  console.error('Failed to fetch portfolio:', error);
85
  } finally {
86
  setLoading(false);
87
  }
88
- }, [user?.id]);
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
- const newItem: PortfolioItem = {
98
- id: Date.now().toString(),
99
- symbol,
100
- shares,
101
- average_price: price,
102
- created_at: new Date().toISOString()
103
- };
104
-
105
- const stored = localStorage.getItem(`portfolio_${user?.id}`) || '[]';
106
- const items: PortfolioItem[] = JSON.parse(stored);
107
- items.push(newItem);
108
- localStorage.setItem(`portfolio_${user?.id}`, JSON.stringify(items));
109
-
110
- fetchPortfolio();
111
- setShowAddForm(false);
112
  };
113
 
114
- const removeFromPortfolio = (id: string) => {
115
- const stored = localStorage.getItem(`portfolio_${user?.id}`) || '[]';
116
- const items: PortfolioItem[] = JSON.parse(stored);
117
- const filtered = items.filter(item => item.id !== id);
118
- localStorage.setItem(`portfolio_${user?.id}`, JSON.stringify(filtered));
119
- fetchPortfolio();
 
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 { loadFavorites, toggleFavorite } from '@/lib/favorites'
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
- const favs = loadFavorites(userKey)
43
- setIsFav(favs.some((f) => f.symbol === String(symbol).toUpperCase()))
 
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 = toggleFavorite(stock.symbol, userKey)
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 { loadFavorites, toggleFavorite } from '@/lib/favorites'
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
- const favs = loadFavorites(userKey)
49
- setFavoriteSymbols(new Set(favs.map((f) => f.symbol)))
 
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 } = toggleFavorite(stock.symbol, userKey)
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 safeParseJson(value: string | null): unknown {
9
- if (!value) return null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  try {
11
- return JSON.parse(value)
12
- } catch {
13
- return null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  }
 
 
15
  }
16
 
17
- function normalizeSymbol(value: unknown): string {
18
- return String(value || '').trim().toUpperCase()
 
 
 
 
 
 
 
 
 
 
 
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
- const items = loadFavorites(userId)
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: