From 9dee2219635a836a6cab23c5b6213955ea22099a Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:32:44 +0200 Subject: [PATCH 1/2] feat(programs): defer admin-data sign behind an explicit unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Viewing an admin program page auto-mounted the stats/admins/judging/signups sections, each of which signs an admin-action message on load — so just *viewing* a program popped a wallet signature (and on mock previews, where /api/admin/session can't mint the cached bearer, every navigation re-prompted). Gate the wallet-admin sections behind a 'LOAD ADMIN DATA' control: viewing the program needs no signature; clicking it mounts the sections, whose concurrent loads dedup to a single wallet popup. Email-admin path unchanged (no popup). --- client/src/pages/AdminProgramPage.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/client/src/pages/AdminProgramPage.tsx b/client/src/pages/AdminProgramPage.tsx index 5cdcdef..a2deadd 100644 --- a/client/src/pages/AdminProgramPage.tsx +++ b/client/src/pages/AdminProgramPage.tsx @@ -90,6 +90,11 @@ const AdminProgramPage = () => { const [filter, setFilter] = useState("submitted"); const [editOpen, setEditOpen] = useState(false); const [adminsReload, setAdminsReload] = useState(0); + // Viewing the program needs no signature. The wallet-admin sections each sign + // an admin-action message when they load their data, so we defer mounting them + // behind an explicit unlock — one click, one signature (the concurrent section + // loads dedup to a single wallet popup) — instead of prompting on page view. + const [adminUnlocked, setAdminUnlocked] = useState(false); const [errorData, setErrorData] = useState<{ walletAddresses: string[]; expectedAddresses: string[]; @@ -319,6 +324,23 @@ const AdminProgramPage = () => { )} {isAdminWallet ? ( + !adminUnlocked ? ( +
+
·ADMIN DATA LOCKED
+

+ Viewing this program needs no signature. Load the admin data + (guests, judging, stats) to manage it; this signs an admin-action + message once. +

+ +
+ ) : ( <> { )} + ) ) : socialCanJudge || socialCanViewAdmin ? ( <>
From 251a440cafd0b686bd43ad9aab668907d86806b3 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:20:36 +0200 Subject: [PATCH 2/2] feat(judging): email winners on publish + surface email errors + test-submit allowlist Part 3 (headline): when results are published, each winning submission (one with a prize) that hasn't been notified gets an email from Stadium with the prize and a note that the team will reach out to arrange collection (Bitrefill giftcards are off-chain). New prize-award template + prize-award.service (best-effort, transient, mirrors the invite/confirmation services), triggered fire-and-forget in setResultsPublished on publish. A new prize_notified_at column makes unpublish/republish a no-op for already-emailed winners; a failed send leaves it unset so a later publish retries. Adds replyTo support to the email transport (PRIZE_CONTACT_EMAIL, defaults to RESEND_FROM_EMAIL). Part 1 (diagnostics): the program-admin invite now surfaces the real Resend error (send_failed: ) instead of a bare send_failed, so a misconfigured sender domain is diagnosable from the UI (admin-only action). Part 2 (testing): SUBMIT_TEST_EMAILS env allowlist lets specific emails bypass the checked-in (Luma signup) gate on the public submit form; off by default, case-insensitive. Such submissions stay flagged eligible:false. Migration 20260613000000 is additive/nullable (apply to prod before deploy). Server suite green (396). --- server/.env.example | 10 ++++ .../__tests__/submission.controller.test.js | 33 ++++++++++- server/api/controllers/program.controller.js | 4 +- .../api/controllers/submission.controller.js | 30 +++++++++- .../program-submission.repository.js | 26 +++++++++ .../__tests__/prize-award.service.test.js | 58 +++++++++++++++++++ server/api/services/email-transport.js | 3 +- .../services/notification-templates/index.js | 2 + .../notification-templates/prize-award.js | 40 +++++++++++++ server/api/services/prize-award.service.js | 58 +++++++++++++++++++ ...0000_program_submission_prize_notified.sql | 5 ++ 11 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 server/api/services/__tests__/prize-award.service.test.js create mode 100644 server/api/services/notification-templates/prize-award.js create mode 100644 server/api/services/prize-award.service.js create mode 100644 supabase/migrations/20260613000000_program_submission_prize_notified.sql diff --git a/server/.env.example b/server/.env.example index 35604f8..791255d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -48,6 +48,16 @@ RESEND_FROM_EMAIL= # comma-separated). Recipients never see it. Leave empty to disable. EMAIL_BCC= +# Optional: reply-to address on the winner prize-award email, so winners can +# reply to arrange collection. Defaults to RESEND_FROM_EMAIL if unset. +PRIZE_CONTACT_EMAIL= + +# Optional: comma-separated email allowlist that bypasses the "checked-in guest" +# (Luma signup) gate on the public submit form. For testing only — leave empty in +# normal operation. Allowlisted submissions are still flagged eligible:false. +# SUBMIT_TEST_EMAILS=sacha@joinwebzero.com +SUBMIT_TEST_EMAILS= + # Public base URL of the client, used to build links in outbound email (e.g. the # program-admin onboarding link). Defaults to the prod URL if unset. FRONTEND_URL=https://stadium.joinwebzero.com diff --git a/server/api/controllers/__tests__/submission.controller.test.js b/server/api/controllers/__tests__/submission.controller.test.js index 7cd23f5..9d0e11a 100644 --- a/server/api/controllers/__tests__/submission.controller.test.js +++ b/server/api/controllers/__tests__/submission.controller.test.js @@ -31,6 +31,9 @@ vi.mock('../../repositories/program-judge-ballot.repository.js', () => ({ default: { isSubmitted: vi.fn() }, })); vi.mock('../../services/program-audit-log.service.js', () => ({ default: { logSafe: vi.fn() } })); +vi.mock('../../services/prize-award.service.js', () => ({ + default: { notifyWinners: vi.fn() }, +})); const programService = (await import('../../services/program.service.js')).default; const scoringService = (await import('../../services/scoring.service.js')).default; @@ -40,6 +43,7 @@ const ballotRepo = (await import('../../repositories/program-judge-ballot.reposi const scoreRepo = (await import('../../repositories/submission-score.repository.js')).default; const signupRepo = (await import('../../repositories/program-signup.repository.js')).default; const confirmationService = (await import('../../services/submission-confirmation.service.js')).default; +const prizeAwardService = (await import('../../services/prize-award.service.js')).default; const submissionController = (await import('../submission.controller.js')).default; const mockRes = () => { @@ -98,6 +102,28 @@ describe('SubmissionController.submit (public)', () => { expect(submissionRepo.create).not.toHaveBeenCalled(); }); + it('lets a SUBMIT_TEST_EMAILS address bypass the checked-in gate', async () => { + programService.findBySlug.mockResolvedValue(PROGRAM); + signupRepo.existsByEmail.mockResolvedValue(false); // NOT on the signup list + submissionRepo.findByEmail.mockResolvedValue(null); + submissionRepo.create.mockResolvedValue({ + submission: { id: 'engine-ab12', lumaEmail: 'ada@example.com', submitterName: 'Ada', projectTitle: 'Engine' }, + duplicate: false, + }); + confirmationService.send.mockResolvedValue({ ok: true }); + const prev = process.env.SUBMIT_TEST_EMAILS; + process.env.SUBMIT_TEST_EMAILS = 'ADA@example.com'; // case-insensitive match + try { + const req = { params: { slug: 'bitrefill' }, body: GOOD_BODY }; + const res = mockRes(); + await submissionController.submit(req, res); + expect(res.status).toHaveBeenCalledWith(201); + expect(submissionRepo.create).toHaveBeenCalled(); + } finally { + process.env.SUBMIT_TEST_EMAILS = prev; + } + }); + it('201s on a first submission and emails a confirmation', async () => { programService.findBySlug.mockResolvedValue(PROGRAM); signupRepo.existsByEmail.mockResolvedValue(true); @@ -427,9 +453,10 @@ describe('SubmissionController results publish (platform admin)', () => { expect(programRepo.setResultsPublished).not.toHaveBeenCalled(); }); - it('publish sets a timestamp', async () => { + it('publish sets a timestamp and notifies winners', async () => { programService.findBySlug.mockResolvedValue(PROGRAM); programRepo.setResultsPublished.mockResolvedValue({ resultsPublishedAt: '2026-06-11T00:00:00.000Z' }); + prizeAwardService.notifyWinners.mockResolvedValue({ ok: true, sent: 0, failed: 0 }); const req = { params: { slug: 'bitrefill' }, user: { address: '5Admin', isGlobalAdmin: true } }; const res = mockRes(); await submissionController.publishResults(req, res); @@ -437,9 +464,10 @@ describe('SubmissionController results publish (platform admin)', () => { const [slug, publishedAt] = programRepo.setResultsPublished.mock.calls[0]; expect(slug).toBe('bitrefill'); expect(publishedAt).toBeTruthy(); // non-null timestamp + expect(prizeAwardService.notifyWinners).toHaveBeenCalledWith({ program: PROGRAM }); }); - it('unpublish clears the timestamp', async () => { + it('unpublish clears the timestamp and does NOT notify winners', async () => { programService.findBySlug.mockResolvedValue(PROGRAM); programRepo.setResultsPublished.mockResolvedValue({ resultsPublishedAt: null }); const req = { params: { slug: 'bitrefill' }, user: { address: '5Admin', isGlobalAdmin: true } }; @@ -447,6 +475,7 @@ describe('SubmissionController results publish (platform admin)', () => { await submissionController.unpublishResults(req, res); expect(res.status).toHaveBeenCalledWith(200); expect(programRepo.setResultsPublished).toHaveBeenCalledWith('bitrefill', null); + expect(prizeAwardService.notifyWinners).not.toHaveBeenCalled(); }); }); diff --git a/server/api/controllers/program.controller.js b/server/api/controllers/program.controller.js index ae56e19..4414076 100644 --- a/server/api/controllers/program.controller.js +++ b/server/api/controllers/program.controller.js @@ -465,7 +465,9 @@ class ProgramController { emailSent = result.ok; emailReason = result.ok ? null : result.reason; } catch (err) { - emailReason = 'send_failed'; + // Surface the real Resend reason (admin-only action) so a misconfigured + // sender domain / sandbox-mode key is diagnosable from the UI, not just logs. + emailReason = `send_failed: ${err?.message || 'unknown error'}`; logger.error('Program admin invite email failed:', err); } diff --git a/server/api/controllers/submission.controller.js b/server/api/controllers/submission.controller.js index aa95966..c5cc127 100644 --- a/server/api/controllers/submission.controller.js +++ b/server/api/controllers/submission.controller.js @@ -7,6 +7,7 @@ import submissionScoreRepository from '../repositories/submission-score.reposito import programJudgeBallotRepository from '../repositories/program-judge-ballot.repository.js'; import { validateSubmission, validateScore, validatePrize, prizeTiersFor } from '../utils/submission.validator.js'; import submissionConfirmationService from '../services/submission-confirmation.service.js'; +import prizeAwardService from '../services/prize-award.service.js'; import auditLog from '../services/program-audit-log.service.js'; // Winner selection + publishing are platform-admin only. After requireProgramAdmin, @@ -38,6 +39,18 @@ async function setResultsPublished(req, res, publish) { targetId: program.id, metadata: null, }); + // On publish, email each (not-yet-notified) winner about their prize. + // Fire-and-forget + best-effort: the response is already sent, and + // prize_notified_at makes a later unpublish/republish a no-op. + if (publish) { + prizeAwardService + .notifyWinners({ program }) + .then((r) => { + if (r?.reason) console.warn(`Prize emails not sent (${r.reason}) for ${program.slug}`); + else console.log(`Prize emails for ${program.slug}: sent ${r.sent}, failed ${r.failed}`); + }) + .catch((e) => console.error('❌ Prize-award notification failed:', e?.message || e)); + } } catch (error) { console.error('❌ Error updating results publish state:', error); res.status(500).json({ status: 'error', message: 'Failed to update results' }); @@ -75,8 +88,21 @@ class SubmissionController { return res.status(400).json({ status: 'error', message: v.error }); } // Only checked-in attendees may submit: the email must be on the program's - // imported Luma (checked-in / approved guest) list. - if (!(await programSignupRepository.existsByEmail(program.id, v.value.lumaEmail))) { + // imported Luma (checked-in / approved guest) list. SUBMIT_TEST_EMAILS is a + // comma-separated allowlist that bypasses this gate for testing (off by + // default; set per-deployment). Such submissions are still flagged + // eligible:false on the leaderboard since they're not in the Luma list. + const testEmails = new Set( + (process.env.SUBMIT_TEST_EMAILS || '') + .split(',') + .map((e) => e.trim().toLowerCase()) + .filter(Boolean), + ); + const submitterEmail = String(v.value.lumaEmail || '').trim().toLowerCase(); + if ( + !testEmails.has(submitterEmail) && + !(await programSignupRepository.existsByEmail(program.id, v.value.lumaEmail)) + ) { return res.status(403).json({ status: 'error', message: 'Only checked-in attendees can submit. Use the email you checked in with on Luma.', diff --git a/server/api/repositories/program-submission.repository.js b/server/api/repositories/program-submission.repository.js index a25b349..7226063 100644 --- a/server/api/repositories/program-submission.repository.js +++ b/server/api/repositories/program-submission.repository.js @@ -27,6 +27,7 @@ const transform = (row) => { paid: row.paid ?? false, paidAt: row.paid_at ?? null, paidBy: row.paid_by ?? null, + prizeNotifiedAt: row.prize_notified_at ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -162,6 +163,31 @@ class ProgramSubmissionRepository { return transform(data); } + // Winners (have a prize) not yet emailed about it. Drives the publish-time + // prize notification so each winner is mailed exactly once. + async listWinnersToNotify(programId) { + const { data, error } = await supabase + .from('program_submissions') + .select('*') + .eq('program_id', programId) + .not('prize_amount', 'is', null) + .is('prize_notified_at', null); + if (error) throw error; + return (data || []).map(transform); + } + + // Stamp that a winner has been emailed about their prize (idempotency marker). + async setPrizeNotified(id) { + const { data, error } = await supabase + .from('program_submissions') + .update({ prize_notified_at: new Date().toISOString() }) + .eq('id', id) + .select('*') + .single(); + if (error) throw error; + return transform(data); + } + async countByProgramId(programId) { const { count, error } = await supabase .from('program_submissions') diff --git a/server/api/services/__tests__/prize-award.service.test.js b/server/api/services/__tests__/prize-award.service.test.js new file mode 100644 index 0000000..8836d40 --- /dev/null +++ b/server/api/services/__tests__/prize-award.service.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../email-transport.js', () => ({ getEmailTransport: vi.fn() })); +vi.mock('../../repositories/program-submission.repository.js', () => ({ + default: { listWinnersToNotify: vi.fn(), setPrizeNotified: vi.fn() }, +})); + +const { getEmailTransport } = await import('../email-transport.js'); +const repo = (await import('../../repositories/program-submission.repository.js')).default; +const service = (await import('../prize-award.service.js')).default; + +const PROGRAM = { id: 'prog-1', name: 'Bitrefill', slug: 'bitrefill-2026' }; +const WINNER = { + id: 's1', + lumaEmail: 'winner@x.com', + submitterName: 'Win', + projectTitle: 'PixelPay', + prizeAmount: 500, + prizeCurrency: 'EUR', + prizeLabel: 'Bitrefill giftcard', +}; + +beforeEach(() => vi.clearAllMocks()); + +describe('prizeAwardService.notifyWinners', () => { + it('best-effort: provider_not_configured when the transport is null (no DB read)', async () => { + getEmailTransport.mockResolvedValue(null); + const r = await service.notifyWinners({ program: PROGRAM }); + expect(r).toMatchObject({ ok: false, reason: 'provider_not_configured' }); + expect(repo.listWinnersToNotify).not.toHaveBeenCalled(); + }); + + it('emails each un-notified winner with prize details + results link, then stamps notified', async () => { + const send = vi.fn().mockResolvedValue({ id: 'res_1' }); + getEmailTransport.mockResolvedValue({ send }); + repo.listWinnersToNotify.mockResolvedValue([WINNER]); + repo.setPrizeNotified.mockResolvedValue({}); + + const r = await service.notifyWinners({ program: PROGRAM }); + expect(r).toMatchObject({ ok: true, sent: 1, failed: 0 }); + + const arg = send.mock.calls[0][0]; + expect(arg.to).toBe('winner@x.com'); + expect(arg.subject).toContain('Bitrefill giftcard'); + expect(arg.html).toContain('/programs/bitrefill-2026'); + expect(repo.setPrizeNotified).toHaveBeenCalledWith('s1'); + }); + + it('does NOT stamp notified when the send fails, so a later publish retries', async () => { + const send = vi.fn().mockRejectedValue(new Error('domain not verified')); + getEmailTransport.mockResolvedValue({ send }); + repo.listWinnersToNotify.mockResolvedValue([WINNER]); + + const r = await service.notifyWinners({ program: PROGRAM }); + expect(r).toMatchObject({ ok: true, sent: 0, failed: 1 }); + expect(repo.setPrizeNotified).not.toHaveBeenCalled(); + }); +}); diff --git a/server/api/services/email-transport.js b/server/api/services/email-transport.js index 1ed9f4f..e36a49b 100644 --- a/server/api/services/email-transport.js +++ b/server/api/services/email-transport.js @@ -22,7 +22,7 @@ export async function getEmailTransport() { .filter(Boolean); return { - async send({ from, to, cc, bcc, subject, html, text }) { + async send({ from, to, cc, bcc, replyTo, subject, html, text }) { const callerBcc = Array.isArray(bcc) ? bcc : bcc ? [bcc] : []; const allBcc = [...callerBcc, ...envBcc]; const { data, error } = await resend.emails.send({ @@ -30,6 +30,7 @@ export async function getEmailTransport() { to, cc, bcc: allBcc.length ? allBcc : undefined, + replyTo: replyTo || undefined, subject, html, text, diff --git a/server/api/services/notification-templates/index.js b/server/api/services/notification-templates/index.js index ed2d858..515c463 100644 --- a/server/api/services/notification-templates/index.js +++ b/server/api/services/notification-templates/index.js @@ -4,6 +4,7 @@ import * as m2Approved from './m2-approved.js'; import * as m2ChangesRequested from './m2-changes-requested.js'; import * as programAdminInvite from './program-admin-invite.js'; import * as submissionConfirmation from './submission-confirmation.js'; +import * as prizeAward from './prize-award.js'; const templates = { application_accepted: applicationAccepted, @@ -12,6 +13,7 @@ const templates = { m2_changes_requested: m2ChangesRequested, program_admin_invite: programAdminInvite, submission_confirmation: submissionConfirmation, + prize_award: prizeAward, }; export function renderEmail(eventType, payload) { diff --git a/server/api/services/notification-templates/prize-award.js b/server/api/services/notification-templates/prize-award.js new file mode 100644 index 0000000..5e7cccd --- /dev/null +++ b/server/api/services/notification-templates/prize-award.js @@ -0,0 +1,40 @@ +import { escapeHtml } from './escape.js'; + +// Notifies a winning submitter that their project won a prize, with the prize +// details, a link to the published results, and a note that the team will reach +// out to arrange collection (Bitrefill prizes are off-chain giftcards). + +const prizeText = (payload) => { + const amount = payload.prizeAmount != null ? String(payload.prizeAmount) : ''; + const currency = payload.prizeCurrency || ''; + const money = `${amount} ${currency}`.trim(); + const label = payload.prizeLabel || ''; + if (label && money) return `${label} (${money})`; + return label || money || 'a prize'; +}; + +export function subject(payload) { + return `You won ${prizeText(payload)} at ${payload.programName}`; +} + +export function html(payload) { + const link = payload.link ?? 'https://stadium.joinwebzero.com'; + const name = payload.submitterName ? `, ${escapeHtml(payload.submitterName)}` : ''; + return `

Congratulations${name}!

+

Your project ${escapeHtml(payload.projectTitle)} won ${escapeHtml(prizeText(payload))} at ${escapeHtml(payload.programName)}.

+

The WebZero team will reach out to arrange delivery of your prize. Just reply to this email if you have any questions or your contact details change.

+

See the published results

`; +} + +export function text(payload) { + const link = payload.link ?? 'https://stadium.joinwebzero.com'; + const name = payload.submitterName ? `, ${payload.submitterName}` : ''; + return `Congratulations${name}! + +Your project "${payload.projectTitle}" won ${prizeText(payload)} at ${payload.programName}. + +The WebZero team will reach out to arrange delivery of your prize. Just reply to this email if you have any questions or your contact details change. + +See the published results: +${link}`; +} diff --git a/server/api/services/prize-award.service.js b/server/api/services/prize-award.service.js new file mode 100644 index 0000000..862d321 --- /dev/null +++ b/server/api/services/prize-award.service.js @@ -0,0 +1,58 @@ +import { getEmailTransport } from './email-transport.js'; +import { renderEmail } from './notification-templates/index.js'; +import programSubmissionRepository from '../repositories/program-submission.repository.js'; + +const FRONTEND_URL = process.env.FRONTEND_URL || 'https://stadium.joinwebzero.com'; + +class PrizeAwardService { + /** + * Email every winner of a program (a submission with a prize) that hasn't yet + * been notified, then stamp prize_notified_at so re-publishing never double- + * emails. Best-effort and transient (the notifications table is wallet-keyed; + * submitters are email-only), mirroring the invite + confirmation services. + * + * Returns { ok, reason?, sent, failed }. ok:false / reason:'provider_not_configured' + * when Resend is unset so the caller can log that winners weren't emailed. + */ + async notifyWinners({ program }) { + const transport = await getEmailTransport(); + if (!transport) return { ok: false, reason: 'provider_not_configured', sent: 0, failed: 0 }; + + const winners = await programSubmissionRepository.listWinnersToNotify(program.id); + const link = `${FRONTEND_URL}/programs/${encodeURIComponent(program.slug)}`; + const replyTo = process.env.PRIZE_CONTACT_EMAIL || process.env.RESEND_FROM_EMAIL; + + let sent = 0; + let failed = 0; + for (const w of winners) { + try { + const { subject, html, text } = renderEmail('prize_award', { + submitterName: w.submitterName, + programName: program.name, + projectTitle: w.projectTitle, + prizeAmount: w.prizeAmount, + prizeCurrency: w.prizeCurrency, + prizeLabel: w.prizeLabel, + link, + }); + await transport.send({ + from: process.env.RESEND_FROM_EMAIL, + to: w.lumaEmail, + replyTo, + subject, + html, + text, + }); + await programSubmissionRepository.setPrizeNotified(w.id); + sent += 1; + } catch (err) { + failed += 1; + // Leave prize_notified_at unset so a later publish retries this winner. + console.error(`❌ Prize-award email failed for ${w.lumaEmail}:`, err?.message || err); + } + } + return { ok: true, sent, failed }; + } +} + +export default new PrizeAwardService(); diff --git a/supabase/migrations/20260613000000_program_submission_prize_notified.sql b/supabase/migrations/20260613000000_program_submission_prize_notified.sql new file mode 100644 index 0000000..f84af81 --- /dev/null +++ b/supabase/migrations/20260613000000_program_submission_prize_notified.sql @@ -0,0 +1,5 @@ +-- Track when a winning submission was emailed about its prize, so publishing +-- results notifies each winner exactly once (unpublish/republish is a no-op for +-- winners already notified). Nullable + additive; safe to apply on a live table. +ALTER TABLE program_submissions + ADD COLUMN IF NOT EXISTS prize_notified_at TIMESTAMPTZ;