From aecb91109d8fadf0a143f0e924465eea233ab370 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 11 Dec 2025 11:59:33 +0100 Subject: [PATCH 1/4] Fix Jest config import for Next 16 --- .dockerignore | 9 +++++++++ Dockerfile | 26 ++++++++++++++++++++++++++ jest.config.js | 4 ++-- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..500fa29 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +.git +.vscode +npm-debug.log +Dockerfile +.dockerignore +.env +.env.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..097842a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1.6 + +FROM node:22-alpine AS base +WORKDIR /app + +FROM base AS deps +RUN apk add --no-cache libc6-compat +COPY package*.json ./ +RUN npm ci + +FROM deps AS builder +COPY . . +RUN npm run build + +FROM base AS runner +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +WORKDIR /app +RUN apk add --no-cache libc6-compat +COPY --from=deps /app/package*.json ./ +RUN npm ci --omit=dev +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/messages ./messages +EXPOSE 3000 +CMD ["npm", "run", "start", "--", "--hostname", "0.0.0.0", "--port", "3000"] diff --git a/jest.config.js b/jest.config.js index 5356dab..b752792 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,4 @@ -import nextJest from 'next/jest'; +import nextJest from 'next/jest.js'; const createJestConfig = nextJest({ // Provide the path to your Next.js app to load next.config.js and .env files in your test environment @@ -16,4 +16,4 @@ const customJestConfig = { }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async -module.exports = createJestConfig(customJestConfig); +export default createJestConfig(customJestConfig); From 530619faa1979725d588aa976e57d3b9f4a193bb Mon Sep 17 00:00:00 2001 From: Ben <69650356+BenNotix@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:39:44 +0100 Subject: [PATCH 2/4] Update Dockerfile --- Dockerfile | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 097842a..ad634b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,67 @@ # syntax=docker/dockerfile:1.6 +# Base image FROM node:22-alpine AS base WORKDIR /app +# Optional but nice to keep analytics off +ENV NEXT_TELEMETRY_DISABLED=1 + +# ----------------------------- +# 1. Dependencies stage +# ----------------------------- FROM base AS deps + +# Install system deps RUN apk add --no-cache libc6-compat + +# Install JS deps COPY package*.json ./ RUN npm ci -FROM deps AS builder +# ----------------------------- +# 2. Build stage +# ----------------------------- +FROM base AS builder + +# Build-time defaults so Next.js doesn't crash when importing env-dependent modules. +# These are NOT your real secrets; they are only used during `next build`. +ARG SESSION_SECRET="dev_session_secret_0123456789_abcdefghijklmnopqrstuvwxyz" +ARG MONGODB_URI="mongodb://localhost:27017/santa-dev" + +ENV SESSION_SECRET=${SESSION_SECRET} +ENV MONGODB_URI=${MONGODB_URI} + +# Bring dependencies and source +COPY --from=deps /app/node_modules ./node_modules COPY . . + +# Build Next.js app RUN npm run build +# ----------------------------- +# 3. Runtime stage +# ----------------------------- FROM base AS runner + ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 + WORKDIR /app + +# System deps RUN apk add --no-cache libc6-compat -COPY --from=deps /app/package*.json ./ + +# Use production deps only in final image +COPY package*.json ./ RUN npm ci --omit=dev + +# Copy built assets COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/messages ./messages + EXPOSE 3000 + +# Dokploy will inject REAL env vars (SESSION_SECRET, MONGODB_URI, etc.) at runtime. CMD ["npm", "run", "start", "--", "--hostname", "0.0.0.0", "--port", "3000"] From 079b7d8cdf528a28c4dabbd3992fb4428ddb3615 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 11 Dec 2025 14:54:01 +0100 Subject: [PATCH 3/4] Add CSV import for group participants --- .../[groupId]/dashboard/GroupDashboard.tsx | 4 + .../dashboard/ParticipantsImportCard.tsx | 217 ++++++++++++++++++ app/api/group/import-participants/route.ts | 127 ++++++++++ messages/en.json | 15 +- messages/es.json | 15 +- messages/pt.json | 15 +- 6 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 app/[locale]/group/[groupId]/dashboard/ParticipantsImportCard.tsx create mode 100644 app/api/group/import-participants/route.ts diff --git a/app/[locale]/group/[groupId]/dashboard/GroupDashboard.tsx b/app/[locale]/group/[groupId]/dashboard/GroupDashboard.tsx index 73297a1..5d0187b 100644 --- a/app/[locale]/group/[groupId]/dashboard/GroupDashboard.tsx +++ b/app/[locale]/group/[groupId]/dashboard/GroupDashboard.tsx @@ -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); @@ -359,6 +360,9 @@ export const GroupDashboard = memo( {/* Owner-only features */} {isOwner && ( <> + {/* Import participants */} + + {/* Invitation Section */} (''); + const [parsedParticipants, setParsedParticipants] = useState([]); + const [parseErrors, setParseErrors] = useState([]); + const [feedback, setFeedback] = useState(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(); + + 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) => { + 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 ( + + + + + {t('importTitle')} + + {t('importDescription')} + + +
+ + {selectedFileName && ( +

{t('selectedFile', { file: selectedFileName })}

+ )} +

{t('importFormat', { example: formatExample })}

+
+ + {parseErrors.length > 0 && ( +
+ {parseErrors.map((error, index) => ( +

{error}

+ ))} +
+ )} + + {parsedParticipants.length > 0 && ( +

+ {t('importPreview', { count: parsedParticipants.length })} +

+ )} + + + + {group.isDrawn && ( +

{t('importDisabledDrawn')}

+ )} + + {feedback && ( +
+ {feedback} +
+ )} +
+
+ ); +} diff --git a/app/api/group/import-participants/route.ts b/app/api/group/import-participants/route.ts new file mode 100644 index 0000000..430c509 --- /dev/null +++ b/app/api/group/import-participants/route.ts @@ -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(); + + 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 } + ); + } +} diff --git a/messages/en.json b/messages/en.json index a3c5147..4e64a2a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index a107389..5d6351d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -79,7 +79,20 @@ "sent": "Enviado", "bounced": "Rebotado", "failed": "Fallido", - "resendEmail": "Reenviar correo de asignación" + "resendEmail": "Reenviar correo de asignación", + "importTitle": "Importar participantes", + "importDescription": "Sube un archivo CSV con los nombres y correos para agregarlos al grupo.", + "importFormat": "Formato CSV: Nombre,Correo. Ejemplo:\n{example}", + "importPreview": "{count} participante(s) listo(s) para importar", + "importButton": "Importar CSV", + "importSuccess": "Se agregaron {added} participante(s). Se omitieron {skipped} correo(s) existente(s).", + "selectedFile": "Archivo seleccionado: {file}", + "csvEmptyError": "Elige un CSV con al menos un participante.", + "csvMissingFields": "Falta nombre o correo en la fila {row}.", + "csvInvalidEmail": "Correo inválido: {email}.", + "csvReadError": "No se pudo leer el archivo CSV. Intenta de nuevo.", + "importDisabledDrawn": "El sorteo ya se realizó. Anúlalo para importar más participantes.", + "csrfMissing": "Token de seguridad no disponible. Actualiza e inténtalo otra vez." }, "lottery": { "run": "Realizar sorteo", diff --git a/messages/pt.json b/messages/pt.json index 2088ced..4529557 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -79,7 +79,20 @@ "sent": "Enviado", "bounced": "Rejeitado", "failed": "Falhou", - "resendEmail": "Reenviar e-mail de atribuição" + "resendEmail": "Reenviar e-mail de atribuição", + "importTitle": "Importar participantes", + "importDescription": "Envie um arquivo CSV com nomes e e-mails para adicioná-los ao grupo.", + "importFormat": "Formato CSV: Nome,Email. Exemplo:\n{example}", + "importPreview": "{count} participante(s) pronto(s) para importar", + "importButton": "Importar CSV", + "importSuccess": "Adicionados {added} participante(s). {skipped} e-mail(s) já existiam.", + "selectedFile": "Arquivo selecionado: {file}", + "csvEmptyError": "Escolha um CSV com pelo menos um participante.", + "csvMissingFields": "Falta nome ou e-mail na linha {row}.", + "csvInvalidEmail": "E-mail inválido: {email}.", + "csvReadError": "Não foi possível ler o arquivo CSV. Tente novamente.", + "importDisabledDrawn": "O sorteio já foi realizado. Anule-o para importar mais participantes.", + "csrfMissing": "Token de segurança indisponível. Atualize e tente novamente." }, "lottery": { "run": "Realizar Sorteio", From b569841c53dec01438dc7bc6bbdfd4e729eaad8c Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 11 Dec 2025 15:06:15 +0100 Subject: [PATCH 4/4] Fix lottery rate limit body handling --- app/api/lottery/run/route.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/api/lottery/run/route.ts b/app/api/lottery/run/route.ts index 09c9152..cf706f3 100644 --- a/app/api/lottery/run/route.ts +++ b/app/api/lottery/run/route.ts @@ -22,17 +22,6 @@ export async function POST(request: NextRequest) { const csrfError = await validateCSRF(request); if (csrfError) return csrfError; - // Rate limit: 3 lottery runs per group per hour - const rateLimitError = await rateLimit(request, { - max: 3, - windowSeconds: 60 * 60, - keyGenerator: async (req) => { - const body = await req.json(); - return body.groupId ? `group:${body.groupId}:lottery` : null; - }, - }); - if (rateLimitError) return rateLimitError; - // Check authentication const session = await getSession(); if (!session.isLoggedIn || !session.participantId) { @@ -40,6 +29,16 @@ export async function POST(request: NextRequest) { } const body = await request.json(); + + // Rate limit: 3 lottery runs per group per hour + const rateLimitError = await rateLimit(request, { + max: 3, + windowSeconds: 60 * 60, + keyGenerator: async () => + body.groupId ? `group:${body.groupId}:lottery` : null, + }); + if (rateLimitError) return rateLimitError; + const { groupId, locale: requestLocale } = runLotterySchema.parse(body); await connectDB();