Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# apportal

Go + huma backend (`backend/`), Next.js + TanStack Query frontend (`frontend/`).
The frontend API client is generated from the backend's OpenAPI spec — after
changing a handler's inputs or outputs, run `make openapi` in `backend/` and
`npm run generate:api` in `frontend/`.

## Never fetch per item — batch it

**If a page renders N items and needs data for each one, fetch it in one
request, not N.** Build a bulk endpoint that takes the ids and returns them
together. This applies to any per-row detail: answers, reviews, assignments,
counts.

The N-request version looks fine locally with three rows and falls apart at
scale — browsers cap concurrent connections per host (~6), so the requests
queue in waves and the page fills in raggedly, each one paying its own round
trip, auth middleware, and query planning.

What this looks like here:

- **Backend:** a collection route (`GET /answers?application_ids=a,b,c`)
alongside the single-item one, backed by `WHERE id = ANY($1::uuid[])`.
Bound the list (`maxBulkApplications`) so the query string and the fan-out
stay sane, and apply the same visibility rules the single-item route does.
- **Frontend:** one query per *batch*, keyed on the id list. Where rows arrive
in pages (infinite scroll), batch per page so loading more doesn't refetch
what's already in hand. Write each response back into the per-item cache
entries with `queryClient.setQueryData` so single-item views stay warm — the
point is fewer requests, not a worse cache.

`useAnswersByApplicationIdBatches` in `frontend/src/lib/queries/answers.ts` is
the worked example.

## Query params the generated client can actually send

huma binds only primitives from a query string, and axios serializes arrays as
`key[]=…` and objects as `key[0][field]=…` — neither of which huma reads. A
`[]struct` query field silently binds nothing, or panics at request time.

So for anything that isn't a scalar, take a `string` and parse it in the
handler: JSON for structured filters (`answer_filters`), comma-separated for
id lists (`application_ids`). Return 422 on malformed input.
105 changes: 100 additions & 5 deletions backend/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,35 @@ components:
- updated_at
- review_status
type: object
ApplicationsOutputBody:
additionalProperties: false
properties:
$schema:
description: A URL to the JSON Schema for this object.
examples:
- https://example.com/schemas/ApplicationsOutputBody.json
format: uri
readOnly: true
type: string
applications:
items:
$ref: "#/components/schemas/ApplicationSummary"
type:
- array
- "null"
stage_counts:
additionalProperties:
format: int64
type: integer
type: object
total:
format: int64
type: integer
required:
- applications
- total
- stage_counts
type: object
AssignRecordingReviewerInputBody:
additionalProperties: false
properties:
Expand Down Expand Up @@ -2219,6 +2248,50 @@ info:
version: 0.1.0
openapi: 3.1.0
paths:
/answers:
get:
description: One request for a page of applications, instead of one per application. Reviewer-only; draft answers are never included.
operationId: list-answers-bulk
parameters:
- description: Comma-separated application IDs
explode: false
in: query
name: application_ids
schema:
description: Comma-separated application IDs
type: string
responses:
"200":
content:
application/json:
schema:
items:
$ref: "#/components/schemas/WrittenAnswer"
type:
- array
- "null"
description: OK
"401":
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ErrorModel"
description: Unauthorized
"422":
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ErrorModel"
description: Unprocessable Entity
"500":
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ErrorModel"
description: Internal Server Error
summary: List written answers for several applications
tags:
- Answers
/applicants:
post:
description: Applicant-facing; upserts by NUID.
Expand Down Expand Up @@ -2357,16 +2430,38 @@ paths:
schema:
description: JSON array of answer filters, e.g. [{"question_id":"…","question_type":"checkbox","values":["Yes"]}]. Values may be a string or an array of strings; a filter matches any of them, and separate filters are AND'd.
type: string
- description: Case-insensitive substring match on the applicant's name, NUID, or email
explode: false
in: query
name: search
schema:
description: Case-insensitive substring match on the applicant's name, NUID, or email
type: string
- description: Max results per page; omit (or 0) to return every match
explode: false
in: query
name: limit
schema:
description: Max results per page; omit (or 0) to return every match
format: int64
maximum: 200
minimum: 0
type: integer
- description: Number of results to skip
explode: false
in: query
name: offset
schema:
description: Number of results to skip
format: int64
minimum: 0
type: integer
responses:
"200":
content:
application/json:
schema:
items:
$ref: "#/components/schemas/ApplicationSummary"
type:
- array
- "null"
$ref: "#/components/schemas/ApplicationsOutputBody"
description: OK
"401":
content:
Expand Down
45 changes: 45 additions & 0 deletions backend/internal/handlers/answers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"

"github.com/danielgtaylor/huma/v2"

Expand Down Expand Up @@ -35,6 +36,50 @@ func (h *answerHandler) register(api huma.API) {
Tags: []string{"Answers"},
Errors: []int{http.StatusNotFound},
}, h.list)

huma.Register(api, huma.Operation{
OperationID: "list-answers-bulk",
Method: http.MethodGet,
Path: "/answers",
Summary: "List written answers for several applications",
Description: "One request for a page of applications, instead of one per application. Reviewer-only; draft answers are never included.",
Tags: []string{"Answers"},
Errors: []int{http.StatusUnauthorized, http.StatusUnprocessableEntity},
}, h.listBulk)
}

// maxBulkApplications bounds both the query string and the fan-out of the
// underlying `= ANY(...)`. Comfortably above any page size the UI uses.
const maxBulkApplications = 200

type ListAnswersBulkInput struct {
// Comma-separated rather than a repeated/array param because huma splits
// this form itself, while the browser client serializes arrays as
// `application_ids[]=…`, which binds to nothing server-side.
ApplicationIDs string `query:"application_ids" doc:"Comma-separated application IDs"`
}

func (h *answerHandler) listBulk(ctx context.Context, in *ListAnswersBulkInput) (*AnswersOutput, error) {
if err := requireReviewer(ctx); err != nil {
return nil, err
}
ids := make([]string, 0, 8)
for _, id := range strings.Split(in.ApplicationIDs, ",") {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return &AnswersOutput{Body: []models.WrittenAnswer{}}, nil
}
if len(ids) > maxBulkApplications {
return nil, huma.Error422UnprocessableEntity("too many application_ids")
}
answers, err := h.store.ListAnswersForApplications(ctx, ids)
if err != nil {
return nil, storeErr(err)
}
return &AnswersOutput{Body: answers}, nil
}

type AnswersOutput struct {
Expand Down
44 changes: 44 additions & 0 deletions backend/internal/handlers/answers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"

"github.com/danielgtaylor/huma/v2"

"github.com/GenerateNU/apportal/backend/internal/middleware"
"github.com/GenerateNU/apportal/backend/internal/models"
)

// Ownership and stage checks need a real database (they fetch the
Expand All @@ -33,3 +37,43 @@ func TestAnswersUpsertRequiresActor(t *testing.T) {
t.Fatalf("got %v, want 401", err)
}
}

func TestAnswersBulkRequiresReviewer(t *testing.T) {
h := &answerHandler{}
in := &ListAnswersBulkInput{ApplicationIDs: "app-1,app-2"}

_, err := h.listBulk(context.Background(), in)
var se huma.StatusError
if !errors.As(err, &se) || se.GetStatus() != http.StatusUnauthorized {
t.Fatalf("got %v, want 401", err)
}
}

// Parsing runs before the store is touched, so the empty and over-limit cases
// are reachable without a database.
func TestAnswersBulkParsesIDs(t *testing.T) {
h := &answerHandler{}
lead := middleware.Actor{NUID: "l1", Roles: []models.UserRole{models.UserRoleLead}}

// Blank entries are dropped, and an empty list short-circuits to no rows
// rather than querying for none.
out, err := h.listBulk(withActor(lead), &ListAnswersBulkInput{ApplicationIDs: " , ,"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Body) != 0 {
t.Fatalf("got %d answers, want 0", len(out.Body))
}

ids := make([]string, maxBulkApplications+1)
for i := range ids {
ids[i] = "app"
}
_, err = h.listBulk(withActor(lead), &ListAnswersBulkInput{
ApplicationIDs: strings.Join(ids, ","),
})
var se huma.StatusError
if !errors.As(err, &se) || se.GetStatus() != http.StatusUnprocessableEntity {
t.Fatalf("got %v, want 422", err)
}
}
35 changes: 32 additions & 3 deletions backend/internal/handlers/applications.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,19 @@ type ApplicationOutput struct {
Body models.Application
}

// ApplicationsOutput is an envelope rather than a bare array because paging
// happens in SQL: a page of rows says nothing about how many matched, and the
// stage tabs need their own counts. Unpaged callers read `applications` and
// ignore the rest.
type ApplicationsOutput struct {
Body []models.ApplicationSummary
Body struct {
Applications []models.ApplicationSummary `json:"applications"`
// Total is every row matching the filter, not just this page.
Total int `json:"total"`
// StageCounts is the same match broken down by stage, ignoring any
// stage filter, so each tab can show a live count.
StageCounts map[string]int `json:"stage_counts"`
}
}

type CreateApplicationInput struct {
Expand Down Expand Up @@ -150,6 +161,9 @@ type ListApplicationsInput struct {
// string — a []AnswerFilterInput field silently binds nothing (or panics,
// depending on how the client serializes it).
AnswerFilters string `query:"answer_filters" doc:"JSON array of answer filters, e.g. [{\"question_id\":\"…\",\"question_type\":\"checkbox\",\"values\":[\"Yes\"]}]. Values may be a string or an array of strings; a filter matches any of them, and separate filters are AND'd."`
Search string `query:"search" doc:"Case-insensitive substring match on the applicant's name, NUID, or email"`
Limit int `query:"limit" doc:"Max results per page; omit (or 0) to return every match" minimum:"0" maximum:"200"`
Offset int `query:"offset" doc:"Number of results to skip" minimum:"0"`
}

func (h *applicationHandler) list(ctx context.Context, in *ListApplicationsInput) (*ApplicationsOutput, error) {
Expand All @@ -176,6 +190,8 @@ func (h *applicationHandler) list(ctx context.Context, in *ListApplicationsInput
UserNUID: in.UserNUID,
AssignedTo: in.AssignedTo,
AnswerFilters: answerFilters,
Search: in.Search,
Offset: in.Offset,
// Only a user listing their own applications by their own identity
// ever sees their own draft — the reviewer queue and lookups of
// someone else's user_nuid never do.
Expand All @@ -196,11 +212,24 @@ func (h *applicationHandler) list(ctx context.Context, in *ListApplicationsInput
filter.Stage = &parsed
}

apps, err := h.store.ListApplications(ctx, filter)
if in.Limit > 0 {
filter.Limit = &in.Limit
}

// The totals cost a full scan each and are invariant for a given filter, so
// only the first page pays for them; later pages reuse what it returned.
page, err := h.store.ListApplicationsPage(ctx, filter, in.Offset == 0)
if err != nil {
return nil, storeErr(err)
}
return &ApplicationsOutput{Body: apps}, nil
out := &ApplicationsOutput{}
out.Body.Applications = page.Items
out.Body.Total = page.Total
out.Body.StageCounts = make(map[string]int, len(page.StageCounts))
for stage, n := range page.StageCounts {
out.Body.StageCounts[string(stage)] = n
}
return out, nil
}

type UpdateApplicationInput struct {
Expand Down
27 changes: 27 additions & 0 deletions backend/internal/store/answers.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ func (in AnswerInput) isEmpty() bool {

const answerColumns = `id, application_id, question_id, answer_text, answer_options, answer_file_path, answer_file_name, submitted_at`

// prefixedAnswerColumns is answerColumns qualified for queries that join
// applications, where the bare names would be ambiguous.
const prefixedAnswerColumns = `wa.id, wa.application_id, wa.question_id, wa.answer_text, wa.answer_options, wa.answer_file_path, wa.answer_file_name, wa.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.
Expand Down Expand Up @@ -87,6 +91,29 @@ func (s *Store) UpsertAnswers(ctx context.Context, applicationID string, inputs
return s.ListAnswers(ctx, applicationID)
}

// ListAnswersForApplications fetches answers for many applications in one
// round trip, for callers rendering a page of applications at once — the
// per-application ListAnswers below turns into a request per row there.
//
// Draft answers are private autosave content, so they are excluded outright
// rather than filtered per caller: this exists for reviewer-facing lists,
// which never show drafts anyway.
func (s *Store) ListAnswersForApplications(ctx context.Context, applicationIDs []string) ([]models.WrittenAnswer, error) {
if len(applicationIDs) == 0 {
return nil, nil
}
const q = `SELECT ` + prefixedAnswerColumns + `
FROM written_answers wa
JOIN applications a ON a.id = wa.application_id
WHERE wa.application_id = ANY($1::uuid[]) AND a.stage != 'draft'
ORDER BY wa.application_id, wa.submitted_at`
rows, err := s.db.Query(ctx, q, applicationIDs)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByPos[models.WrittenAnswer])
}

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)
Expand Down
Loading
Loading