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
18 changes: 13 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,19 @@ next-env.d.ts
# Isolated pass component test bundle
.pass-test/

# spreadsheets in the repo root (bank statements, registration exports with
# students' personal data, ad-hoc sheets): never commit
/*.xlsx
/*.xls
/*.csv
# spreadsheets anywhere in the repo (bank statements, registration exports with
# students' personal data, UTR comparisons, ad-hoc sheets): never commit
*.xlsx
*.xls
*.xlsm
*.xlsb
*.csv
*.tsv
*.ods
*.numbers

# generated import/processing output (event sheets, dry-run reports, SQL plans)
/output/

# Office lock files
~$*
Expand Down
106 changes: 106 additions & 0 deletions src/app/api/admin/registrations/on-spot/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyRegistrationStaff } from '@/lib/auth-server';
import { isFilterSafeEmail, readJsonObject } from '@/lib/security';
import { isFee, MAX_FEE, ON_SPOT_PHASE } from '@/lib/seats';

export const runtime = 'nodejs';

// No time limit in practice: the grant is given in person at the desk and lasts until it is used. The columns are
// NOT NULL, so this is just a long way off.
const VALID_FOR_MS = 30 * 24 * 60 * 60 * 1000;

/**
* POST /api/admin/registrations/on-spot { email, amount }
* Registration-Team/Admin only. ALLOW ON-SPOT: a walk-in at the college desk. There is no public on-spot page and no
* switch: only the account staff name here may register, at `amount`, with no accommodation and no registration kit.
* Staff only do this for someone standing at the desk, so the desk is the location check. It doesn't create a
* registration: the student fills in the usual form, pays and submits their proof, and it is reviewed as usual
* (approve only once the UTR and amount are on the bank statement). Granting the same email again replaces the grant.
*/
export async function POST(req: NextRequest) {
try {
const auth = await verifyRegistrationStaff(req);
if ('error' in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}

const parsed = await readJsonObject(req);
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status });
const { email, amount } = parsed.value;

const normalized = typeof email === 'string' ? email.toLowerCase().trim() : '';
if (!normalized || !isFilterSafeEmail(normalized)) {
return NextResponse.json({ error: 'Enter the email address the student signs in with.' }, { status: 400 });
}
if (!isFee(amount)) {
return NextResponse.json({ error: `The fee must be a whole number of rupees from 1 to ${MAX_FEE}.` }, { status: 400 });
}

const { supabase, user } = auth;
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('id')
.eq('email', normalized)
.maybeSingle();
if (profileError) {
console.error('/api/admin/registrations/on-spot profile lookup failed:', profileError);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
if (!profile) {
return NextResponse.json(
{ error: 'No account with that email. Ask the student to sign in on the site once, then try again.' },
{ status: 404 }
);
}
if (profile.id === user.id) {
return NextResponse.json({ error: 'You cannot allow yourself. Ask another staff member.' }, { status: 403 });
}

const { data: active, error: activeError } = await supabase
.from('registrations')
.select('registration_id')
.eq('user_id', profile.id)
.neq('status', 'rejected')
.limit(1)
.maybeSingle();
if (activeError) {
console.error('/api/admin/registrations/on-spot registration lookup failed:', activeError);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
if (active) {
return NextResponse.json({ error: `This student already has registration ${active.registration_id}.` }, { status: 409 });
}

// Only one pass counts at a time: the newest.
const now = new Date();
const { error: retireError } = await supabase
.from('payment_passes')
.update({ used_at: now.toISOString() })
.eq('user_id', profile.id)
.is('used_at', null);
if (retireError) {
console.error('/api/admin/registrations/on-spot retire failed:', retireError);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
// pay_by in the future: the form shows the payment details at this fee. accommodation false: on-spot never has it.
const until = new Date(now.getTime() + VALID_FOR_MS).toISOString();
const { error: insertError } = await supabase.from('payment_passes').insert({
user_id: profile.id,
phase: ON_SPOT_PHASE,
amount,
pay_by: until,
submit_by: until,
issued_by: user.id,
note: 'on-spot',
accommodation: false,
});
if (insertError) {
console.error('/api/admin/registrations/on-spot insert failed:', insertError);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err: unknown) {
console.error('/api/admin/registrations/on-spot error:', err);
return NextResponse.json({ error: 'Failed to allow on-spot registration.' }, { status: 500 });
}
}
2 changes: 2 additions & 0 deletions src/app/api/pass/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createRateLimiter } from '@/lib/security';
import { decryptEntryToken, encryptEntryToken, entryPassKey, mintEntryToken } from '@/lib/entry-pass';
import type { ParticipantPass } from '@/lib/pass-experience';
import { attemptsLeft } from '@/lib/rejections';
import { ON_SPOT_PHASE } from '@/lib/seats';

export const runtime = 'nodejs';

Expand Down Expand Up @@ -60,6 +61,7 @@ export async function POST(req: NextRequest) {
name: mine.name ?? null,
college: details.data?.college ?? null,
accommodation: details.data?.accommodation !== false,
onSpot: details.data?.phase === ON_SPOT_PHASE,
checkedIn: !!mine.checked_in,
token: null as string | null,
};
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
PHONE_IN_USE_ERROR_MESSAGE,
} from '@/lib/validation';
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft, isAttemptsUsedUpError } from '@/lib/rejections';
import { accommodationForSubmit, FORM_OUT_OF_DATE_MESSAGE, isAccommodationRefusal, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
import { accommodationForSubmit, FORM_OUT_OF_DATE_MESSAGE, isAccommodationRefusal, isOnSpotPhase, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
import {
classifyStudentType,
createRateLimiter,
Expand Down Expand Up @@ -248,7 +248,9 @@ export async function POST(req: NextRequest) {

// Saved when the student started paying (start-payment), never taken from the body alone. Null: this form never told
// them whether accommodation is included.
const accommodation = accommodationForSubmit(input.gender, input.no_accommodation, payment);
// On-spot (staff at the desk, phase 0) never includes accommodation, whatever the form sent.
const onSpot = isOnSpotPhase(payment.phase);
const accommodation = onSpot ? false : accommodationForSubmit(input.gender, input.no_accommodation, payment);
if (accommodation === null) {
return NextResponse.json({ code: 'form_out_of_date', error: FORM_OUT_OF_DATE_MESSAGE, field: 'no_accommodation' }, { status: 409 });
}
Expand Down
29 changes: 24 additions & 5 deletions src/app/api/register/start-payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getAuthenticatedUser } from '@/lib/auth-server';
import { getSupabaseAdmin } from '@/lib/supabase';
import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate';
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections';
import { FORM_OUT_OF_DATE_MESSAGE, MALE_ACCOMMODATION_CLOSED_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
import { FORM_OUT_OF_DATE_MESSAGE, isOnSpotPhase, MALE_ACCOMMODATION_CLOSED_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
import { createRateLimiter, readJsonObject, REGISTRATION_GENDERS } from '@/lib/security';

export const runtime = 'nodejs';
Expand Down Expand Up @@ -70,11 +70,28 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
}

// An on-spot pass (staff at the desk, POST /api/admin/registrations/on-spot) never includes accommodation, whatever
// the box says, and it isn't swapped for the online fee.
const { data: latestPass, error: passError } = await client
.from('payment_passes')
.select('phase')
.eq('user_id', user.id)
.is('used_at', null)
.gt('submit_by', new Date().toISOString())
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (passError) {
console.error('/api/register/start-payment pass lookup failed:', passError);
return NextResponse.json({ error: 'Could not start the payment. Please try again.' }, { status: 500 });
}
const onSpot = isOnSpotPhase(latestPass?.phase);

const { data, error } = await client.rpc('start_payment_with_accommodation', {
p_user_id: user.id,
p_fresh: body.value.fresh === true,
p_fresh: onSpot ? false : body.value.fresh === true,
p_gender: gender,
p_no_accommodation: noAccommodation,
p_no_accommodation: onSpot ? true : noAccommodation,
});
if (error || !data) {
console.error('/api/register/start-payment pass failed:', error);
Expand All @@ -94,8 +111,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
state: data.state,
amount: data.amount,
currentFee: data.current_fee ?? null,
accommodation: data.accommodation,
// An on-spot student is offered only their on-spot fee.
currentFee: onSpot ? null : data.current_fee ?? null,
accommodation: onSpot ? false : data.accommodation,
onSpot: isOnSpotPhase(data.phase),
});
} catch (err: unknown) {
console.error('API /api/register/start-payment error:', err);
Expand Down
18 changes: 14 additions & 4 deletions src/components/innovision/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import ReferralLeaderboard from './admin/ReferralLeaderboard';
import AmbassadorsManager from './admin/AmbassadorsManager';
import SeatCounterCard from './admin/SeatCounterCard';
import { httpErrorMessage } from '@/lib/api-errors';
import { isOnSpotPhase, phaseLabel } from '@/lib/seats';
import { MAX_REGISTRATION_ATTEMPTS, ordinal, REJECTION_REASONS, rejectionLabel, type RejectionReason } from '@/lib/rejections';
import { ID_CARD_REUPLOAD_REASONS, idCardReuploadLabel, type IdCardReuploadReason } from '@/lib/id-cards';

Expand Down Expand Up @@ -78,7 +79,7 @@ export default function AdminDashboard({
// Client-side over the loaded list: registrations with or without their college ID card.
const [idCardFilter, setIdCardFilter] = useState<'all' | 'missing' | 'uploaded'>('all');
// Client-side over the loaded list too: registrations with or without accommodation.
const [accommodationFilter, setAccommodationFilter] = useState<'all' | 'with' | 'without'>('all');
const [accommodationFilter, setAccommodationFilter] = useState<'all' | 'with' | 'without' | 'onspot'>('all');
// Bumped whenever the seat count may have moved (list reloaded, registration reviewed): the SEAT COUNTER card reloads.
const [seatsKey, setSeatsKey] = useState(0);

Expand Down Expand Up @@ -453,7 +454,10 @@ export default function AdminDashboard({
const visibleRegistrations = registrations.filter(
(r) =>
(idCardFilter === 'all' || (r.status !== 'rejected' && (idCardFilter === 'missing') === !r.id_card_path)) &&
(accommodationFilter === 'all' || (accommodationFilter === 'with') === (r.accommodation !== false))
(accommodationFilter === 'all' ||
(accommodationFilter === 'onspot'
? isOnSpotPhase(r.phase)
: (accommodationFilter === 'with') === (r.accommodation !== false)))
);

// Export Registrations CSV
Expand Down Expand Up @@ -506,7 +510,7 @@ export default function AdminDashboard({
rejectionLabel(r.rejection_reason),
r.attempt ?? 1,
r.amount,
r.phase ?? '',
phaseLabel(r.phase),
r.allowed_by ? 'Yes' : '',
r.utr,
r.payment_proof_path,
Expand Down Expand Up @@ -1441,7 +1445,7 @@ export default function AdminDashboard({
aria-label="Accommodation"
value={accommodationFilter}
onChange={(e) => {
setAccommodationFilter(e.target.value as 'all' | 'with' | 'without');
setAccommodationFilter(e.target.value as 'all' | 'with' | 'without' | 'onspot');
setRegPage(1);
}}
style={{
Expand All @@ -1457,6 +1461,7 @@ export default function AdminDashboard({
<option value="all">Any Accommodation</option>
<option value="with">With Accommodation</option>
<option value="without">No Accommodation</option>
<option value="onspot">On-spot Only</option>
</select>

<button
Expand Down Expand Up @@ -1629,6 +1634,11 @@ export default function AdminDashboard({
<div style={{ fontSize: '12px', color: reg.accommodation === false ? 'oklch(0.76 0.14 35)' : 'rgba(236,232,223,0.5)' }}>
{reg.accommodation === false ? 'No accommodation' : 'Accommodation'}
</div>
{isOnSpotPhase(reg.phase) && (
<div style={{ fontSize: '11px', fontWeight: 700, letterSpacing: '.12em', color: 'oklch(0.8 0.12 85)' }}>
ON-SPOT · NO KIT
</div>
)}
</td>

{/* College & Enrollment */}
Expand Down
4 changes: 2 additions & 2 deletions src/components/innovision/ApprovedPassTicket.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createPortal } from 'react-dom';
import { QRCodeSVG } from 'qrcode.react';
import EventTicket from '@/components/ui/event-ticket';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import type { ParticipantPass } from '@/lib/pass-experience';
import { passAccommodationLabel, type ParticipantPass } from '@/lib/pass-experience';
import { EVENT_DATE, VENUE_CITY } from '@/config/site';
import PassKeepsake from './PassKeepsake';
import styles from './ApprovedPassTicket.module.css';
Expand Down Expand Up @@ -86,7 +86,7 @@ export default function ApprovedPassTicket({ pass, animate = false }: { pass: Pa
<button type="button" onClick={savePdf} disabled={saving} aria-busy={saving} className={styles.print}>{saving ? 'Preparing PDF…' : 'Save PDF'}</button>
{pass.token && <button ref={qrButton} type="button" onClick={() => setQrOpen(true)}>View entry QR</button>}
</div>
<p className={styles.note}>Accommodation: {pass.accommodation === false ? 'not included' : 'included'}</p>
<p className={styles.note}>Accommodation: {passAccommodationLabel(pass).toLowerCase()}</p>
<p className={styles.note}>{pass.qrUnavailable || 'Keep your pass handy. Your registration ID is always available here.'}</p>
{saveError && <p className={styles.note} role="alert">{saveError}</p>}

Expand Down
6 changes: 6 additions & 0 deletions src/components/innovision/AuthOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ export default function AuthOverlay({ v }: { v: V }) {
<div style={{ display: v.d.s2, flexDirection: "column", gap: "22px" }}>
<h3 data-s-in="" style={{ margin: "0 0 4px", fontFamily: "var(--font-display)", fontWeight: "400", fontSize: "clamp(24px,2.2vw,32px)", lineHeight: "1.1" }}>Pay the fee</h3>
{/* The server's answer, not the box: seen before any way to pay, since a payment can't be refunded. */}
{offer?.onSpot ? (
<p data-s-in="" role="status" style={{ margin: "0", padding: "12px 14px", border: "1px solid oklch(0.78 0.075 85)", background: "rgba(220,183,106,.08)", fontSize: "13px", lineHeight: "1.5", color: "rgba(236,232,223,.9)" }}>
<span style={{ display: "block", fontSize: "12px", fontWeight: "700", letterSpacing: ".12em", color: "oklch(0.78 0.075 85)" }}>ON-SPOT REGISTRATION · ₹{offer.amount}</span>
No accommodation and no registration kit are included.
</p>
) : null}
{offer ? (
<p data-s-in="" role="status" style={{ margin: "0", padding: "12px 14px", border: "1px solid rgba(236,232,223,.18)", background: "rgba(236,232,223,.04)", fontSize: "12px", fontWeight: "700", letterSpacing: ".12em", lineHeight: "1.45", color: "rgba(236,232,223,.86)" }}>
ACCOMMODATION: <span style={{ color: offer.accommodation ? "oklch(0.78 0.075 85)" : "oklch(0.76 0.14 35)" }}>{offer.accommodation ? "INCLUDED" : "NOT INCLUDED"}</span>
Expand Down
4 changes: 2 additions & 2 deletions src/components/innovision/EntryPassCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useEffect, useState } from 'react';
import { isApprovedPass, passStatusMessage, type ParticipantPass } from '@/lib/pass-experience';
import { isApprovedPass, passAccommodationLabel, passStatusMessage, type ParticipantPass } from '@/lib/pass-experience';
import { passRequest } from './pass-client';
import PassIssued from './PassIssued';
import IdCardUpload from './IdCardUpload';
Expand Down Expand Up @@ -204,7 +204,7 @@ function PassState({
) : null}

{isPending ? (
<p className={styles.statusDesc}>Accommodation: {pass.accommodation === false ? 'not included' : 'included'}</p>
<p className={styles.statusDesc}>Accommodation: {passAccommodationLabel(pass).toLowerCase()}</p>
) : null}

{reason ? (
Expand Down
2 changes: 1 addition & 1 deletion src/components/innovision/Innovision.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2676,7 +2676,7 @@ export class Innovision extends Component<Props, State> {
// offline, server error) means no payment details.
const preview = REGISTRATION_PREVIEW_ENABLED && user.id === 'dev-preview-user';
const started = preview
? Promise.resolve({ offer: { state: 'pay' as const, amount: PREVIEW_REGISTRATION_FEE, currentFee: PREVIEW_REGISTRATION_FEE, accommodation: !no_accommodation } })
? Promise.resolve({ offer: { state: 'pay' as const, amount: PREVIEW_REGISTRATION_FEE, currentFee: PREVIEW_REGISTRATION_FEE, accommodation: !no_accommodation, onSpot: false } })
: startPayment(gender, no_accommodation);
this.setState({ busyLbl: referred_by_code ? 'CHECKING CODE' : 'CHECKING' });
Promise.all([started, referred_by_code ? checkReferralCode(referred_by_code) : null]).then(([pay, res]) => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/innovision/PassTicket.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { isApprovedPass, type ParticipantPass } from '@/lib/pass-experience';
import { isApprovedPass, passAccommodationLabel, type ParticipantPass } from '@/lib/pass-experience';
import ApprovedPassTicket from './ApprovedPassTicket';
import styles from './PassIssued.module.css';
import { EVENT_DATE } from '@/config/site';
Expand Down Expand Up @@ -51,7 +51,7 @@ export default function PassTicket({ pass, animate = false }: {
</div>
<div className={styles.metaCol}>
<span className={styles.label}>ACCOMMODATION</span>
<span className={styles.value}>{pass.accommodation === false ? 'Not included' : 'Included'}</span>
<span className={styles.value}>{passAccommodationLabel(pass)}</span>
</div>
</div>
</div>
Expand Down
Loading
Loading