chore(phase0): packages·subscription 제거 — 페이지/API/cron/vercel.json + 파급(stats·members·saju) 수정
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,14 +27,12 @@ export async function GET() {
|
||||
// 각 회원의 주문 수 + 결제 금액 집계
|
||||
const enriched = await Promise.all(
|
||||
(profiles ?? []).map(async (p: { id: string; email: string; full_name: string; created_at: string }) => {
|
||||
const [ordersRes, paymentsRes, subsRes] = await Promise.all([
|
||||
const [ordersRes, paymentsRes] = await Promise.all([
|
||||
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('user_id', p.id).eq('status', 'paid'),
|
||||
supabase.from('payments').select('amount').eq('user_id', p.id).eq('status', 'paid'),
|
||||
supabase.from('subscriptions').select('product_id, status, expires_at').eq('user_id', p.id).eq('status', 'active').order('created_at', { ascending: false }).limit(1),
|
||||
]);
|
||||
const totalPaid = (paymentsRes.data ?? []).reduce((s: number, x: { amount: number }) => s + x.amount, 0);
|
||||
const activeSub = subsRes.data?.[0] ?? null;
|
||||
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid, activeSub };
|
||||
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid };
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -54,6 +54,5 @@ const DEFAULT_SERVICES = [
|
||||
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 },
|
||||
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
|
||||
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
|
||||
{ id: 'packages', name: 'SaaS 제품 허브(구)', description: '구 /packages 페이지', is_active: false, order_index: 104 },
|
||||
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
|
||||
];
|
||||
|
||||
@@ -15,20 +15,18 @@ export async function GET() {
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// 병렬 쿼리
|
||||
const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes, subsRes] = await Promise.all([
|
||||
const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes] = await Promise.all([
|
||||
supabase.from('profiles').select('id', { count: 'exact', head: true }),
|
||||
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('status', 'paid'),
|
||||
supabase.from('payments').select('amount').eq('status', 'paid'),
|
||||
supabase.from('contact_requests').select('id', { count: 'exact', head: true }).eq('status', 'pending'),
|
||||
supabase.from('payments').select('amount, created_at').eq('status', 'paid').order('created_at', { ascending: true }),
|
||||
supabase.from('subscriptions').select('id', { count: 'exact', head: true }).eq('status', 'active'),
|
||||
]);
|
||||
|
||||
const totalMembers = profilesRes.count ?? 0;
|
||||
const totalOrders = ordersRes.count ?? 0;
|
||||
const totalRevenue = (paymentsRes.data ?? []).reduce((sum: number, p: { amount: number }) => sum + p.amount, 0);
|
||||
const pendingContacts = contactsRes.count ?? 0;
|
||||
const activeSubscribers = subsRes.count ?? 0;
|
||||
|
||||
// 최근 6개월 월별 수익 집계
|
||||
const monthly: Record<string, number> = {};
|
||||
@@ -49,5 +47,5 @@ export async function GET() {
|
||||
|
||||
const monthlyChart = Object.entries(monthly).map(([month, revenue]) => ({ month, revenue }));
|
||||
|
||||
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, activeSubscribers, monthlyChart });
|
||||
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, monthlyChart });
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { sendMessage } from '@/lib/telegram';
|
||||
|
||||
/**
|
||||
* GET /api/cron/subscription-expiry
|
||||
* Vercel Cron: 매일 01:00 KST (UTC 16:00) 실행
|
||||
* - 만료된 구독 → status='expired'
|
||||
* - 3일 후 만료 예정 구독 → 텔레그램 알림 발송
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
// Vercel Cron 인증
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const now = new Date().toISOString();
|
||||
const in3days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// 1. 만료된 구독 처리
|
||||
const { data: expired, error: expireError } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({ status: 'expired' })
|
||||
.eq('status', 'active')
|
||||
.lt('expires_at', now)
|
||||
.select('id, user_id, product_id');
|
||||
|
||||
if (expireError) {
|
||||
console.error('subscription expiry error:', expireError);
|
||||
}
|
||||
|
||||
// 2. 3일 후 만료 예정 → 텔레그램 알림
|
||||
const { data: expiringSoon } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, user_id, product_id, expires_at, profiles!inner(telegram_chat_id)')
|
||||
.eq('status', 'active')
|
||||
.eq('auto_renew', false)
|
||||
.lt('expires_at', in3days)
|
||||
.gt('expires_at', now);
|
||||
|
||||
const PLAN_NAMES: Record<string, string> = {
|
||||
lotto_gold: '🥇 골드',
|
||||
lotto_platinum: '💎 플래티넘',
|
||||
lotto_diamond: '👑 다이아',
|
||||
};
|
||||
|
||||
let notified = 0;
|
||||
if (expiringSoon) {
|
||||
for (const sub of expiringSoon) {
|
||||
const profile = sub.profiles as unknown as { telegram_chat_id: string | null };
|
||||
const chatId = profile?.telegram_chat_id;
|
||||
if (!chatId) continue;
|
||||
|
||||
const expiresAt = new Date(sub.expires_at).toLocaleDateString('ko-KR');
|
||||
const planName = PLAN_NAMES[sub.product_id] ?? sub.product_id;
|
||||
|
||||
await sendMessage(
|
||||
chatId,
|
||||
`⏰ *구독 만료 안내*\n\n` +
|
||||
`로또 번호 추천 *${planName}* 플랜이\n` +
|
||||
`*${expiresAt}*에 만료됩니다.\n\n` +
|
||||
`지속적인 번호 추천을 받으시려면\n` +
|
||||
`마이페이지에서 구독을 갱신해 주세요.\n\n` +
|
||||
`👉 https://jaengseung-made.com/mypage`
|
||||
);
|
||||
notified++;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
expired_count: expired?.length ?? 0,
|
||||
notified_count: notified,
|
||||
processed_at: now,
|
||||
});
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
/**
|
||||
* PATCH /api/subscription/[id]
|
||||
* action: 'cancel' | 'toggle_autorenew'
|
||||
*
|
||||
* cancel — 구독 즉시 해지 (status='cancelled', auto_renew=false)
|
||||
* toggle_autorenew — 자동갱신 on/off 전환
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
let body: { action?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { action } = body;
|
||||
|
||||
// 본인 구독인지 확인
|
||||
const { data: sub, error: fetchError } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, status, auto_renew, expires_at')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchError || !sub) {
|
||||
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (action === 'cancel') {
|
||||
if (sub.status === 'cancelled') {
|
||||
return NextResponse.json({ error: 'ALREADY_CANCELLED' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: 'cancelled',
|
||||
auto_renew: false,
|
||||
cancelled_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: '구독이 해지되었습니다. 만료일까지는 서비스를 계속 이용할 수 있습니다.',
|
||||
expires_at: sub.expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'toggle_autorenew') {
|
||||
if (sub.status === 'cancelled' || sub.status === 'expired') {
|
||||
return NextResponse.json({ error: 'SUBSCRIPTION_NOT_ACTIVE' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newValue = !sub.auto_renew;
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({ auto_renew: newValue })
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, auto_renew: newValue });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'INVALID_ACTION' }, { status: 400 });
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
|
||||
/**
|
||||
* GET /api/subscription
|
||||
* 내 활성/만료 구독 목록 조회
|
||||
* - auth 검증은 anon client, DB 조회는 admin client (RLS 우회)
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
// admin client로 RLS 우회 (subscriptions 테이블 SELECT 정책 없을 때도 동작)
|
||||
const admin = createAdminClient();
|
||||
const { data, error } = await admin
|
||||
.from('subscriptions')
|
||||
.select('id, product_id, status, auto_renew, started_at, expires_at, cancelled_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR', detail: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, subscriptions: data ?? [] });
|
||||
}
|
||||
Reference in New Issue
Block a user