Files
jaengseung-made/app/api/admin/survey/route.ts
gahusb fa9b05c7e8 feat(api): /api/admin/survey GET — 목록 + 통계 + CSV export
- ?range=all|today|week 필터
- ?format=csv → BOM 포함 UTF-8 CSV 다운로드 (Excel 호환)
- 통계: 각 질문별 카운트 분포 + 만족도 평균 + 이메일률 + 완료시간 중간값
- admin HMAC cookie 인증 (verifyAdminTokenNode)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:36:40 +09:00

165 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
interface SurveyRow {
id: string;
created_at: string;
age_range: string | null;
status: string | null;
awareness_freq: string | null;
tools_used: string[] | null;
tools_other: string | null;
cost_range: string | null;
best_tool: string | null;
best_satisfy: number | null;
free_opinion: string | null;
email: string | null;
user_agent: string | null;
referrer: string | null;
utm_source: string | null;
utm_medium: string | null;
utm_campaign: string | null;
completion_seconds: number | null;
}
export async function GET(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(request.url);
const range = url.searchParams.get('range') ?? 'all';
const format = url.searchParams.get('format') ?? 'json';
const supabase = createAdminClient();
let query = supabase
.from('survey_responses')
.select('*')
.order('created_at', { ascending: false });
if (range === 'today') {
const today = new Date();
today.setHours(0, 0, 0, 0);
query = query.gte('created_at', today.toISOString());
} else if (range === 'week') {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
query = query.gte('created_at', weekAgo.toISOString());
}
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const rows: SurveyRow[] = (data ?? []) as SurveyRow[];
if (format === 'csv') {
const csv = toCsv(rows);
return new Response(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="contour-survey-${range}-${new Date().toISOString().slice(0, 10)}.csv"`,
},
});
}
return NextResponse.json({
total: rows.length,
stats: computeStats(rows),
responses: rows,
});
}
function toCsv(rows: SurveyRow[]): string {
if (rows.length === 0) return 'id,created_at\n';
const headers: (keyof SurveyRow)[] = [
'id',
'created_at',
'age_range',
'status',
'awareness_freq',
'tools_used',
'tools_other',
'cost_range',
'best_tool',
'best_satisfy',
'free_opinion',
'email',
'user_agent',
'referrer',
'utm_source',
'utm_medium',
'utm_campaign',
'completion_seconds',
];
// BOM for Excel UTF-8 호환
const bom = '';
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(
headers
.map((h) => {
const v = r[h];
if (v == null) return '';
if (Array.isArray(v)) return `"${v.join('|').replace(/"/g, '""')}"`;
return `"${String(v).replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`;
})
.join(',')
);
}
return bom + lines.join('\n');
}
function counts(rows: SurveyRow[], key: keyof SurveyRow): Record<string, number> {
return rows.reduce((acc, r) => {
const v = r[key];
if (v != null && typeof v === 'string') {
acc[v] = (acc[v] ?? 0) + 1;
}
return acc;
}, {} as Record<string, number>);
}
function computeStats(rows: SurveyRow[]) {
const satisfyValues = rows
.map((r) => r.best_satisfy)
.filter((n): n is number => typeof n === 'number');
const satisfyAvg =
satisfyValues.length > 0
? (satisfyValues.reduce((s, n) => s + n, 0) / satisfyValues.length).toFixed(2)
: '0';
const completionValues = rows
.map((r) => r.completion_seconds)
.filter((n): n is number => typeof n === 'number');
const completionMedian = median(completionValues);
return {
age_range: counts(rows, 'age_range'),
status: counts(rows, 'status'),
awareness_freq: counts(rows, 'awareness_freq'),
cost_range: counts(rows, 'cost_range'),
best_tool: counts(rows, 'best_tool'),
satisfy_avg: satisfyAvg,
email_rate: rows.length === 0 ? '0' : ((rows.filter((r) => r.email).length / rows.length) * 100).toFixed(1),
completion_seconds_median: completionMedian,
};
}
function median(arr: number[]): number {
if (arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
}