Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/[locale]/group/[groupId]/dashboard/GroupDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { GroupHeader } from './GroupHeader';
import { InvitationSection } from './InvitationSection';
import { LotteryDialogs } from './LotteryDialogs';
import { MyAssignmentCard } from './MyAssignmentCard';
import { ParticipantsImportCard } from './ParticipantsImportCard';
import { ParticipantsSection } from './ParticipantsSection';

dayjs.extend(localizedFormat);
Expand Down Expand Up @@ -359,6 +360,9 @@ export const GroupDashboard = memo(
{/* Owner-only features */}
{isOwner && (
<>
{/* Import participants */}
<ParticipantsImportCard group={group} />

{/* Invitation Section */}
<InvitationSection
inviteLink={inviteLink}
Expand Down
217 changes: 217 additions & 0 deletions app/[locale]/group/[groupId]/dashboard/ParticipantsImportCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
'use client';

import { useCallback, useMemo, useState } from 'react';
import { Upload } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';

import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { useCSRF } from '@/lib/hooks/useCSRF';
import { Group } from '@/types/shared';

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

type ParsedParticipant = {
name: string;
email: string;
};

type ParseResult = {
participants: ParsedParticipant[];
errors: string[];
};

type ParticipantsImportCardProps = {
group: Group;
};

export function ParticipantsImportCard({ group }: ParticipantsImportCardProps) {
const t = useTranslations('participants');
const tCommon = useTranslations('common');
const [selectedFileName, setSelectedFileName] = useState<string>('');
const [parsedParticipants, setParsedParticipants] = useState<ParsedParticipant[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [feedback, setFeedback] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const router = useRouter();
const { token: csrfToken } = useCSRF();

const formatExample = useMemo(() => 'Name,Email\nJohn Doe,john@example.com', []);

const parseCsvContent = useCallback((content: string): ParseResult => {
const cleaned = content.replace(/\uFEFF/g, '').trim();
if (!cleaned) return { participants: [], errors: [t('csvEmptyError')] };

const rows = cleaned.split(/\r?\n/).filter((row) => row.trim().length > 0);
if (rows.length === 0) return { participants: [], errors: [t('csvEmptyError')] };

const [firstRow, ...rest] = rows;
const hasHeader = /name/i.test(firstRow.split(/[,;]/)[0] || '') && /email/i.test(firstRow.split(/[,;]/)[1] || '');
const dataRows = hasHeader ? rest : rows;

const participants: ParsedParticipant[] = [];
const errors: string[] = [];
const seenEmails = new Set<string>();

dataRows.forEach((row, index) => {
const delimiter = row.includes(';') ? ';' : ',';
const [rawName, rawEmail] = row.split(delimiter).map((value) => value.replace(/^\"|\"$/g, '').trim());

if (!rawName || !rawEmail) {
errors.push(t('csvMissingFields', { row: index + 1 }));
return;
}

if (!emailRegex.test(rawEmail)) {
errors.push(t('csvInvalidEmail', { email: rawEmail }));
return;
}

const email = rawEmail.toLowerCase();
if (seenEmails.has(email)) {
return;
}

seenEmails.add(email);
participants.push({ name: rawName, email });
});

return { participants, errors };
}, [t]);

const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
setFeedback(null);
setParseErrors([]);

if (!file) {
setSelectedFileName('');
setParsedParticipants([]);
return;
}

setSelectedFileName(file.name);

try {
const text = await file.text();
const result = parseCsvContent(text);
setParsedParticipants(result.participants);
setParseErrors(result.errors);
} catch (error) {
console.error('Failed to read CSV file', error);
setParseErrors([t('csvReadError')]);
setParsedParticipants([]);
}
},
[parseCsvContent, t]
);

const handleImport = useCallback(async () => {
if (!csrfToken) {
setFeedback(t('csrfMissing'));
return;
}

if (parsedParticipants.length === 0) {
setFeedback(t('csvEmptyError'));
return;
}

if (group.isDrawn) {
setFeedback(t('importDisabledDrawn'));
return;
}

setIsImporting(true);
setFeedback(null);

try {
const response = await fetch('/api/group/import-participants', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({
groupId: group.id,
participants: parsedParticipants,
}),
});

const result = await response.json();

if (!response.ok) {
throw new Error(result.error || tCommon('error'));
}

const addedMessage = t('importSuccess', {
added: result.addedCount ?? parsedParticipants.length,
skipped: result.skippedExisting ?? 0,
});

setFeedback(addedMessage);
router.refresh();
} catch (error) {
console.error('Import participants failed', error);
setFeedback(error instanceof Error ? error.message : tCommon('error'));
} finally {
setIsImporting(false);
}
}, [csrfToken, group.id, group.isDrawn, parsedParticipants, router, t, tCommon]);

return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
{t('importTitle')}
</CardTitle>
<CardDescription>{t('importDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Input type="file" accept=".csv" onChange={handleFileChange} />
{selectedFileName && (
<p className="text-sm text-muted-foreground">{t('selectedFile', { file: selectedFileName })}</p>
)}
<p className="text-xs text-muted-foreground whitespace-pre-wrap">{t('importFormat', { example: formatExample })}</p>
</div>

{parseErrors.length > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive space-y-1">
{parseErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}

{parsedParticipants.length > 0 && (
<p className="text-sm text-muted-foreground">
{t('importPreview', { count: parsedParticipants.length })}
</p>
)}

<Button
onClick={handleImport}
disabled={isImporting || parsedParticipants.length === 0 || group.isDrawn}
className="w-full md:w-auto"
>
{isImporting ? tCommon('loading') : t('importButton')}
</Button>

{group.isDrawn && (
<p className="text-sm text-muted-foreground">{t('importDisabledDrawn')}</p>
)}

{feedback && (
<div className="rounded-md border border-primary/20 bg-primary/10 p-3 text-sm text-primary-foreground dark:text-primary">
{feedback}
</div>
)}
</CardContent>
</Card>
);
}
127 changes: 127 additions & 0 deletions app/api/group/import-participants/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

import { Group } from '@/lib/db/models/Group';
import { Participant } from '@/lib/db/models/Participant';
import { connectDB } from '@/lib/db/mongodb';
import { validateCSRF } from '@/lib/middleware/csrf';
import { getSession } from '@/lib/session';

const importParticipantsSchema = z.object({
groupId: z.string(),
participants: z
.array(
z.object({
name: z.string().trim().min(1),
email: z.string().email(),
})
)
.min(1)
.max(200),
});

export async function POST(request: NextRequest) {
try {
const csrfError = await validateCSRF(request);
if (csrfError) return csrfError;

const session = await getSession();
if (!session.isLoggedIn || !session.participantId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const body = await request.json();
const { groupId, participants } = importParticipantsSchema.parse(body);

await connectDB();

const requester = await Participant.findById(session.participantId);
if (!requester) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const group = await Group.findById(groupId);
if (!group) {
return NextResponse.json({ error: 'Group not found' }, { status: 404 });
}

if (requester.email !== group.owner_email) {
return NextResponse.json(
{ error: 'Only the group owner can import participants' },
{ status: 403 }
);
}

if (group.is_drawn) {
return NextResponse.json(
{ error: 'Cannot import participants after lottery has been drawn' },
{ status: 400 }
);
}

const normalizedParticipants = participants.map((participant) => ({
name: participant.name.trim(),
email: participant.email.toLowerCase(),
}));

const uniqueParticipants: typeof normalizedParticipants = [];
const seenEmails = new Set<string>();

for (const participant of normalizedParticipants) {
if (!seenEmails.has(participant.email)) {
seenEmails.add(participant.email);
uniqueParticipants.push(participant);
}
}

const existingParticipants = await Participant.find({
group_id: groupId,
email: { $in: Array.from(seenEmails) },
});

const existingEmails = new Set(existingParticipants.map((p) => p.email));
const participantsToCreate = uniqueParticipants.filter(
(participant) => !existingEmails.has(participant.email)
);

if (participantsToCreate.length === 0) {
return NextResponse.json({
success: true,
addedCount: 0,
skippedExisting: existingEmails.size,
});
}

const createdParticipants = await Participant.insertMany(
participantsToCreate.map((participant) => ({
group_id: groupId,
name: participant.name,
email: participant.email,
verification_code: null,
code_expires_at: null,
code_sent_at: null,
}))
);

await Group.findByIdAndUpdate(groupId, {
$push: { participants: { $each: createdParticipants.map((p) => p._id) } },
});

return NextResponse.json({
success: true,
addedCount: createdParticipants.length,
skippedExisting: existingEmails.size,
});
} catch (error) {
console.error('Import participants error:', error);

if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}

return NextResponse.json(
{ error: 'Failed to import participants' },
{ status: 500 }
);
}
}
15 changes: 14 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,20 @@
"sent": "Sent",
"bounced": "Bounced",
"failed": "Failed",
"resendEmail": "Resend assignment email"
"resendEmail": "Resend assignment email",
"importTitle": "Import participants",
"importDescription": "Upload a CSV file with participant names and emails to add them to this group.",
"importFormat": "CSV format: Name,Email. Example:\n{example}",
"importPreview": "{count} participants ready to import",
"importButton": "Import CSV",
"importSuccess": "Added {added} participant(s). Skipped {skipped} existing email(s).",
"selectedFile": "Selected file: {file}",
"csvEmptyError": "Please choose a CSV file with at least one participant.",
"csvMissingFields": "Missing name or email on row {row}.",
"csvInvalidEmail": "Invalid email: {email}.",
"csvReadError": "Could not read the CSV file. Please try again.",
"importDisabledDrawn": "The lottery has already been drawn. Void it to import more participants.",
"csrfMissing": "Security token not available. Please refresh and try again."
},
"lottery": {
"run": "Run Lottery",
Expand Down
Loading