Skip to content

Commit 003d58c

Browse files
committed
feat/csv: 00-padded checkpoints
1 parent 44107b1 commit 003d58c

File tree

10 files changed

+457
-350
lines changed

10 files changed

+457
-350
lines changed

eloop-bruno/bruno.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"version": "1",
3+
"name": "eloop",
4+
"type": "collection",
5+
"ignore": [
6+
"node_modules",
7+
".git"
8+
]
9+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { NextResponse } from 'next/server';
2+
import { auth } from '@/lib/auth';
3+
import { turso } from '@/lib/db/client';
4+
5+
/**
6+
* GET /api/admin/export/checkpoint
7+
* Admin-only checkpoint scan logs CSV exporter.
8+
* Returns scan logs with resolved user names, checkpoint prefixed with zero-padded order.
9+
* Usage: /api/admin/export/checkpoint?event_id=<id>
10+
* Supports key auth via header x-export-key or ?key=...
11+
*/
12+
export async function GET(req: Request) {
13+
try {
14+
const url = new URL(req.url);
15+
// Key-based auth: header x-export-key or ?key=...
16+
const expectedKey = process.env.CSV_EXPORT_KEY;
17+
const headerKey = req.headers.get('x-export-key');
18+
const queryKey = url.searchParams.get('key');
19+
const providedKey = headerKey ?? queryKey ?? null;
20+
21+
if (providedKey) {
22+
if (!expectedKey || providedKey !== expectedKey) {
23+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24+
}
25+
// key matched: allow access
26+
} else {
27+
// fallback to session auth
28+
const session = await auth();
29+
if (!session?.user || (session.user.role !== 'admin' && session.user.role !== 'organizer')) {
30+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
31+
}
32+
}
33+
34+
const eventId = url.searchParams.get('event_id');
35+
if (!eventId) {
36+
return NextResponse.json({ error: 'event_id required' }, { status: 400 });
37+
}
38+
39+
// Get event details to retrieve checkpoint order
40+
const eventResult = await turso.execute({
41+
sql: 'SELECT id, name, checkpoints FROM events WHERE id = ?',
42+
args: [eventId]
43+
});
44+
45+
if (eventResult.rows.length === 0) {
46+
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
47+
}
48+
49+
const eventRow = eventResult.rows[0];
50+
const eventName = String(eventRow.name ?? 'event');
51+
let checkpointOrder: string[] = [];
52+
try {
53+
const cp = eventRow.checkpoints;
54+
if (typeof cp === 'string') {
55+
checkpointOrder = JSON.parse(cp as string);
56+
} else if (Array.isArray(cp)) {
57+
checkpointOrder = cp as string[];
58+
}
59+
} catch (e) {
60+
checkpointOrder = [];
61+
}
62+
63+
// Build order map for checkpoint prefixing
64+
const orderMap = new Map<string, number>();
65+
checkpointOrder.forEach((c, i) => orderMap.set(c, i));
66+
67+
// Query: join scan_logs with users for volunteer and participant
68+
const result = await turso.execute({
69+
sql: `
70+
SELECT
71+
s.id,
72+
s.checkpoint,
73+
s.scan_status,
74+
s.error_message,
75+
s.created_at,
76+
u_part.name as participant_name,
77+
u_vol.name as volunteer_name
78+
FROM scan_logs s
79+
LEFT JOIN users u_part ON s.user_id = u_part.id
80+
LEFT JOIN users u_vol ON s.volunteer_id = u_vol.id
81+
WHERE s.event_id = ?
82+
ORDER BY s.created_at ASC
83+
`,
84+
args: [eventId]
85+
});
86+
87+
if (result.rows.length === 0) {
88+
return NextResponse.json({ error: 'No scan logs found' }, { status: 404 });
89+
}
90+
91+
// Excel-friendly formatter for timestamps
92+
const fmtExcelDT = (ts: number | string | undefined | null) => {
93+
if (ts === undefined || ts === null || ts === '') return '';
94+
const d = new Date(Number(ts));
95+
if (Number.isNaN(d.getTime())) return String(ts);
96+
const options: Intl.DateTimeFormatOptions = {
97+
year: 'numeric', month: '2-digit', day: '2-digit',
98+
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
99+
timeZone: 'Asia/Kolkata'
100+
};
101+
const parts = new Intl.DateTimeFormat('en-GB', options).formatToParts(d);
102+
const get = (type: string) => parts.find(p => p.type === type)?.value ?? '';
103+
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
104+
};
105+
106+
// CSV headers (consistent per export)
107+
const headers = ['checkpoint', 'participant_name', 'volunteer_name', 'scan_time', 'scan_status', 'error_message'];
108+
109+
// Build rows with checkpoint ordering
110+
const pad = (n: number) => String(n).padStart(2, '0');
111+
const built = result.rows.map((r: Record<string, unknown>) => {
112+
const checkpoint = String(r.checkpoint ?? '');
113+
const participant = String(r.participant_name ?? '');
114+
const volunteer = String(r.volunteer_name ?? '');
115+
const createdAt = Number(r.created_at ?? Date.now());
116+
const scanTime = fmtExcelDT(createdAt);
117+
const status = String(r.scan_status ?? '');
118+
let error = String(r.error_message ?? '');
119+
120+
// Sanitize error message (replace newlines and commas)
121+
error = error.replace(/\r?\n/g, ' ').replace(/,/g, ';');
122+
123+
const orderIndex = orderMap.has(checkpoint) ? (orderMap.get(checkpoint) as number) : Number.MAX_SAFE_INTEGER;
124+
return { checkpoint, participant, volunteer, scanTime, status, error, createdAt, orderIndex };
125+
});
126+
127+
// Sort by checkpoint order, then by created_at
128+
built.sort((a, b) => {
129+
if (a.orderIndex !== b.orderIndex) return a.orderIndex - b.orderIndex;
130+
return a.createdAt - b.createdAt;
131+
});
132+
133+
// Prefix checkpoint with zero-padded index
134+
const rows = built.map(r => {
135+
const idx = r.orderIndex === Number.MAX_SAFE_INTEGER ? 99 : r.orderIndex;
136+
const pref = `${pad(idx)}-${r.checkpoint}`;
137+
return [pref, r.participant, r.volunteer, r.scanTime, r.status, r.error];
138+
});
139+
140+
const escapeCell = (c: unknown) => `"${String(c ?? '').replace(/"/g, '""')}"`;
141+
const csv = [headers.map(h => escapeCell(h)).join(','), ...rows.map(r => r.map(c => escapeCell(c)).join(','))].join('\n');
142+
143+
const now = new Date();
144+
const ts = now.toISOString().replace(/[:]/g, '-').replace(/T/, '_').split('.')[0];
145+
const safeName = eventName.replace(/[^a-z0-9]/gi, '_');
146+
147+
return new Response(csv, {
148+
status: 200,
149+
headers: {
150+
'Content-Type': 'text/csv',
151+
'Content-Disposition': `attachment; filename="${safeName}_checkpoint_scans_${ts}.csv"`
152+
}
153+
});
154+
} catch (err) {
155+
console.error('Error exporting checkpoint CSV:', err);
156+
return NextResponse.json({ error: 'Failed to export checkpoint logs' }, { status: 500 });
157+
}
158+
}

0 commit comments

Comments
 (0)