diff --git a/README.md b/README.md index 5fec5eaf..34d78ac4 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,8 @@ pnpm dev ## Running Tests -Tests run against a dedicated `nexus_test` Postgres database on the same Docker container as dev — this keeps test transaction/rollback semantics identical to prod (SQLite doesn't support the savepoints the test fixtures rely on). Create it once: +Tests run against a dedicated `nexus_test` Postgres database on the same Docker container as dev — this keeps test transaction/rollback semantics identical to prod (SQLite doesn't support the savepoints the test fixtures rely on). `nexus_test` is created automatically by `db-init/01-create-test-db.sql` the first time the container starts on a fresh `postgres_data` volume (Postgres's `docker-entrypoint-initdb.d` convention) — no manual step needed, including after `docker-compose down -v`. -```bash -docker exec backend-db-1 createdb -U nexus nexus_test -``` - -Then run: ```bash cd backend pytest diff --git a/backend/.gitignore b/backend/.gitignore index 53c373ea..545f1f86 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -16,6 +16,8 @@ credentials.json # Database *.db *.sqlite +*.dump +.db-backup/ # Testing .pytest_cache/ diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 9f810ecb..b238b72a 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -7,7 +7,7 @@ from app.core.users import check_if_email_exists, find_user_by_id from app.core.profile_status import compute_missing_profile_fields, is_profile_complete from app.db.session import get_db -from app.models.models import User +from app.models.models import User, UserCompetitionExperience, UserVolunteerExperience, Event from app.schemas.user import ( UserFullResponse, UserMeFullResponse, UserSlimResponse, UserMeSlimResponse, UserUpdate, AdminUserUpdate @@ -108,12 +108,15 @@ def get_me( if full: user = ( db.query(User) - .options(selectinload(User.competition_experience), selectinload(User.volunteer_experience)) + .options( + selectinload(User.competition_experience).selectinload(UserCompetitionExperience.event).selectinload(Event.category), + selectinload(User.volunteer_experience).selectinload(UserVolunteerExperience.event).selectinload(Event.category), + ) .filter(User.id == current_user.id) .first() ) response = UserMeFullResponse.model_validate(user) - response.missing_profile_fields = compute_missing_profile_fields(user) # relationships loaded, no db needed + response.missing_profile_fields = compute_missing_profile_fields(user) return response response = UserMeSlimResponse.model_validate(current_user) diff --git a/backend/app/core/profile_status.py b/backend/app/core/profile_status.py index cf74111d..4e1786ff 100644 --- a/backend/app/core/profile_status.py +++ b/backend/app/core/profile_status.py @@ -27,7 +27,7 @@ def _has_volunteer_rows(user: User, db: Optional[Session]) -> bool: def compute_missing_profile_fields(user: User, *, db: Optional[Session] = None) -> list[str]: - always_required = ["phone", "date_of_birth", "pronouns", "shirt_size", "dietary_restriction"] + always_required = ["phone", "date_of_birth", "shirt_size", "dietary_restriction"] missing = [f for f in always_required if not getattr(user, f)] if not user.student_status: diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index 26e215c5..1aeb3715 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -21,7 +21,7 @@ class EventCategoryUpdate(BaseModel): class EventResponse(BaseModel): id: int name: str - category_id: int + category: EventCategoryResponse model_config = {"from_attributes": True} diff --git a/backend/app/schemas/user_experience.py b/backend/app/schemas/user_experience.py index a22799b4..a1052ef0 100644 --- a/backend/app/schemas/user_experience.py +++ b/backend/app/schemas/user_experience.py @@ -1,6 +1,8 @@ from pydantic import BaseModel, model_validator from typing import Optional +from app.schemas.event import EventResponse + class CompetitionExperienceCreate(BaseModel): event_id: int @@ -14,7 +16,7 @@ class CompetitionExperienceUpdate(BaseModel): class CompetitionExperienceResponse(BaseModel): id: int - event_id: int + event: EventResponse school: str notes: Optional[str] = None @@ -50,7 +52,7 @@ class VolunteerExperienceResponse(BaseModel): tournament_name: str year: int - event_id: Optional[int] = None + event: Optional[EventResponse] = None role: str notes: Optional[VolunteerExperienceNotes] = None diff --git a/backend/db-init/01-create-test-db.sql b/backend/db-init/01-create-test-db.sql new file mode 100644 index 00000000..5ebef330 --- /dev/null +++ b/backend/db-init/01-create-test-db.sql @@ -0,0 +1,5 @@ +-- Runs automatically on first init of a fresh postgres_data volume +-- (docker-entrypoint-initdb.d convention). Creates the dedicated test +-- database so `pytest` works without a manual `createdb` step, even +-- after the volume has been dropped and recreated. +CREATE DATABASE nexus_test; diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index 439bf0c3..51adb629 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -10,6 +10,7 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + - ./db-init:/docker-entrypoint-initdb.d volumes: postgres_data: \ No newline at end of file diff --git a/backend/tests/api/test_events.py b/backend/tests/api/test_events.py index f34eecb9..5fd1265e 100644 --- a/backend/tests/api/test_events.py +++ b/backend/tests/api/test_events.py @@ -32,7 +32,7 @@ def test_admin_can_create_event(self, client, admin_user, event_category): assert res.status_code == 201 data = res.json() assert data["name"] == "Boomilever" - assert data["category_id"] == event_category.id + assert data["category"]["id"] == event_category.id def test_non_admin_forbidden(self, client, td_user, event_category): login(client, "td@test.com", "tdpass") @@ -67,7 +67,7 @@ def test_update_category_id(self, client, admin_user, event_category_factory, ev login(client, "admin@test.com", "adminpass") res = client.patch(f"/events/{event.id}/", json={"category_id": other_category.id}) assert res.status_code == 200 - assert res.json()["category_id"] == other_category.id + assert res.json()["category"]["id"] == other_category.id def test_missing_event_404(self, client, admin_user): login(client, "admin@test.com", "adminpass") diff --git a/backend/tests/api/test_user_experience.py b/backend/tests/api/test_user_experience.py index 5bb2bb24..7a118c9d 100644 --- a/backend/tests/api/test_user_experience.py +++ b/backend/tests/api/test_user_experience.py @@ -16,7 +16,7 @@ def test_valid_event_and_school(self, client, td_user, event): }) assert res.status_code == 201 data = res.json() - assert data["event_id"] == event.id + assert data["event"]["id"] == event.id assert data["school"] == "MIT" def test_invalid_event_id_404(self, client, td_user): @@ -49,7 +49,7 @@ def test_partial_update(self, client, db, td_user, event): assert res.status_code == 200 data = res.json() assert data["school"] == "Caltech" - assert data["event_id"] == event.id # untouched + assert data["event"]["id"] == event.id # untouched def test_missing_entry_404(self, client, td_user): login(client, "td@test.com", "tdpass") @@ -116,7 +116,7 @@ def test_minimal_fields(self, client, td_user): assert data["tournament_name"] == "Regionals" assert data["year"] == 2025 assert data["role"] == "Event Supervisor" - assert data["event_id"] is None + assert data["event"] is None assert data["notes"] is None def test_with_event_id_no_notes_event(self, client, td_user, event): @@ -128,7 +128,7 @@ def test_with_event_id_no_notes_event(self, client, td_user, event): "event_id": event.id, }) assert res.status_code == 201 - assert res.json()["event_id"] == event.id + assert res.json()["event"]["id"] == event.id def test_with_notes_event_no_event_id(self, client, td_user): login(client, "td@test.com", "tdpass") @@ -140,7 +140,7 @@ def test_with_notes_event_no_event_id(self, client, td_user): }) assert res.status_code == 201 data = res.json() - assert data["event_id"] is None + assert data["event"] is None assert data["notes"]["event"] == "Custom Event Name" def test_event_id_and_notes_event_mutually_exclusive(self, client, td_user, event): diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index de6fde68..1a193415 100644 --- a/backend/tests/api/test_users.py +++ b/backend/tests/api/test_users.py @@ -216,7 +216,7 @@ def test_competition_and_volunteer_experience_populated(self, client, td_user, d assert len(data["competition_experience"]) == 1 assert data["competition_experience"][0]["school"] == "MIT" - assert data["competition_experience"][0]["event_id"] == event.id + assert data["competition_experience"][0]["event"]["id"] == event.id assert len(data["volunteer_experience"]) == 1 assert data["volunteer_experience"][0]["tournament_name"] == "Regionals" diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx index dd23aee4..0e32a916 100644 --- a/frontend/app/(auth)/sign-up/page.tsx +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -9,17 +9,19 @@ import { useRouter } from "next/navigation" import { checkPassword, formatPhone, validateEmail, validatePassword, validatePhone, validateDateOfBirth } from "@/lib/auth" import { IconArrowLeft, IconCheckCircle, IconXCircle } from "@/components/ui/Icons" import { Input } from "@/components/ui/Input" -import { Combobox } from "@/components/ui/Combobox" import { Button } from "@/components/ui/Button" -import { Select } from "@/components/ui/Select" -import { RadioGroup } from "@/components/ui/RadioGroup" -import { Textarea } from "@/components/ui/Textarea" import { Modal } from "@/components/ui/Modal" +import { ProfileCard } from "@/components/profile/ProfileCard" +import { ProfileQuestion } from "@/components/profile/ProfileQuestion" +import { + PronounsField, StudentStatusField, + UniversityField, MajorField, YearLevelField, GraduationYearField, + EmployerField, YesNoField, ShirtSizeField, DietaryRestrictionField, +} from "@/components/profile/ProfileFields" import { - CompetitionExperienceTable, CompetitionExperienceDraft, isCompetitionRowValid, - VolunteerExperienceTable, VolunteerExperienceDraft, isVolunteerRowValid + CompetitionExperienceSpreadsheet, CompetitionExperienceDraft, isCompetitionRowValid, + VolunteerExperienceSpreadsheet, VolunteerExperienceDraft, isVolunteerRowValid } from "@/components/profile/ExperienceTables" -import { ProfileQuestion } from "@/components/profile/ProfileQuestion" import { useFormattedInputChange } from "@/lib/useFormattedInput" @@ -48,28 +50,11 @@ const STATE = { COMPLETE: 14, } as const -const COMMON_PRONOUNS = ["she/her", "he/him", "they/them", "she/they", "he/they", "any pronouns"] - export default function SignUpPage() { - // ── Sign-up step states ────────────────────────────────────────────────── - // 1 Account creation form - // 2 Student status question - // 3 University, major, year level, graduation year (student path) - // 4 Employer (non-student path) - // 5 Competed in Science Olympiad before? (yes / no) - // 6 Competition experience text - // 7 Volunteered for Science Olympiad before? (yes / no) - // 8 Volunteering experience text - // 9 Shirt size - // 10 Dietary restrictions? (yes / no) - // 11 Dietary restriction text - // 12 Complete button activated - // ──────────────────────────────────────────────────────────────────────── const [state, setState] = useState(STATE.ACCOUNT) const [user, setUser] = useState(null) const [loading, setLoading] = useState(false) - const [name, setName] = useState<{ first: string, last: string }>({ first: '', last: '' }) const [email, setEmail] = useState('') const [phone, setPhone] = useState('') @@ -256,9 +241,6 @@ export default function SignUpPage() { return } - // TODO: decide partial-save recovery behavior — if updateMe() succeeds but a - // competition/volunteer experience POST fails mid-loop, flags are saved but - // rows may be incomplete. For now: surface the error, don't redirect. try { await usersApi.updateMe(cleaned) @@ -436,414 +418,366 @@ export default function SignUpPage() { )} {state >= STATE.DATE_OF_BIRTH && ( -
+
{showVerifyModal && } + +
+

NEXUS

+
-
-

NEXUS

-
- -
-

Complete Your Profile

-
- -
- setState(STATE.PRONOUNS)} - onNext={() => { - const err = validateDateOfBirth(profileData.date_of_birth ?? '') - if (err) { - setErrors(er => ({ ...er, date_of_birth: err })) - return - } - setState(STATE.PRONOUNS) - }} - isActive={state === STATE.DATE_OF_BIRTH} - > { - setProfileData(d => ({ ...d, date_of_birth: e.target.value })) - setErrors(er => ({ ...er, date_of_birth: undefined })) - }} - error={errors.date_of_birth} - fullWidth - /> - - - {state >= STATE.PRONOUNS && ( +
+

Complete Your Profile

+
+ setState(STATE.STUDENT_STATUS)} + question="What is your date of birth?" + onSkip={() => setState(STATE.PRONOUNS)} onNext={() => { - if (!profileData.pronouns?.trim()) { - setErrors(er => ({ ...er, pronouns: "Cannot be empty." })) + const err = validateDateOfBirth(profileData.date_of_birth ?? '') + if (err) { + setErrors(er => ({ ...er, date_of_birth: err })) return } - setState(STATE.STUDENT_STATUS) + setState(STATE.PRONOUNS) }} - isActive={state === STATE.PRONOUNS} - > - p} - getLabel={p => p} - value={profileData.pronouns ?? ''} - allowFreeText - placeholder="Type your pronouns..." - error={errors.pronouns} - onChange={(text) => { - setProfileData(d => ({ ...d, pronouns: text })) - setErrors(er => ({ ...er, pronouns: undefined })) - }} + isActive={state === STATE.DATE_OF_BIRTH} + > { + setProfileData(d => ({ ...d, date_of_birth: e.target.value })) + setErrors(er => ({ ...er, date_of_birth: undefined })) + }} + error={errors.date_of_birth} + fullWidth /> - )} - - { state >= STATE.STUDENT_STATUS && ( - setState(STATE.STUDENT_STATUS + 3)} - isActive={state === STATE.STUDENT_STATUS} - >= STATE.UNIVERSITY && (profileData.student_status === "Undergraduate" || profileData.student_status === "Graduate") && ( +
+ + { - setProfileData(d => ({...d, university: e.target.value})) + error={errors.university} + onChange={(v) => { + setProfileData(d => ({...d, university: v})) setErrors(er => ({...er, university: undefined})) }} - error={errors.university} - /> - - - + + + { - setProfileData(d => ({...d, major: e.target.value})) + error={errors.major} + onChange={(v) => { + setProfileData(d => ({...d, major: v})) setErrors(er => ({...er, major: undefined})) }} - error={errors.major} - /> - - - 0 ? setErrors(er => ({...er, ...ers})) : setState(STATE.UNIVERSITY + 2) + }} + isActive={state === STATE.UNIVERSITY} + > + { - const raw = e.target.value.replace(/\D/g, '').slice(0, 4) - setErrors(er => ({ ...er, graduation_year: raw.length > 0 && raw.length < 4 ? "Must be a valid year." : undefined })) - setProfileData(d => ({ ...d, graduation_year: raw ? Number(raw) : undefined })) - }} - error={errors.graduation_year} - /> - -
- )} + onValidate={(err) => setErrors(er => ({ ...er, graduation_year: err }))} + onChange={(v) => setProfileData(d => ({ ...d, graduation_year: v }))} + /> +
+ + )} - {state >= STATE.EMPLOYER && profileData.student_status === "Non-Student" && ( - setState(STATE.COMPETED_BEFORE)} - onNext={() => { - !profileData.employer ? setErrors(er => ({...er, employer: "Cannot be empty."})) : setState(STATE.COMPETED_BEFORE) - }} - isActive={state === STATE.EMPLOYER} - >= STATE.EMPLOYER && profileData.student_status === "Non-Student" && ( + setState(STATE.COMPETED_BEFORE)} + onNext={() => { + !profileData.employer ? setErrors(er => ({...er, employer: "Cannot be empty."})) : setState(STATE.COMPETED_BEFORE) + }} + isActive={state === STATE.EMPLOYER} + > + { - setProfileData(d => ({...d, employer: e.target.value})) + error={errors.employer} + onChange={(v) => { + setProfileData(d => ({...d, employer: v})) setErrors(er => ({...er, employer: undefined})) }} - error={errors.employer} - /> - - )} + /> + + )} - {state >= STATE.COMPETED_BEFORE && ( - { - setState(STATE.COMPETED_BEFORE + 2) - }} - isActive={state === STATE.COMPETED_BEFORE} - > - { - const val = v === "yes" - setProfileData(d => ({ ...d, has_competition_experience: val })) - if (state >= STATE.COMPETED_BEFORE + 2) return - setState(val ? STATE.COMPETITION_EXP : STATE.COMPETED_BEFORE + 2) + {state >= STATE.COMPETED_BEFORE && ( + { + setState(STATE.COMPETED_BEFORE + 2) }} - options={[ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, - ]} - showCircle={false} - solid - /> - - )} + isActive={state === STATE.COMPETED_BEFORE} + > + { + setProfileData(d => ({ ...d, has_competition_experience: val })) + if (state >= STATE.COMPETED_BEFORE + 2) return + setState(val ? STATE.COMPETITION_EXP : STATE.COMPETED_BEFORE + 2) + }} + /> + + )} - {state >= STATE.COMPETITION_EXP && profileData.has_competition_experience && ( - { - setProfileData(d => ({ ...d, has_competition_experience: undefined })) - setCompetitionRows([]) - setState(STATE.VOLUNTEERED_BEFORE) - }} - onNext={() => { - if (competitionRows.length === 0 || !competitionRows.every(isCompetitionRowValid)) { - setErrors(er => ({ ...er, competition_exp: "Each entry needs a school and a matched event." })) - return - } - setState(STATE.VOLUNTEERED_BEFORE) - }} - isActive={state === STATE.COMPETITION_EXP} - > - - {errors.competition_exp && ( -

- {errors.competition_exp} -

- )} -
- )} + {state >= STATE.COMPETITION_EXP && profileData.has_competition_experience && ( + { + setProfileData(d => ({ ...d, has_competition_experience: undefined })) + setCompetitionRows([]) + setState(STATE.VOLUNTEERED_BEFORE) + }} + onNext={() => { + if (competitionRows.length === 0 || !competitionRows.every(isCompetitionRowValid)) { + setErrors(er => ({ ...er, competition_exp: "Each entry needs a school and a matched event." })) + return + } + setState(STATE.VOLUNTEERED_BEFORE) + }} + isActive={state === STATE.COMPETITION_EXP} + > + + {errors.competition_exp && ( +

+ {errors.competition_exp} +

+ )} +
+ )} - {state >= STATE.VOLUNTEERED_BEFORE && ( - { - setState(STATE.SHIRT_SIZE) - }} - isActive={state === STATE.VOLUNTEERED_BEFORE} - > - { - const val = v === "yes" - setProfileData(d => ({ ...d, has_volunteer_experience: val })) - if (state >= STATE.SHIRT_SIZE) return - setState(val ? STATE.VOLUNTEERING_EXP : STATE.SHIRT_SIZE) + {state >= STATE.VOLUNTEERED_BEFORE && ( + { + setState(STATE.SHIRT_SIZE) }} - options={[ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, - ]} - showCircle={false} - solid - /> - - )} + isActive={state === STATE.VOLUNTEERED_BEFORE} + > + { + setProfileData(d => ({ ...d, has_volunteer_experience: val })) + if (state >= STATE.SHIRT_SIZE) return + setState(val ? STATE.VOLUNTEERING_EXP : STATE.SHIRT_SIZE) + }} + /> + + )} - {state >= STATE.VOLUNTEERING_EXP && profileData.has_volunteer_experience && ( - { - setProfileData(d => ({ ...d, has_volunteer_experience: undefined })) - setVolunteerRows([]) - setState(STATE.SHIRT_SIZE) - }} - onNext={() => { - if (volunteerRows.length === 0 || !volunteerRows.every(isVolunteerRowValid)) { - setErrors(er => ({ ...er, volunteering_exp: "Each entry needs a tournament name, a 4-digit year, and a role." })) - return - } - setState(STATE.SHIRT_SIZE) - }} - isActive={state === STATE.VOLUNTEERING_EXP} - > - - {errors.volunteering_exp && ( -

- {errors.volunteering_exp} -

- )} -
- )} + {state >= STATE.VOLUNTEERING_EXP && profileData.has_volunteer_experience && ( + { + setProfileData(d => ({ ...d, has_volunteer_experience: undefined })) + setVolunteerRows([]) + setState(STATE.SHIRT_SIZE) + }} + onNext={() => { + if (volunteerRows.length === 0 || !volunteerRows.every(isVolunteerRowValid)) { + setErrors(er => ({ ...er, volunteering_exp: "Each entry needs a tournament name, a 4-digit year, and a role." })) + return + } + setState(STATE.SHIRT_SIZE) + }} + isActive={state === STATE.VOLUNTEERING_EXP} + > + + {errors.volunteering_exp && ( +

+ {errors.volunteering_exp} +

+ )} +
+ )} - {state >= STATE.SHIRT_SIZE && ( - { - setProfileData(d => ({ ...d, shirt_size: undefined })) - setState(STATE.DIETARY_RESTRICTIONS) - }} - isActive={state === STATE.SHIRT_SIZE} - > - { - setProfileData(d => ({ ...d, shirt_size: v as SHIRT_SIZE })) - if (state === STATE.SHIRT_SIZE) setState(STATE.DIETARY_RESTRICTIONS) + {state >= STATE.SHIRT_SIZE && ( + { + setProfileData(d => ({ ...d, shirt_size: undefined })) + setState(STATE.DIETARY_RESTRICTIONS) }} - options={["XS", "S", "M", "L", "XL", "XXL"].map(size => ({ value: size, label: size }))} - showCircle={false} - solid - /> - - )} + isActive={state === STATE.SHIRT_SIZE} + > + { + setProfileData(d => ({ ...d, shirt_size: v })) + if (state === STATE.SHIRT_SIZE) setState(STATE.DIETARY_RESTRICTIONS) + }} + /> + + )} - {state >= STATE.DIETARY_RESTRICTIONS && ( - { - setState(STATE.COMPLETE) - }} - isActive={state === STATE.DIETARY_RESTRICTIONS} - > - { - const val = v === "yes" - setHasDietary(val) - if (state >= STATE.COMPLETE) return - setState(val ? STATE.DIETARY_TEXT : STATE.COMPLETE) + {state >= STATE.DIETARY_RESTRICTIONS && ( + { + setState(STATE.COMPLETE) }} - options={[ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, - ]} - showCircle={false} - solid - /> - - )} + isActive={state === STATE.DIETARY_RESTRICTIONS} + > + { + setHasDietary(val) + if (state >= STATE.COMPLETE) return + setState(val ? STATE.DIETARY_TEXT : STATE.COMPLETE) + }} + /> + + )} - {state >= STATE.DIETARY_TEXT && hasDietary && ( - { - setHasDietary(null) - setState(STATE.COMPLETE) - }} - onNext={() => { - !profileData.dietary_restriction ? setErrors(er => ({...er, dietary_restriction: "Cannot be empty."})) - : setState(STATE.COMPLETE) - }} - isActive={state === STATE.DIETARY_TEXT} - >