From efb561b7b932f304eb62cd112bbd35ee6c124d1f Mon Sep 17 00:00:00 2001 From: Nickatak Date: Thu, 18 Jun 2026 02:58:18 -0700 Subject: [PATCH 1/8] feat: Dissolve Project, reshape Opportunity for sign-in-only listings 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 #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) --- backend/accounts/apps.py | 2 +- backend/accounts/tests/common.py | 5 +- backend/ctj_api/admin.py | 9 +- backend/ctj_api/apps.py | 6 +- ...02_dissolve_project_reshape_opportunity.py | 118 +++++++++++ backend/ctj_api/models.py | 127 ++++++------ backend/ctj_api/permissions.py | 67 +++--- backend/ctj_api/serializers.py | 68 ++----- backend/ctj_api/tests/common.py | 47 ++--- backend/ctj_api/tests/test_opportunities.py | 192 +++++++++++++++++- backend/ctj_api/tests/test_projects.py | 20 -- backend/ctj_api/urls.py | 4 +- backend/ctj_api/views.py | 89 ++------ .../src/app/(with-nav)/opportunities/page.tsx | 15 ++ .../components/OpportunityCard.module.css | 157 ++++++++++++++ .../components/OpportunityCard.tsx | 168 +++++++++++++++ .../components/OpportunityListPage.module.css | 24 +++ .../components/OpportunityListPage.tsx | 38 ++++ .../opportunities/data/sampleOpportunities.ts | 112 ++++++++++ 19 files changed, 972 insertions(+), 296 deletions(-) create mode 100644 backend/ctj_api/migrations/0002_dissolve_project_reshape_opportunity.py delete mode 100644 backend/ctj_api/tests/test_projects.py create mode 100644 frontend/src/app/(with-nav)/opportunities/page.tsx create mode 100644 frontend/src/features/opportunities/components/OpportunityCard.module.css create mode 100644 frontend/src/features/opportunities/components/OpportunityCard.tsx create mode 100644 frontend/src/features/opportunities/components/OpportunityListPage.module.css create mode 100644 frontend/src/features/opportunities/components/OpportunityListPage.tsx create mode 100644 frontend/src/features/opportunities/data/sampleOpportunities.ts diff --git a/backend/accounts/apps.py b/backend/accounts/apps.py index c441066c..400fe5f3 100644 --- a/backend/accounts/apps.py +++ b/backend/accounts/apps.py @@ -7,7 +7,7 @@ class AccountsConfig(AppConfig): Owns the `CustomUser` model (the project's `AUTH_USER_MODEL`), the per-user detail endpoint, the auth flow endpoints (signup, login, logout, me, csrf), and their permission classes / - serializers. Domain models (Opportunity, Project, etc.) stay in + serializers. Domain models (Opportunity, Role, etc.) stay in `ctj_api`; cross-app FKs to user use `settings.AUTH_USER_MODEL` so the boundary stays explicit. diff --git a/backend/accounts/tests/common.py b/backend/accounts/tests/common.py index d3a506fd..82fb900f 100644 --- a/backend/accounts/tests/common.py +++ b/backend/accounts/tests/common.py @@ -3,9 +3,8 @@ Each helper saves a `CustomUser` instance with sensible defaults that callers can override via keyword arguments. Per-test-file `setUp` methods import only the helpers they need. Domain factories -(CoP, Role, Skill, Project, Opportunity) live in -`ctj_api.tests.common` and are imported separately when a test -spans both apps. +(CoP, Role, Skill, Opportunity) live in `ctj_api.tests.common` and +are imported separately when a test spans both apps. """ from accounts.models import CustomUser diff --git a/backend/ctj_api/admin.py b/backend/ctj_api/admin.py index f6671a03..f4f2a413 100644 --- a/backend/ctj_api/admin.py +++ b/backend/ctj_api/admin.py @@ -1,12 +1,11 @@ """Django admin registrations for CTJ's domain models. -All six domain models are registered with the default `ModelAdmin` +All five domain models are registered with the default `ModelAdmin` (no customization). Effect: admins see every field on every row, with no list filters, search fields, or read-only protections. Enough for Stage 1 curation of the admin-managed reference tables -(`Skill`, `Role`, `Project`, `CommunityOfPractice`) plus emergency -edits to opportunities. The `CustomUser` admin lives in -`accounts.admin`. +(`Skill`, `Role`, `CommunityOfPractice`) plus emergency edits to +opportunities. The `CustomUser` admin lives in `accounts.admin`. If a model's admin needs filters, list display, or search later, register it with a dedicated `ModelAdmin` subclass instead of the @@ -23,7 +22,6 @@ from .models import ( CommunityOfPractice, Opportunity, - Project, Role, Skill, SkillMatrix, @@ -32,6 +30,5 @@ admin.site.register(CommunityOfPractice) admin.site.register(Role) admin.site.register(Skill) -admin.site.register(Project) admin.site.register(SkillMatrix) admin.site.register(Opportunity) diff --git a/backend/ctj_api/apps.py b/backend/ctj_api/apps.py index a2ff8f5c..1cbea116 100644 --- a/backend/ctj_api/apps.py +++ b/backend/ctj_api/apps.py @@ -5,9 +5,9 @@ class CtjApiConfig(AppConfig): """Django app config for `ctj_api`, the project's domain app. Owns the recruitment-catalog and taxonomy models (Opportunity, - Project, Role, Skill, SkillMatrix, CommunityOfPractice) and - their views/serializers/permissions/urls. The `CustomUser` - identity model and the auth flow live in the `accounts` app. + Role, Skill, SkillMatrix, CommunityOfPractice) and their + views/serializers/permissions/urls. The `CustomUser` identity + model and the auth flow live in the `accounts` app. `default_auto_field` is set to `BigAutoField` to silence Django's startup warning about implicit auto field selection, but in diff --git a/backend/ctj_api/migrations/0002_dissolve_project_reshape_opportunity.py b/backend/ctj_api/migrations/0002_dissolve_project_reshape_opportunity.py new file mode 100644 index 00000000..558c94ca --- /dev/null +++ b/backend/ctj_api/migrations/0002_dissolve_project_reshape_opportunity.py @@ -0,0 +1,118 @@ +# Generated by Django 6.0.4 on 2026-06-02, then hand-edited: +# - file renamed for legibility, +# - added a RunPython data step at the front for the `"on hold"` -> +# `"on_hold"` status rename and the `min_experience_required` +# NULL -> "" backfill. The RunPython has to come *before* the +# `AlterField` on `status` (so the new choices validate) and +# *before* the `AlterField` on `min_experience_required` (the new +# shape isn't `null=True` anymore; backfilling avoids the +# NOT-NULL transition tripping on existing NULL rows). +# +# Schema-wise this migration dissolves the `Project` model: the +# `Opportunity.project` FK is removed, the project's `meeting_times` +# JSON moves onto `Opportunity` as a column, and the project name +# becomes the free-text `Opportunity.project_name`. Existing project +# associations are dropped (pre-launch reshape; PeopleDepot owns +# projects upstream). The `overview` / `responsibilities` card text +# also moves onto `Opportunity` (decoupled from `Role`). + +from django.db import migrations, models + + +def _fix_opportunity_data(apps, schema_editor): + """Forward data migration: rename `"on hold"` status and backfill NULL experience.""" + Opportunity = apps.get_model("ctj_api", "Opportunity") + Opportunity.objects.filter(status="on hold").update(status="on_hold") + Opportunity.objects.filter(min_experience_required__isnull=True).update( + min_experience_required="" + ) + + +def _revert_opportunity_data(apps, schema_editor): + """Reverse: put `"on hold"` back. The NULL backfill isn't + reversible (we can't tell which `""` came from a NULL row), so + leave those as empty strings on revert. + """ + Opportunity = apps.get_model("ctj_api", "Opportunity") + Opportunity.objects.filter(status="on_hold").update(status="on hold") + + +class Migration(migrations.Migration): + + dependencies = [ + ("ctj_api", "0001_initial"), + ] + + operations = [ + migrations.RunPython(_fix_opportunity_data, _revert_opportunity_data), + migrations.RemoveField( + model_name="opportunity", + name="project", + ), + migrations.AddField( + model_name="opportunity", + name="project_name", + field=models.CharField( + blank=True, + default="", + help_text=( + "Free-text project name. Not a FK - PeopleDepot owns " + "projects upstream. Doubles as the (deliberately weak) " + "project filter key." + ), + max_length=255, + ), + ), + migrations.AddField( + model_name="opportunity", + name="overview", + field=models.TextField( + blank=True, + default="", + help_text='Card "Role Overview" section: what this position is and does.', + ), + ), + migrations.AddField( + model_name="opportunity", + name="responsibilities", + field=models.TextField( + blank=True, + default="", + help_text='Card "Responsibilities" section: what is expected of the volunteer.', + ), + ), + migrations.AddField( + model_name="opportunity", + name="meeting_times", + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name="opportunity", + name="min_experience_required", + field=models.CharField( + blank=True, + default="", + help_text="min_experience_required: junior, senior, mid-level, etc.", + max_length=50, + ), + ), + migrations.AlterField( + model_name="opportunity", + name="status", + field=models.CharField( + choices=[ + ("open", "Open"), + ("closed", "Closed"), + ("on_hold", "On hold"), + ("filled", "Filled"), + ("draft", "Draft"), + ], + default="draft", + help_text="Status will determine how the opportunity is shown.", + max_length=20, + ), + ), + migrations.DeleteModel( + name="Project", + ), + ] diff --git a/backend/ctj_api/models.py b/backend/ctj_api/models.py index 204bb49e..29ee089b 100644 --- a/backend/ctj_api/models.py +++ b/backend/ctj_api/models.py @@ -1,12 +1,11 @@ """Domain models for the CTJ platform. -This module defines the six Django models that back CTJ's core +This module defines the five Django models that back CTJ's core flows: the practice-area taxonomy (`CommunityOfPractice`, `Role`), the skill system (`Skill`, `SkillMatrix`), and the recruitment -catalog (`Project`, `Opportunity`). The user / identity model -(`CustomUser`) lives in the `accounts` app; FKs to it use -`settings.AUTH_USER_MODEL` so this module avoids importing the -user model directly. +catalog (`Opportunity`). The user / identity model (`CustomUser`) +lives in the `accounts` app; FKs to it use `settings.AUTH_USER_MODEL` +so this module avoids importing the user model directly. Stage 1 / Stage 2 boundary: several of these tables are locally curated in Stage 1 and migrate to PeopleDepot-backed shapes in @@ -150,45 +149,6 @@ def __str__(self): return self.name -class Project(models.Model): - """ - Summary: - - A real-world HfLA project that opportunities are posted under. - - Business workflow: - - HfLA runs many concurrent civic-tech projects (Tabler, Food - Oasis, CivicTechJobs itself, etc.); each owns its own - opportunities and meeting cadence. - - `people_depot_project_id` is the link to the upstream PeopleDepot - project record (PD owns project metadata; CTJ stores only what - it needs locally for the qualifier flow). - - Current policy: - - Stage 1: local table populated alongside PD. - - Stage 2: `Opportunity.project` swaps to a PeopleDepot UUID - reference and this table goes away. - - Lifecycle control: - - `admin-managed` (Stage 1). - - Visibility: - - `public-read` via `/api/projects/`. - """ - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - people_depot_project_id = models.CharField(max_length=255, unique=True) - name = models.CharField(max_length=50) - meeting_times = models.JSONField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = "projects" - - def __str__(self): - return self.name - - class SkillMatrix(models.Model): """ Summary: @@ -241,43 +201,54 @@ def __str__(self): class Opportunity(models.Model): """ Summary: - - An open volunteer position posted under a Project. + - An open volunteer position - the unit volunteers browse and match + against. Sign-in required to view; there are no public listings. Business workflow: - The recruitment-catalog row that the matching algorithm ranks against a user's `SkillMatrix`. - - Belongs to one `Project` (the team it's part of), takes the - title of one `Role` (the kind of position), declares its - required skills via a `SkillMatrix` reference, and tracks - status through a small enum (open / closed / on hold / filled / - draft). - - Posted by a project manager; viewable publicly in the catalog. + - Names its project as free-text (`project_name`), takes the title + of one `Role` (the kind of position), carries its own card + content (`overview`, `body`, `responsibilities`), declares its + required skills via a `SkillMatrix` reference, and tracks status + through a small enum (open / closed / on_hold / filled / draft). + - Posted by a project manager; visible to authenticated users only. Current policy: + - `project_name` is plain free-text, not a foreign key: project + metadata is not modeled locally. PeopleDepot owns Projects/Roles + upstream; an external reference may replace this string later. + Doubles as the (deliberately weak) project filter key. + - `meeting_times` (JSON) lives here too: an opportunity is shown to + a volunteer if they can make at least one of its meeting slots. + - `overview` / `responsibilities` are the opportunity's own card + content (the "Role Overview" and "Responsibilities" sections), + authored per-opportunity rather than inherited from `Role`. - `created_by` uses `on_delete=SET_NULL`: deleting a user does not - cascade-delete their posted opportunities. Loss of authorship - is preferable to losing the public-facing listings. - - `min_experience_required` is currently a nullable `CharField`, - which violates Django's "use empty string for missing - `CharField`" convention (DJ001). The TODO inline is to drop - `null=True` in a follow-up migration; the `noqa` keeps current - state lint-clean until then. - - Stage 2: `project` and `role` swap to PeopleDepot UUID references; - local `Project` and `Role` tables go away. + cascade-delete their posted opportunities. Loss of authorship is + preferable to losing the listings. + - `status` defaults to `"draft"`: PMs work in draft, transition to + `"open"` to publish. Conservative default. Lifecycle control: - `creator-managed` (only the creator can update; any PM can delete) - see `OpportunityPermission` in `ctj_api.permissions`. Visibility: - - `public-read` for browsing. + - `auth-read` for browsing (sign-in required; no public listings). - `pm-write` for creation; `creator-write` for updates; `pm-delete` for deletion. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - project = models.ForeignKey( - Project, on_delete=models.CASCADE, related_name="opportunities" + project_name = models.CharField( + max_length=255, + default="", + blank=True, + help_text=( + "Free-text project name. Not a FK - PeopleDepot owns projects " + "upstream. Doubles as the (deliberately weak) project filter key." + ), ) role = models.ForeignKey( Role, @@ -285,12 +256,20 @@ class Opportunity(models.Model): related_name="opportunities", help_text="Role.title will be the title of the opportunity.", ) + overview = models.TextField( + default="", + blank=True, + help_text='Card "Role Overview" section: what this position is and does.', + ) body = models.TextField(help_text="A description of the opportunity.") - # TODO: drop null=True (Django convention is "" for missing CharField); - # requires a migration, deferred to a follow-up PR. - min_experience_required = models.CharField( # noqa: DJ001 + responsibilities = models.TextField( + default="", + blank=True, + help_text='Card "Responsibilities" section: what is expected of the volunteer.', + ) + min_experience_required = models.CharField( max_length=50, - null=True, + default="", blank=True, help_text="min_experience_required: junior, senior, mid-level, etc.", ) @@ -305,6 +284,13 @@ class Opportunity(models.Model): ("in_person", "In Person"), ], ) + # JSON shape: list of objects with keys `team` (str), `day` (str), + # `start` ("HH:MM"), `end` ("HH:MM"). Example entry: + # `{"team": "Developer Team", "day": "Wed", "start": "12:30", "end": "13:30"}`. + # Not enforced by a JSONSchema validator yet -- the card renders + # whatever's there. A volunteer is shown the opportunity if they can + # make at least one slot. + meeting_times = models.JSONField(null=True, blank=True) skills_required_matrix = models.OneToOneField( SkillMatrix, on_delete=models.SET_NULL, @@ -318,11 +304,12 @@ class Opportunity(models.Model): choices=[ ("open", "Open"), ("closed", "Closed"), - ("on hold", "On hold"), + ("on_hold", "On hold"), ("filled", "Filled"), ("draft", "Draft"), ], - help_text="Status will determine how the opportunity will be shown publicly.", + default="draft", + help_text="Status will determine how the opportunity is shown.", ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -341,4 +328,4 @@ class Meta: verbose_name_plural = "Opportunities" def __str__(self): - return f"{self.role.title} @ {self.project.name}" + return f"{self.role.title} @ {self.project_name}" diff --git a/backend/ctj_api/permissions.py b/backend/ctj_api/permissions.py index f25f3840..94f1bd3a 100644 --- a/backend/ctj_api/permissions.py +++ b/backend/ctj_api/permissions.py @@ -7,19 +7,18 @@ class OpportunityPermission(permissions.BasePermission): """Permission policy for the Opportunity recruitment-catalog endpoints. Policy: - - Safe methods (GET/HEAD/OPTIONS): allowed for everyone (public read). + - Safe methods (GET/HEAD/OPTIONS): allowed for any *authenticated* + user. There are no public listings - browsing requires sign-in. - POST (create): allowed only for users with `isProjectManager=True`. - - PUT (full update): allowed only for the user who created the - opportunity (`obj.created_by == request.user`). + - PUT and PATCH (full / partial update): allowed only for the user + who created the opportunity (`obj.created_by == request.user`). + PATCH mirrors PUT - same authorization semantics, smaller + payload. - DELETE: allowed for any project manager - (`isProjectManager=True`), not only the creator. - - PATCH: not branched on; falls through to `return False` in both - `has_permission` and `has_object_permission`. Practical effect: - PATCH on `/api/opportunities//` is always 403'd, even though - `OpportunityViewSet` is a `ModelViewSet` that nominally exposes - it. Flagging as a bug - if partial updates should be allowed - (likely scoped to the creator like PUT), this class needs PATCH - branches in both methods. Deferred out of this docs-only PR. + (`isProjectManager=True`), not only the creator. Deletion is + intentionally broader than update so PMs can co-moderate + abandoned listings; update stays creator-scoped so PMs don't + step on each other's wording. Used by: - `OpportunityViewSet` (`/api/opportunities/`). @@ -33,29 +32,29 @@ def has_permission(self, request, view): method and properties of `request.user`. Branches: - - Safe methods (GET/HEAD/OPTIONS): always allowed (public - read; the catalog should be browsable without auth). + - Safe methods (GET/HEAD/OPTIONS): allowed only for + authenticated users (no public listings - sign-in required + to browse). - POST: requires `request.user.isProjectManager == True`. Uses `getattr(..., False)` so anonymous users (whose `request.user` is an `AnonymousUser` with no `isProjectManager` attribute) are rejected without raising `AttributeError`. - - PUT and DELETE: passes through if the user is authenticated. - The per-row creator/PM check happens in + - PUT, PATCH, DELETE: passes through if the user is + authenticated. The per-row creator/PM check happens in `has_object_permission` once DRF has loaded the target row. - - PATCH and anything else: falls through to `return False`. - See the class docstring for the PATCH bug flag. + - Anything else: falls through to `return False`. """ - # Allow safe methods for all users + # Reads require sign-in (no public listings) if request.method in permissions.SAFE_METHODS: - return True + return request.user.is_authenticated # Only PM's can create opportunities if request.method == "POST": return getattr(request.user, "isProjectManager", False) - # For PUT and DELETE, defer to object-level permissions - if request.method in ["PUT", "DELETE"]: + # For PUT, PATCH and DELETE, defer to object-level permissions + if request.method in ["PUT", "PATCH", "DELETE"]: return request.user.is_authenticated return False @@ -68,24 +67,24 @@ def has_object_permission(self, request, view, obj): instance via `obj`. Branches: - - Safe methods (GET/HEAD/OPTIONS): always allowed (mirrors - the same allowance in `has_permission`). - - PUT: only the user who created the opportunity can update - it (`obj.created_by == request.user`). + - Safe methods (GET/HEAD/OPTIONS): allowed for authenticated + users (mirrors the same allowance in `has_permission`). + - PUT and PATCH: only the user who created the opportunity + can update it (`obj.created_by == request.user`). PATCH + shares PUT's authorization semantics. - DELETE: any project manager can delete (the request-level check confirmed authentication; this confirms PM status). - Note this is broader than PUT - any PM can delete anyone's - opportunity, but only the creator can update their own. - - PATCH and anything else: falls through to `return False`. - See the class docstring for the PATCH bug flag. + Broader than update - any PM can delete anyone's + opportunity, but only the creator can edit their own. + - Anything else: falls through to `return False`. """ - # Read permissions are allowed to any request, - # so we'll always allow GET, HEAD or OPTIONS requests. + # Reads require sign-in (no public listings); has_permission + # already gated anonymous users, this mirrors it at object level. if request.method in permissions.SAFE_METHODS: - return True + return request.user.is_authenticated - # PUT permissions are only allowed to the PM that created the opportunity. - if request.method == "PUT": + # PUT/PATCH only by the user that created the opportunity. + if request.method in ["PUT", "PATCH"]: return obj.created_by == request.user # Any PM can delete any opportunity diff --git a/backend/ctj_api/serializers.py b/backend/ctj_api/serializers.py index 37d8cc13..b0cea004 100644 --- a/backend/ctj_api/serializers.py +++ b/backend/ctj_api/serializers.py @@ -19,10 +19,8 @@ from ctj_api.models import ( CommunityOfPractice, Opportunity, - Project, Role, Skill, - SkillMatrix, ) @@ -45,12 +43,15 @@ class Meta: model = Opportunity fields = [ "id", - "project", + "project_name", "role", + "overview", "body", + "responsibilities", "min_experience_required", "min_hours_required", "work_environment", + "meeting_times", "skills_required_matrix", "status", "created_by", @@ -71,49 +72,26 @@ class OpportunityWriteSerializer(serializers.ModelSerializer): Used by: - `OpportunityViewSet.create` (`POST /api/opportunities/`). - `OpportunityViewSet.update` / `partial_update` - (`PUT/PATCH /api/opportunities//`). Note: PATCH is currently - 403'd by `OpportunityPermission` (no PATCH branch); flagged for - fix in `ctj_api.permissions`. + (`PUT/PATCH /api/opportunities//`). """ class Meta: model = Opportunity fields = [ - "project", + "project_name", "role", + "overview", "body", + "responsibilities", "min_experience_required", "min_hours_required", "work_environment", + "meeting_times", "skills_required_matrix", "status", ] -class SkillMatrixSerializer(serializers.ModelSerializer): - """Read/write serializer for `SkillMatrix` records. - - Note: defined but never imported. Not split into Read/Write - because the class is currently dead code. - The cleanup PR drops it. If `SkillMatrix` becomes API-exposed - later, replace this with `SkillMatrixReadSerializer` (and - `SkillMatrixWriteSerializer` if a write endpoint is added). - Deferred out of this shape-only PR. - - Used by: - - (none currently). - """ - - class Meta: - model = SkillMatrix - fields = [ - "id", - "skill_matrix", - "created_at", - "updated_at", - ] - - class CommunityOfPracticeReadSerializer(serializers.ModelSerializer): """Read serializer for `CommunityOfPractice` records. @@ -144,7 +122,13 @@ class RoleReadSerializer(serializers.ModelSerializer): class Meta: model = Role - fields = ["id", "title", "community_of_practice", "created_at", "updated_at"] + fields = [ + "id", + "title", + "community_of_practice", + "created_at", + "updated_at", + ] class SkillReadSerializer(serializers.ModelSerializer): @@ -158,23 +142,3 @@ class SkillReadSerializer(serializers.ModelSerializer): class Meta: model = Skill fields = ["id", "name", "communities_of_practice", "created_at", "updated_at"] - - -class ProjectReadSerializer(serializers.ModelSerializer): - """Read serializer for `Project` records. - - Used by: - - `project_list` FBV (`GET /api/projects/`). - - `project_detail` FBV (`GET /api/projects//`). - """ - - class Meta: - model = Project - fields = [ - "id", - "people_depot_project_id", - "name", - "meeting_times", - "created_at", - "updated_at", - ] diff --git a/backend/ctj_api/tests/common.py b/backend/ctj_api/tests/common.py index 270dda3f..2f53442e 100644 --- a/backend/ctj_api/tests/common.py +++ b/backend/ctj_api/tests/common.py @@ -4,8 +4,8 @@ callers can override via keyword arguments. The helpers do not return shared state - each call creates a new row. Callers wire factories together explicitly when they need relationships (e.g. an `Opportunity` -needs a `Project`, a `Role`, and a `created_by` user; the test's -`setUp` creates the dependencies and passes them in). +needs a `Role` and a `created_by` user; the test's `setUp` creates the +dependencies and passes them in - the project is now plain free-text). User factories (`make_pm_user`, `make_regular_user`) live in `accounts.tests.common` since the `CustomUser` model lives in the @@ -18,7 +18,6 @@ from ctj_api.models import ( CommunityOfPractice, Opportunity, - Project, Role, Skill, ) @@ -36,9 +35,16 @@ def make_cop( ) -def make_role(*, title: str = "Developer", cop: CommunityOfPractice) -> Role: +def make_role( + *, + title: str = "Developer", + cop: CommunityOfPractice, +) -> Role: """Create a Role row anchored to the given CoP.""" - return Role.objects.create(title=title, community_of_practice=cop) + return Role.objects.create( + title=title, + community_of_practice=cop, + ) def make_skill(*, name: str = "Python") -> Skill: @@ -46,43 +52,38 @@ def make_skill(*, name: str = "Python") -> Skill: return Skill.objects.create(name=name) -def make_project( - *, - name: str = "Civic Tech Jobs", - people_depot_project_id: str = "1234-abcd", -) -> Project: - """Create a Project row.""" - return Project.objects.create( - name=name, - people_depot_project_id=people_depot_project_id, - ) - - def make_opportunity( *, - project: Project, role: Role, created_by: CustomUser, + project_name: str = "Civic Tech Jobs", + overview: str = "", body: str = "Test opportunity", + responsibilities: str = "", min_experience_required: str = "junior", min_hours_required: int = 10, work_environment: str = "remote", + meeting_times: list | None = None, status: str = "open", ) -> Opportunity: """Create an Opportunity row. - Caller supplies the FK rows (project, role, created_by) explicitly - because tests typically want to assert against a specific PM user - or project; default-constructing them inside the helper would - obscure those references. + Caller supplies the FK rows (role, created_by) explicitly because + tests typically want to assert against a specific PM user; the + project is now plain free-text (`project_name`). Tests that exercise + card rendering should pass explicit `overview` / `responsibilities` + / `meeting_times`. """ return Opportunity.objects.create( - project=project, role=role, + project_name=project_name, + overview=overview, body=body, + responsibilities=responsibilities, min_experience_required=min_experience_required, min_hours_required=min_hours_required, work_environment=work_environment, + meeting_times=meeting_times, status=status, created_by=created_by, ) diff --git a/backend/ctj_api/tests/test_opportunities.py b/backend/ctj_api/tests/test_opportunities.py index bd37c6f2..3b16bdd2 100644 --- a/backend/ctj_api/tests/test_opportunities.py +++ b/backend/ctj_api/tests/test_opportunities.py @@ -7,7 +7,6 @@ from ctj_api.tests.common import ( make_cop, make_opportunity, - make_project, make_role, ) @@ -15,36 +14,135 @@ class OpportunityTests(APITestCase): """`OpportunityViewSet` behavior at `/api/opportunities/`. - Reads are public; mutations are gated by `OpportunityPermission`. - Only PMs can create; only the creator can update; any PM can - delete. PATCH is currently always 403'd (flagged for fix in - `ctj_api.permissions`). + Sign-in is required for everything (no public listings). Reads + need authentication; mutations are further gated by + `OpportunityPermission`: only PMs can create; only the creator can + update (PUT/PATCH); any PM can delete (broader than update so PMs + can co-moderate abandoned listings). """ def setUp(self): self.client = APIClient() self.pm_user = make_pm_user() + self.other_pm_user = make_pm_user( + username="other_pm", + email="other_pm@example.com", + people_depot_user_id="other_pm_pd_id", + ) self.regular_user = make_regular_user() self.cop = make_cop() self.role = make_role(cop=self.cop) - self.project = make_project() self.opportunity = make_opportunity( - project=self.project, role=self.role, created_by=self.pm_user, ) - def test_anonymous_can_list_opportunities(self): - """Opportunity list is publicly readable (no auth required).""" + # -- Read -------------------------------------------------------- + + def test_anonymous_cannot_list_opportunities(self): + """Anonymous users are rejected: there are no public listings.""" + response = self.client.get("/api/opportunities/") + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) + + def test_authenticated_user_can_list_opportunities(self): + """Any signed-in user (PM or not) can read the listing.""" + self.client.force_authenticate(user=self.regular_user) response = self.client.get("/api/opportunities/") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertGreater(len(response.data), 0) + def test_list_exposes_card_fields(self): + """The reshaped card content (`project_name`, `overview`, + `responsibilities`, `meeting_times`) is part of the read shape.""" + self.client.force_authenticate(user=self.pm_user) + opp = make_opportunity( + role=self.role, + created_by=self.pm_user, + project_name="Tabler", + overview="Builds the platform.", + responsibilities="Code, review, deploy.", + meeting_times=[ + {"team": "Dev", "day": "Wed", "start": "12:30", "end": "13:30"} + ], + ) + response = self.client.get(f"/api/opportunities/{opp.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["project_name"], "Tabler") + self.assertEqual(response.data["overview"], "Builds the platform.") + self.assertEqual(response.data["responsibilities"], "Code, review, deploy.") + self.assertEqual(response.data["meeting_times"][0]["day"], "Wed") + + def test_detail_returns_404_for_missing_id(self): + """GET on an unknown opportunity UUID returns 404 (when signed in).""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.get( + "/api/opportunities/00000000-0000-0000-0000-000000000000/" + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # -- Create ------------------------------------------------------ + + def test_pm_can_create_opportunity(self): + """PMs can POST a new opportunity; the response carries the new row.""" + self.client.force_authenticate(user=self.pm_user) + payload = { + "project_name": "Tabler", + "role": str(self.role.id), + "body": "Backend engineer for the Tabler project.", + "min_experience_required": "mid-level", + "min_hours_required": 8, + "work_environment": "remote", + "status": "open", + } + response = self.client.post("/api/opportunities/", payload) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["body"], payload["body"]) + self.assertEqual(response.data["project_name"], "Tabler") + + def test_create_without_status_defaults_to_draft(self): + """Omitting `status` on POST yields `status="draft"` (the model default).""" + self.client.force_authenticate(user=self.pm_user) + payload = { + "project_name": "Food Oasis", + "role": str(self.role.id), + "body": "Draft-by-default check.", + "min_hours_required": 5, + "work_environment": "hybrid", + } + response = self.client.post("/api/opportunities/", payload) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "draft") + + def test_create_on_hold_status_accepted(self): + """`status="on_hold"` (underscored) is accepted; the literal-space + legacy value was renamed in migration 0002.""" + self.client.force_authenticate(user=self.pm_user) + payload = { + "project_name": "Food Oasis", + "role": str(self.role.id), + "body": "On-hold posting.", + "min_hours_required": 5, + "work_environment": "in_person", + "status": "on_hold", + } + response = self.client.post("/api/opportunities/", payload) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "on_hold") + + def test_create_validates_required_fields(self): + """Missing required fields (role, body, etc.) return 400.""" + self.client.force_authenticate(user=self.pm_user) + response = self.client.post("/api/opportunities/", {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_regular_user_cannot_create_opportunity(self): """Non-PM users get 403 on POST /api/opportunities/.""" self.client.force_authenticate(user=self.regular_user) payload = { - "project": str(self.project.id), + "project_name": "Tabler", "role": str(self.role.id), "body": "Unauthorized create attempt", "min_experience_required": "senior", @@ -54,3 +152,77 @@ def test_regular_user_cannot_create_opportunity(self): } response = self.client.post("/api/opportunities/", payload) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # -- Update (PUT / PATCH) ---------------------------------------- + + def test_creator_can_put_update(self): + """The creating PM can PUT a full update to their own opportunity.""" + self.client.force_authenticate(user=self.pm_user) + payload = { + "project_name": "Tabler", + "role": str(self.role.id), + "body": "Updated description.", + "min_experience_required": "senior", + "min_hours_required": 12, + "work_environment": "hybrid", + "status": "open", + } + response = self.client.put( + f"/api/opportunities/{self.opportunity.id}/", payload + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["body"], "Updated description.") + + def test_creator_can_patch_update(self): + """The creating PM can PATCH a partial update (regression for the + previous always-403 PATCH gap in `OpportunityPermission`).""" + self.client.force_authenticate(user=self.pm_user) + response = self.client.patch( + f"/api/opportunities/{self.opportunity.id}/", + {"body": "Patched body only."}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.opportunity.refresh_from_db() + self.assertEqual(self.opportunity.body, "Patched body only.") + + def test_non_creator_pm_cannot_put_update(self): + """A PM who didn't create the opportunity cannot PUT it.""" + self.client.force_authenticate(user=self.other_pm_user) + payload = { + "project_name": "Tabler", + "role": str(self.role.id), + "body": "Hostile takeover.", + "min_experience_required": "senior", + "min_hours_required": 5, + "work_environment": "remote", + "status": "open", + } + response = self.client.put( + f"/api/opportunities/{self.opportunity.id}/", payload + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_non_creator_pm_cannot_patch_update(self): + """A PM who didn't create the opportunity cannot PATCH it either.""" + self.client.force_authenticate(user=self.other_pm_user) + response = self.client.patch( + f"/api/opportunities/{self.opportunity.id}/", + {"body": "Hostile partial takeover."}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # -- Delete ------------------------------------------------------ + + def test_any_pm_can_delete_others_opportunity(self): + """A PM who didn't create the opportunity can still DELETE it + (deletion is intentionally broader than update so PMs can + co-moderate abandoned listings).""" + self.client.force_authenticate(user=self.other_pm_user) + response = self.client.delete(f"/api/opportunities/{self.opportunity.id}/") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_regular_user_cannot_delete_opportunity(self): + """Non-PM users get 403 on DELETE.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.delete(f"/api/opportunities/{self.opportunity.id}/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/ctj_api/tests/test_projects.py b/backend/ctj_api/tests/test_projects.py deleted file mode 100644 index 6de0c9f3..00000000 --- a/backend/ctj_api/tests/test_projects.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Tests for `/api/projects/` (read-only catalog).""" - -from rest_framework import status -from rest_framework.test import APITestCase - -from ctj_api.tests.common import make_project - - -class ProjectReadTests(APITestCase): - """Read-only catalog endpoints for `Project`.""" - - def setUp(self): - self.project = make_project() - - def test_list_returns_all_projects(self): - """GET on the Project list returns 200 with all rows serialized.""" - response = self.client.get("/api/projects/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]["name"], "Civic Tech Jobs") diff --git a/backend/ctj_api/urls.py b/backend/ctj_api/urls.py index d6fbd818..f5b84e83 100644 --- a/backend/ctj_api/urls.py +++ b/backend/ctj_api/urls.py @@ -6,7 +6,7 @@ function-based view (see `ctj_api.views` for the shape rule): - `healthcheck`: the liveness endpoint. -- `communities-of-practice/`, `roles/`, `skills/`, `projects/`: +- `communities-of-practice/`, `roles/`, `skills/`: list + detail FBV pairs for read-only catalog resources. The user detail endpoint (`users//`) lives in `accounts.urls` @@ -41,8 +41,6 @@ path("roles//", views.role_detail), path("skills/", views.skill_list), path("skills//", views.skill_detail), - path("projects/", views.project_list), - path("projects//", views.project_detail), re_path(r"^", include(router.urls)), # Catch-all for incorrect API routes re_path(r"^.*$", views.api_not_found), diff --git a/backend/ctj_api/views.py b/backend/ctj_api/views.py index 4d0d1216..e7287be3 100644 --- a/backend/ctj_api/views.py +++ b/backend/ctj_api/views.py @@ -1,16 +1,17 @@ """DRF views for CTJ's domain endpoints, mounted under `/api/`. This module hosts the read/write endpoints for the recruitment -catalog: opportunities, projects, roles, the skill catalog, and -the community-of-practice taxonomy. It also provides the -unauthenticated `healthcheck` endpoint and a JSON-shaped catch-all -404 handler for unknown `/api/*` paths. The per-user detail -endpoint lives in `accounts.views`. +catalog: opportunities, roles, the skill catalog, and the +community-of-practice taxonomy. It also provides the unauthenticated +`healthcheck` endpoint and a JSON-shaped catch-all 404 handler for +unknown `/api/*` paths. The per-user detail endpoint lives in +`accounts.views`. Routing for these views lives in `ctj_api.urls` (mounted at `/api/` from `backend.urls`); permission classes live in -`ctj_api.permissions`. Most read endpoints are public; mutations -are gated per-view by DRF permission classes. +`ctj_api.permissions`. The opportunities endpoint requires sign-in +for all methods (no public listings); the read-only catalog FBVs +(roles, skills, communities-of-practice) remain public. View shape convention: - Full-CRUD resources (>=4 actions of list/create/retrieve/update/ @@ -34,7 +35,6 @@ from ctj_api.models import ( CommunityOfPractice, Opportunity, - Project, Role, Skill, ) @@ -43,7 +43,6 @@ CommunityOfPracticeReadSerializer, OpportunityReadSerializer, OpportunityWriteSerializer, - ProjectReadSerializer, RoleReadSerializer, SkillReadSerializer, ) @@ -133,9 +132,11 @@ class OpportunityViewSet(viewsets.ModelViewSet): """ Summary: - Full-CRUD endpoint for the `Opportunity` recruitment catalog. - - Reads are public; mutations are gated by `OpportunityPermission` - (in `ctj_api.permissions`): only project managers can create, - only the creator can update, and any PM can delete. + - Sign-in required for everything (no public listings). Reads need + authentication; mutations are further gated by + `OpportunityPermission` (in `ctj_api.permissions`): only project + managers can create, only the creator can update, and any PM can + delete. - Kept as a `ModelViewSet` because the view exposes the full CRUD surface; narrower views use FBVs. @@ -157,23 +158,22 @@ class OpportunityViewSet(viewsets.ModelViewSet): - DELETE / delete Auth: - - IsAuthenticatedOrReadOnly + OpportunityPermission + - IsAuthenticated + OpportunityPermission Errors: - 400: Validation error on create/update (`OpportunityWriteSerializer` rejected the payload). - - 401: Unauthenticated mutation. + - 401: Unauthenticated request (reads and writes both require + sign-in). - 403: `OpportunityPermission` denied (e.g. non-PM trying to - create, non-creator trying to update). Note: PATCH is always - 403'd due to a gap in `OpportunityPermission` (no PATCH branch); - flagged for fix in `ctj_api.permissions`. + create, non-creator trying to update or partial-update). - 404: No opportunity exists with the given ID. """ queryset = Opportunity.objects.all() serializer_class = OpportunityReadSerializer permission_classes = ( - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, OpportunityPermission, ) @@ -351,56 +351,3 @@ def skill_detail(request, pk): skill = get_object_or_404(Skill, pk=pk) serializer = SkillReadSerializer(skill) return Response(serializer.data) - - -@api_view(["GET"]) -@permission_classes([permissions.AllowAny]) -def project_list(request): - """ - Summary: - - Public list of the `Project` table. - - Stage 1: admins curate the project list through Django admin - (`/admin/`). - - Stage 2: the project source moves to PeopleDepot. - - Flow: - 1. Fetch all `Project` rows. - 2. Serialize via `ProjectReadSerializer` and return 200. - - URL: - - GET /api/projects/ - - Auth: - - Public (`AllowAny`). - - Errors: - - (none) - """ - projects = Project.objects.all() - serializer = ProjectReadSerializer(projects, many=True) - return Response(serializer.data) - - -@api_view(["GET"]) -@permission_classes([permissions.AllowAny]) -def project_detail(request, pk): - """ - Summary: - - Public retrieval of a single `Project` row by UUID. - - Flow: - 1. Look up the row by primary-key UUID. - 2. Serialize via `ProjectReadSerializer` and return 200. - - URL: - - GET /api/projects// - - Auth: - - Public (`AllowAny`). - - Errors: - - 404: No project exists with the given UUID. - """ - project = get_object_or_404(Project, pk=pk) - serializer = ProjectReadSerializer(project) - return Response(serializer.data) diff --git a/frontend/src/app/(with-nav)/opportunities/page.tsx b/frontend/src/app/(with-nav)/opportunities/page.tsx new file mode 100644 index 00000000..3a14856f --- /dev/null +++ b/frontend/src/app/(with-nav)/opportunities/page.tsx @@ -0,0 +1,15 @@ +/** + * Opportunity listing route at `/opportunities`. + * + * Renders inside the `(with-nav)` route group's layout. Defers to the + * `OpportunityListPage` feature component, which renders the + * Opportunity listing card. Live data fetching will replace the + * sample source once the auth-gated `/api/opportunities/` client + * lands. + */ + +import { OpportunityListPage } from "@/features/opportunities/components/OpportunityListPage"; + +export default function Page() { + return ; +} diff --git a/frontend/src/features/opportunities/components/OpportunityCard.module.css b/frontend/src/features/opportunities/components/OpportunityCard.module.css new file mode 100644 index 00000000..e2a9f85d --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityCard.module.css @@ -0,0 +1,157 @@ +.card { + display: flex; + gap: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-grey); + border-radius: 16px; + background-color: var(--color-white); + box-shadow: 0 10px 30px -15px rgb(0 0 0 / 25%); +} + +/* Left: prose column */ +.main { + flex: 1 1 0; + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; +} + +.titleBlock { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.role { + color: var(--color-charcoal); +} + +.experience { + font-weight: var(--weight-bold); + color: var(--color-charcoal); + text-transform: capitalize; +} + +.metaRow { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-top: 4px; +} + +.metaChip { + padding: 4px 12px; + border-radius: var(--radius-sm); + background-color: var(--color-tan-light); + color: var(--color-charcoal); + font-size: 0.85rem; +} + +.section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sectionLabel { + font-weight: var(--weight-bold); + color: var(--color-charcoal); +} + +/* Right: metadata sidebar */ +.sidebar { + flex: 0 0 240px; + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-left: var(--space-4); + border-left: 1px solid var(--color-grey-light); +} + +.sidebarHead { + display: flex; + flex-direction: column; + gap: 4px; +} + +.project { + font-weight: var(--weight-bold); + color: var(--color-charcoal); +} + +.posted { + color: var(--color-grey-dark); +} + +.statusBadge { + align-self: flex-start; + padding: 2px 12px; + border-radius: var(--radius-xlarge); + font-size: 0.8rem; + font-weight: var(--weight-medium); + white-space: nowrap; +} + +.statusOpen { + background-color: var(--color-green); + color: var(--color-white); +} + +.statusOnHold { + background-color: var(--color-tan); + color: var(--color-charcoal); +} + +.statusFilled { + background-color: var(--color-blue); + color: var(--color-white); +} + +.statusClosed, +.statusDraft { + background-color: var(--color-grey-light); + color: var(--color-grey-dark); +} + +.sidebarSection { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.sidebarLabel { + font-weight: var(--weight-bold); + color: var(--color-charcoal); +} + +.meetingList { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.meeting { + display: flex; + flex-direction: column; +} + +.meetingTeam { + font-weight: var(--weight-bold); + color: var(--color-grey-dark); +} + +/* Stack the sidebar under the prose on narrow viewports (md: 769px). */ +@media (width <= 768px) { + .card { + flex-direction: column; + } + + .sidebar { + flex-basis: auto; + padding-left: 0; + padding-top: var(--space-3); + border-left: none; + border-top: 1px solid var(--color-grey-light); + } +} diff --git a/frontend/src/features/opportunities/components/OpportunityCard.tsx b/frontend/src/features/opportunities/components/OpportunityCard.tsx new file mode 100644 index 00000000..22cd0da4 --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityCard.tsx @@ -0,0 +1,168 @@ +/** + * Presentational card for a single Opportunity, used by the + * `/opportunities` listing page to preview the reshaped Opportunity + * shape. + * + * Two-column layout matching the Figma target: a prose column on the + * left ("About the Project" / "Role Overview" / "Responsibilities & + * Requirements") and a metadata sidebar on the right (project name + + * posted date, grouped meeting times, and the condensed skills list). + * + * Departures from the legacy Figma, per Ryan's clarification (`blah.`): + * - No "Program Area" chip (concept deleted). + * - Skills are one flat list with no Tech/Languages split and no + * ratings (ratings are intentionally never surfaced). + * - No project logo (the project is now a free-text name, no image). + * - No login banner / "create account" CTA (sign-in is required). + */ + +import Typography from "@/shared/components/Typography"; +import { cn } from "@/shared/lib/utils"; + +import styles from "./OpportunityCard.module.css"; + +import type { + Opportunity, + OpportunityStatus, + WorkEnvironment, +} from "@/features/opportunities/data/sampleOpportunities"; + +const WORK_ENVIRONMENT_LABELS: Record = { + remote: "Remote", + hybrid: "Hybrid", + in_person: "In Person", +}; + +const STATUS_LABELS: Record = { + open: "Open", + closed: "Closed", + on_hold: "On hold", + filled: "Filled", + draft: "Draft", +}; + +// Maps each status to a CSS-module class so the badge color tracks state. +const STATUS_CLASSES: Record = { + open: styles.statusOpen, + closed: styles.statusClosed, + on_hold: styles.statusOnHold, + filled: styles.statusFilled, + draft: styles.statusDraft, +}; + +function OpportunityCard({ opportunity }: { opportunity: Opportunity }) { + const { + project_name, + role_title, + overview, + body, + responsibilities, + min_experience_required, + min_hours_required, + work_environment, + meeting_times, + skills, + status, + posted, + } = opportunity; + + return ( +
+ {/* Left: prose column */} +
+
+ + {role_title} + + {min_experience_required ? ( + + {min_experience_required} + + ) : null} +
    +
  • {min_hours_required} hrs/week
  • +
  • + {WORK_ENVIRONMENT_LABELS[work_environment]} +
  • +
+
+ + {body ? ( +
+ + About the Project + + {body} +
+ ) : null} + + {overview ? ( +
+ + Role Overview + + {overview} +
+ ) : null} + + {responsibilities ? ( +
+ + Responsibilities & Requirements + + {responsibilities} +
+ ) : null} +
+ + {/* Right: metadata sidebar */} + +
+ ); +} + +export { OpportunityCard }; diff --git a/frontend/src/features/opportunities/components/OpportunityListPage.module.css b/frontend/src/features/opportunities/components/OpportunityListPage.module.css new file mode 100644 index 00000000..b3f313d1 --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityListPage.module.css @@ -0,0 +1,24 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: var(--space-6) var(--space-3) var(--space-10); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.intro { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.subtitle { + color: var(--color-grey-dark); +} + +.list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} diff --git a/frontend/src/features/opportunities/components/OpportunityListPage.tsx b/frontend/src/features/opportunities/components/OpportunityListPage.tsx new file mode 100644 index 00000000..b8e18050 --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityListPage.tsx @@ -0,0 +1,38 @@ +/** + * Top-level component for the `/opportunities` listing page. + * + * Currently renders the reshaped Opportunity card against static + * sample data (see `sampleOpportunities`). The sample source will be + * swapped for a live fetch of the auth-gated `/api/opportunities/` + * endpoint once the API client lands. + * + * Mounted by `/opportunities` in the `(with-nav)` route group. + */ + +import { sampleOpportunities } from "@/features/opportunities/data/sampleOpportunities"; +import Typography from "@/shared/components/Typography"; + +import { OpportunityCard } from "./OpportunityCard"; +import styles from "./OpportunityListPage.module.css"; + +function OpportunityListPage() { + return ( +
+
+ Opportunities + + Listing preview — static sample data. In the app this surface + requires sign-in (no public listings). + +
+ +
+ {sampleOpportunities.map((opportunity) => ( + + ))} +
+
+ ); +} + +export { OpportunityListPage }; diff --git a/frontend/src/features/opportunities/data/sampleOpportunities.ts b/frontend/src/features/opportunities/data/sampleOpportunities.ts new file mode 100644 index 00000000..77d01950 --- /dev/null +++ b/frontend/src/features/opportunities/data/sampleOpportunities.ts @@ -0,0 +1,112 @@ +/** + * Static sample data for the `/opportunities` listing page. + * + * Shapes a handful of opportunities the way the reshaped + * `OpportunityReadSerializer` returns them (project as free-text + * `project_name`, card content `overview` / `body` / + * `responsibilities` owned by the opportunity, `meeting_times` JSON, + * status enum). Two display-only conveniences are inlined that the + * real wire shape returns as opaque references: `role_title` (the API + * returns `role` as a UUID) and `skills` (the API returns + * `skills_required_matrix` as a UUID; matrix *ratings* are + * intentionally never surfaced, so only skill names appear here). + * + * This page renders mock data on purpose: the live + * `/api/opportunities/` endpoint now requires sign-in, so a static + * page is the quickest way to eyeball the card without auth plumbing. + */ + +export type OpportunityStatus = + | "open" + | "closed" + | "on_hold" + | "filled" + | "draft"; + +export type WorkEnvironment = "remote" | "hybrid" | "in_person"; + +interface MeetingSlot { + team: string; + day: string; + start: string; // "HH:MM" + end: string; // "HH:MM" +} + +export interface Opportunity { + id: string; + project_name: string; + role_title: string; + overview: string; + body: string; + responsibilities: string; + min_experience_required: string; + min_hours_required: number; + work_environment: WorkEnvironment; + meeting_times: MeetingSlot[]; + skills: string[]; + status: OpportunityStatus; + // Display-only: the real serializer returns `created_at` (ISO); + // pre-formatted here for the preview ("Posted: ..." in the sidebar). + posted: string; +} + +export const sampleOpportunities: Opportunity[] = [ + { + id: "11111111-1111-1111-1111-111111111111", + project_name: "Food Oasis", + role_title: "Backend Engineer", + overview: + "Backend engineers build and maintain the APIs and data models that power the platform's core flows.", + body: "We're looking for a backend engineer to extend our Django REST API: new endpoints for the food-resource catalog, query performance work, and test coverage.", + responsibilities: + "Design and implement REST endpoints, write tests, review pull requests, and pair with frontend on contract design.", + min_experience_required: "mid-level", + min_hours_required: 10, + work_environment: "remote", + meeting_times: [ + { team: "Dev Team", day: "Wed", start: "18:00", end: "19:00" }, + { team: "All Hands", day: "Sun", start: "10:00", end: "11:00" }, + ], + skills: ["Python", "Django", "PostgreSQL", "REST APIs"], + status: "open", + posted: "May 12, 2026", + }, + { + id: "22222222-2222-2222-2222-222222222222", + project_name: "Tabler", + role_title: "Product Designer", + overview: + "Product designers own the end-to-end experience: research, flows, wireframes, and high-fidelity UI.", + body: "Help redesign the onboarding flow. You'll run lightweight research, produce Figma prototypes, and hand off specs to engineering.", + responsibilities: + "Lead design reviews, maintain the Figma source of truth, and validate designs with real volunteers.", + min_experience_required: "senior", + min_hours_required: 8, + work_environment: "hybrid", + meeting_times: [ + { team: "Design Team", day: "Tue", start: "17:30", end: "18:30" }, + ], + skills: ["Figma", "User Research", "Prototyping"], + status: "open", + posted: "May 28, 2026", + }, + { + id: "33333333-3333-3333-3333-333333333333", + project_name: "Civic Tech Jobs", + role_title: "Frontend Engineer", + overview: + "Frontend engineers build the React/Next.js interfaces volunteers interact with day to day.", + body: "Build out the opportunity browse and detail surfaces in Next.js with CSS Modules, wiring them to the API client.", + responsibilities: + "Ship accessible, responsive components; write component tests; keep the design system consistent.", + min_experience_required: "junior", + min_hours_required: 6, + work_environment: "remote", + meeting_times: [ + { team: "Dev Team", day: "Thu", start: "19:00", end: "20:00" }, + ], + skills: ["TypeScript", "React", "Next.js", "CSS"], + status: "on_hold", + posted: "Apr 30, 2026", + }, +]; From 906bea7b2c1c5ab672804742b7e8a5c83f92bcd1 Mon Sep 17 00:00:00 2001 From: Nickatak Date: Thu, 18 Jun 2026 07:00:59 -0700 Subject: [PATCH 2/8] feat: Add filter bar to /opportunities (Availability, Project, Skills) 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) --- .../components/OpportunityFilters.module.css | 52 +++++++++ .../components/OpportunityFilters.tsx | 101 ++++++++++++++++++ .../components/OpportunityListPage.module.css | 6 ++ .../components/OpportunityListPage.tsx | 60 +++++++++-- .../opportunities/data/sampleCurrentUser.ts | 32 ++++++ .../src/features/opportunities/lib/filters.ts | 98 +++++++++++++++++ 6 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 frontend/src/features/opportunities/components/OpportunityFilters.module.css create mode 100644 frontend/src/features/opportunities/components/OpportunityFilters.tsx create mode 100644 frontend/src/features/opportunities/data/sampleCurrentUser.ts create mode 100644 frontend/src/features/opportunities/lib/filters.ts diff --git a/frontend/src/features/opportunities/components/OpportunityFilters.module.css b/frontend/src/features/opportunities/components/OpportunityFilters.module.css new file mode 100644 index 00000000..60063fa6 --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityFilters.module.css @@ -0,0 +1,52 @@ +.bar { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + padding: var(--space-3); + background-color: var(--color-tan-light); + border-radius: var(--radius-sm); + border: 1px solid var(--color-grey-light); +} + +.group { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 200px; + border: 0; + padding: 0; + margin: 0; +} + +.groupLabel { + color: var(--color-grey-dark); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.textInput { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-grey); + border-radius: var(--radius-sm); + background-color: var(--color-white); + font: inherit; + color: var(--color-charcoal); +} + +.textInput:focus { + outline: 2px solid var(--color-blue); + outline-offset: 1px; +} + +.radioRow { + display: flex; + gap: var(--space-3); +} + +.radioLabel { + display: inline-flex; + align-items: center; + gap: var(--space-1); + color: var(--color-charcoal); + cursor: pointer; +} diff --git a/frontend/src/features/opportunities/components/OpportunityFilters.tsx b/frontend/src/features/opportunities/components/OpportunityFilters.tsx new file mode 100644 index 00000000..376f4d74 --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityFilters.tsx @@ -0,0 +1,101 @@ +/** + * Filter bar for the `/opportunities` listing page. + * + * Three controls per the spec (`blah.`): + * - Availability: ON/OFF checkbox; ON keeps opportunities the user + * can attend at least one meeting of. + * - Project: free-text substring filter against `project_name`. + * - Skills: two-state radio (OFF / Partial Match, ≥30% overlap). + * + * Controlled component - the parent owns the filter state and the + * filtering itself. This file is presentational + binding only. + */ + +"use client"; + +import { Checkbox } from "@/shared/components/Checkbox"; +import Typography from "@/shared/components/Typography"; + +import styles from "./OpportunityFilters.module.css"; + +import type { + OpportunityFilterState, + SkillsFilterMode, +} from "@/features/opportunities/lib/filters"; + +interface OpportunityFiltersProps { + filters: OpportunityFilterState; + onAvailabilityChange: (value: boolean) => void; + onProjectQueryChange: (value: string) => void; + onSkillsModeChange: (value: SkillsFilterMode) => void; +} + +function OpportunityFilters({ + filters, + onAvailabilityChange, + onProjectQueryChange, + onSkillsModeChange, +}: OpportunityFiltersProps) { + return ( +
+
+ + Availability + + onAvailabilityChange(event.target.checked)} + /> +
+ +
+ + onProjectQueryChange(event.target.value)} + /> +
+ +
+ + + Skills + + +
+ + +
+
+
+ ); +} + +export { OpportunityFilters }; diff --git a/frontend/src/features/opportunities/components/OpportunityListPage.module.css b/frontend/src/features/opportunities/components/OpportunityListPage.module.css index b3f313d1..a9fec361 100644 --- a/frontend/src/features/opportunities/components/OpportunityListPage.module.css +++ b/frontend/src/features/opportunities/components/OpportunityListPage.module.css @@ -22,3 +22,9 @@ flex-direction: column; gap: var(--space-3); } + +.empty { + color: var(--color-grey-dark); + text-align: center; + padding: var(--space-4) 0; +} diff --git a/frontend/src/features/opportunities/components/OpportunityListPage.tsx b/frontend/src/features/opportunities/components/OpportunityListPage.tsx index b8e18050..d8b7a18a 100644 --- a/frontend/src/features/opportunities/components/OpportunityListPage.tsx +++ b/frontend/src/features/opportunities/components/OpportunityListPage.tsx @@ -2,20 +2,49 @@ * Top-level component for the `/opportunities` listing page. * * Currently renders the reshaped Opportunity card against static - * sample data (see `sampleOpportunities`). The sample source will be - * swapped for a live fetch of the auth-gated `/api/opportunities/` - * endpoint once the API client lands. + * sample data (see `sampleOpportunities`) and a stand-in current user + * (see `sampleCurrentUser`) that the Availability and Skills filters + * compare against. The sample sources will be swapped for a live + * fetch of the auth-gated `/api/opportunities/` endpoint and the + * signed-in user once the API client lands. * * Mounted by `/opportunities` in the `(with-nav)` route group. */ +"use client"; + +import { useMemo, useState } from "react"; + +import { sampleCurrentUser } from "@/features/opportunities/data/sampleCurrentUser"; import { sampleOpportunities } from "@/features/opportunities/data/sampleOpportunities"; +import { applyOpportunityFilters } from "@/features/opportunities/lib/filters"; import Typography from "@/shared/components/Typography"; import { OpportunityCard } from "./OpportunityCard"; +import { OpportunityFilters } from "./OpportunityFilters"; import styles from "./OpportunityListPage.module.css"; +import type { + OpportunityFilterState, + SkillsFilterMode, +} from "@/features/opportunities/lib/filters"; + +const INITIAL_FILTERS: OpportunityFilterState = { + availability: false, + projectQuery: "", + skillsMode: "off", +}; + function OpportunityListPage() { + const [filters, setFilters] = + useState(INITIAL_FILTERS); + + const visibleOpportunities = useMemo( + () => + applyOpportunityFilters(sampleOpportunities, sampleCurrentUser, filters), + [filters], + ); + return (
@@ -26,10 +55,29 @@ function OpportunityListPage() {
+ + setFilters((prev) => ({ ...prev, availability })) + } + onProjectQueryChange={(projectQuery) => + setFilters((prev) => ({ ...prev, projectQuery })) + } + onSkillsModeChange={(skillsMode: SkillsFilterMode) => + setFilters((prev) => ({ ...prev, skillsMode })) + } + /> +
- {sampleOpportunities.map((opportunity) => ( - - ))} + {visibleOpportunities.length === 0 ? ( + + No opportunities match the current filters. + + ) : ( + visibleOpportunities.map((opportunity) => ( + + )) + )}
); diff --git a/frontend/src/features/opportunities/data/sampleCurrentUser.ts b/frontend/src/features/opportunities/data/sampleCurrentUser.ts new file mode 100644 index 00000000..aec27412 --- /dev/null +++ b/frontend/src/features/opportunities/data/sampleCurrentUser.ts @@ -0,0 +1,32 @@ +/** + * Static sample "current user" data used by the `/opportunities` + * listing page to drive the Availability and Skills filters while the + * page is rendering against mock data. + * + * The Availability and Skills filters compare each opportunity against + * a user-side input (the user's reachable meeting windows / known + * skill names) - in the live app those come from the signed-in user. + * Until the API client + auth wire-up land, this file stands in. When + * the swap happens, this source goes away and the filter helpers move + * to reading the same shape off the auth context. + */ + +interface AvailabilitySlot { + day: string; // e.g. "Tue"; matched case-insensitively against meeting_times.day + start: string; // "HH:MM" + end: string; // "HH:MM" +} + +export interface CurrentUser { + availability: AvailabilitySlot[]; + skills: string[]; +} + +export const sampleCurrentUser: CurrentUser = { + availability: [ + { day: "Wed", start: "17:00", end: "21:00" }, + { day: "Thu", start: "18:00", end: "20:30" }, + { day: "Sun", start: "09:00", end: "12:00" }, + ], + skills: ["TypeScript", "React", "PostgreSQL", "Figma"], +}; diff --git a/frontend/src/features/opportunities/lib/filters.ts b/frontend/src/features/opportunities/lib/filters.ts new file mode 100644 index 00000000..ceccaa71 --- /dev/null +++ b/frontend/src/features/opportunities/lib/filters.ts @@ -0,0 +1,98 @@ +/** + * Filter predicates for the `/opportunities` listing surface. + * + * Encodes the filter contract from the spec (`blah.`): + * - Availability ON/OFF. ON keeps an opportunity if the user can + * attend at least one of its meeting times. + * - Project: free-text substring match against `project_name` + * (case-insensitive, trimmed). Empty query is a no-op. + * - Skills OFF / Partial Match (30% threshold). Partial match + * keeps an opportunity if at least 30% of its required skills + * also appear in the user's skill list (case-insensitive set + * overlap; opportunities with no skills always pass to avoid + * hiding listings that haven't tagged any). + */ + +import type { CurrentUser } from "@/features/opportunities/data/sampleCurrentUser"; +import type { Opportunity } from "@/features/opportunities/data/sampleOpportunities"; + +export type SkillsFilterMode = "off" | "partial_match"; + +export interface OpportunityFilterState { + availability: boolean; + projectQuery: string; + skillsMode: SkillsFilterMode; +} + +const SKILL_PARTIAL_MATCH_THRESHOLD = 0.3; + +function timeToMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +function intervalsOverlap( + aStart: string, + aEnd: string, + bStart: string, + bEnd: string, +): boolean { + return ( + Math.max(timeToMinutes(aStart), timeToMinutes(bStart)) < + Math.min(timeToMinutes(aEnd), timeToMinutes(bEnd)) + ); +} + +function matchesAvailability( + opportunity: Opportunity, + user: CurrentUser, +): boolean { + return opportunity.meeting_times.some((slot) => + user.availability.some( + (window) => + window.day.toLowerCase() === slot.day.toLowerCase() && + intervalsOverlap(slot.start, slot.end, window.start, window.end), + ), + ); +} + +function matchesProject(opportunity: Opportunity, query: string): boolean { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return true; + return opportunity.project_name.toLowerCase().includes(trimmed); +} + +function matchesSkills( + opportunity: Opportunity, + user: CurrentUser, + mode: SkillsFilterMode, +): boolean { + if (mode === "off") return true; + // Don't hide opportunities that haven't tagged any skills - we have + // no signal to score them against, and excluding them would surprise. + if (opportunity.skills.length === 0) return true; + const userSkills = new Set(user.skills.map((s) => s.toLowerCase())); + const overlap = opportunity.skills.filter((skill) => + userSkills.has(skill.toLowerCase()), + ).length; + return overlap / opportunity.skills.length >= SKILL_PARTIAL_MATCH_THRESHOLD; +} + +export function applyOpportunityFilters( + opportunities: Opportunity[], + user: CurrentUser, + filters: OpportunityFilterState, +): Opportunity[] { + return opportunities.filter((opportunity) => { + if (filters.availability && !matchesAvailability(opportunity, user)) { + return false; + } + if (!matchesProject(opportunity, filters.projectQuery)) { + return false; + } + if (!matchesSkills(opportunity, user, filters.skillsMode)) { + return false; + } + return true; + }); +} From 44e67d9d76ab08f2fda79043563334cfa872f7cf Mon Sep 17 00:00:00 2001 From: Nickatak Date: Thu, 18 Jun 2026 08:24:22 -0700 Subject: [PATCH 3/8] feat: Restructure /opportunities filter UI to match Figma layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `
` / ``; 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) --- .../components/OpportunityFilters.module.css | 158 ++++++++++++++--- .../components/OpportunityFilters.tsx | 159 +++++++++++++----- .../components/OpportunityListPage.module.css | 40 ++++- .../components/OpportunityListPage.tsx | 72 +++++--- 4 files changed, 335 insertions(+), 94 deletions(-) diff --git a/frontend/src/features/opportunities/components/OpportunityFilters.module.css b/frontend/src/features/opportunities/components/OpportunityFilters.module.css index 60063fa6..67fda5be 100644 --- a/frontend/src/features/opportunities/components/OpportunityFilters.module.css +++ b/frontend/src/features/opportunities/components/OpportunityFilters.module.css @@ -1,27 +1,137 @@ -.bar { +.sidebar { display: flex; - flex-wrap: wrap; - gap: var(--space-4); + flex-direction: column; + gap: var(--space-2); padding: var(--space-3); - background-color: var(--color-tan-light); + background-color: var(--color-white); border-radius: var(--radius-sm); - border: 1px solid var(--color-grey-light); } -.group { +.head { display: flex; - flex-direction: column; - gap: var(--space-1); - min-width: 200px; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); +} + +.title { + margin: 0; +} + +.clearAll { + background: none; border: 0; padding: 0; + color: var(--color-charcoal); + text-decoration: underline; + cursor: pointer; + font: inherit; +} + +.clearAll:disabled { + color: var(--color-grey); + cursor: default; + text-decoration: none; +} + +.chipList { + list-style: none; margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 4px var(--space-1) 4px var(--space-2); + background-color: var(--color-blue); + color: var(--color-white); + border-radius: 999px; + font-size: 0.875rem; + line-height: 1.2; } -.groupLabel { - color: var(--color-grey-dark); - text-transform: uppercase; - letter-spacing: 0.04em; +.chipLabel { + white-space: nowrap; +} + +.chipRemove { + background: none; + border: 0; + color: var(--color-white); + font-size: 1rem; + line-height: 1; + padding: 0 4px; + cursor: pointer; + border-radius: 50%; +} + +.chipRemove:hover { + background-color: rgb(255 255 255 / 20%); +} + +.divider { + border: 0; + border-top: 1px solid var(--color-grey-light); + margin: var(--space-1) 0; + width: 100%; +} + +.section { + border-bottom: 1px solid var(--color-grey-light); +} + +.section:last-of-type { + border-bottom: 0; +} + +.sectionHead { + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) 0; + cursor: pointer; + font-weight: 600; + color: var(--color-charcoal); +} + +.sectionHead::-webkit-details-marker { + display: none; +} + +.sectionHead::after { + content: ""; + width: 8px; + height: 8px; + border-right: 2px solid var(--color-grey-dark); + border-bottom: 2px solid var(--color-grey-dark); + transform: rotate(45deg) translate(-2px, -2px); + transition: transform 150ms ease; +} + +.section[open] .sectionHead::after { + transform: rotate(-135deg) translate(-2px, -2px); +} + +.sectionBody { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: 0 0 var(--space-2); +} + +.toggleLabel, +.radioLabel { + display: flex; + align-items: center; + gap: var(--space-1); + color: var(--color-charcoal); + cursor: pointer; } .textInput { @@ -31,6 +141,7 @@ background-color: var(--color-white); font: inherit; color: var(--color-charcoal); + width: 100%; } .textInput:focus { @@ -38,15 +149,14 @@ outline-offset: 1px; } -.radioRow { - display: flex; - gap: var(--space-3); -} - -.radioLabel { - display: inline-flex; - align-items: center; - gap: var(--space-1); - color: var(--color-charcoal); - cursor: pointer; +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } diff --git a/frontend/src/features/opportunities/components/OpportunityFilters.tsx b/frontend/src/features/opportunities/components/OpportunityFilters.tsx index 376f4d74..4a933f5f 100644 --- a/frontend/src/features/opportunities/components/OpportunityFilters.tsx +++ b/frontend/src/features/opportunities/components/OpportunityFilters.tsx @@ -1,19 +1,27 @@ /** - * Filter bar for the `/opportunities` listing page. + * Filter sidebar for the `/opportunities` listing page. * - * Three controls per the spec (`blah.`): + * Layout mirrors the Figma: a sticky left rail with the "Filters (N)" + * count + "Clear all" link, a row of active-filter chips, a divider, + * then one collapsible section per filter. Sections use `
` + * for native a11y / keyboard support. + * + * Content per the spec (`blah.`) - the legacy Figma's Roles / + * Experience / Program Area / Tech / Languages sections are gone; + * Tech + Languages condensed into Skills: * - Availability: ON/OFF checkbox; ON keeps opportunities the user * can attend at least one meeting of. * - Project: free-text substring filter against `project_name`. * - Skills: two-state radio (OFF / Partial Match, ≥30% overlap). * * Controlled component - the parent owns the filter state and the - * filtering itself. This file is presentational + binding only. + * filtering itself. */ "use client"; -import { Checkbox } from "@/shared/components/Checkbox"; +import { useMemo } from "react"; + import Typography from "@/shared/components/Typography"; import styles from "./OpportunityFilters.module.css"; @@ -28,6 +36,13 @@ interface OpportunityFiltersProps { onAvailabilityChange: (value: boolean) => void; onProjectQueryChange: (value: string) => void; onSkillsModeChange: (value: SkillsFilterMode) => void; + onClearAll: () => void; +} + +interface ActiveChip { + key: string; + label: string; + clear: () => void; } function OpportunityFilters({ @@ -35,43 +50,105 @@ function OpportunityFilters({ onAvailabilityChange, onProjectQueryChange, onSkillsModeChange, + onClearAll, }: OpportunityFiltersProps) { + const activeChips = useMemo(() => { + const chips: ActiveChip[] = []; + if (filters.availability) { + chips.push({ + key: "availability", + label: "Available to me", + clear: () => onAvailabilityChange(false), + }); + } + const trimmedQuery = filters.projectQuery.trim(); + if (trimmedQuery) { + chips.push({ + key: "project", + label: `Project: ${trimmedQuery}`, + clear: () => onProjectQueryChange(""), + }); + } + if (filters.skillsMode === "partial_match") { + chips.push({ + key: "skills", + label: "Skills: Partial match (30%)", + clear: () => onSkillsModeChange("off"), + }); + } + return chips; + }, [filters, onAvailabilityChange, onProjectQueryChange, onSkillsModeChange]); + return ( -
-
- - Availability - - onAvailabilityChange(event.target.checked)} - /> -
+
+
+ ); } diff --git a/frontend/src/features/opportunities/components/OpportunityListPage.module.css b/frontend/src/features/opportunities/components/OpportunityListPage.module.css index a9fec361..0f5e4f15 100644 --- a/frontend/src/features/opportunities/components/OpportunityListPage.module.css +++ b/frontend/src/features/opportunities/components/OpportunityListPage.module.css @@ -1,5 +1,5 @@ .page { - max-width: 960px; + max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-3) var(--space-10); display: flex; @@ -17,6 +17,30 @@ color: var(--color-grey-dark); } +.layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: var(--space-5); + align-items: start; +} + +.filterRail { + position: sticky; + top: var(--space-3); + align-self: start; +} + +.results { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.resultsCount { + color: var(--color-grey-dark); + text-align: right; +} + .list { display: flex; flex-direction: column; @@ -28,3 +52,17 @@ text-align: center; padding: var(--space-4) 0; } + +@media (width <= 768px) { + .layout { + grid-template-columns: 1fr; + } + + .filterRail { + position: static; + } + + .resultsCount { + text-align: left; + } +} diff --git a/frontend/src/features/opportunities/components/OpportunityListPage.tsx b/frontend/src/features/opportunities/components/OpportunityListPage.tsx index d8b7a18a..0771eb5f 100644 --- a/frontend/src/features/opportunities/components/OpportunityListPage.tsx +++ b/frontend/src/features/opportunities/components/OpportunityListPage.tsx @@ -1,12 +1,13 @@ /** * Top-level component for the `/opportunities` listing page. * - * Currently renders the reshaped Opportunity card against static - * sample data (see `sampleOpportunities`) and a stand-in current user - * (see `sampleCurrentUser`) that the Availability and Skills filters - * compare against. The sample sources will be swapped for a live - * fetch of the auth-gated `/api/opportunities/` endpoint and the - * signed-in user once the API client lands. + * Two-column layout matching the Figma: a sticky filter sidebar on + * the left and a results column on the right (results count + card + * list). Currently renders the reshaped Opportunity card against + * static sample data (see `sampleOpportunities`) and a stand-in + * current user (see `sampleCurrentUser`) that the Availability and + * Skills filters compare against. Both sample sources go away in the + * same swap once the API client + auth wire-up land. * * Mounted by `/opportunities` in the `(with-nav)` route group. */ @@ -55,30 +56,45 @@ function OpportunityListPage() { - - setFilters((prev) => ({ ...prev, availability })) - } - onProjectQueryChange={(projectQuery) => - setFilters((prev) => ({ ...prev, projectQuery })) - } - onSkillsModeChange={(skillsMode: SkillsFilterMode) => - setFilters((prev) => ({ ...prev, skillsMode })) - } - /> +
+
+ + setFilters((prev) => ({ ...prev, availability })) + } + onProjectQueryChange={(projectQuery) => + setFilters((prev) => ({ ...prev, projectQuery })) + } + onSkillsModeChange={(skillsMode: SkillsFilterMode) => + setFilters((prev) => ({ ...prev, skillsMode })) + } + onClearAll={() => setFilters(INITIAL_FILTERS)} + /> +
-
- {visibleOpportunities.length === 0 ? ( - - No opportunities match the current filters. +
+ + {visibleOpportunities.length}{" "} + {visibleOpportunities.length === 1 ? "result" : "results"} - ) : ( - visibleOpportunities.map((opportunity) => ( - - )) - )} -
+ +
+ {visibleOpportunities.length === 0 ? ( + + No opportunities match the current filters. + + ) : ( + visibleOpportunities.map((opportunity) => ( + + )) + )} +
+
+
); } From 1f237844e681ce896a1aae17c187497f28251841 Mon Sep 17 00:00:00 2001 From: Nickatak Date: Thu, 18 Jun 2026 10:12:18 -0700 Subject: [PATCH 4/8] refactor: Promote project name into the card title row, drop status badge 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) --- .../components/OpportunityCard.module.css | 52 ++++--------------- .../components/OpportunityCard.tsx | 45 ++++------------ .../opportunities/data/sampleOpportunities.ts | 7 +-- 3 files changed, 23 insertions(+), 81 deletions(-) diff --git a/frontend/src/features/opportunities/components/OpportunityCard.module.css b/frontend/src/features/opportunities/components/OpportunityCard.module.css index e2a9f85d..6310b897 100644 --- a/frontend/src/features/opportunities/components/OpportunityCard.module.css +++ b/frontend/src/features/opportunities/components/OpportunityCard.module.css @@ -23,10 +23,21 @@ gap: var(--space-1); } +.titleRow { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px var(--space-2); +} + .role { color: var(--color-charcoal); } +.project { + color: var(--color-grey-dark); +} + .experience { font-weight: var(--weight-bold); color: var(--color-charcoal); @@ -69,51 +80,10 @@ border-left: 1px solid var(--color-grey-light); } -.sidebarHead { - display: flex; - flex-direction: column; - gap: 4px; -} - -.project { - font-weight: var(--weight-bold); - color: var(--color-charcoal); -} - .posted { color: var(--color-grey-dark); } -.statusBadge { - align-self: flex-start; - padding: 2px 12px; - border-radius: var(--radius-xlarge); - font-size: 0.8rem; - font-weight: var(--weight-medium); - white-space: nowrap; -} - -.statusOpen { - background-color: var(--color-green); - color: var(--color-white); -} - -.statusOnHold { - background-color: var(--color-tan); - color: var(--color-charcoal); -} - -.statusFilled { - background-color: var(--color-blue); - color: var(--color-white); -} - -.statusClosed, -.statusDraft { - background-color: var(--color-grey-light); - color: var(--color-grey-dark); -} - .sidebarSection { display: flex; flex-direction: column; diff --git a/frontend/src/features/opportunities/components/OpportunityCard.tsx b/frontend/src/features/opportunities/components/OpportunityCard.tsx index 22cd0da4..c7298b9f 100644 --- a/frontend/src/features/opportunities/components/OpportunityCard.tsx +++ b/frontend/src/features/opportunities/components/OpportunityCard.tsx @@ -17,13 +17,11 @@ */ import Typography from "@/shared/components/Typography"; -import { cn } from "@/shared/lib/utils"; import styles from "./OpportunityCard.module.css"; import type { Opportunity, - OpportunityStatus, WorkEnvironment, } from "@/features/opportunities/data/sampleOpportunities"; @@ -33,23 +31,6 @@ const WORK_ENVIRONMENT_LABELS: Record = { in_person: "In Person", }; -const STATUS_LABELS: Record = { - open: "Open", - closed: "Closed", - on_hold: "On hold", - filled: "Filled", - draft: "Draft", -}; - -// Maps each status to a CSS-module class so the badge color tracks state. -const STATUS_CLASSES: Record = { - open: styles.statusOpen, - closed: styles.statusClosed, - on_hold: styles.statusOnHold, - filled: styles.statusFilled, - draft: styles.statusDraft, -}; - function OpportunityCard({ opportunity }: { opportunity: Opportunity }) { const { project_name, @@ -62,7 +43,6 @@ function OpportunityCard({ opportunity }: { opportunity: Opportunity }) { work_environment, meeting_times, skills, - status, posted, } = opportunity; @@ -71,9 +51,14 @@ function OpportunityCard({ opportunity }: { opportunity: Opportunity }) { {/* Left: prose column */}
- - {role_title} - +
+ + {role_title} + + + {project_name} + +
{min_experience_required ? ( {min_experience_required} @@ -117,17 +102,9 @@ function OpportunityCard({ opportunity }: { opportunity: Opportunity }) { {/* Right: metadata sidebar */}
+ )} ); } diff --git a/frontend/src/features/opportunities/data/sampleCurrentUser.ts b/frontend/src/features/opportunities/data/sampleCurrentUser.ts deleted file mode 100644 index aec27412..00000000 --- a/frontend/src/features/opportunities/data/sampleCurrentUser.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Static sample "current user" data used by the `/opportunities` - * listing page to drive the Availability and Skills filters while the - * page is rendering against mock data. - * - * The Availability and Skills filters compare each opportunity against - * a user-side input (the user's reachable meeting windows / known - * skill names) - in the live app those come from the signed-in user. - * Until the API client + auth wire-up land, this file stands in. When - * the swap happens, this source goes away and the filter helpers move - * to reading the same shape off the auth context. - */ - -interface AvailabilitySlot { - day: string; // e.g. "Tue"; matched case-insensitively against meeting_times.day - start: string; // "HH:MM" - end: string; // "HH:MM" -} - -export interface CurrentUser { - availability: AvailabilitySlot[]; - skills: string[]; -} - -export const sampleCurrentUser: CurrentUser = { - availability: [ - { day: "Wed", start: "17:00", end: "21:00" }, - { day: "Thu", start: "18:00", end: "20:30" }, - { day: "Sun", start: "09:00", end: "12:00" }, - ], - skills: ["TypeScript", "React", "PostgreSQL", "Figma"], -}; diff --git a/frontend/src/features/opportunities/data/sampleOpportunities.ts b/frontend/src/features/opportunities/data/sampleOpportunities.ts deleted file mode 100644 index 4b3b9580..00000000 --- a/frontend/src/features/opportunities/data/sampleOpportunities.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Static sample data for the `/opportunities` listing page. - * - * Shapes a handful of opportunities the way the reshaped - * `OpportunityReadSerializer` returns them (project as free-text - * `project_name`, card content `overview` / `body` / - * `responsibilities` owned by the opportunity, `meeting_times` JSON, - * status enum). Two display-only conveniences are inlined that the - * real wire shape returns as opaque references: `role_title` (the API - * returns `role` as a UUID) and `skills` (the API returns - * `skills_required_matrix` as a UUID; matrix *ratings* are - * intentionally never surfaced, so only skill names appear here). - * - * This page renders mock data on purpose: the live - * `/api/opportunities/` endpoint now requires sign-in, so a static - * page is the quickest way to eyeball the card without auth plumbing. - */ - -type OpportunityStatus = "open" | "closed" | "on_hold" | "filled" | "draft"; - -export type WorkEnvironment = "remote" | "hybrid" | "in_person"; - -interface MeetingSlot { - team: string; - day: string; - start: string; // "HH:MM" - end: string; // "HH:MM" -} - -export interface Opportunity { - id: string; - project_name: string; - role_title: string; - overview: string; - body: string; - responsibilities: string; - min_experience_required: string; - min_hours_required: number; - work_environment: WorkEnvironment; - meeting_times: MeetingSlot[]; - skills: string[]; - status: OpportunityStatus; -} - -export const sampleOpportunities: Opportunity[] = [ - { - id: "11111111-1111-1111-1111-111111111111", - project_name: "Food Oasis", - role_title: "Backend Engineer", - overview: - "Backend engineers build and maintain the APIs and data models that power the platform's core flows.", - body: "We're looking for a backend engineer to extend our Django REST API: new endpoints for the food-resource catalog, query performance work, and test coverage.", - responsibilities: - "Design and implement REST endpoints, write tests, review pull requests, and pair with frontend on contract design.", - min_experience_required: "mid-level", - min_hours_required: 10, - work_environment: "remote", - meeting_times: [ - { team: "Dev Team", day: "Wed", start: "18:00", end: "19:00" }, - { team: "All Hands", day: "Sun", start: "10:00", end: "11:00" }, - ], - skills: ["Python", "Django", "PostgreSQL", "REST APIs"], - status: "open", - }, - { - id: "22222222-2222-2222-2222-222222222222", - project_name: "Tabler", - role_title: "Product Designer", - overview: - "Product designers own the end-to-end experience: research, flows, wireframes, and high-fidelity UI.", - body: "Help redesign the onboarding flow. You'll run lightweight research, produce Figma prototypes, and hand off specs to engineering.", - responsibilities: - "Lead design reviews, maintain the Figma source of truth, and validate designs with real volunteers.", - min_experience_required: "senior", - min_hours_required: 8, - work_environment: "hybrid", - meeting_times: [ - { team: "Design Team", day: "Tue", start: "17:30", end: "18:30" }, - ], - skills: ["Figma", "User Research", "Prototyping"], - status: "open", - }, - { - id: "33333333-3333-3333-3333-333333333333", - project_name: "Civic Tech Jobs", - role_title: "Frontend Engineer", - overview: - "Frontend engineers build the React/Next.js interfaces volunteers interact with day to day.", - body: "Build out the opportunity browse and detail surfaces in Next.js with CSS Modules, wiring them to the API client.", - responsibilities: - "Ship accessible, responsive components; write component tests; keep the design system consistent.", - min_experience_required: "junior", - min_hours_required: 6, - work_environment: "remote", - meeting_times: [ - { team: "Dev Team", day: "Thu", start: "19:00", end: "20:00" }, - ], - skills: ["TypeScript", "React", "Next.js", "CSS"], - status: "open", - }, -]; diff --git a/frontend/src/features/opportunities/lib/filters.ts b/frontend/src/features/opportunities/lib/filters.ts index ceccaa71..f8e37779 100644 --- a/frontend/src/features/opportunities/lib/filters.ts +++ b/frontend/src/features/opportunities/lib/filters.ts @@ -3,18 +3,23 @@ * * Encodes the filter contract from the spec (`blah.`): * - Availability ON/OFF. ON keeps an opportunity if the user can - * attend at least one of its meeting times. + * attend at least one of its meeting times. Opportunities with + * no `meeting_times` set (null) never pass when ON, since we + * have no slot to match against. * - Project: free-text substring match against `project_name` * (case-insensitive, trimmed). Empty query is a no-op. * - Skills OFF / Partial Match (30% threshold). Partial match - * keeps an opportunity if at least 30% of its required skills - * also appear in the user's skill list (case-insensitive set - * overlap; opportunities with no skills always pass to avoid - * hiding listings that haven't tagged any). + * keeps an opportunity if at least 30% of its `skill_names` + * also appear in the user's `skill_names` (case-insensitive set + * overlap); opportunities with no skills always pass to avoid + * hiding listings that haven't tagged any. + * + * Pure functions over the live wire shapes (`Opportunity` from + * `opportunitiesApi` and the current `User` off the auth context). */ -import type { CurrentUser } from "@/features/opportunities/data/sampleCurrentUser"; -import type { Opportunity } from "@/features/opportunities/data/sampleOpportunities"; +import type { User } from "@/shared/lib/api/auth"; +import type { Opportunity } from "@/shared/lib/api/opportunities"; export type SkillsFilterMode = "off" | "partial_match"; @@ -43,12 +48,10 @@ function intervalsOverlap( ); } -function matchesAvailability( - opportunity: Opportunity, - user: CurrentUser, -): boolean { +function matchesAvailability(opportunity: Opportunity, user: User): boolean { + if (!opportunity.meeting_times || !user.meeting_availability) return false; return opportunity.meeting_times.some((slot) => - user.availability.some( + user.meeting_availability!.some( (window) => window.day.toLowerCase() === slot.day.toLowerCase() && intervalsOverlap(slot.start, slot.end, window.start, window.end), @@ -64,23 +67,25 @@ function matchesProject(opportunity: Opportunity, query: string): boolean { function matchesSkills( opportunity: Opportunity, - user: CurrentUser, + user: User, mode: SkillsFilterMode, ): boolean { if (mode === "off") return true; // Don't hide opportunities that haven't tagged any skills - we have // no signal to score them against, and excluding them would surprise. - if (opportunity.skills.length === 0) return true; - const userSkills = new Set(user.skills.map((s) => s.toLowerCase())); - const overlap = opportunity.skills.filter((skill) => + if (opportunity.skill_names.length === 0) return true; + const userSkills = new Set(user.skill_names.map((s) => s.toLowerCase())); + const overlap = opportunity.skill_names.filter((skill) => userSkills.has(skill.toLowerCase()), ).length; - return overlap / opportunity.skills.length >= SKILL_PARTIAL_MATCH_THRESHOLD; + return ( + overlap / opportunity.skill_names.length >= SKILL_PARTIAL_MATCH_THRESHOLD + ); } export function applyOpportunityFilters( opportunities: Opportunity[], - user: CurrentUser, + user: User, filters: OpportunityFilterState, ): Opportunity[] { return opportunities.filter((opportunity) => { diff --git a/frontend/src/shared/lib/api/auth.ts b/frontend/src/shared/lib/api/auth.ts index 53d34e8c..81e41a9e 100644 --- a/frontend/src/shared/lib/api/auth.ts +++ b/frontend/src/shared/lib/api/auth.ts @@ -11,6 +11,18 @@ import { apiFetch } from "./client"; +/** + * One availability window the user can attend. Parallels the + * `meeting_availability` JSON shape on `CustomUser`; same fields as + * `MeetingSlot` minus `team`. Used by the Availability filter on the + * `/opportunities` browse surface. + */ +interface AvailabilitySlot { + day: string; + start: string; // "HH:MM" + end: string; // "HH:MM" +} + /** * Response shape of `GET /api/auth/me/`, `POST /api/auth/login/`, * `POST /api/auth/signup/`, and `GET /api/users//`. @@ -18,7 +30,10 @@ import { apiFetch } from "./client"; * Mirrors `CustomUserReadSerializer.Meta.fields` on the backend. * `community_of_practice` and `skills_learned_matrix` are foreign * key UUIDs (not nested objects); resolve them via separate - * endpoints if the UI needs full records. + * endpoints if the UI needs full records. `skill_names` is the + * serializer-resolved alphabetical list of the user's skill names + * (sourced off `skills_learned_matrix`); the browse surface's Skills + * filter reads this directly. */ export type User = { id: string; @@ -27,8 +42,9 @@ export type User = { email: string; community_of_practice: string | null; skills_learned_matrix: string | null; + skill_names: string[]; max_available_hours: number | null; - meeting_availability: unknown; + meeting_availability: AvailabilitySlot[] | null; isProjectManager: boolean; created_at: string; updated_at: string; diff --git a/frontend/src/shared/lib/api/opportunities.ts b/frontend/src/shared/lib/api/opportunities.ts new file mode 100644 index 00000000..61ea7a8d --- /dev/null +++ b/frontend/src/shared/lib/api/opportunities.ts @@ -0,0 +1,69 @@ +/** + * Typed wrappers for the `/api/opportunities/` endpoint. + * + * Mirrors `ctj_api.serializers.OpportunityReadSerializer`. The + * derived display fields `role_title` (from `role.title`) and + * `skill_names` (alphabetically-sorted skill names resolved from + * `skills_required_matrix`) save the listing card from N+1 + * lookups against `/api/roles/` and `/api/skills/`; the underlying + * `role` and `skills_required_matrix` UUIDs stay on the wire for + * matching-algorithm consumers. + * + * The list action filters to `status="open"` server-side + * (`OpportunityViewSet.get_queryset`) - the listing surface is the + * volunteer-facing catalog, drafts / on-hold / filled / closed + * aren't part of it. Retrieve still returns any single opportunity + * (the PM CMS will read off retrieve later). + * + * All wrappers use the shared `apiFetch` client. + */ + +import { apiFetch } from "./client"; + +type OpportunityStatus = "open" | "closed" | "on_hold" | "filled" | "draft"; + +export type WorkEnvironment = "remote" | "hybrid" | "in_person"; + +/** + * One meeting slot on an opportunity. `Opportunity.meeting_times` + * is a list of these. Parallels `User.meeting_availability` shape + * minus the `team` key (opportunity-side metadata). + */ +interface MeetingSlot { + team: string; + day: string; + start: string; // "HH:MM" + end: string; // "HH:MM" +} + +/** + * Read shape of an opportunity. Mirrors + * `OpportunityReadSerializer.Meta.fields`. + */ +export type Opportunity = { + id: string; + project_name: string; + role: string; + role_title: string; + overview: string; + body: string; + responsibilities: string; + min_experience_required: string; + min_hours_required: number; + work_environment: WorkEnvironment; + meeting_times: MeetingSlot[] | null; + skills_required_matrix: string | null; + skill_names: string[]; + status: OpportunityStatus; + created_by: string | null; + created_at: string; + updated_at: string; +}; + +export const opportunitiesApi = { + /** List opportunities (server-filtered to `status="open"`). */ + list: () => apiFetch("/api/opportunities/"), + + /** Retrieve a single opportunity by UUID (any status). */ + retrieve: (id: string) => apiFetch(`/api/opportunities/${id}/`), +}; diff --git a/frontend/tests/contexts/AuthContext.test.tsx b/frontend/tests/contexts/AuthContext.test.tsx index 8a414495..f36fa4d0 100644 --- a/frontend/tests/contexts/AuthContext.test.tsx +++ b/frontend/tests/contexts/AuthContext.test.tsx @@ -23,6 +23,7 @@ const baseUser: User = { email: "test@example.com", community_of_practice: null, skills_learned_matrix: null, + skill_names: [], max_available_hours: null, meeting_availability: null, isProjectManager: false, From 02a2cb8fcdfa9afe89484ee744b8e6f71367daf6 Mon Sep 17 00:00:00 2001 From: Nickatak Date: Wed, 24 Jun 2026 17:46:11 -0700 Subject: [PATCH 8/8] feat: Add seed_dev management command + make db-seed for dev fixtures 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) --- Makefile | 6 +- backend/ctj_api/management/__init__.py | 0 .../ctj_api/management/commands/__init__.py | 0 .../ctj_api/management/commands/seed_dev.py | 141 ++++++++++++++++++ docs/developer/installation.md | 13 ++ docs/developer/quickstart-guide.md | 1 + 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 backend/ctj_api/management/__init__.py create mode 100644 backend/ctj_api/management/commands/__init__.py create mode 100644 backend/ctj_api/management/commands/seed_dev.py diff --git a/Makefile b/Makefile index 3ea2f945..f842650c 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ local-test local-test-backend local-test-frontend \ local-clean local-kill-ports \ docker-up db-up docker-down docker-logs docker-watch \ - db-migrate db-makemigrations db-reset db-reset-hard db-grant-test-db-perms \ + db-migrate db-makemigrations db-seed db-reset db-reset-hard db-grant-test-db-perms \ stage-smoke stage-up stage-down \ docker-shell-backend docker-shell-frontend docker-shell-db \ lint install-hooks @@ -57,6 +57,7 @@ help: @echo " make docker-watch Run compose watch (live host->container sync)" @echo " make db-migrate Apply Django migrations against dev DB" @echo " make db-makemigrations Create Django migration files (in container)" + @echo " make db-seed Seed dev DB with a PM user + a few open opportunities" @echo " make db-reset Truncate all app data (keeps schema + migrations)" @echo " make db-reset-hard Drop dev DB volume and recreate DB container" @echo " make db-grant-test-db-perms Grant test-DB CREATEDB perms (for Django tests)" @@ -205,6 +206,9 @@ db-migrate: db-makemigrations: $(DEV_COMPOSE) exec $(BACKEND_SERVICE) python manage.py makemigrations +db-seed: + $(DEV_COMPOSE) exec $(BACKEND_SERVICE) python manage.py seed_dev + db-reset: @echo "This will truncate all app data (schema + migrations preserved). Type 'yes' to confirm:" @read ans && [ "$$ans" = "yes" ] || (echo "Aborted."; exit 1) diff --git a/backend/ctj_api/management/__init__.py b/backend/ctj_api/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ctj_api/management/commands/__init__.py b/backend/ctj_api/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ctj_api/management/commands/seed_dev.py b/backend/ctj_api/management/commands/seed_dev.py new file mode 100644 index 00000000..9da49afb --- /dev/null +++ b/backend/ctj_api/management/commands/seed_dev.py @@ -0,0 +1,141 @@ +"""Idempotent dev-only seed: one PM user, one CoP, one Role, three Opportunities. + +Invoke via `make db-seed` (in-container) or +`python manage.py seed_dev` (host, after `make db-up`). Safe to re-run: +all rows are looked up by stable natural keys and updated in place. + +This command exists to make local UI work usable without clicking +through admin to hand-build the dependency graph. It is NOT used by +production code paths, NOT imported by views, and NOT loaded in +tests. The committed-data prohibition (memory: feedback_no_hardcoded_data) +explicitly carves out dev-only seed scripts. +""" + +from django.core.management.base import BaseCommand +from django.db import transaction + +from accounts.models import CustomUser +from ctj_api.models import CommunityOfPractice, Opportunity, Role + +DEV_USER_EMAIL = "dev@example.com" +DEV_USER_PASSWORD = "password123!" + + +class Command(BaseCommand): + help = "Seed the dev database with a PM user and a few open Opportunities." + + @transaction.atomic + def handle(self, *args, **options): + cop, _ = CommunityOfPractice.objects.get_or_create( + practice_area=CommunityOfPractice.PracticeAreas.ENGINEERING, + defaults={"description": "Engineering CoP"}, + ) + + role_backend, _ = Role.objects.get_or_create( + title="Backend Engineer", community_of_practice=cop + ) + role_frontend, _ = Role.objects.get_or_create( + title="Frontend Engineer", community_of_practice=cop + ) + role_sre, _ = Role.objects.get_or_create( + title="Site Reliability Engineer", community_of_practice=cop + ) + + user, created = CustomUser.objects.get_or_create( + email=DEV_USER_EMAIL, + defaults={ + "username": DEV_USER_EMAIL, + "name": "Dev User", + "people_depot_user_id": f"local:{DEV_USER_EMAIL}", + "isProjectManager": True, + "is_staff": True, + "is_superuser": True, + }, + ) + if created: + user.set_password(DEV_USER_PASSWORD) + user.save() + else: + updated_flags = False + for flag in ("isProjectManager", "is_staff", "is_superuser"): + if not getattr(user, flag): + setattr(user, flag, True) + updated_flags = True + if updated_flags: + user.save() + + opportunities = [ + { + "project_name": "Civic Tech Jobs", + "role": role_backend, + "overview": "Build the Django + DRF backend for the CTJ matcher.", + "body": "Wire the opportunity catalog, matching pipeline, and DRF.", + "responsibilities": "Ship endpoints, review PRs, pair with frontend.", + "min_experience_required": "mid-level", + "min_hours_required": 8, + "work_environment": "remote", + "meeting_times": [ + { + "team": "Developer Team", + "day": "Wed", + "start": "12:30", + "end": "13:30", + } + ], + }, + { + "project_name": "Civic Tech Jobs", + "role": role_frontend, + "overview": "Build the Next.js surfaces for browsing opportunities.", + "body": "Own the /opportunities listing, filter UI, and cards.", + "responsibilities": "Implement Figma flows, write Vitest, keep a11y.", + "min_experience_required": "junior", + "min_hours_required": 6, + "work_environment": "remote", + "meeting_times": [ + { + "team": "Developer Team", + "day": "Wed", + "start": "12:30", + "end": "13:30", + } + ], + }, + { + "project_name": "HfLA Tools", + "role": role_sre, + "overview": "Keep HfLA's shared dev infrastructure healthy.", + "body": "Tune CI, run migrations, and own the staging environment.", + "responsibilities": "Monitor deploys, triage stage, write runbooks.", + "min_experience_required": "senior", + "min_hours_required": 10, + "work_environment": "hybrid", + "meeting_times": [ + {"team": "Ops", "day": "Tue", "start": "18:00", "end": "19:00"} + ], + }, + ] + + for spec in opportunities: + Opportunity.objects.update_or_create( + project_name=spec["project_name"], + role=spec["role"], + defaults={ + "overview": spec["overview"], + "body": spec["body"], + "responsibilities": spec["responsibilities"], + "min_experience_required": spec["min_experience_required"], + "min_hours_required": spec["min_hours_required"], + "work_environment": spec["work_environment"], + "meeting_times": spec["meeting_times"], + "status": "open", + "created_by": user, + }, + ) + + self.stdout.write( + self.style.SUCCESS( + f"Seeded {Opportunity.objects.count()} opportunities. " + f"Login: {DEV_USER_EMAIL} / {DEV_USER_PASSWORD}" + ) + ) diff --git a/docs/developer/installation.md b/docs/developer/installation.md index f1dc8c92..0b8666a7 100644 --- a/docs/developer/installation.md +++ b/docs/developer/installation.md @@ -89,6 +89,19 @@ docker compose run django python manage.py createsuperuser That account can sign into Django admin at http://localhost:8000/admin/ and is what you'll use to exercise PM-gated flows. Subsequent admin and PM elevations happen through Django admin's UI (toggle the `isProjectManager` flag on the user record). +### Seed user (browse-surface fixtures) + +`make db-seed` creates a deterministic PM user and three open opportunities so the `/opportunities` surface has something to render without building the dependency graph by hand. The command is idempotent (re-running is a no-op except for refreshing the opportunity fields), and the data is non-production - the seed lives in [`backend/ctj_api/management/commands/seed_dev.py`](../../backend/ctj_api/management/commands/seed_dev.py) and is never invoked by views or tests. + +| Field | Value | +|-------|-------| +| Email / username | `dev@example.com` | +| Password | `password123!` | +| `isProjectManager` | `true` | +| `is_staff` / `is_superuser` | `true` (signs into Django admin at `/admin/`) | + +Sign in at http://localhost:3000/login. The seeded opportunities cover a mix of work environments (remote, hybrid) and experience levels (junior, mid-level, senior) so the filter UI on `/opportunities` is exerciseable on a fresh DB. + Stage 2 will introduce Cognito JWT verification and PeopleDepot client mocks; that work lands when the upstream PeopleDepot deployment posture stabilizes. See [backend.md](backend.md#auth) for the Stage 2 design. ## Additional Resources diff --git a/docs/developer/quickstart-guide.md b/docs/developer/quickstart-guide.md index fb6cce40..b4534179 100644 --- a/docs/developer/quickstart-guide.md +++ b/docs/developer/quickstart-guide.md @@ -68,6 +68,7 @@ Common DB targets (against the running dev stack): ```sh make db-migrate # apply Django migrations make db-makemigrations # create migration files +make db-seed # seed a PM user + a few open opportunities (idempotent) make db-reset # truncate all app data (keeps schema + migrations); prompts for 'yes' make db-reset-hard # drop the volume and recreate the DB container; prompts for 'yes' make db-grant-test-db-perms # grant the test runner CREATEDB perms