Skip to content

feat: Opportunities - reshape model + ship the listing surface#754

Open
Nickatak wants to merge 8 commits into
hackforla:developfrom
Nickatak:feat/opportunities
Open

feat: Opportunities - reshape model + ship the listing surface#754
Nickatak wants to merge 8 commits into
hackforla:developfrom
Nickatak:feat/opportunities

Conversation

@Nickatak

@Nickatak Nickatak commented Jun 18, 2026

Copy link
Copy Markdown
Member

feat/opportunities: dissolve Project, reshape Opportunity, ship the listing surface

Off develop. #752 (feat/auth) and #753
(feat/navbar-trim-org-links) merged 2026-06-01; this PR is eight
commits directly on top of the current develop tip, no stacked-PR
coupling.

Summary

Two related shifts in one PR plus a small dev-tooling rider: the
backend reshape and the volunteer-facing /opportunities browse
surface wired to the result.

Backend — applied on top of the inherited Opportunity / Project /
Role layer:

  • Project dissolves entirely. project_name moves onto
    Opportunity as free-text (deliberately weak as a filter key);
    meeting_times JSON moves onto Opportunity; the
    Opportunity.project FK, ProjectSerializer, ProjectViewSet,
    /api/projects/ URL, admin registration, and projects test suite
    are all removed. CommunityOfPractice / role-taxonomy is
    untouched here — that's the next PR.
  • Opportunity.overview (Role Overview, now owned by Opportunity)
    is added; Role.overview / Role.responsibilities are backed
    out. Experience level stays on Opportunity as
    min_experience_required but per the filter spec is no longer a
    filter dimension (still displayed).
  • Public-read is dropped across the board.
    OpportunityPermission no longer green-lights anonymous
    SAFE_METHODS; this closes the SAFE_METHODS PATCH gap (BUG-003)
    in the same change.
  • Status enum: "on hold""on_hold"; default flips to
    "draft". OpportunityViewSet.get_queryset() filters the list
    action to status="open" so the volunteer-facing catalog only
    ever surfaces open roles; retrieve / update / destroy still
    operate against the full set (PM CMS reads off retrieve).
  • OpportunityReadSerializer adds two derived display-only fields:
    role_title (from role.title) and skill_names: list[str]
    (alphabetically resolved from skills_required_matrix).
    CustomUserReadSerializer adds the parallel skill_names for the
    current user. Both save the listing card from N+1 lookups; the
    underlying FK UUIDs stay on the wire for matching consumers.
  • CustomUser.meeting_availability JSON shape now documented:
    [{"day": "Wed", "start": "17:00", "end": "21:00"}, ...],
    parallel to Opportunity.meeting_times minus the team key.
  • Migration 0002_dissolve_project_reshape_opportunity performs
    the reshape and backfills "on hold" rows via RunPython;
    migration accounts.0003 carries the new help_text on
    meeting_availability.
  • Cleanup riders: Opportunity.min_experience_required drops
    null=True / default="" (closes DJ001 from Bump dependencies + replace lint stack #737's deferred
    items); dead SkillMatrixSerializer removed (BUG-002).

Frontend — the /opportunities browse surface, sign-in-gated,
zero hardcoded data:

  • Two-column page: sticky left filter sidebar (Figma-shaped:
    Filters (N) count, Clear all, active-filter chips,
    collapsible <details> sections per filter), results column on
    the right with an N results count.
  • Three filters: Availability ON/OFF (matches against
    the user's meeting_availability), Project free-text substring,
    Skills OFF / Partial Match (≥30% overlap on skill_names). Pure
    predicate code in features/opportunities/lib/filters.ts.
  • Card layout: role title + project name in the main title row
    (Role Title · Project), experience + chips below, prose
    sections, right rail with Meeting Times and Skills. No status
    badge, no posted date, no logo, no "create account" CTA —
    see Decisions baked in.
  • Wired to the live API: opportunitiesApi.list() fetches the
    open opportunities, the auth context (useAuth()) provides the
    current user. State branches: auth loading, anonymous (sign-in
    prompt linking /login), fetch in flight, fetch error, zero
    results. No hardcoded sample data anywhere in the repo.

Dev toolingmake db-seed (new) runs a seed_dev
management command that idempotently creates one PM/admin user
(dev@example.com / password123!; isProjectManager + is_staff

  • is_superuser set so the same account also drives Django admin)
    and three open opportunities. Dev-only; never imported by views or
    tests. Exempt from the no-hardcoded-data rule (test fixtures and
    dev-only seed scripts are carved out). See
    docs/developer/installation.md for credentials and
    docs/developer/quickstart-guide.md for the make-target entry.

Commits

Eight commits.

  1. feat: Dissolve Project, reshape Opportunity for sign-in-only listings (19 files; +972 / −296; backend reshape + migration +
    initial frontend listing scaffold).
  2. feat: Add filter bar to /opportunities (Availability, Project, Skills) (6 files; +343 / −6; client-side filter state, pure
    predicate code in lib/filters.ts).
  3. feat: Restructure /opportunities filter UI to match Figma layout (4 files; +335 / −94; two-column grid with sticky
    sidebar, Filters (N) header + Clear all, active-filter chips,
    collapsible <details> sections, N results count).
  4. refactor: Promote project name into the card title row, drop status badge (3 files; +23 / −81; project name moves to the
    main title row, status badge removed - the listing surface only
    ever shows open opportunities by design).
  5. refactor: Drop posted-date from the card; reframe as project-role catalog (3 files; +13 / −21; this surface
    replaces HfLA's current GitHub-listed project-role catalog,
    so the "Posted: ..." framing is wrong - volunteers join an
    open role, they don't apply to a posting).
  6. feat: Resolve display-only fields on Opportunity + User; filter list to open (8 files; +215 / −2; role_title +
    skill_names derived fields on both Read serializers,
    queryset filter on the list action, meeting_availability
    shape documented, tests).
  7. feat: Wire /opportunities to live API; delete sample-data scaffolding (9 files; +283 / −260; opportunitiesApi client,
    User type updates, listing page becomes a real fetching
    component with loading / error / unauthenticated states,
    sample data files deleted).
  8. feat: Add seed_dev management command + make db-seed for dev fixtures (6 files; +160 / −1; idempotent seed for one
    PM/admin user + three open opportunities so /opportunities
    renders on a fresh DB without hand-building the dependency
    graph through admin).

Decisions baked in

  • The browse surface is a project-role catalog, not a job feed.
    The page replaces HfLA's current GitHub-listed project-role
    catalog - volunteers join an open project role; they don't apply
    to a recent posting. That framing drives: sign-in required
    (you're joining, not browsing), only open roles ever appear (no
    status badge needed), no posted-date metadata, no
    create-account CTA. Worth holding the line on: small UI choices
    ("Apply" buttons, "New this week" sort, etc.) follow from this
    framing and re-introduce job-board patterns we deliberately cut.
  • No hardcoded data in committed code. Listing data comes off
    /api/opportunities/; the current user comes off the auth
    context. No sampleX.ts files; the only "data" in the repo are
    test fixtures (in tests/ paths) and the dev-only seed script
    (not imported by views or tests).
  • Project dissolves into Opportunity, no FK. Everything
    belongs to the Opportunity — no Project data at all. Project
    identity is a string on Opportunity, a deliberately weak filter
    key. PeopleDepot will be the eventual source of truth for
    project metadata; no people_depot_project_id on Opportunity
    yet (out of scope, comes when PeopleDepot integration lands).
  • Role keeps its model + FK. The role-taxonomy drop is the
    next PR (CommunityOfPractice / Role.community_of_practice /
    Skill.communities_of_practice /
    CustomUser.community_of_practice / frontend CoP picker).
    Leaving Role intact here keeps the PR scoped.
  • Sign-in required, hard. SAFE_METHODS no longer grants
    anonymous access. Model docstrings + any "anonymous-visible"
    framing in the README will need a follow-up sweep — caught a
    few; flagged the rest as a follow-up.
  • List action server-filters to status="open". Drafts,
    on-hold, filled, and closed opportunities never appear in the
    browse list — the listing surface is the volunteer-facing
    catalog. Retrieve / update / destroy still hit the full set
    so PMs can edit non-open records via the upcoming CMS.
  • Display fields resolved at the serializer. role_title
    and skill_names are derived on the wire so the card can
    render with one request, not one + N. The underlying
    role: UUID / skills_required_matrix: UUID stay on the wire
    for matching-algorithm consumers.
  • overview belongs to Opportunity, not Role. The Figma's
    Role Overview prose travels with the opportunity, not the role.
    Backing this off Role lets the role dataset stay generic.
  • Skill partial-match threshold = 30%. Opportunities with no
    tagged skills always pass — we have no signal to score them and
    excluding them would hide listings just because their PM hasn't
    tagged skills yet.

Questions for review

  1. project_name required on publish? Currently blank=True
    (draft-friendly). Figma always shows a project name; we may
    want it required once status flips to "open". Proposing:
    keep blank=True at the model layer, enforce on the publish
    transition in serializer-level validation — but happy to make
    it column-level required if you'd rather.
  2. Catalog endpoints — auth-gated too? /api/opportunities/
    requires sign-in. The catalog endpoints (/api/roles/,
    /api/skills/, /api/communities-of-practice/) are still
    public (AllowAny). The "require sign-ins" direction here was
    about listings specifically; scope is undecided for catalog.
    Easy to flip if you want a flat policy.
  3. body label. With Project dissolved, the Figma's "About
    the Project" prose now lives in Opportunity.body. The listing
    card labels that section "About the Project" — reads slightly
    oddly (it's opportunity-authored now). Keep, or rename to
    "About this opportunity"?

Test plan

  • Backend: make local-test-backend → 45/45 pass (39 prior +
    6 new for role_title / skill_names / list-status filter /
    /api/auth/me skill_names).
  • Backend lints: ruff check clean; mypy clean (36 source
    files); bandit -c pyproject.toml -r ctj_api/ accounts/ backend/ → 0 findings.
  • Migrations: makemigrations --check reports no drift after
    both new migrations (ctj_api.0002 + accounts.0003).
  • Frontend: npx vitest run → 37/37 pass (3 pre-existing
    skips); lint:types / lint:css / lint:dead (knip) /
    eslint clean.
  • /opportunities returns 200; anonymous sees the sign-in
    prompt; authenticated user sees the filter sidebar + result
    list (or empty state).
  • Anonymous GET /api/opportunities/ returns 403 with the
    not_authenticated envelope code.
  • Browser walkthrough: sign in, see the listing populated;
    apply each filter and confirm results adjust; clear all;
    sign out and confirm the sign-in prompt returns; PM admin
    still CRUDs opportunities via Django admin end-to-end.

Follow-ups (out of scope for this PR)

  • CoP / role-taxonomy deletion. CommunityOfPractice,
    PracticeAreas, /api/communities-of-practice/,
    Role.community_of_practice, Skill.communities_of_practice,
    CustomUser.community_of_practice, frontend copData.ts +
    qualifier CoP picker. Will be its own PR stacked on this one.
  • Opportunity detail + PM CMS pages (/opportunities/<id>,
    /opportunities/new, /opportunities/<id>/edit). PM-only,
    reads off the retrieve action (which intentionally isn't
    filtered by status).
  • Filter unit tests. Predicates in lib/filters.ts are pure
    and unit-testable; tests come with the broader opportunities
    test suite (queued behind detail + CMS).
  • N+1 on skill_names. The current implementation issues one
    extra Skill.objects.filter per opportunity in the list
    response. Acceptable at current scale (open listings are
    small); revisit with a prefetch or denormalization when the
    catalog grows.
  • Docstring / README "anonymous-visible" sweep. A small
    number of model + README hints still describe browse as
    public-readable; one more pass after the auth-gating posture
    settles.
  • Status admin polish. The status dropdown in Django admin
    still shows the old labels in some places; tidy after the
    "on_hold" rename settles in.
  • Seed script revisits next PR. The seed touches the CoP /
    Role taxonomy that's being removed in the follow-up PR; it
    rewrites alongside the deletion. Carried in this PR so the
    surface it tests is usable on a fresh DB today.

Nickatak and others added 8 commits June 18, 2026 02:58
Per Ryan's clarification (the spec captured locally as `blah.`),
Opportunity listings are no longer public-readable and the Project
model collapses into Opportunity:

- Dissolve `Project` entirely. `project_name` moves onto Opportunity
  as a free-text `CharField`; `meeting_times` JSON moves on; the
  Opportunity.project FK, ProjectSerializer, ProjectViewSet,
  /api/projects/ URL, admin registration, and projects test suite
  are all removed.
- Add `Opportunity.overview` (Role Overview, now owned by Opportunity)
  and back out `Role.overview` / `Role.responsibilities`. Experience
  level stays on Opportunity as `min_experience_required` but is
  dropped as a filter dimension (still exposed for display).
- Drop public-read across the board. `OpportunityPermission` no longer
  green-lights anonymous SAFE_METHODS; browse and detail are
  authenticated-only. Closes the SAFE_METHODS PATCH gap (BUG-003) at
  the same time.
- Status enum: "on hold" -> "on_hold" (machine-friendly identifier),
  default flips to "draft" so newly-created opportunities aren't
  publishable by accident.
- `Opportunity.min_experience_required`: drop `null=True` /
  `default=""` (closes DJ001 - was on hackforla#737's deferred items list).
- Remove the dead `SkillMatrixSerializer` (BUG-002).
- Migration 0002 dissolves Project, reshapes Opportunity, and backfills
  existing "on hold" rows to "on_hold" via RunPython.

Frontend: new `/opportunities` listing page wired through a feature
directory at `features/opportunities/`. Two-column card matching the
Figma target minus the deletions blah. calls out (no Program Area
chip, no Tech/Languages split, no skill ratings, no project logo, no
create-account CTA). Renders against static sample data for now -
the source will swap to a live fetch of `/api/opportunities/` once
the API client lands. Subtitle on the page calls out the
sign-in-required posture so the dev preview is honest about the
mock-data state.

Backend: 39/39 tests pass; ruff / mypy (36 files) / bandit clean on
project code.
Frontend: lint:types / lint:css / lint:dead clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the filter contract from blah.:
  - Availability ON/OFF: keeps opportunities the user can attend at
    least one meeting of (day match + time-window overlap).
  - Project: free-text substring filter against project_name,
    case-insensitive and trimmed; empty query is a no-op.
  - Skills OFF / Partial Match (30%): partial match keeps an
    opportunity if at least 30% of its required skills appear in the
    user's skill list (case-insensitive set overlap). Opportunities
    with no tagged skills always pass to avoid hiding untagged
    listings.

Filtering needs a "current user" (their meeting availability + their
skill list) to compare against. The page is still pre-auth mock data,
so this lands a `sampleCurrentUser` alongside `sampleOpportunities`.
Both go away in the same swap once the API client and signed-in
user wire-up land - the filter helpers read off the same shape, so
the swap is just changing the import sources.

OpportunityListPage becomes a client component (filter state +
useMemo over the filtered list). Pure predicate code sits in
`features/opportunities/lib/filters.ts` so the wiring is testable
in isolation when tests come.

Frontend: lint:types / lint:css / lint:dead / eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier filter bar was a horizontal strip above the listing -
adequate for wiring the filter state but a poor match for the Figma
target. This restructures it into the Figma's left-sidebar shape:

- Page becomes a two-column grid (`280px 1fr`) with the filter
  sidebar sticky to the viewport top. Mobile breakpoint at 768px
  stacks the layout and unsticks the sidebar.
- Sidebar header: `Filters (N)` count + a `Clear all` button that
  disables when no filter is active.
- Active-filter chips render between the header and the section
  list (one chip per non-default filter), each with an inline `×`
  to clear that single filter.
- Three collapsible filter sections (Availability / Project /
  Skills) built on native `<details>` / `<summary>`; the marker
  is replaced with a CSS chevron that rotates on open. Default
  closed.
- Results column gains an `N results` count above the card list.

Legacy Figma filter sections (Roles / Experience Level / Program
Area / Tech / Languages) are intentionally absent per `blah.`.

Behavior unchanged - same filter contract, same predicate code in
`lib/filters.ts`. This is presentation only plus the new chip /
clear-all controls.

Frontend: lint:types / lint:css / lint:dead / eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…adge

Two card-layout changes per Nick's review:

- Project name moves from the right rail into the main column title
  row, sitting next to the role title (baseline-aligned, secondary
  color). The right rail keeps only `Posted: ...` + the Meeting
  Times / Skills sections.
- The status badge is gone entirely - the listing surface only ever
  shows open opportunities by design, so the "Open" label was
  redundant and the other states wouldn't render here anyway. The
  `status` field stays on `Opportunity` (matches the API shape and
  the underlying admin) but doesn't render.

Drops the now-dead `STATUS_LABELS` / `STATUS_CLASSES` maps and the
five `.status*` CSS classes; `OpportunityStatus` is no longer
exported from the sample-data module (still used internally as the
`status` field's type).

Frontend: lint:types / lint:css / lint:dead / eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…alog

The browse surface is the replacement for HfLA's current GitHub-listed
project-role catalog - volunteers join an open project role, not apply
to a recent job posting. A "Posted: <date>" line frames it as a feed of
recent listings, which is the wrong mental model.

Removes the posted-date line from `OpportunityCard`, drops the
`posted` field from the `Opportunity` type + sample data, and updates
the card docstring to spell the framing out so future edits hold the
line. Normalizes the one remaining `status: "on_hold"` sample to
`"open"` to match the design rule that the listing only ever shows
open opportunities.

Frontend: lint:types / lint:css / lint:dead / eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…to open

Frontend listing card needs `role_title` (Role.title) and a flat
`skill_names: list[str]` on each opportunity to render without N+1
`/api/roles/<id>/` and `/api/skills/<id>/` lookups. Same shape need on
the current user (`/api/auth/me/`) so the Skills warm-referral filter
can read the user's known skill names directly. Surfacing both as
serializer-level derived fields keeps the underlying FK UUIDs on the
wire for matching-algorithm consumers.

Backend changes:
- `OpportunityReadSerializer` adds `role_title = ReadOnlyField(source=
  "role.title")` and `skill_names = SerializerMethodField()` resolving
  `skills_required_matrix` -> alphabetically sorted skill names. Empty
  list (not null) when no matrix is attached.
- `CustomUserReadSerializer` adds the same `skill_names` field against
  `skills_learned_matrix`. Both serializers share a
  `_resolve_skill_names` helper in `ctj_api.serializers`.
- `OpportunityViewSet.get_queryset()` filters the list action to
  `status="open"` only - the browse surface is the volunteer-facing
  catalog, drafts / on-hold / filled / closed aren't part of it.
  Retrieve / update / destroy still operate against the full set so
  PMs (and the upcoming CMS surface) can edit non-open records.
- `CustomUser.meeting_availability` JSON shape now documented:
  `[{"day": "Wed", "start": "17:00", "end": "21:00"}, ...]`, mirroring
  `Opportunity.meeting_times` shape minus the `team` key. Migration
  `accounts.0003` carries the field's new `help_text`.

Tests:
- `test_list_filters_to_status_open` covers the queryset filter
  (all four non-open statuses excluded).
- `test_retrieve_returns_non_open_opportunity` confirms retrieve
  isn't filtered (PM-CMS path stays intact).
- `test_list_exposes_role_title_and_skill_names` covers the new
  derived fields and the alphabetical sort.
- `test_list_skill_names_empty_when_no_matrix` covers the empty
  case (no matrix attached -> `[]`, not null).
- `test_me_includes_skill_names_resolved_from_matrix` +
  `test_me_returns_empty_skill_names_when_no_matrix` mirror the same
  coverage on the user-side serializer.

Backend: 45/45 tests pass; ruff / mypy (36 files) / bandit clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The browse surface now reads off the real endpoints. No more mock
data in the repo.

Frontend:
- New `opportunitiesApi` client at `src/shared/lib/api/opportunities.ts`,
  mirroring `OpportunityReadSerializer` (including the new derived
  `role_title` and `skill_names` fields). Exposes `.list()`
  (server-filtered to `status="open"`) and `.retrieve(id)`.
- `User` type in `src/shared/lib/api/auth.ts` gains `skill_names:
  string[]` (mirrors the serializer addition) and
  `meeting_availability: AvailabilitySlot[] | null` (replacing the
  prior `unknown`, now that the JSON shape is documented on the
  backend model).
- `OpportunityListPage` becomes a real data-fetching component:
  reads `useAuth()` for the current user, fetches the list in a
  `useEffect`, and branches on (a) auth loading, (b) anonymous user
  (renders a sign-in prompt linking `/login`), (c) fetch in flight,
  (d) fetch error, (e) zero results after filtering.
- `OpportunityCard` and `lib/filters.ts` swap their type imports
  from the sample-data files to the API client + auth types.
  `meeting_times` is nullable now (matches the model's
  `null=True`), so the card's render conditional + the Availability
  predicate both handle the null case.
- Deletes `data/sampleOpportunities.ts` + `data/sampleCurrentUser.ts`.
- `AuthContext.test.tsx`'s `baseUser` fixture updated for the new
  `skill_names` field.

Frontend: vitest 37/37 pass + 3 pre-existing skips; lint:types /
lint:css / lint:dead / eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Idempotent dev-only seed: one PM/admin user (dev@example.com /
password123!, isProjectManager + is_staff + is_superuser) and three
open opportunities so /opportunities renders without hand-building
the dependency graph through admin.

Dev-only, never imported by views or tests; exempt from the no-
hardcoded-data rule (test fixtures / dev-only seed scripts are
explicitly carved out). Both seeded data and admin flags carry
forward to the upcoming PM CMS work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Nickatak Nickatak changed the title WIP: feat: Opportunities - reshape model + ship the listing surface feat: Opportunities - reshape model + ship the listing surface Jun 25, 2026
@Nickatak Nickatak marked this pull request as ready for review June 25, 2026 01:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant