feat: 로또 번호 히스토리 저장 + 마이페이지 구독정보/히스토리 표시
- POST /api/lotto/history: 생성 번호 저장 API - GET /api/lotto/history: 히스토리 조회 API - 번호 생성 시 자동 히스토리 저장 (NAS/클라이언트 출처 구분) - 합계 표시 복원 - 마이페이지: 활성 구독 카드 (D-day, 만료일 표시) - 마이페이지: 로또 기록 탭 추가 (번호볼 + 출처 + 플랜 표시) - Supabase 마이그레이션: lotto_history 테이블 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
69
app/api/lotto/history/route.ts
Normal file
69
app/api/lotto/history/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/lotto/history
|
||||||
|
* 생성된 로또 번호 조합을 히스토리에 저장
|
||||||
|
* Body: { numbers: number[], source: 'nas' | 'client', plan_id: string }
|
||||||
|
*
|
||||||
|
* GET /api/lotto/history
|
||||||
|
* 내 로또 번호 히스토리 조회
|
||||||
|
* Query: limit (기본 50)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { numbers?: number[]; source?: string; plan_id?: string };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { numbers, source = 'client', plan_id } = body;
|
||||||
|
if (!Array.isArray(numbers) || numbers.length !== 6 || !plan_id) {
|
||||||
|
return NextResponse.json({ error: 'INVALID_BODY' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from('lotto_history').insert({
|
||||||
|
user_id: user.id,
|
||||||
|
numbers,
|
||||||
|
source,
|
||||||
|
plan_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('lotto_history insert error:', error);
|
||||||
|
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Number(req.nextUrl.searchParams.get('limit') ?? '50'), 200);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('lotto_history')
|
||||||
|
.select('id, numbers, source, plan_id, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, history: data ?? [] });
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'profile' | 'saju' | 'payments' | 'orders';
|
type Tab = 'profile' | 'saju' | 'lotto' | 'payments' | 'orders';
|
||||||
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
|
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
|
||||||
|
|
||||||
interface SajuRecord {
|
interface SajuRecord {
|
||||||
@@ -48,6 +48,26 @@ interface Order {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LottoHistoryItem {
|
||||||
|
id: number;
|
||||||
|
numbers: number[];
|
||||||
|
source: string;
|
||||||
|
plan_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveSubscription {
|
||||||
|
product_id: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_LABELS: Record<string, { label: string; emoji: string; color: string }> = {
|
||||||
|
lotto_gold: { label: '골드', emoji: '🥇', color: 'amber' },
|
||||||
|
lotto_platinum: { label: '플래티넘', emoji: '💎', color: 'sky' },
|
||||||
|
lotto_diamond: { label: '다이아', emoji: '👑', color: 'violet' },
|
||||||
|
};
|
||||||
|
|
||||||
export default function MyPage() {
|
export default function MyPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -57,6 +77,8 @@ export default function MyPage() {
|
|||||||
const [sajuRecords, setSajuRecords] = useState<SajuRecord[]>([]);
|
const [sajuRecords, setSajuRecords] = useState<SajuRecord[]>([]);
|
||||||
const [payments, setPayments] = useState<Payment[]>([]);
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
const [orders, setOrders] = useState<Order[]>([]);
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
const [lottoHistory, setLottoHistory] = useState<LottoHistoryItem[]>([]);
|
||||||
|
const [activeSubscriptions, setActiveSubscriptions] = useState<ActiveSubscription[]>([]);
|
||||||
|
|
||||||
// 텔레그램 연동 상태
|
// 텔레그램 연동 상태
|
||||||
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
|
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
|
||||||
@@ -109,6 +131,35 @@ export default function MyPage() {
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
setTelegramChatId(profile?.telegram_chat_id ?? null);
|
setTelegramChatId(profile?.telegram_chat_id ?? null);
|
||||||
|
|
||||||
|
// 활성 구독 조회 (paid 상태의 lotto 플랜)
|
||||||
|
const LOTTO_PLANS = ['lotto_gold', 'lotto_platinum', 'lotto_diamond'];
|
||||||
|
const { data: subs } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('product_id, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('status', 'paid')
|
||||||
|
.in('product_id', LOTTO_PLANS)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (subs && subs.length > 0) {
|
||||||
|
const activeSubs: ActiveSubscription[] = subs.map((s) => {
|
||||||
|
const createdAt = new Date(s.created_at);
|
||||||
|
const expiresAt = new Date(createdAt);
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 31);
|
||||||
|
return { product_id: s.product_id, created_at: s.created_at, expires_at: expiresAt.toISOString() };
|
||||||
|
});
|
||||||
|
setActiveSubscriptions(activeSubs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로또 히스토리 조회
|
||||||
|
const { data: history } = await supabase
|
||||||
|
.from('lotto_history')
|
||||||
|
.select('id, numbers, source, plan_id, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(50);
|
||||||
|
setLottoHistory(history ?? []);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
init();
|
init();
|
||||||
@@ -178,6 +229,7 @@ export default function MyPage() {
|
|||||||
const tabs: { key: Tab; label: string; count?: number }[] = [
|
const tabs: { key: Tab; label: string; count?: number }[] = [
|
||||||
{ key: 'profile', label: '내 정보' },
|
{ key: 'profile', label: '내 정보' },
|
||||||
{ key: 'saju', label: '사주 기록', count: sajuRecords.length },
|
{ key: 'saju', label: '사주 기록', count: sajuRecords.length },
|
||||||
|
{ key: 'lotto', label: '🎰 로또 기록', count: lottoHistory.length },
|
||||||
{ key: 'payments', label: '결제 내역', count: payments.length },
|
{ key: 'payments', label: '결제 내역', count: payments.length },
|
||||||
{ key: 'orders', label: '의뢰 내역', count: orders.length },
|
{ key: 'orders', label: '의뢰 내역', count: orders.length },
|
||||||
];
|
];
|
||||||
@@ -269,6 +321,58 @@ export default function MyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 구독 중인 서비스 */}
|
||||||
|
{activeSubscriptions.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||||
|
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
|
||||||
|
<div className="w-1 h-5 bg-gradient-to-b from-amber-400 to-orange-500 rounded-full" />
|
||||||
|
구독 중인 서비스
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activeSubscriptions.map((sub) => {
|
||||||
|
const info = PLAN_LABELS[sub.product_id];
|
||||||
|
const expiresDate = new Date(sub.expires_at);
|
||||||
|
const daysLeft = Math.max(0, Math.ceil((expiresDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||||
|
const isExpired = daysLeft === 0;
|
||||||
|
return (
|
||||||
|
<div key={sub.product_id + sub.created_at}
|
||||||
|
className={`flex items-center justify-between p-4 rounded-xl border ${isExpired ? 'border-slate-200 bg-slate-50' : 'border-amber-200 bg-amber-50/50'}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{info?.emoji ?? '🎟'}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-[#04102b]">
|
||||||
|
로또 번호 추천 {info?.label ?? sub.product_id}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{new Date(sub.created_at).toLocaleDateString('ko-KR')} 구독 시작
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{isExpired ? (
|
||||||
|
<span className="text-xs font-bold text-slate-400 bg-slate-100 px-2 py-1 rounded-lg">만료됨</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={`text-sm font-bold ${daysLeft <= 5 ? 'text-red-500' : 'text-amber-600'}`}>
|
||||||
|
D-{daysLeft}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">{expiresDate.toLocaleDateString('ko-KR')} 만료</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<a href="/services/lotto/recommend"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-semibold text-amber-600 hover:text-amber-700 transition">
|
||||||
|
번호 추천받기 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 텔레그램 연동 카드 */}
|
{/* 텔레그램 연동 카드 */}
|
||||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||||
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
|
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
|
||||||
@@ -416,6 +520,56 @@ export default function MyPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 로또 번호 기록 */}
|
||||||
|
{tab === 'lotto' && (
|
||||||
|
<div>
|
||||||
|
{lottoHistory.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="🎰"
|
||||||
|
title="생성된 번호 기록이 없습니다"
|
||||||
|
desc="로또 번호 추천 페이지에서 번호를 생성하면 여기에 기록됩니다"
|
||||||
|
linkHref="/services/lotto/recommend"
|
||||||
|
linkLabel="번호 추천받기"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs text-slate-400 mb-1">총 {lottoHistory.length}개 조합 생성</div>
|
||||||
|
{lottoHistory.map((item) => {
|
||||||
|
const info = PLAN_LABELS[item.plan_id];
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="bg-white rounded-2xl border border-[#dbe8ff] px-5 py-4 flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{item.numbers.map((n) => {
|
||||||
|
const color =
|
||||||
|
n <= 10 ? 'bg-yellow-400 text-yellow-900' :
|
||||||
|
n <= 20 ? 'bg-blue-500 text-white' :
|
||||||
|
n <= 30 ? 'bg-red-500 text-white' :
|
||||||
|
n <= 40 ? 'bg-slate-500 text-white' :
|
||||||
|
'bg-green-500 text-white';
|
||||||
|
return (
|
||||||
|
<span key={n} className={`w-8 h-8 rounded-full ${color} flex items-center justify-center text-xs font-black shadow-sm`}>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${item.source === 'nas' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-slate-100 text-slate-500'}`}>
|
||||||
|
{item.source === 'nas' ? 'NAS 추천' : '로컬 생성'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-600 font-semibold">{info?.emoji} {info?.label}</span>
|
||||||
|
<span className="text-xs text-slate-400">{new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 사주 기록 */}
|
{/* 사주 기록 */}
|
||||||
{tab === 'saju' && (
|
{tab === 'saju' && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -274,6 +274,16 @@ export default function LottoRecommendPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 히스토리 저장 (fire-and-forget) ──
|
||||||
|
const saveHistory = (numbers: number[], source: 'nas' | 'client') => {
|
||||||
|
if (!plan) return;
|
||||||
|
fetch('/api/lotto/history', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ numbers, source, plan_id: plan }),
|
||||||
|
}).catch(() => {/* 저장 실패는 조용히 무시 */});
|
||||||
|
};
|
||||||
|
|
||||||
// ── 프리미엄 번호 생성 ──
|
// ── 프리미엄 번호 생성 ──
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (proState === 'loading' || combos.length >= MAX_COMBOS) return;
|
if (proState === 'loading' || combos.length >= MAX_COMBOS) return;
|
||||||
@@ -292,6 +302,7 @@ export default function LottoRecommendPage() {
|
|||||||
const newCombos: Combo[] = Array.from({ length: count }, () => {
|
const newCombos: Combo[] = Array.from({ length: count }, () => {
|
||||||
idRef.current += 1;
|
idRef.current += 1;
|
||||||
const { numbers, metrics } = clientMonteCarlo();
|
const { numbers, metrics } = clientMonteCarlo();
|
||||||
|
saveHistory(numbers, 'client');
|
||||||
return { id: idRef.current, numbers, metrics, createdAt: new Date() };
|
return { id: idRef.current, numbers, metrics, createdAt: new Date() };
|
||||||
});
|
});
|
||||||
setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS));
|
setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS));
|
||||||
@@ -305,16 +316,20 @@ export default function LottoRecommendPage() {
|
|||||||
const data: BatchResponse = await res.json();
|
const data: BatchResponse = await res.json();
|
||||||
const newCombos: Combo[] = (data.items ?? []).map((item) => {
|
const newCombos: Combo[] = (data.items ?? []).map((item) => {
|
||||||
idRef.current += 1;
|
idRef.current += 1;
|
||||||
return { id: idRef.current, numbers: [...item.numbers].sort((a,b)=>a-b), metrics: item.metrics, createdAt: new Date() };
|
const numbers = [...item.numbers].sort((a,b)=>a-b);
|
||||||
|
saveHistory(numbers, 'nas');
|
||||||
|
return { id: idRef.current, numbers, metrics: item.metrics, createdAt: new Date() };
|
||||||
});
|
});
|
||||||
setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS));
|
setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS));
|
||||||
} else {
|
} else {
|
||||||
const data: RecommendResponse = await res.json();
|
const data: RecommendResponse = await res.json();
|
||||||
if (!data.numbers?.length) throw new Error('EMPTY_RESULT');
|
if (!data.numbers?.length) throw new Error('EMPTY_RESULT');
|
||||||
idRef.current += 1;
|
idRef.current += 1;
|
||||||
|
const numbers = [...data.numbers].sort((a,b)=>a-b);
|
||||||
|
saveHistory(numbers, 'nas');
|
||||||
setCombos((prev) => [...prev, {
|
setCombos((prev) => [...prev, {
|
||||||
id: idRef.current,
|
id: idRef.current,
|
||||||
numbers: [...data.numbers].sort((a,b)=>a-b),
|
numbers,
|
||||||
metrics: data.metrics,
|
metrics: data.metrics,
|
||||||
overlap: data.recent_overlap?.repeated_numbers,
|
overlap: data.recent_overlap?.repeated_numbers,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -583,7 +598,7 @@ export default function LottoRecommendPage() {
|
|||||||
{/* 메트릭 */}
|
{/* 메트릭 */}
|
||||||
{latestCombo?.metrics && !isProLoading && (
|
{latestCombo?.metrics && !isProLoading && (
|
||||||
<div style={{ display:'flex',gap:'1rem',justifyContent:'center',marginBottom:'1.25rem',flexWrap:'wrap' }}>
|
<div style={{ display:'flex',gap:'1rem',justifyContent:'center',marginBottom:'1.25rem',flexWrap:'wrap' }}>
|
||||||
{[{l:'홀수',v:`${latestCombo.metrics.odd}개`},{l:'짝수',v:`${latestCombo.metrics.even}개`},{l:'범위',v:latestCombo.metrics.range}].map(s=>(
|
{[{l:'합계',v:latestCombo.metrics.sum},{l:'홀수',v:`${latestCombo.metrics.odd}개`},{l:'짝수',v:`${latestCombo.metrics.even}개`},{l:'범위',v:latestCombo.metrics.range}].map(s=>(
|
||||||
<div key={s.l} style={{ background:'rgba(251,191,36,.07)',border:'1px solid rgba(251,191,36,.12)',borderRadius:'.5rem',padding:'.3rem .75rem',textAlign:'center' }}>
|
<div key={s.l} style={{ background:'rgba(251,191,36,.07)',border:'1px solid rgba(251,191,36,.12)',borderRadius:'.5rem',padding:'.3rem .75rem',textAlign:'center' }}>
|
||||||
<div style={{ color:'#fbbf24',fontSize:'.85rem',fontWeight:800 }}>{s.v}</div>
|
<div style={{ color:'#fbbf24',fontSize:'.85rem',fontWeight:800 }}>{s.v}</div>
|
||||||
<div style={{ color:'rgba(253,230,138,.4)',fontSize:'.62rem' }}>{s.l}</div>
|
<div style={{ color:'rgba(253,230,138,.4)',fontSize:'.62rem' }}>{s.l}</div>
|
||||||
@@ -637,7 +652,7 @@ export default function LottoRecommendPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display:'flex',alignItems:'center',gap:'.75rem' }}>
|
<div style={{ display:'flex',alignItems:'center',gap:'.75rem' }}>
|
||||||
{c.metrics&&<span style={{ color:'rgba(251,191,36,.4)',fontSize:'.68rem' }}>홀 {c.metrics.odd} · 짝 {c.metrics.even}</span>}
|
{c.metrics&&<span style={{ color:'rgba(251,191,36,.4)',fontSize:'.68rem' }}>합 {c.metrics.sum} · 홀 {c.metrics.odd}</span>}
|
||||||
<div style={{ color:'rgba(255,255,255,.18)',fontSize:'.68rem' }}>{c.createdAt.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit',second:'2-digit'})}</div>
|
<div style={{ color:'rgba(255,255,255,.18)',fontSize:'.68rem' }}>{c.createdAt.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit',second:'2-digit'})}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
21
supabase/migrations/003_lotto_history.sql
Normal file
21
supabase/migrations/003_lotto_history.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- ─── 로또 번호 히스토리 테이블 ──────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS lotto_history (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
numbers integer[] NOT NULL,
|
||||||
|
source text NOT NULL DEFAULT 'client', -- 'nas' | 'client'
|
||||||
|
plan_id text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE lotto_history ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "lotto_history_select_own" ON lotto_history
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "lotto_history_insert_own" ON lotto_history
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS lotto_history_user_created ON lotto_history (user_id, created_at DESC);
|
||||||
Reference in New Issue
Block a user