- ?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>
165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
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);
|
||
}
|