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
23 changes: 23 additions & 0 deletions client/src/pages/AdminProgramPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ const AdminProgramPage = () => {
const [filter, setFilter] = useState<Filter>("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[];
Expand Down Expand Up @@ -319,6 +324,23 @@ const AdminProgramPage = () => {
)}

{isAdminWallet ? (
!adminUnlocked ? (
<div className="panel px-4 py-10 text-center mb-3">
<div className="label-hw text-display mb-2">·ADMIN DATA LOCKED</div>
<p className="label-hw-dim mb-4 max-w-prose mx-auto">
Viewing this program needs no signature. Load the admin data
(guests, judging, stats) to manage it; this signs an admin-action
message once.
</p>
<button
type="button"
onClick={() => setAdminUnlocked(true)}
className="font-mono text-[10px] tracking-[0.14em] border border-display bg-display text-shell hover:bg-display-dim px-4 py-1.5"
>
LOAD ADMIN DATA ▸
</button>
</div>
) : (
<>
<ProgramStatsHeader
programSlug={program.slug}
Expand Down Expand Up @@ -409,6 +431,7 @@ const AdminProgramPage = () => {
</>
)}
</>
)
) : socialCanJudge || socialCanViewAdmin ? (
<>
<div className="panel px-3 py-2.5 mb-3 flex flex-wrap items-center gap-2">
Expand Down
10 changes: 10 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions server/api/controllers/__tests__/submission.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -427,26 +453,29 @@ 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);
expect(res.status).toHaveBeenCalledWith(200);
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 } };
const res = mockRes();
await submissionController.unpublishResults(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(programRepo.setResultsPublished).toHaveBeenCalledWith('bitrefill', null);
expect(prizeAwardService.notifyWinners).not.toHaveBeenCalled();
});
});

Expand Down
4 changes: 3 additions & 1 deletion server/api/controllers/program.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
30 changes: 28 additions & 2 deletions server/api/controllers/submission.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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.',
Expand Down
26 changes: 26 additions & 0 deletions server/api/repositories/program-submission.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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')
Expand Down
58 changes: 58 additions & 0 deletions server/api/services/__tests__/prize-award.service.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 2 additions & 1 deletion server/api/services/email-transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ 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({
from,
to,
cc,
bcc: allBcc.length ? allBcc : undefined,
replyTo: replyTo || undefined,
subject,
html,
text,
Expand Down
2 changes: 2 additions & 0 deletions server/api/services/notification-templates/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions server/api/services/notification-templates/prize-award.js
Original file line number Diff line number Diff line change
@@ -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 `<p>Congratulations${name}!</p>
<p>Your project <strong>${escapeHtml(payload.projectTitle)}</strong> won <strong>${escapeHtml(prizeText(payload))}</strong> at <strong>${escapeHtml(payload.programName)}</strong>.</p>
<p>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.</p>
<p><a href="${escapeHtml(link)}">See the published results</a></p>`;
}

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}`;
}
Loading