From d8f9d67d0dd71ab82574e88b961f0a62241fde18 Mon Sep 17 00:00:00 2001 From: adescoteaux1 Date: Tue, 16 Jun 2026 19:37:45 -0400 Subject: [PATCH 1/2] initial schema --- .../migrations/20260616233655_init.sql | 410 ++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 backend/internal/supabase/migrations/20260616233655_init.sql diff --git a/backend/internal/supabase/migrations/20260616233655_init.sql b/backend/internal/supabase/migrations/20260616233655_init.sql new file mode 100644 index 0000000..4611b67 --- /dev/null +++ b/backend/internal/supabase/migrations/20260616233655_init.sql @@ -0,0 +1,410 @@ +CREATE TYPE role AS ENUM ( + 'software_engineer', + 'software_designer' +); + +CREATE TYPE application_stage AS ENUM ( + 'submitted', -- applicant has submitted + 'tl_review', -- assigned to 3 TLs for written review + 'chief_review', -- chiefs reviewing TL scores, deciding interview invites + 'interview_scheduled', -- invited; interview not yet conducted + 'interview_conducted', -- interviewer has left notes; assigned reviewers watching recording + 'interview_review', -- assigned TLs reviewing the recording + 'selection', -- all TLs reviewing all interviews, choosing their team + 'accepted', + 'rejected', + 'withdrawn' +); + +CREATE TYPE reviewer_role AS ENUM ( + 'tl', + 'chief' +); + +-- Written review: numeric 1–10 +-- Interview review: qualitative +CREATE TYPE interview_rating AS ENUM ( + 'do_not_hire', + 'good', + 'great', + 'must_hire' +); + +CREATE TYPE question_type AS ENUM ( + 'short_answer', + 'long_answer', + 'multiple_choice', + 'checkbox', + 'url' -- e.g. GitHub repo link, portfolio +); + +CREATE TYPE cycle_status AS ENUM ( + 'draft', + 'open', + 'closed', + 'archived' +); + + +-- ============================================================ +-- USERS (members / staff who log in and review) +-- NUID is the PK per spec +-- ============================================================ + +CREATE TABLE users ( + nuid TEXT PRIMARY KEY, -- Northeastern NUID + email TEXT NOT NULL UNIQUE, + full_name TEXT NOT NULL, + reviewer_role reviewer_role, -- NULL if not a reviewer + github_username TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + + +-- ============================================================ +-- APPLICATION CYCLES +-- One cycle per semester (e.g. "Fall 2026") +-- ============================================================ + +CREATE TABLE cycles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, -- e.g. "Fall 2026" + status cycle_status NOT NULL DEFAULT 'draft', + opens_at TIMESTAMPTZ, + closes_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + + +-- ============================================================ +-- QUESTIONS +-- Injectable per cycle; tied to a role or global (role IS NULL) +-- ============================================================ + +CREATE TABLE questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cycle_id UUID NOT NULL REFERENCES cycles(id) ON DELETE CASCADE, + role role, -- NULL = shown to all roles + question_text TEXT NOT NULL, + question_type question_type NOT NULL DEFAULT 'long_answer', + is_required BOOLEAN NOT NULL DEFAULT TRUE, + display_order INT NOT NULL DEFAULT 0, + options JSONB, -- for multiple_choice / checkbox + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + + +-- ============================================================ +-- APPLICANTS +-- Separate from users — external people applying +-- NUID is PK per spec +-- ============================================================ + +CREATE TABLE applicants ( + nuid TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + full_name TEXT NOT NULL, + github_username TEXT, + graduation_year INT, + major TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + + +-- ============================================================ +-- APPLICATIONS +-- One row per applicant × role × cycle +-- ============================================================ + +CREATE TABLE applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cycle_id UUID NOT NULL REFERENCES cycles(id), + applicant_nuid TEXT NOT NULL REFERENCES applicants(nuid), + role role NOT NULL, + stage application_stage NOT NULL DEFAULT 'submitted', + + -- Availability collected at submission time + availability JSONB, -- e.g. { "mon_am": true, "tue_pm": false, ... } + + resume_url TEXT, -- Storage URL (Supabase Storage bucket) + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (cycle_id, applicant_nuid, role) +); + + +-- ============================================================ +-- WRITTEN ANSWERS +-- One row per question per application +-- ============================================================ + +CREATE TABLE written_answers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + question_id UUID NOT NULL REFERENCES questions(id), + answer_text TEXT, + answer_options JSONB, -- for checkbox / multiple choice responses + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, question_id) +); + + +-- ============================================================ +-- CODE CHALLENGE SUBMISSIONS +-- GitHub-based; scores populated externally (deferred per spec) +-- ============================================================ + +CREATE TABLE code_challenges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cycle_id UUID NOT NULL REFERENCES cycles(id), + role role NOT NULL, + name TEXT NOT NULL, -- e.g. "Backend Challenge 1" + github_repo_url TEXT, -- template repo to fork + instructions TEXT, + due_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE code_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + challenge_id UUID NOT NULL REFERENCES code_challenges(id), + github_repo_url TEXT NOT NULL, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Score fields intentionally loose for now (deferred) + -- Populate via webhook / external job when scores land + raw_score NUMERIC, + score_details JSONB, -- test case breakdown etc. TBD + score_updated_at TIMESTAMPTZ, + + UNIQUE (application_id, challenge_id) +); + + +-- ============================================================ +-- TL REVIEW ASSIGNMENTS +-- Chiefs assign TLs to written apps +-- ============================================================ + +CREATE TABLE tl_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + tl_nuid TEXT NOT NULL REFERENCES users(nuid), + assigned_by TEXT NOT NULL REFERENCES users(nuid), -- chief who assigned + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, tl_nuid) +); + + +-- ============================================================ +-- WRITTEN REVIEWS (TL stage) +-- Score 1–10 + reasoning per answer + overall +-- ============================================================ + +CREATE TABLE written_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + reviewer_nuid TEXT NOT NULL REFERENCES users(nuid), + + overall_score INT CHECK (overall_score BETWEEN 1 AND 10), + reasoning TEXT, -- overall written reasoning + + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, reviewer_nuid) +); + +-- Per-answer scores within a written review +CREATE TABLE written_review_answer_scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + review_id UUID NOT NULL REFERENCES written_reviews(id) ON DELETE CASCADE, + answer_id UUID NOT NULL REFERENCES written_answers(id) ON DELETE CASCADE, + score INT CHECK (score BETWEEN 1 AND 10), + comment TEXT, + + UNIQUE (review_id, answer_id) +); + + +-- ============================================================ +-- CHIEF REVIEW (after TL reviews, before interview decision) +-- ============================================================ + +CREATE TABLE chief_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + reviewer_nuid TEXT NOT NULL REFERENCES users(nuid), + notes TEXT, + advance_to_interview BOOLEAN, -- NULL = undecided, TRUE/FALSE = decided + decided_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, reviewer_nuid) +); + + +-- ============================================================ +-- INTERVIEW ASSIGNMENTS +-- Chiefs assign one interviewer (TL or Chief) per interview, +-- plus 2 TLs to review the recording afterward +-- ============================================================ + +CREATE TABLE interview_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + assigned_by TEXT NOT NULL REFERENCES users(nuid), -- chief who assigned + interviewer_nuid TEXT NOT NULL REFERENCES users(nuid), -- conducts the interview + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id) -- one interviewer per application +); + +-- Chiefs assign 2 TLs to watch the recording and leave comments +CREATE TABLE interview_review_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + tl_nuid TEXT NOT NULL REFERENCES users(nuid), + assigned_by TEXT NOT NULL REFERENCES users(nuid), + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, tl_nuid) +); + + +-- ============================================================ +-- INTERVIEWS +-- Interviewer fills this out after conducting the interview +-- ============================================================ + +CREATE TABLE interviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + interviewer_nuid TEXT NOT NULL REFERENCES users(nuid), + scheduled_at TIMESTAMPTZ, + conducted_at TIMESTAMPTZ, + recording_url TEXT, -- embed link (Notion, Loom, etc.) + notes TEXT, -- interviewer's notes from the session + comments TEXT, -- interviewer's overall comments + rating interview_rating,-- interviewer gives the rating + submitted_at TIMESTAMPTZ, -- NULL until interviewer finalises + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id) -- one interview record per application +); + + +-- ============================================================ +-- INTERVIEW RECORDING REVIEWS +-- The 2 assigned TLs watch the recording and leave comments. +-- All TLs can also view (handled at app layer via RLS/policy), +-- but only assigned ones submit a formal review. +-- ============================================================ + +CREATE TABLE interview_recording_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + interview_id UUID NOT NULL REFERENCES interviews(id) ON DELETE CASCADE, + reviewer_nuid TEXT NOT NULL REFERENCES users(nuid), + comments TEXT, + rating interview_rating, + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (interview_id, reviewer_nuid) +); + + +-- ============================================================ +-- TL SELECTIONS (draft / final pick stage) +-- After all interviews are in, every TL marks who they want. +-- Chiefs use this to make final accept/reject decisions. +-- ============================================================ + +CREATE TABLE tl_selections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cycle_id UUID NOT NULL REFERENCES cycles(id), + tl_nuid TEXT NOT NULL REFERENCES users(nuid), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + note TEXT, -- optional reasoning + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (cycle_id, tl_nuid, application_id) +); + + +-- ============================================================ +-- INDEXES +-- ============================================================ + +CREATE INDEX idx_applications_cycle ON applications(cycle_id); +CREATE INDEX idx_applications_applicant ON applications(applicant_nuid); +CREATE INDEX idx_applications_stage ON applications(stage); +CREATE INDEX idx_written_answers_app ON written_answers(application_id); +CREATE INDEX idx_written_reviews_app ON written_reviews(application_id); +CREATE INDEX idx_tl_assignments_tl ON tl_assignments(tl_nuid); +CREATE INDEX idx_tl_assignments_app ON tl_assignments(application_id); +CREATE INDEX idx_chief_reviews_app ON chief_reviews(application_id); +CREATE INDEX idx_interview_assignments_app ON interview_assignments(application_id); +CREATE INDEX idx_interview_rev_assign_app ON interview_review_assignments(application_id); +CREATE INDEX idx_interviews_app ON interviews(application_id); +CREATE INDEX idx_recording_reviews_inter ON interview_recording_reviews(interview_id); +CREATE INDEX idx_tl_selections_cycle_tl ON tl_selections(cycle_id, tl_nuid); +CREATE INDEX idx_tl_selections_app ON tl_selections(application_id); +CREATE INDEX idx_code_submissions_app ON code_submissions(application_id); +CREATE INDEX idx_questions_cycle_role ON questions(cycle_id, role); + + +-- ============================================================ +-- updated_at TRIGGER (apply to all tables that have it) +-- ============================================================ + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_applicants_updated_at + BEFORE UPDATE ON applicants + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_applications_updated_at + BEFORE UPDATE ON applications + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_written_reviews_updated_at + BEFORE UPDATE ON written_reviews + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_chief_reviews_updated_at + BEFORE UPDATE ON chief_reviews + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_interviews_updated_at + BEFORE UPDATE ON interviews + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_recording_reviews_updated_at + BEFORE UPDATE ON interview_recording_reviews + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_tl_selections_updated_at + BEFORE UPDATE ON tl_selections + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); \ No newline at end of file From 053166ad570ea091cdec0a406c0f096cf04cca2c Mon Sep 17 00:00:00 2001 From: adescoteaux1 Date: Fri, 19 Jun 2026 13:18:35 -0400 Subject: [PATCH 2/2] backend initialization --- backend/Dockerfile | 2 +- backend/cmd/server/main.go | 6 +- backend/go.mod | 13 +- backend/go.sum | 24 ++- backend/internal/db/db.go | 26 ++- backend/internal/handlers/answers.go | 82 ++++++++ backend/internal/handlers/api.go | 63 ++++++ backend/internal/handlers/api_test.go | 105 ++++++++++ backend/internal/handlers/applicants.go | 78 ++++++++ backend/internal/handlers/applications.go | 164 ++++++++++++++++ backend/internal/handlers/challenges.go | 100 ++++++++++ backend/internal/handlers/code_submissions.go | 70 +++++++ backend/internal/handlers/cycles.go | 149 ++++++++++++++ backend/internal/handlers/questions.go | 181 ++++++++++++++++++ backend/internal/handlers/router.go | 96 ++++++---- backend/internal/handlers/users.go | 166 ++++++++++++++++ backend/internal/middleware/actor.go | 47 +++++ backend/internal/middleware/logging.go | 34 ++++ backend/internal/middleware/recovery.go | 20 ++ backend/internal/models/enums.go | 95 +++++++++ backend/internal/models/models.go | 94 +++++++++ backend/internal/models/openapi.go | 45 +++++ backend/internal/store/answers.go | 61 ++++++ backend/internal/store/applicants.go | 59 ++++++ backend/internal/store/applications.go | 108 +++++++++++ backend/internal/store/challenges.go | 51 +++++ backend/internal/store/code_submissions.go | 43 +++++ backend/internal/store/cycles.go | 84 ++++++++ backend/internal/store/errors.go | 17 ++ backend/internal/store/json.go | 14 ++ backend/internal/store/questions.go | 95 +++++++++ backend/internal/store/store.go | 25 +++ backend/internal/store/users.go | 97 ++++++++++ info.md | 34 ++++ 34 files changed, 2279 insertions(+), 69 deletions(-) create mode 100644 backend/internal/handlers/answers.go create mode 100644 backend/internal/handlers/api.go create mode 100644 backend/internal/handlers/api_test.go create mode 100644 backend/internal/handlers/applicants.go create mode 100644 backend/internal/handlers/applications.go create mode 100644 backend/internal/handlers/challenges.go create mode 100644 backend/internal/handlers/code_submissions.go create mode 100644 backend/internal/handlers/cycles.go create mode 100644 backend/internal/handlers/questions.go create mode 100644 backend/internal/handlers/users.go create mode 100644 backend/internal/middleware/actor.go create mode 100644 backend/internal/middleware/logging.go create mode 100644 backend/internal/middleware/recovery.go create mode 100644 backend/internal/models/enums.go create mode 100644 backend/internal/models/models.go create mode 100644 backend/internal/models/openapi.go create mode 100644 backend/internal/store/answers.go create mode 100644 backend/internal/store/applicants.go create mode 100644 backend/internal/store/applications.go create mode 100644 backend/internal/store/challenges.go create mode 100644 backend/internal/store/code_submissions.go create mode 100644 backend/internal/store/cycles.go create mode 100644 backend/internal/store/errors.go create mode 100644 backend/internal/store/json.go create mode 100644 backend/internal/store/questions.go create mode 100644 backend/internal/store/store.go create mode 100644 backend/internal/store/users.go create mode 100644 info.md diff --git a/backend/Dockerfile b/backend/Dockerfile index 228c804..165044b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /src diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9c959e0..8d2e33d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -29,11 +29,7 @@ func main() { slog.Error("failed to open database", "error", err) os.Exit(1) } - defer func() { - if err := database.Close(); err != nil { - slog.Error("error closing database", "error", err) - } - }() + defer database.Close() server := &http.Server{ Addr: ":" + cfg.Port, diff --git a/backend/go.mod b/backend/go.mod index a2409a0..af4ab8a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,14 +1,17 @@ module github.com/GenerateNU/apportal/backend -go 1.23.0 +go 1.25.0 -require github.com/jackc/pgx/v5 v5.7.6 +require ( + github.com/danielgtaylor/huma/v2 v2.38.0 + github.com/jackc/pgx/v5 v5.7.6 +) require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 7600ae8..ea1fabe 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,6 +1,12 @@ +github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY= +github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -14,14 +20,16 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 805b029..b66defc 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -2,21 +2,31 @@ package db import ( "context" - "database/sql" "time" - _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) -func Open(ctx context.Context, databaseURL string) (*sql.DB, error) { - database, err := sql.Open("pgx", databaseURL) +// Open creates a native pgx connection pool. The native pgx interface (rather +// than database/sql) is used so the store can rely on pgx helpers like +// pgx.CollectRows. +func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(databaseURL) if err != nil { return nil, err } - database.SetMaxOpenConns(5) - database.SetMaxIdleConns(5) - database.SetConnMaxLifetime(30 * time.Minute) + config.MaxConns = 5 + config.MaxConnLifetime = 30 * time.Minute - return database, nil + // Supabase's connection poolers (PgBouncer/Supavisor in transaction mode, + // e.g. port 6543) do not support the server-side named prepared-statement + // cache that pgx uses by default — reused pooled connections collide with + // "prepared statement already exists" (42P05). QueryExecModeExec uses an + // unnamed prepared statement per query, which is connection-local and + // pooler-safe while keeping proper server-side parameter typing. + config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec + + return pgxpool.NewWithConfig(ctx, config) } diff --git a/backend/internal/handlers/answers.go b/backend/internal/handlers/answers.go new file mode 100644 index 0000000..f1d800c --- /dev/null +++ b/backend/internal/handlers/answers.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +type answerHandler struct { + store *store.Store +} + +func (h *answerHandler) register(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "upsert-answers", + Method: http.MethodPut, + Path: "/applications/{id}/answers", + Summary: "Submit or update written answers", + Description: "Bulk upsert keyed on (application, question).", + Tags: []string{"Answers"}, + }, h.upsert) + + huma.Register(api, huma.Operation{ + OperationID: "list-answers", + Method: http.MethodGet, + Path: "/applications/{id}/answers", + Summary: "List an application's written answers", + Tags: []string{"Answers"}, + }, h.list) +} + +type AnswersOutput struct { + Body []models.WrittenAnswer +} + +type UpsertAnswersInput struct { + ID string `path:"id" doc:"Application ID"` + Body struct { + Answers []struct { + QuestionID string `json:"question_id"` + AnswerText *string `json:"answer_text,omitempty"` + AnswerOptions json.RawMessage `json:"answer_options,omitempty"` + } `json:"answers" minItems:"1"` + } +} + +func (h *answerHandler) upsert(ctx context.Context, in *UpsertAnswersInput) (*AnswersOutput, error) { + inputs := make([]store.AnswerInput, 0, len(in.Body.Answers)) + for _, a := range in.Body.Answers { + if a.QuestionID == "" { + return nil, huma.Error422UnprocessableEntity("each answer requires a question_id") + } + inputs = append(inputs, store.AnswerInput{ + QuestionID: a.QuestionID, + AnswerText: a.AnswerText, + AnswerOptions: a.AnswerOptions, + }) + } + + answers, err := h.store.UpsertAnswers(ctx, in.ID, inputs) + if err != nil { + return nil, storeErr(err) + } + return &AnswersOutput{Body: answers}, nil +} + +type ListAnswersInput struct { + ID string `path:"id" doc:"Application ID"` +} + +func (h *answerHandler) list(ctx context.Context, in *ListAnswersInput) (*AnswersOutput, error) { + answers, err := h.store.ListAnswers(ctx, in.ID) + if err != nil { + return nil, storeErr(err) + } + return &AnswersOutput{Body: answers}, nil +} diff --git a/backend/internal/handlers/api.go b/backend/internal/handlers/api.go new file mode 100644 index 0000000..6c748b7 --- /dev/null +++ b/backend/internal/handlers/api.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/middleware" + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +// writeJSON / writeError back the plain net/http health & root routes that are +// not Huma operations. +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// storeErr maps store sentinel errors to the matching Huma HTTP error so the +// operation returns the right status code (and it appears in the OpenAPI spec). +func storeErr(err error) error { + switch { + case errors.Is(err, store.ErrNotFound): + return huma.Error404NotFound("not found") + case errors.Is(err, store.ErrConflict): + return huma.Error409Conflict("already exists") + default: + slog.Error("unexpected store error", "error", err) + return huma.Error500InternalServerError("internal server error") + } +} + +// requireReviewer rejects calls lacking a valid reviewer identity. The actor is +// populated by middleware.WithActor from request headers (auth stub). +func requireReviewer(ctx context.Context) error { + actor, ok := middleware.ActorFrom(ctx) + if !ok || !actor.Role.Valid() { + return huma.Error401Unauthorized("reviewer identity required") + } + return nil +} + +// requireChief rejects calls that are not made by a chief. +func requireChief(ctx context.Context) error { + actor, ok := middleware.ActorFrom(ctx) + if !ok || actor.NUID == "" { + return huma.Error401Unauthorized("reviewer identity required") + } + if actor.Role != models.ReviewerRoleChief { + return huma.Error403Forbidden("chief role required") + } + return nil +} diff --git a/backend/internal/handlers/api_test.go b/backend/internal/handlers/api_test.go new file mode 100644 index 0000000..364c69d --- /dev/null +++ b/backend/internal/handlers/api_test.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/middleware" + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +func TestWriteJSON(t *testing.T) { + rec := httptest.NewRecorder() + writeJSON(rec, http.StatusCreated, map[string]string{"hello": "world"}) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("content-type = %q, want application/json", ct) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body["hello"] != "world" { + t.Fatalf("body = %v, want hello=world", body) + } +} + +func TestStoreErr(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {"not found", store.ErrNotFound, http.StatusNotFound}, + {"conflict", store.ErrConflict, http.StatusConflict}, + {"other", errExample("boom"), http.StatusInternalServerError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := storeErr(tc.err) + var se huma.StatusError + if !errors.As(got, &se) { + t.Fatalf("storeErr did not return a huma.StatusError: %T", got) + } + if se.GetStatus() != tc.want { + t.Fatalf("status = %d, want %d", se.GetStatus(), tc.want) + } + }) + } +} + +func TestRequireChief(t *testing.T) { + chief := middleware.Actor{NUID: "c1", Role: models.ReviewerRoleChief} + tl := middleware.Actor{NUID: "t1", Role: models.ReviewerRoleTL} + + cases := []struct { + name string + ctx context.Context + wantErr bool + wantCode int + }{ + {"no actor", context.Background(), true, http.StatusUnauthorized}, + {"tl", withActor(tl), true, http.StatusForbidden}, + {"chief", withActor(chief), false, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := requireChief(tc.ctx) + if tc.wantErr { + var se huma.StatusError + if !errors.As(err, &se) || se.GetStatus() != tc.wantCode { + t.Fatalf("got %v, want status %d", err, tc.wantCode) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// withActor returns a context carrying the given actor, mirroring what +// middleware.WithActor does from request headers. +func withActor(a middleware.Actor) context.Context { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-NUID", a.NUID) + r.Header.Set("X-Role", string(a.Role)) + captured := r.Context() + middleware.WithActor(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { + captured = req.Context() + })).ServeHTTP(httptest.NewRecorder(), r) + return captured +} + +type errExample string + +func (e errExample) Error() string { return string(e) } diff --git a/backend/internal/handlers/applicants.go b/backend/internal/handlers/applicants.go new file mode 100644 index 0000000..2a5d317 --- /dev/null +++ b/backend/internal/handlers/applicants.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +type applicantHandler struct { + store *store.Store +} + +func (h *applicantHandler) register(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "upsert-applicant", + Method: http.MethodPost, + Path: "/applicants", + Summary: "Create or update an applicant", + Description: "Applicant-facing; upserts by NUID.", + Tags: []string{"Applicants"}, + Errors: []int{http.StatusConflict}, + }, h.upsert) + + huma.Register(api, huma.Operation{ + OperationID: "get-applicant", + Method: http.MethodGet, + Path: "/applicants/{nuid}", + Summary: "Get an applicant", + Tags: []string{"Applicants"}, + Errors: []int{http.StatusNotFound}, + }, h.get) +} + +type ApplicantOutput struct { + Body models.Applicant +} + +type UpsertApplicantInput struct { + Body struct { + NUID string `json:"nuid"` + Email string `json:"email"` + FullName string `json:"full_name"` + GithubUsername *string `json:"github_username,omitempty"` + GraduationYear *int `json:"graduation_year,omitempty"` + Major *string `json:"major,omitempty"` + } +} + +func (h *applicantHandler) upsert(ctx context.Context, in *UpsertApplicantInput) (*ApplicantOutput, error) { + applicant, err := h.store.UpsertApplicant(ctx, store.ApplicantUpsert{ + NUID: in.Body.NUID, + Email: in.Body.Email, + FullName: in.Body.FullName, + GithubUsername: in.Body.GithubUsername, + GraduationYear: in.Body.GraduationYear, + Major: in.Body.Major, + }) + if err != nil { + return nil, storeErr(err) + } + return &ApplicantOutput{Body: applicant}, nil +} + +type ApplicantNUIDInput struct { + NUID string `path:"nuid"` +} + +func (h *applicantHandler) get(ctx context.Context, in *ApplicantNUIDInput) (*ApplicantOutput, error) { + applicant, err := h.store.GetApplicant(ctx, in.NUID) + if err != nil { + return nil, storeErr(err) + } + return &ApplicantOutput{Body: applicant}, nil +} diff --git a/backend/internal/handlers/applications.go b/backend/internal/handlers/applications.go new file mode 100644 index 0000000..5401375 --- /dev/null +++ b/backend/internal/handlers/applications.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +type applicationHandler struct { + store *store.Store +} + +func (h *applicationHandler) register(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "create-application", + Method: http.MethodPost, + Path: "/applications", + Summary: "Submit an application", + Description: "One application per applicant, role, and cycle.", + Tags: []string{"Applications"}, + DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusConflict}, + }, h.create) + + huma.Register(api, huma.Operation{ + OperationID: "get-application", + Method: http.MethodGet, + Path: "/applications/{id}", + Summary: "Get an application", + Tags: []string{"Applications"}, + Errors: []int{http.StatusNotFound}, + }, h.get) + + huma.Register(api, huma.Operation{ + OperationID: "list-applications", + Method: http.MethodGet, + Path: "/applications", + Summary: "List applications", + Description: "Reviewer queue; filter by cycle_id, role, and stage.", + Tags: []string{"Applications"}, + Errors: []int{http.StatusUnauthorized}, + }, h.list) + + huma.Register(api, huma.Operation{ + OperationID: "update-application", + Method: http.MethodPatch, + Path: "/applications/{id}", + Summary: "Update an application", + Description: "Set resume_url, availability, or advance/withdraw the stage.", + Tags: []string{"Applications"}, + Errors: []int{http.StatusNotFound}, + }, h.update) +} + +type ApplicationOutput struct { + Body models.Application +} + +type ApplicationsOutput struct { + Body []models.Application +} + +type CreateApplicationInput struct { + Body struct { + CycleID string `json:"cycle_id"` + ApplicantNUID string `json:"applicant_nuid"` + Role models.Role `json:"role"` + Availability json.RawMessage `json:"availability,omitempty"` + ResumeURL *string `json:"resume_url,omitempty"` + } +} + +func (h *applicationHandler) create(ctx context.Context, in *CreateApplicationInput) (*ApplicationOutput, error) { + if !in.Body.Role.Valid() { + return nil, huma.Error422UnprocessableEntity("valid role is required") + } + + app, err := h.store.CreateApplication(ctx, store.ApplicationCreate{ + CycleID: in.Body.CycleID, + ApplicantNUID: in.Body.ApplicantNUID, + Role: in.Body.Role, + Availability: in.Body.Availability, + ResumeURL: in.Body.ResumeURL, + }) + if err != nil { + return nil, storeErr(err) + } + return &ApplicationOutput{Body: app}, nil +} + +type ApplicationIDInput struct { + ID string `path:"id"` +} + +func (h *applicationHandler) get(ctx context.Context, in *ApplicationIDInput) (*ApplicationOutput, error) { + app, err := h.store.GetApplication(ctx, in.ID) + if err != nil { + return nil, storeErr(err) + } + return &ApplicationOutput{Body: app}, nil +} + +type ListApplicationsInput struct { + CycleID string `query:"cycle_id"` + Role string `query:"role"` + Stage string `query:"stage"` +} + +func (h *applicationHandler) list(ctx context.Context, in *ListApplicationsInput) (*ApplicationsOutput, error) { + if err := requireReviewer(ctx); err != nil { + return nil, err + } + filter := store.ApplicationFilter{CycleID: in.CycleID} + if in.Role != "" { + parsed := models.Role(in.Role) + if !parsed.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid role") + } + filter.Role = &parsed + } + if in.Stage != "" { + parsed := models.ApplicationStage(in.Stage) + if !parsed.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid stage") + } + filter.Stage = &parsed + } + + apps, err := h.store.ListApplications(ctx, filter) + if err != nil { + return nil, storeErr(err) + } + return &ApplicationsOutput{Body: apps}, nil +} + +type UpdateApplicationInput struct { + ID string `path:"id"` + Body struct { + Stage *models.ApplicationStage `json:"stage,omitempty"` + Availability json.RawMessage `json:"availability,omitempty"` + ResumeURL *string `json:"resume_url,omitempty"` + } +} + +func (h *applicationHandler) update(ctx context.Context, in *UpdateApplicationInput) (*ApplicationOutput, error) { + if in.Body.Stage != nil && !in.Body.Stage.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid stage") + } + + app, err := h.store.UpdateApplication(ctx, in.ID, store.ApplicationUpdate{ + Stage: in.Body.Stage, + Availability: in.Body.Availability, + ResumeURL: in.Body.ResumeURL, + }) + if err != nil { + return nil, storeErr(err) + } + return &ApplicationOutput{Body: app}, nil +} diff --git a/backend/internal/handlers/challenges.go b/backend/internal/handlers/challenges.go new file mode 100644 index 0000000..b5f977b --- /dev/null +++ b/backend/internal/handlers/challenges.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +type challengeHandler struct { + store *store.Store +} + +func (h *challengeHandler) register(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "list-cycle-challenges", + Method: http.MethodGet, + Path: "/cycles/{id}/challenges", + Summary: "List a cycle's code challenges", + Tags: []string{"Code challenges"}, + }, h.list) + + huma.Register(api, huma.Operation{ + OperationID: "create-challenge", + Method: http.MethodPost, + Path: "/cycles/{id}/challenges", + Summary: "Create a code challenge", + Description: "Chief only.", + Tags: []string{"Code challenges"}, + DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden}, + }, h.create) +} + +type ChallengeOutput struct { + Body models.CodeChallenge +} + +type ChallengesOutput struct { + Body []models.CodeChallenge +} + +type CreateChallengeInput struct { + ID string `path:"id" doc:"Cycle ID"` + Body struct { + Role models.Role `json:"role"` + Name string `json:"name"` + GithubRepoURL *string `json:"github_repo_url,omitempty"` + Instructions *string `json:"instructions,omitempty"` + DueAt *time.Time `json:"due_at,omitempty"` + } +} + +func (h *challengeHandler) create(ctx context.Context, in *CreateChallengeInput) (*ChallengeOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + if !in.Body.Role.Valid() { + return nil, huma.Error422UnprocessableEntity("valid role is required") + } + + challenge, err := h.store.CreateChallenge(ctx, store.ChallengeCreate{ + CycleID: in.ID, + Role: in.Body.Role, + Name: in.Body.Name, + GithubRepoURL: in.Body.GithubRepoURL, + Instructions: in.Body.Instructions, + DueAt: in.Body.DueAt, + }) + if err != nil { + return nil, storeErr(err) + } + return &ChallengeOutput{Body: challenge}, nil +} + +type ListChallengesInput struct { + ID string `path:"id" doc:"Cycle ID"` + Role string `query:"role" doc:"Optional role filter"` +} + +func (h *challengeHandler) list(ctx context.Context, in *ListChallengesInput) (*ChallengesOutput, error) { + var role *models.Role + if in.Role != "" { + parsed := models.Role(in.Role) + if !parsed.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid role") + } + role = &parsed + } + + challenges, err := h.store.ListChallenges(ctx, in.ID, role) + if err != nil { + return nil, storeErr(err) + } + return &ChallengesOutput{Body: challenges}, nil +} diff --git a/backend/internal/handlers/code_submissions.go b/backend/internal/handlers/code_submissions.go new file mode 100644 index 0000000..05befe8 --- /dev/null +++ b/backend/internal/handlers/code_submissions.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +type codeSubmissionHandler struct { + store *store.Store +} + +func (h *codeSubmissionHandler) register(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "upsert-code-submission", + Method: http.MethodPut, + Path: "/applications/{id}/code-submission", + Summary: "Submit or update the code challenge link", + Description: "Records the GitHub repo URL. Scores are populated externally (deferred).", + Tags: []string{"Code submissions"}, + }, h.upsert) + + huma.Register(api, huma.Operation{ + OperationID: "list-code-submissions", + Method: http.MethodGet, + Path: "/applications/{id}/code-submission", + Summary: "List an application's code submissions", + Tags: []string{"Code submissions"}, + }, h.list) +} + +type CodeSubmissionOutput struct { + Body models.CodeSubmission +} + +type CodeSubmissionsOutput struct { + Body []models.CodeSubmission +} + +type UpsertCodeSubmissionInput struct { + ID string `path:"id" doc:"Application ID"` + Body struct { + ChallengeID string `json:"challenge_id"` + GithubRepoURL string `json:"github_repo_url"` + } +} + +func (h *codeSubmissionHandler) upsert(ctx context.Context, in *UpsertCodeSubmissionInput) (*CodeSubmissionOutput, error) { + sub, err := h.store.UpsertCodeSubmission(ctx, in.ID, in.Body.ChallengeID, in.Body.GithubRepoURL) + if err != nil { + return nil, storeErr(err) + } + return &CodeSubmissionOutput{Body: sub}, nil +} + +type CodeSubmissionAppInput struct { + ID string `path:"id" doc:"Application ID"` +} + +func (h *codeSubmissionHandler) list(ctx context.Context, in *CodeSubmissionAppInput) (*CodeSubmissionsOutput, error) { + subs, err := h.store.ListCodeSubmissions(ctx, in.ID) + if err != nil { + return nil, storeErr(err) + } + return &CodeSubmissionsOutput{Body: subs}, nil +} diff --git a/backend/internal/handlers/cycles.go b/backend/internal/handlers/cycles.go new file mode 100644 index 0000000..a5a4baa --- /dev/null +++ b/backend/internal/handlers/cycles.go @@ -0,0 +1,149 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +type cycleHandler struct { + store *store.Store +} + +func (h *cycleHandler) register(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "list-cycles", + Method: http.MethodGet, + Path: "/cycles", + Summary: "List cycles", + Tags: []string{"Cycles"}, + }, h.list) + + huma.Register(api, huma.Operation{ + OperationID: "get-cycle", + Method: http.MethodGet, + Path: "/cycles/{id}", + Summary: "Get a cycle", + Tags: []string{"Cycles"}, + Errors: []int{http.StatusNotFound}, + }, h.get) + + huma.Register(api, huma.Operation{ + OperationID: "create-cycle", + Method: http.MethodPost, + Path: "/cycles", + Summary: "Create a cycle", + Description: "Chief only.", + Tags: []string{"Cycles"}, + DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden}, + }, h.create) + + huma.Register(api, huma.Operation{ + OperationID: "update-cycle", + Method: http.MethodPatch, + Path: "/cycles/{id}", + Summary: "Update a cycle", + Description: "Chief only.", + Tags: []string{"Cycles"}, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, h.update) +} + +// CycleOutput wraps a single cycle response body. +type CycleOutput struct { + Body models.Cycle +} + +// CyclesOutput wraps a list of cycles. +type CyclesOutput struct { + Body []models.Cycle +} + +type CreateCycleInput struct { + Body struct { + Name string `json:"name"` + Status models.CycleStatus `json:"status,omitempty"` + OpensAt *time.Time `json:"opens_at,omitempty"` + ClosesAt *time.Time `json:"closes_at,omitempty"` + } +} + +func (h *cycleHandler) create(ctx context.Context, in *CreateCycleInput) (*CycleOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + status := in.Body.Status + if status == "" { + status = models.CycleDraft + } + if !status.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid status") + } + + cycle, err := h.store.CreateCycle(ctx, store.CycleCreate{ + Name: in.Body.Name, + Status: status, + OpensAt: in.Body.OpensAt, + ClosesAt: in.Body.ClosesAt, + }) + if err != nil { + return nil, storeErr(err) + } + return &CycleOutput{Body: cycle}, nil +} + +func (h *cycleHandler) list(ctx context.Context, _ *struct{}) (*CyclesOutput, error) { + cycles, err := h.store.ListCycles(ctx) + if err != nil { + return nil, storeErr(err) + } + return &CyclesOutput{Body: cycles}, nil +} + +type CycleIDInput struct { + ID string `path:"id"` +} + +func (h *cycleHandler) get(ctx context.Context, in *CycleIDInput) (*CycleOutput, error) { + cycle, err := h.store.GetCycle(ctx, in.ID) + if err != nil { + return nil, storeErr(err) + } + return &CycleOutput{Body: cycle}, nil +} + +type UpdateCycleInput struct { + ID string `path:"id"` + Body struct { + Name *string `json:"name,omitempty"` + Status *models.CycleStatus `json:"status,omitempty"` + OpensAt *time.Time `json:"opens_at,omitempty"` + ClosesAt *time.Time `json:"closes_at,omitempty"` + } +} + +func (h *cycleHandler) update(ctx context.Context, in *UpdateCycleInput) (*CycleOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + if in.Body.Status != nil && !in.Body.Status.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid status") + } + + cycle, err := h.store.UpdateCycle(ctx, in.ID, store.CycleUpdate{ + Name: in.Body.Name, + Status: in.Body.Status, + OpensAt: in.Body.OpensAt, + ClosesAt: in.Body.ClosesAt, + }) + if err != nil { + return nil, storeErr(err) + } + return &CycleOutput{Body: cycle}, nil +} diff --git a/backend/internal/handlers/questions.go b/backend/internal/handlers/questions.go new file mode 100644 index 0000000..6452f04 --- /dev/null +++ b/backend/internal/handlers/questions.go @@ -0,0 +1,181 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +type questionHandler struct { + store *store.Store +} + +func (h *questionHandler) register(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "list-cycle-questions", + Method: http.MethodGet, + Path: "/cycles/{id}/questions", + Summary: "List a cycle's questions", + Description: "Optional ?role= returns that role's questions plus global ones.", + Tags: []string{"Questions"}, + }, h.list) + + huma.Register(api, huma.Operation{ + OperationID: "create-question", + Method: http.MethodPost, + Path: "/cycles/{id}/questions", + Summary: "Create a question", + Description: "Chief only.", + Tags: []string{"Questions"}, + DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden}, + }, h.create) + + huma.Register(api, huma.Operation{ + OperationID: "update-question", + Method: http.MethodPatch, + Path: "/questions/{id}", + Summary: "Update a question", + Description: "Chief only.", + Tags: []string{"Questions"}, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, h.update) + + huma.Register(api, huma.Operation{ + OperationID: "delete-question", + Method: http.MethodDelete, + Path: "/questions/{id}", + Summary: "Delete a question", + Description: "Chief only.", + Tags: []string{"Questions"}, + DefaultStatus: http.StatusNoContent, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, h.delete) +} + +type QuestionOutput struct { + Body models.Question +} + +type QuestionsOutput struct { + Body []models.Question +} + +type CreateQuestionInput struct { + ID string `path:"id" doc:"Cycle ID"` + Body struct { + Role *models.Role `json:"role,omitempty" doc:"Omit for a global question shown to all roles"` + QuestionText string `json:"question_text"` + QuestionType models.QuestionType `json:"question_type,omitempty"` + IsRequired *bool `json:"is_required,omitempty"` + DisplayOrder int `json:"display_order,omitempty"` + Options json.RawMessage `json:"options,omitempty"` + } +} + +func (h *questionHandler) create(ctx context.Context, in *CreateQuestionInput) (*QuestionOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + qType := in.Body.QuestionType + if qType == "" { + qType = models.QuestionLongAnswer + } + if !qType.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid question_type") + } + if in.Body.Role != nil && !in.Body.Role.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid role") + } + required := true + if in.Body.IsRequired != nil { + required = *in.Body.IsRequired + } + + q, err := h.store.CreateQuestion(ctx, store.QuestionCreate{ + CycleID: in.ID, + Role: in.Body.Role, + QuestionText: in.Body.QuestionText, + QuestionType: qType, + IsRequired: required, + DisplayOrder: in.Body.DisplayOrder, + Options: in.Body.Options, + }) + if err != nil { + return nil, storeErr(err) + } + return &QuestionOutput{Body: q}, nil +} + +type ListQuestionsInput struct { + ID string `path:"id" doc:"Cycle ID"` + Role string `query:"role" doc:"Optional role filter"` +} + +func (h *questionHandler) list(ctx context.Context, in *ListQuestionsInput) (*QuestionsOutput, error) { + var role *models.Role + if in.Role != "" { + parsed := models.Role(in.Role) + if !parsed.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid role") + } + role = &parsed + } + + questions, err := h.store.ListQuestions(ctx, in.ID, role) + if err != nil { + return nil, storeErr(err) + } + return &QuestionsOutput{Body: questions}, nil +} + +type UpdateQuestionInput struct { + ID string `path:"id" doc:"Question ID"` + Body struct { + QuestionText *string `json:"question_text,omitempty"` + QuestionType *models.QuestionType `json:"question_type,omitempty"` + IsRequired *bool `json:"is_required,omitempty"` + DisplayOrder *int `json:"display_order,omitempty"` + Options json.RawMessage `json:"options,omitempty"` + } +} + +func (h *questionHandler) update(ctx context.Context, in *UpdateQuestionInput) (*QuestionOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + if in.Body.QuestionType != nil && !in.Body.QuestionType.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid question_type") + } + + q, err := h.store.UpdateQuestion(ctx, in.ID, store.QuestionUpdate{ + QuestionText: in.Body.QuestionText, + QuestionType: in.Body.QuestionType, + IsRequired: in.Body.IsRequired, + DisplayOrder: in.Body.DisplayOrder, + Options: in.Body.Options, + }) + if err != nil { + return nil, storeErr(err) + } + return &QuestionOutput{Body: q}, nil +} + +type DeleteQuestionInput struct { + ID string `path:"id" doc:"Question ID"` +} + +func (h *questionHandler) delete(ctx context.Context, in *DeleteQuestionInput) (*struct{}, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + if err := h.store.DeleteQuestion(ctx, in.ID); err != nil { + return nil, storeErr(err) + } + return nil, nil +} diff --git a/backend/internal/handlers/router.go b/backend/internal/handlers/router.go index 2a57eca..8d16619 100644 --- a/backend/internal/handlers/router.go +++ b/backend/internal/handlers/router.go @@ -2,74 +2,86 @@ package handlers import ( "context" - "database/sql" - "encoding/json" "net/http" "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humago" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/GenerateNU/apportal/backend/internal/middleware" + "github.com/GenerateNU/apportal/backend/internal/store" ) +// Router holds shared dependencies for the plain (non-Huma) health endpoints. type Router struct { - database *sql.DB + database *pgxpool.Pool } -func NewRouter(database *sql.DB) http.Handler { - router := &Router{database: database} +// NewRouter builds the full HTTP handler. It mounts the plain health checks on +// a ServeMux, creates a Huma API over the same mux (which auto-serves the +// OpenAPI spec at /openapi.json|yaml and Scalar docs at /docs), registers every +// domain's typed operations, then wraps the mux in the middleware chain. +func NewRouter(database *pgxpool.Pool) http.Handler { + st := store.New(database) mux := http.NewServeMux() - mux.HandleFunc("/", router.root) - mux.HandleFunc("/healthz", router.health) - mux.HandleFunc("/readyz", router.ready) - return mux -} -func (router *Router) root(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } + // Plain liveness/readiness routes (not part of the documented API surface). + router := &Router{database: database} + mux.HandleFunc("GET /", router.root) + mux.HandleFunc("GET /healthz", router.health) + mux.HandleFunc("GET /readyz", router.ready) - writeJSON(writer, http.StatusOK, map[string]string{ - "message": "apportal backend is running", - }) + // Huma API: OpenAPI 3.1 generated from the typed operations below, rendered + // with Scalar (loaded from CDN) at /docs. + config := huma.DefaultConfig("apportal API", "0.1.0") + config.DocsRenderer = huma.DocsRendererScalar + config.Info.Description = "Generate application portal — applications, reviews, and the hiring pipeline." + api := humago.New(mux, config) + + (&cycleHandler{store: st}).register(api) + (&questionHandler{store: st}).register(api) + (&challengeHandler{store: st}).register(api) + (&userHandler{store: st}).register(api) + (&applicantHandler{store: st}).register(api) + (&applicationHandler{store: st}).register(api) + (&answerHandler{store: st}).register(api) + (&codeSubmissionHandler{store: st}).register(api) + + // Middleware applies outermost-first. + return middleware.Recovery(middleware.Logging(middleware.WithActor(mux))) } -func (router *Router) health(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) +func (router *Router) root(w http.ResponseWriter, r *http.Request) { + // "GET /" is a catch-all subtree pattern, so guard it to the exact root + // and 404 anything else that no specific route matched. + if r.URL.Path != "/" { + writeError(w, http.StatusNotFound, "not found") return } - - writeJSON(writer, http.StatusOK, map[string]string{ - "status": "ok", + writeJSON(w, http.StatusOK, map[string]string{ + "message": "apportal backend is running", + "docs": "/docs", }) } -func (router *Router) ready(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func (router *Router) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} - ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second) +func (router *Router) ready(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() status := http.StatusOK - response := map[string]string{ - "status": "ok", - "database": "up", - } + response := map[string]string{"status": "ok", "database": "up"} - if err := router.database.PingContext(ctx); err != nil { + if err := router.database.Ping(ctx); err != nil { status = http.StatusServiceUnavailable response["status"] = "unhealthy" response["database"] = "down" response["error"] = err.Error() } - writeJSON(writer, status, response) -} - -func writeJSON(writer http.ResponseWriter, status int, payload any) { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(status) - _ = json.NewEncoder(writer).Encode(payload) + writeJSON(w, status, response) } diff --git a/backend/internal/handlers/users.go b/backend/internal/handlers/users.go new file mode 100644 index 0000000..47596aa --- /dev/null +++ b/backend/internal/handlers/users.go @@ -0,0 +1,166 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" + "github.com/GenerateNU/apportal/backend/internal/store" +) + +type userHandler struct { + store *store.Store +} + +func (h *userHandler) register(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "list-users", + Method: http.MethodGet, + Path: "/users", + Summary: "List reviewers", + Description: "Chief only.", + Tags: []string{"Users"}, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden}, + }, h.list) + + huma.Register(api, huma.Operation{ + OperationID: "get-user", + Method: http.MethodGet, + Path: "/users/{nuid}", + Summary: "Get a reviewer", + Description: "Chief only.", + Tags: []string{"Users"}, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, h.get) + + huma.Register(api, huma.Operation{ + OperationID: "create-user", + Method: http.MethodPost, + Path: "/users", + Summary: "Create a reviewer", + Description: "Chief only.", + Tags: []string{"Users"}, + DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusConflict}, + }, h.create) + + huma.Register(api, huma.Operation{ + OperationID: "update-user", + Method: http.MethodPatch, + Path: "/users/{nuid}", + Summary: "Update a reviewer", + Description: "Chief only.", + Tags: []string{"Users"}, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict}, + }, h.update) +} + +type UserOutput struct { + Body models.User +} + +type UsersOutput struct { + Body []models.User +} + +type CreateUserInput struct { + Body struct { + NUID string `json:"nuid"` + Email string `json:"email"` + FullName string `json:"full_name"` + ReviewerRole *models.ReviewerRole `json:"reviewer_role,omitempty"` + GithubUsername *string `json:"github_username,omitempty"` + } +} + +func (h *userHandler) create(ctx context.Context, in *CreateUserInput) (*UserOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + if in.Body.ReviewerRole != nil && !in.Body.ReviewerRole.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid reviewer_role") + } + + user, err := h.store.CreateUser(ctx, store.UserCreate{ + NUID: in.Body.NUID, + Email: in.Body.Email, + FullName: in.Body.FullName, + ReviewerRole: in.Body.ReviewerRole, + GithubUsername: in.Body.GithubUsername, + }) + if err != nil { + return nil, storeErr(err) + } + return &UserOutput{Body: user}, nil +} + +type ListUsersInput struct { + ReviewerRole string `query:"reviewer_role" doc:"Optional reviewer role filter"` +} + +func (h *userHandler) list(ctx context.Context, in *ListUsersInput) (*UsersOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + var reviewerRole *models.ReviewerRole + if in.ReviewerRole != "" { + parsed := models.ReviewerRole(in.ReviewerRole) + if !parsed.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid reviewer_role") + } + reviewerRole = &parsed + } + + users, err := h.store.ListUsers(ctx, reviewerRole) + if err != nil { + return nil, storeErr(err) + } + return &UsersOutput{Body: users}, nil +} + +type UserNUIDInput struct { + NUID string `path:"nuid"` +} + +func (h *userHandler) get(ctx context.Context, in *UserNUIDInput) (*UserOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + user, err := h.store.GetUser(ctx, in.NUID) + if err != nil { + return nil, storeErr(err) + } + return &UserOutput{Body: user}, nil +} + +type UpdateUserInput struct { + NUID string `path:"nuid"` + Body struct { + Email *string `json:"email,omitempty"` + FullName *string `json:"full_name,omitempty"` + ReviewerRole *models.ReviewerRole `json:"reviewer_role,omitempty"` + GithubUsername *string `json:"github_username,omitempty"` + } +} + +func (h *userHandler) update(ctx context.Context, in *UpdateUserInput) (*UserOutput, error) { + if err := requireChief(ctx); err != nil { + return nil, err + } + if in.Body.ReviewerRole != nil && !in.Body.ReviewerRole.Valid() { + return nil, huma.Error422UnprocessableEntity("invalid reviewer_role") + } + + user, err := h.store.UpdateUser(ctx, in.NUID, store.UserUpdate{ + Email: in.Body.Email, + FullName: in.Body.FullName, + ReviewerRole: in.Body.ReviewerRole, + GithubUsername: in.Body.GithubUsername, + }) + if err != nil { + return nil, storeErr(err) + } + return &UserOutput{Body: user}, nil +} diff --git a/backend/internal/middleware/actor.go b/backend/internal/middleware/actor.go new file mode 100644 index 0000000..2683c65 --- /dev/null +++ b/backend/internal/middleware/actor.go @@ -0,0 +1,47 @@ +// Package middleware holds net/http middleware: the auth-stub actor extractor, +// request logging, and panic recovery. +package middleware + +import ( + "context" + "net/http" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +// Actor is the authenticated caller. This is a stub: identity is read from +// request headers (X-NUID / X-Role) rather than a verified session/token. +// Swapping in real NUID login means changing only how Actor gets populated. +type Actor struct { + NUID string + Role models.ReviewerRole +} + +type contextKey struct{} + +var actorKey contextKey + +// WithActor reads X-NUID and X-Role headers and, when present, stores an Actor +// in the request context. Absent headers leave the context empty so that +// applicant-facing (unauthenticated) routes still work. +func WithActor(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nuid := r.Header.Get("X-NUID") + if nuid != "" { + actor := Actor{ + NUID: nuid, + Role: models.ReviewerRole(r.Header.Get("X-Role")), + } + r = r.WithContext(context.WithValue(r.Context(), actorKey, actor)) + } + next.ServeHTTP(w, r) + }) +} + +// ActorFrom returns the Actor stored on the context, if any. Authorization +// checks live in the handlers package (requireReviewer/requireChief) so they +// can return Huma errors that appear in the OpenAPI spec. +func ActorFrom(ctx context.Context) (Actor, bool) { + actor, ok := ctx.Value(actorKey).(Actor) + return actor, ok +} diff --git a/backend/internal/middleware/logging.go b/backend/internal/middleware/logging.go new file mode 100644 index 0000000..b38cc20 --- /dev/null +++ b/backend/internal/middleware/logging.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "log/slog" + "net/http" + "time" +) + +// statusRecorder captures the status code written by downstream handlers. +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +// Logging emits one structured log line per request with method, path, +// status, and duration. +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "duration_ms", time.Since(start).Milliseconds(), + ) + }) +} diff --git a/backend/internal/middleware/recovery.go b/backend/internal/middleware/recovery.go new file mode 100644 index 0000000..c9e4a89 --- /dev/null +++ b/backend/internal/middleware/recovery.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "log/slog" + "net/http" +) + +// Recovery converts a panic in a downstream handler into a 500 response +// instead of crashing the server. +func Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + slog.Error("panic recovered", "error", rec, "path", r.URL.Path) + http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/models/enums.go b/backend/internal/models/enums.go new file mode 100644 index 0000000..6fb4989 --- /dev/null +++ b/backend/internal/models/enums.go @@ -0,0 +1,95 @@ +package models + +// Enum types mirror the Postgres ENUMs defined in the init migration. +// They are plain strings so they encode/decode cleanly to JSON and SQL. + +type Role string + +const ( + RoleSoftwareEngineer Role = "software_engineer" + RoleSoftwareDesigner Role = "software_designer" +) + +func (r Role) Valid() bool { + switch r { + case RoleSoftwareEngineer, RoleSoftwareDesigner: + return true + } + return false +} + +type ApplicationStage string + +const ( + StageSubmitted ApplicationStage = "submitted" + StageTLReview ApplicationStage = "tl_review" + StageChiefReview ApplicationStage = "chief_review" + StageInterviewScheduled ApplicationStage = "interview_scheduled" + StageInterviewConducted ApplicationStage = "interview_conducted" + StageInterviewReview ApplicationStage = "interview_review" + StageSelection ApplicationStage = "selection" + StageAccepted ApplicationStage = "accepted" + StageRejected ApplicationStage = "rejected" + StageWithdrawn ApplicationStage = "withdrawn" +) + +func (s ApplicationStage) Valid() bool { + switch s { + case StageSubmitted, StageTLReview, StageChiefReview, StageInterviewScheduled, + StageInterviewConducted, StageInterviewReview, StageSelection, + StageAccepted, StageRejected, StageWithdrawn: + return true + } + return false +} + +type ReviewerRole string + +const ( + ReviewerRoleTL ReviewerRole = "tl" + ReviewerRoleChief ReviewerRole = "chief" +) + +func (r ReviewerRole) Valid() bool { + switch r { + case ReviewerRoleTL, ReviewerRoleChief: + return true + } + return false +} + +type QuestionType string + +const ( + QuestionShortAnswer QuestionType = "short_answer" + QuestionLongAnswer QuestionType = "long_answer" + QuestionMultipleChoice QuestionType = "multiple_choice" + QuestionCheckbox QuestionType = "checkbox" + QuestionURL QuestionType = "url" +) + +func (q QuestionType) Valid() bool { + switch q { + case QuestionShortAnswer, QuestionLongAnswer, QuestionMultipleChoice, + QuestionCheckbox, QuestionURL: + return true + } + return false +} + +type CycleStatus string + +const ( + CycleDraft CycleStatus = "draft" + CycleOpen CycleStatus = "open" + CycleClosed CycleStatus = "closed" + CycleArchived CycleStatus = "archived" +) + +func (c CycleStatus) Valid() bool { + switch c { + case CycleDraft, CycleOpen, CycleClosed, CycleArchived: + return true + } + return false +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..1227c9d --- /dev/null +++ b/backend/internal/models/models.go @@ -0,0 +1,94 @@ +package models + +import ( + "encoding/json" + "time" +) + +// Structs map to the tables in the init migration. Nullable columns use +// pointers; JSONB columns use json.RawMessage so they pass through untouched. + +type User struct { + NUID string `json:"nuid"` + Email string `json:"email"` + FullName string `json:"full_name"` + ReviewerRole *ReviewerRole `json:"reviewer_role,omitempty"` + GithubUsername *string `json:"github_username,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Cycle struct { + ID string `json:"id"` + Name string `json:"name"` + Status CycleStatus `json:"status"` + OpensAt *time.Time `json:"opens_at,omitempty"` + ClosesAt *time.Time `json:"closes_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Question struct { + ID string `json:"id"` + CycleID string `json:"cycle_id"` + Role *Role `json:"role,omitempty"` + QuestionText string `json:"question_text"` + QuestionType QuestionType `json:"question_type"` + IsRequired bool `json:"is_required"` + DisplayOrder int `json:"display_order"` + Options json.RawMessage `json:"options,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type CodeChallenge struct { + ID string `json:"id"` + CycleID string `json:"cycle_id"` + Role Role `json:"role"` + Name string `json:"name"` + GithubRepoURL *string `json:"github_repo_url,omitempty"` + Instructions *string `json:"instructions,omitempty"` + DueAt *time.Time `json:"due_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Applicant struct { + NUID string `json:"nuid"` + Email string `json:"email"` + FullName string `json:"full_name"` + GithubUsername *string `json:"github_username,omitempty"` + GraduationYear *int `json:"graduation_year,omitempty"` + Major *string `json:"major,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Application struct { + ID string `json:"id"` + CycleID string `json:"cycle_id"` + ApplicantNUID string `json:"applicant_nuid"` + Role Role `json:"role"` + Stage ApplicationStage `json:"stage"` + Availability json.RawMessage `json:"availability,omitempty"` + ResumeURL *string `json:"resume_url,omitempty"` + SubmittedAt time.Time `json:"submitted_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type WrittenAnswer struct { + ID string `json:"id"` + ApplicationID string `json:"application_id"` + QuestionID string `json:"question_id"` + AnswerText *string `json:"answer_text,omitempty"` + AnswerOptions json.RawMessage `json:"answer_options,omitempty"` + SubmittedAt time.Time `json:"submitted_at"` +} + +type CodeSubmission struct { + ID string `json:"id"` + ApplicationID string `json:"application_id"` + ChallengeID string `json:"challenge_id"` + GithubRepoURL string `json:"github_repo_url"` + SubmittedAt time.Time `json:"submitted_at"` + RawScore *float64 `json:"raw_score,omitempty"` + ScoreDetails json.RawMessage `json:"score_details,omitempty"` + ScoreUpdatedAt *time.Time `json:"score_updated_at,omitempty"` +} diff --git a/backend/internal/models/openapi.go b/backend/internal/models/openapi.go new file mode 100644 index 0000000..13268e4 --- /dev/null +++ b/backend/internal/models/openapi.go @@ -0,0 +1,45 @@ +package models + +import "github.com/danielgtaylor/huma/v2" + +// The enum types implement huma.SchemaProvider so that, wherever they appear in +// request/response bodies, the generated OpenAPI renders them as a string with +// an explicit `enum` list rather than a plain string. + +func enumSchema(values ...string) *huma.Schema { + enum := make([]any, len(values)) + for i, v := range values { + enum[i] = v + } + return &huma.Schema{Type: huma.TypeString, Enum: enum} +} + +func (Role) Schema(huma.Registry) *huma.Schema { + return enumSchema(string(RoleSoftwareEngineer), string(RoleSoftwareDesigner)) +} + +func (ApplicationStage) Schema(huma.Registry) *huma.Schema { + return enumSchema( + string(StageSubmitted), string(StageTLReview), string(StageChiefReview), + string(StageInterviewScheduled), string(StageInterviewConducted), + string(StageInterviewReview), string(StageSelection), + string(StageAccepted), string(StageRejected), string(StageWithdrawn), + ) +} + +func (ReviewerRole) Schema(huma.Registry) *huma.Schema { + return enumSchema(string(ReviewerRoleTL), string(ReviewerRoleChief)) +} + +func (QuestionType) Schema(huma.Registry) *huma.Schema { + return enumSchema( + string(QuestionShortAnswer), string(QuestionLongAnswer), + string(QuestionMultipleChoice), string(QuestionCheckbox), string(QuestionURL), + ) +} + +func (CycleStatus) Schema(huma.Registry) *huma.Schema { + return enumSchema( + string(CycleDraft), string(CycleOpen), string(CycleClosed), string(CycleArchived), + ) +} diff --git a/backend/internal/store/answers.go b/backend/internal/store/answers.go new file mode 100644 index 0000000..a5c97b6 --- /dev/null +++ b/backend/internal/store/answers.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +// AnswerInput is one answer in a bulk upsert. +type AnswerInput struct { + QuestionID string + AnswerText *string + AnswerOptions json.RawMessage +} + +const answerColumns = `id, application_id, question_id, answer_text, answer_options, submitted_at` + +// UpsertAnswers writes all answers for an application in a single transaction, +// keyed on the (application_id, question_id) unique constraint, and returns the +// full current answer set. +func (s *Store) UpsertAnswers(ctx context.Context, applicationID string, inputs []AnswerInput) ([]models.WrittenAnswer, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + + const q = ` + INSERT INTO written_answers (application_id, question_id, answer_text, answer_options) + VALUES ($1, $2, $3, $4::jsonb) + ON CONFLICT (application_id, question_id) DO UPDATE SET + answer_text = EXCLUDED.answer_text, + answer_options = EXCLUDED.answer_options, + submitted_at = NOW()` + for _, in := range inputs { + if _, err := tx.Exec(ctx, q, applicationID, in.QuestionID, + in.AnswerText, jsonArg(in.AnswerOptions)); err != nil { + if uniqueViolation(err) { + return nil, ErrConflict + } + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return s.ListAnswers(ctx, applicationID) +} + +func (s *Store) ListAnswers(ctx context.Context, applicationID string) ([]models.WrittenAnswer, error) { + const q = `SELECT ` + answerColumns + ` FROM written_answers WHERE application_id = $1 ORDER BY submitted_at` + rows, err := s.db.Query(ctx, q, applicationID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByPos[models.WrittenAnswer]) +} diff --git a/backend/internal/store/applicants.go b/backend/internal/store/applicants.go new file mode 100644 index 0000000..54373e1 --- /dev/null +++ b/backend/internal/store/applicants.go @@ -0,0 +1,59 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +type ApplicantUpsert struct { + NUID string + Email string + FullName string + GithubUsername *string + GraduationYear *int + Major *string +} + +const applicantColumns = `nuid, email, full_name, github_username, graduation_year, major, created_at, updated_at` + +// UpsertApplicant inserts a new applicant or updates the existing one keyed by +// NUID. A clash on the unique email (belonging to a different NUID) is a conflict. +func (s *Store) UpsertApplicant(ctx context.Context, in ApplicantUpsert) (models.Applicant, error) { + const q = ` + INSERT INTO applicants (nuid, email, full_name, github_username, graduation_year, major) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (nuid) DO UPDATE SET + email = EXCLUDED.email, + full_name = EXCLUDED.full_name, + github_username = EXCLUDED.github_username, + graduation_year = EXCLUDED.graduation_year, + major = EXCLUDED.major + RETURNING ` + applicantColumns + rows, err := s.db.Query(ctx, q, in.NUID, in.Email, in.FullName, + in.GithubUsername, in.GraduationYear, in.Major) + if err != nil { + return models.Applicant{}, err + } + a, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Applicant]) + if uniqueViolation(err) { + return a, ErrConflict + } + return a, err +} + +func (s *Store) GetApplicant(ctx context.Context, nuid string) (models.Applicant, error) { + const q = `SELECT ` + applicantColumns + ` FROM applicants WHERE nuid = $1` + rows, err := s.db.Query(ctx, q, nuid) + if err != nil { + return models.Applicant{}, err + } + a, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Applicant]) + if errors.Is(err, pgx.ErrNoRows) { + return a, ErrNotFound + } + return a, err +} diff --git a/backend/internal/store/applications.go b/backend/internal/store/applications.go new file mode 100644 index 0000000..d3e93ce --- /dev/null +++ b/backend/internal/store/applications.go @@ -0,0 +1,108 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "strconv" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +type ApplicationCreate struct { + CycleID string + ApplicantNUID string + Role models.Role + Availability json.RawMessage + ResumeURL *string +} + +type ApplicationUpdate struct { + Stage *models.ApplicationStage + Availability json.RawMessage + ResumeURL *string +} + +// ApplicationFilter holds optional list filters; empty fields are ignored. +type ApplicationFilter struct { + CycleID string + Role *models.Role + Stage *models.ApplicationStage +} + +const applicationColumns = `id, cycle_id, applicant_nuid, role, stage, availability, resume_url, submitted_at, updated_at` + +func (s *Store) CreateApplication(ctx context.Context, in ApplicationCreate) (models.Application, error) { + const q = ` + INSERT INTO applications (cycle_id, applicant_nuid, role, availability, resume_url) + VALUES ($1, $2, $3, $4, $5) + RETURNING ` + applicationColumns + rows, err := s.db.Query(ctx, q, in.CycleID, in.ApplicantNUID, in.Role, + jsonArg(in.Availability), in.ResumeURL) + if err != nil { + return models.Application{}, err + } + a, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Application]) + if uniqueViolation(err) { + return a, ErrConflict + } + return a, err +} + +func (s *Store) GetApplication(ctx context.Context, id string) (models.Application, error) { + const q = `SELECT ` + applicationColumns + ` FROM applications WHERE id = $1` + rows, err := s.db.Query(ctx, q, id) + if err != nil { + return models.Application{}, err + } + a, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Application]) + if errors.Is(err, pgx.ErrNoRows) { + return a, ErrNotFound + } + return a, err +} + +func (s *Store) ListApplications(ctx context.Context, f ApplicationFilter) ([]models.Application, error) { + query := `SELECT ` + applicationColumns + ` FROM applications WHERE 1 = 1` + args := []any{} + if f.CycleID != "" { + args = append(args, f.CycleID) + query += ` AND cycle_id = $` + strconv.Itoa(len(args)) + } + if f.Role != nil { + args = append(args, *f.Role) + query += ` AND role = $` + strconv.Itoa(len(args)) + } + if f.Stage != nil { + args = append(args, *f.Stage) + query += ` AND stage = $` + strconv.Itoa(len(args)) + } + query += ` ORDER BY submitted_at DESC` + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByPos[models.Application]) +} + +func (s *Store) UpdateApplication(ctx context.Context, id string, in ApplicationUpdate) (models.Application, error) { + const q = ` + UPDATE applications SET + stage = COALESCE($2, stage), + availability = COALESCE($3::jsonb, availability), + resume_url = COALESCE($4, resume_url) + WHERE id = $1 + RETURNING ` + applicationColumns + rows, err := s.db.Query(ctx, q, id, in.Stage, jsonArg(in.Availability), in.ResumeURL) + if err != nil { + return models.Application{}, err + } + a, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Application]) + if errors.Is(err, pgx.ErrNoRows) { + return a, ErrNotFound + } + return a, err +} diff --git a/backend/internal/store/challenges.go b/backend/internal/store/challenges.go new file mode 100644 index 0000000..9ea1014 --- /dev/null +++ b/backend/internal/store/challenges.go @@ -0,0 +1,51 @@ +package store + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +type ChallengeCreate struct { + CycleID string + Role models.Role + Name string + GithubRepoURL *string + Instructions *string + DueAt *time.Time +} + +const challengeColumns = `id, cycle_id, role, name, github_repo_url, instructions, due_at, created_at` + +func (s *Store) CreateChallenge(ctx context.Context, in ChallengeCreate) (models.CodeChallenge, error) { + const q = ` + INSERT INTO code_challenges (cycle_id, role, name, github_repo_url, instructions, due_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING ` + challengeColumns + rows, err := s.db.Query(ctx, q, in.CycleID, in.Role, in.Name, + in.GithubRepoURL, in.Instructions, in.DueAt) + if err != nil { + return models.CodeChallenge{}, err + } + return pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.CodeChallenge]) +} + +// ListChallenges returns a cycle's challenges, optionally filtered to one role. +func (s *Store) ListChallenges(ctx context.Context, cycleID string, role *models.Role) ([]models.CodeChallenge, error) { + query := `SELECT ` + challengeColumns + ` FROM code_challenges WHERE cycle_id = $1` + args := []any{cycleID} + if role != nil { + query += ` AND role = $2` + args = append(args, *role) + } + query += ` ORDER BY created_at` + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByPos[models.CodeChallenge]) +} diff --git a/backend/internal/store/code_submissions.go b/backend/internal/store/code_submissions.go new file mode 100644 index 0000000..cb38b1b --- /dev/null +++ b/backend/internal/store/code_submissions.go @@ -0,0 +1,43 @@ +package store + +import ( + "context" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +const codeSubmissionColumns = `id, application_id, challenge_id, github_repo_url, submitted_at, raw_score, score_details, score_updated_at` + +// UpsertCodeSubmission records (or replaces) the GitHub repo link for an +// application's challenge. Score fields are deferred and left untouched here — +// they are populated externally. +func (s *Store) UpsertCodeSubmission(ctx context.Context, applicationID, challengeID, githubRepoURL string) (models.CodeSubmission, error) { + const q = ` + INSERT INTO code_submissions (application_id, challenge_id, github_repo_url) + VALUES ($1, $2, $3) + ON CONFLICT (application_id, challenge_id) DO UPDATE SET + github_repo_url = EXCLUDED.github_repo_url, + submitted_at = NOW() + RETURNING ` + codeSubmissionColumns + rows, err := s.db.Query(ctx, q, applicationID, challengeID, githubRepoURL) + if err != nil { + return models.CodeSubmission{}, err + } + c, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.CodeSubmission]) + if uniqueViolation(err) { + return c, ErrConflict + } + return c, err +} + +// ListCodeSubmissions returns every code submission for an application. +func (s *Store) ListCodeSubmissions(ctx context.Context, applicationID string) ([]models.CodeSubmission, error) { + const q = `SELECT ` + codeSubmissionColumns + ` FROM code_submissions WHERE application_id = $1 ORDER BY submitted_at` + rows, err := s.db.Query(ctx, q, applicationID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByPos[models.CodeSubmission]) +} diff --git a/backend/internal/store/cycles.go b/backend/internal/store/cycles.go new file mode 100644 index 0000000..de835dc --- /dev/null +++ b/backend/internal/store/cycles.go @@ -0,0 +1,84 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +// CycleCreate carries the fields needed to create a cycle. +type CycleCreate struct { + Name string + Status models.CycleStatus + OpensAt *time.Time + ClosesAt *time.Time +} + +// CycleUpdate carries partial-update fields; nil pointers are left unchanged. +type CycleUpdate struct { + Name *string + Status *models.CycleStatus + OpensAt *time.Time + ClosesAt *time.Time +} + +const cycleColumns = `id, name, status, opens_at, closes_at, created_at` + +func (s *Store) CreateCycle(ctx context.Context, in CycleCreate) (models.Cycle, error) { + const q = ` + INSERT INTO cycles (name, status, opens_at, closes_at) + VALUES ($1, $2, $3, $4) + RETURNING ` + cycleColumns + rows, err := s.db.Query(ctx, q, in.Name, in.Status, in.OpensAt, in.ClosesAt) + if err != nil { + return models.Cycle{}, err + } + return pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Cycle]) +} + +func (s *Store) ListCycles(ctx context.Context) ([]models.Cycle, error) { + const q = `SELECT ` + cycleColumns + ` FROM cycles ORDER BY created_at DESC` + rows, err := s.db.Query(ctx, q) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByPos[models.Cycle]) +} + +func (s *Store) GetCycle(ctx context.Context, id string) (models.Cycle, error) { + const q = `SELECT ` + cycleColumns + ` FROM cycles WHERE id = $1` + rows, err := s.db.Query(ctx, q, id) + if err != nil { + return models.Cycle{}, err + } + c, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Cycle]) + if errors.Is(err, pgx.ErrNoRows) { + return c, ErrNotFound + } + return c, err +} + +func (s *Store) UpdateCycle(ctx context.Context, id string, in CycleUpdate) (models.Cycle, error) { + // COALESCE keeps the existing value when the corresponding input is NULL. + const q = ` + UPDATE cycles SET + name = COALESCE($2, name), + status = COALESCE($3, status), + opens_at = COALESCE($4, opens_at), + closes_at = COALESCE($5, closes_at) + WHERE id = $1 + RETURNING ` + cycleColumns + rows, err := s.db.Query(ctx, q, id, in.Name, in.Status, in.OpensAt, in.ClosesAt) + if err != nil { + return models.Cycle{}, err + } + c, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Cycle]) + if errors.Is(err, pgx.ErrNoRows) { + return c, ErrNotFound + } + return c, err +} diff --git a/backend/internal/store/errors.go b/backend/internal/store/errors.go new file mode 100644 index 0000000..b20835c --- /dev/null +++ b/backend/internal/store/errors.go @@ -0,0 +1,17 @@ +package store + +import ( + "errors" + + "github.com/jackc/pgx/v5/pgconn" +) + +// uniqueViolation reports whether err is a Postgres unique-constraint violation +// (SQLSTATE 23505). The pgx stdlib driver surfaces these as *pgconn.PgError. +func uniqueViolation(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return false +} diff --git a/backend/internal/store/json.go b/backend/internal/store/json.go new file mode 100644 index 0000000..23ede23 --- /dev/null +++ b/backend/internal/store/json.go @@ -0,0 +1,14 @@ +package store + +import "encoding/json" + +// jsonArg converts a JSONB payload into a driver argument. An empty/absent +// payload becomes NULL; otherwise it is passed as a string. pgx then sends it +// with an unknown OID so Postgres parses it as the column's jsonb type — +// passing []byte instead would be encoded as bytea and rejected by jsonb. +func jsonArg(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + return string(raw) +} diff --git a/backend/internal/store/questions.go b/backend/internal/store/questions.go new file mode 100644 index 0000000..c8a96f4 --- /dev/null +++ b/backend/internal/store/questions.go @@ -0,0 +1,95 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +type QuestionCreate struct { + CycleID string + Role *models.Role // nil = global (shown to all roles) + QuestionText string + QuestionType models.QuestionType + IsRequired bool + DisplayOrder int + Options json.RawMessage +} + +type QuestionUpdate struct { + QuestionText *string + QuestionType *models.QuestionType + IsRequired *bool + DisplayOrder *int + Options json.RawMessage +} + +const questionColumns = `id, cycle_id, role, question_text, question_type, is_required, display_order, options, created_at` + +func (s *Store) CreateQuestion(ctx context.Context, in QuestionCreate) (models.Question, error) { + const q = ` + INSERT INTO questions (cycle_id, role, question_text, question_type, is_required, display_order, options) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING ` + questionColumns + rows, err := s.db.Query(ctx, q, in.CycleID, in.Role, in.QuestionText, + in.QuestionType, in.IsRequired, in.DisplayOrder, jsonArg(in.Options)) + if err != nil { + return models.Question{}, err + } + return pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Question]) +} + +// ListQuestions returns a cycle's questions ordered for display. When role is +// non-nil, it returns that role's questions plus global ones (role IS NULL). +func (s *Store) ListQuestions(ctx context.Context, cycleID string, role *models.Role) ([]models.Question, error) { + query := `SELECT ` + questionColumns + ` FROM questions WHERE cycle_id = $1` + args := []any{cycleID} + if role != nil { + query += ` AND (role = $2 OR role IS NULL)` + args = append(args, *role) + } + query += ` ORDER BY display_order, created_at` + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByPos[models.Question]) +} + +func (s *Store) UpdateQuestion(ctx context.Context, id string, in QuestionUpdate) (models.Question, error) { + const q = ` + UPDATE questions SET + question_text = COALESCE($2, question_text), + question_type = COALESCE($3, question_type), + is_required = COALESCE($4, is_required), + display_order = COALESCE($5, display_order), + options = COALESCE($6::jsonb, options) + WHERE id = $1 + RETURNING ` + questionColumns + rows, err := s.db.Query(ctx, q, id, in.QuestionText, in.QuestionType, + in.IsRequired, in.DisplayOrder, jsonArg(in.Options)) + if err != nil { + return models.Question{}, err + } + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.Question]) + if errors.Is(err, pgx.ErrNoRows) { + return result, ErrNotFound + } + return result, err +} + +func (s *Store) DeleteQuestion(ctx context.Context, id string) error { + tag, err := s.db.Exec(ctx, `DELETE FROM questions WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..e87724f --- /dev/null +++ b/backend/internal/store/store.go @@ -0,0 +1,25 @@ +// Package store is the repository layer: it owns the *sql.DB and exposes +// typed methods that run raw SQL (via the pgx stdlib driver) and return models. +package store + +import ( + "errors" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotFound is returned when a lookup matches no rows. Handlers map it to 404. +var ErrNotFound = errors.New("not found") + +// ErrConflict is returned when a write violates a uniqueness constraint. +// Handlers map it to 409. +var ErrConflict = errors.New("conflict") + +// Store holds the connection pool shared by every domain method. +type Store struct { + db *pgxpool.Pool +} + +func New(db *pgxpool.Pool) *Store { + return &Store{db: db} +} diff --git a/backend/internal/store/users.go b/backend/internal/store/users.go new file mode 100644 index 0000000..ed57a7f --- /dev/null +++ b/backend/internal/store/users.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +type UserCreate struct { + NUID string + Email string + FullName string + ReviewerRole *models.ReviewerRole + GithubUsername *string +} + +type UserUpdate struct { + Email *string + FullName *string + ReviewerRole *models.ReviewerRole + GithubUsername *string +} + +const userColumns = `nuid, email, full_name, reviewer_role, github_username, created_at, updated_at` + +func (s *Store) CreateUser(ctx context.Context, in UserCreate) (models.User, error) { + const q = ` + INSERT INTO users (nuid, email, full_name, reviewer_role, github_username) + VALUES ($1, $2, $3, $4, $5) + RETURNING ` + userColumns + rows, err := s.db.Query(ctx, q, in.NUID, in.Email, in.FullName, + in.ReviewerRole, in.GithubUsername) + if err != nil { + return models.User{}, err + } + u, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.User]) + if uniqueViolation(err) { + return u, ErrConflict + } + return u, err +} + +func (s *Store) ListUsers(ctx context.Context, reviewerRole *models.ReviewerRole) ([]models.User, error) { + query := `SELECT ` + userColumns + ` FROM users` + args := []any{} + if reviewerRole != nil { + query += ` WHERE reviewer_role = $1` + args = append(args, *reviewerRole) + } + query += ` ORDER BY full_name` + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByPos[models.User]) +} + +func (s *Store) GetUser(ctx context.Context, nuid string) (models.User, error) { + const q = `SELECT ` + userColumns + ` FROM users WHERE nuid = $1` + rows, err := s.db.Query(ctx, q, nuid) + if err != nil { + return models.User{}, err + } + u, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.User]) + if errors.Is(err, pgx.ErrNoRows) { + return u, ErrNotFound + } + return u, err +} + +func (s *Store) UpdateUser(ctx context.Context, nuid string, in UserUpdate) (models.User, error) { + const q = ` + UPDATE users SET + email = COALESCE($2, email), + full_name = COALESCE($3, full_name), + reviewer_role = COALESCE($4, reviewer_role), + github_username = COALESCE($5, github_username) + WHERE nuid = $1 + RETURNING ` + userColumns + rows, err := s.db.Query(ctx, q, nuid, in.Email, in.FullName, + in.ReviewerRole, in.GithubUsername) + if err != nil { + return models.User{}, err + } + u, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.User]) + if errors.Is(err, pgx.ErrNoRows) { + return u, ErrNotFound + } + if uniqueViolation(err) { + return u, ErrConflict + } + return u, err +} diff --git a/info.md b/info.md new file mode 100644 index 0000000..3a29f6b --- /dev/null +++ b/info.md @@ -0,0 +1,34 @@ +**Application Portal — Context & Interview Flow Summary** + +**Roles:** Software Engineer, Software Designer. Applicants can apply to multiple roles per cycle, one application per role. + +**Login:** NUID as primary key, email stored but not used as identifier (too fragile). No OAuth/SSO. + +**Application:** Collects written answers to injectable per-cycle questions, resume (Supabase Storage), availability (JSONB blob), and a GitHub repo link for the code challenge. + +--- + +**Full Pipeline:** + +1. **Submission** — Applicant submits written answers, resume, availability, and GitHub challenge link. + +2. **TL Written Review** — Chiefs assign each application to 3 TLs. Each TL scores the written answers 1–10 per question plus an overall score and written reasoning. + +3. **Chief Review** — Chiefs review TL scores and decide which applicants advance to interviews. No formal rating — just a boolean advance decision with notes. + +4. **Interview Assignment** — Chiefs assign one interviewer (TL or Chief) per application, plus 2 different TLs to review the recording afterward. + +5. **Interview Conducted** — The assigned interviewer conducts the interview, then fills out: session notes, overall comments, a rating (`do_not_hire / good / great / must_hire`), and a link to the recording. + +6. **Recording Review** — The 2 assigned TLs watch the recording and each leave comments and a rating. All TLs can view all interviews at the app/RLS layer but only assigned ones submit a formal review. + +7. **Selection** — All TLs review all interviews and mark who they want on their team. Chiefs use these selections to resolve conflicts and make final accept/reject decisions. + +--- + +**Other Key Notes:** +- Code challenge scores are deferred — `raw_score` and `score_details JSONB` fields exist and will be populated externally (webhook/job) when ready. Whether past-semester scores are visible to reviewers is a flagged decision, not a blocker. +- Interview recordings are embedded links (Loom, Notion, etc.) — no file upload. +- Questions are injectable per cycle and can be role-specific or global. +- Reviewing can happen while applications are still open. +- `application_stage` enum tracks every step: `submitted → tl_review → chief_review → interview_scheduled → interview_conducted → interview_review → selection → accepted/rejected/withdrawn`. \ No newline at end of file