From 19326b504cf5929085fcd921b55e1cc2c34cb5e4 Mon Sep 17 00:00:00 2001 From: Elvis020 Date: Mon, 3 Aug 2026 13:15:33 +0000 Subject: [PATCH] feat(conference): add phase-first delivery planning - Add edition-scoped conference phases, future-edition ownership, ordered delivery windows, and target-date boundary validation across persistence, APIs, and task forms. - Seed the agreed Phase 1 responsibilities and make Work Plan and Timeline operate from the selected phase with compact pagination and focused delivery signals. - Redesign People & Access with a denser member table, Navii avatars, owner-only email visibility, required display names, and a slim owner-only Organizer/Volunteer role switcher. - Restrict conference task visibility and editing by role and assignment, give volunteers an assigned-work-only interface, and revoke active sessions immediately when an owner changes a member role. - Refine navigation stability, typography, date and collaborator controls, scrollbar treatment, volunteer applications, loading states, and supporting documentation. - Verify with pnpm test -- --reporter=dot (71 files, 304 tests), pnpm build, and git diff --check. --- docs/annual-conference-plan.md | 18 +- docs/architecture.md | 9 +- docs/auth.md | 2 + docs/changelog.md | 50 ++ docs/decisions.md | 26 +- docs/implementation.md | 16 +- docs/reference/routes.md | 8 +- docs/user-guides/organizer-guide.md | 10 +- lib/annual-conference-access.test.ts | 1 + lib/annual-conference-access.ts | 12 + lib/annual-conference-work-plan.test.ts | 179 ++++- lib/annual-conference-work-plan.ts | 266 ++++++- lib/app-boot.ts | 10 +- lib/design-system.ts | 4 +- lib/mock-db/annual-conference-work-plan.ts | 132 +++- lib/supabase/annual-conference-work-plan.ts | 148 ++++ server/admin-api-access.test.ts | 5 + server/admin-api-access.ts | 6 + server/annual-conference-phases.test.ts | 91 +++ ...annual-conference-volunteer-access.test.ts | 61 +- server/app.ts | 415 ++++++++++- src/App.vue | 35 +- src/annual-conference.ts | 4 +- src/components/AnnualConferenceNav.vue | 157 ++++- src/components/AnnualConferenceTaskDrawer.vue | 12 +- src/components/AnnualConferenceTaskForm.vue | 68 +- src/components/AppDropdown.vue | 24 +- src/components/AppMultiSelectDropdown.vue | 37 +- src/components/ui/AppDatePicker.vue | 2 +- .../AdminOrganizersPageSkeleton.vue | 65 +- src/lib/api.ts | 65 ++ src/router.ts | 7 +- src/styles.css | 23 +- src/typography-system.test.ts | 16 +- .../AdminAnnualConferenceTimelineView.vue | 649 ++++++++++++++++++ src/views/admin/AdminAnnualConferenceView.vue | 20 +- .../AdminAnnualConferenceWorkPlanView.vue | 325 +++++---- src/views/admin/AdminOrganizersView.vue | 343 ++++++--- src/views/admin/AdminVolunteerView.vue | 36 +- ...0260802120000_annual_conference_phases.sql | 137 ++++ ..._annual_conference_phase_1_assignments.sql | 52 ++ types/supabase.ts | 57 ++ 42 files changed, 3166 insertions(+), 437 deletions(-) create mode 100644 server/annual-conference-phases.test.ts create mode 100644 src/views/admin/AdminAnnualConferenceTimelineView.vue create mode 100644 supabase/migrations/20260802120000_annual_conference_phases.sql create mode 100644 supabase/migrations/20260803110000_annual_conference_phase_1_assignments.sql diff --git a/docs/annual-conference-plan.md b/docs/annual-conference-plan.md index 63b5d79..df8aedf 100644 --- a/docs/annual-conference-plan.md +++ b/docs/annual-conference-plan.md @@ -4,7 +4,7 @@ **Plan status:** Active planning; Overview and Work plan available -**Last updated:** 2026-07-26 +**Last updated:** 2026-08-03 **Maintainers:** DevCongress organizers; Angela (`angelateyvi@gmail.com`) manages new task creation @@ -64,22 +64,27 @@ Organizer Console The first operational workspace slice is now in place: - the primary organizer navigation exposes **Annual Conference**, not a global Volunteer Hub; -- `/organizer-console/annual-conference/2026` is the active-edition overview; +- `/organizer-console/annual-conference/:year` is an edition-scoped overview, with 2026 as the initial edition; - the provisional date is **19 December 2026**; - Work plan is live at `/organizer-console/annual-conference/2026/work-plan`, seeded once from `DevCongress 2026 — Event Checklist.xlsx`; - Volunteers is nested at `/organizer-console/annual-conference/2026/volunteers`; -- Overview, Work plan, and Volunteers are interactive today; future modules remain visible as planned structure rather than empty routes; +- Overview, Work plan, Timeline, and the 2026 Volunteers workflow are interactive today; future modules remain visible as planned structure rather than empty routes; - the former organizer volunteer paths redirect to the new workspace; - the existing public form, QR link, campaign ID, submission API, and stored applications are unchanged. +- each accountable owner or collaborator can edit their assigned tasks; the edition planning owner can edit every task, add tasks, manage phases, and create the next edition; +- 2026 has fixed **Phase 1** (1–31 August) and **Phase 2** (1 September–19 December); 12 confirmed kickoff tasks are assigned to Phase 1 and the remaining 15 tasks stay in **No phase** until organizers classify them; +- future editions can define any number of non-overlapping phases, select a planning owner or inherit the previous edition's owner, and reorder, rename, redate, or delete phases without deleting tasks; +- task target dates remain nullable but are prioritized by the conference-health dashboard; an assigned task's target date cannot exceed its phase end date; - owners and organizers can add an authenticated user with the **Volunteer** role from People & Access; - volunteer sessions land in Annual Conference, see only tasks where their email is accountable or collaborating, and can update only those task statuses; - volunteer access is enforced at both the organizer router and API boundary; Events, hubs, access management, applicant records, task creation, task reassignment, task-detail editing, and organizer-only internal notes remain unavailable; -- every authenticated organizer can edit every task, while only `angelateyvi@gmail.com` can add a task; +- Work plan and Timeline are phase-scoped by default: they open on the current phase, or the next available phase outside an active window, and recalculate every metric and task section when organizers switch to another phase, **No phase**, or the entire conference; +- conference health keeps objective completion, elapsed phase time, overdue/blocked pressure, due-soon counts, and planning confidence visible at a glance; planning confidence measures task coverage across dates, phases, and accountable owners; - the first named spreadsheet owner is accountable and the remaining names are collaborators; `All`, `TBD`, and blank owners remain unassigned; - new owner and collaborator selections use active organizer emails as stable identities, while the UI shows organizer names and preserves unchanged legacy spreadsheet assignments; - finance is deferred to a later restricted module, and reminders are not part of this release. -The work plan uses relational `annual_conference_editions` and `annual_conference_tasks` Supabase tables in production, with a local JSON fallback for development. The one-time seed contains 26 tasks: 24 Not started and 2 Done. +The work plan uses relational `annual_conference_editions`, `annual_conference_phases`, and `annual_conference_tasks` Supabase tables in production, with a local JSON fallback for development. The initial plan contains the 26 imported checklist tasks plus the new Volunteer recruitment task: 25 Not started and 2 Done. ### Annual workspace modules @@ -87,6 +92,7 @@ The work plan uses relational `annual_conference_editions` and `annual_conferenc | --- | --- | --- | | Overview | One operational picture of the active edition | Key decisions, milestones, owners, blockers, readiness, and cross-workstream status | | Work plan | Replace the shared spreadsheet | Workstreams, tasks, owners, deadlines, dependencies, priority, status, and activity history | +| Timeline | Judge delivery health and schedule confidence | Phase completion, time versus work, schedule signals, phase windows, and a paginated planning-gap table whose goal is zero rows | | Programme | Build what happens on stage and in rooms | CFP, reviews, keynotes, speakers, talks/workshops/panels/demos, rooms, technical needs, and run of show | | Volunteers | Move from interest to useful assignments | Intake, review, roles, teams, shifts, assignments, briefing, and status | | Registration | Manage the attendee journey | Registration/ticketing, capacity, attendee communication, check-in, badges, cancellations, and refunds where applicable | @@ -139,7 +145,7 @@ The intended result is one December 2026 volunteer dataset regardless of which v | --- | --- | --- | --- | --- | | Exact date or dates | Done | Unassigned | 19 Dec 2026 | 19 December 2026 is the provisional starting point; confirmation remains a later decision | | Conference theme | Not started | Unassigned | TBD | Gates keynote outreach, CFP framing, programme, sponsorship deck, and creative | -| Venue | Not started | Angela | TBD | UPSA and Accra Digital Centre are the current candidates; Elijah and Elvis collaborate | +| Venue | Not started | Elijah | TBD | UPSA and Accra Digital Centre are the current candidates; Elvis collaborates | | Attendance target | Not started | Unassigned | TBD | Needed for capacity, ticketing, catering, badges, swag, connectivity, and budget | | Keynote speaker or speakers | Not started | Elijah | TBD | Patrick G. Awuah is preferred; the original shortlist also mentioned the NSMQ quiz mistress | | Ticketing approach | In progress | Unassigned | TBD | Paid registration is confirmed for December; choose the payment provider and define payment, refund, reconciliation, and failure ownership after the free monthly flow is proven | diff --git a/docs/architecture.md b/docs/architecture.md index a0e7424..5296a35 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,8 +108,9 @@ Public API evolution is additive: archive list and detail payloads expose `archi - `[adminBase]/events` — event management overview - `[adminBase]/website-preview/events` — authenticated preview of the exact public meetup collection contract - `[adminBase]/website-preview/events/[slug]` — authenticated website-shaped preview of one public meetup DTO -- `[adminBase]/annual-conference/2026` — active annual-edition overview -- `[adminBase]/annual-conference/2026/work-plan` — shared annual-conference task plan +- `[adminBase]/annual-conference/[year]` — selected annual-edition overview and future-edition creation +- `[adminBase]/annual-conference/[year]/work-plan` — shared annual-conference task plan with phase assignment +- `[adminBase]/annual-conference/[year]/timeline` — phase management and target-date timeline - `[adminBase]/annual-conference/2026/volunteers` — December volunteer intake operations - `[adminBase]/events/new` — create event form - `[adminBase]/attendance` — monthly attendance ledger and cross-month insights @@ -133,7 +134,9 @@ Public API evolution is additive: archive list and detail payloads expose `archi - `/api/auth/session`, `/api/auth/admin/exchange`, `/api/auth/admin/callback`, `/api/auth/logout` — Google OAuth and app-owned organizer session lifecycle - `/api/admin/organizers*` — owner-only organizer email allowlist management - `/api/admin/audit-log` — owner-only audit ledger for organizer sign-ins and successful admin mutations -- `/api/annual-conference/[year]/work-plan` — organizer-only annual edition/task reads, named-organizer task creation, and all-organizer task edits +- `/api/annual-conference/editions` — organizer-only edition list and planning-owner-restricted next-edition creation +- `/api/annual-conference/[year]/work-plan` — organizer-only annual edition/phase/task reads, edition-owner task creation, and all-organizer task edits +- `/api/annual-conference/[year]/phases` — edition planning-owner phase creation, editing, ordering, and deletion - `/api/attendance/monthly` — admin-only monthly attendance ledger, import coverage, and cross-month insights - `/api/events` — all events, create event - `/api/events/[eventId]` — event detail, status update, and admin-only removal diff --git a/docs/auth.md b/docs/auth.md index 195e33b..c57227b 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -80,6 +80,8 @@ Required setup: Organizer access still depends on `admin_memberships`. A successful Google login does not grant organizer permissions unless the verified email is active in the allowlist. +Only owners can change an existing member between the Organizer and Volunteer roles. The People & Access directory exposes this as an inline role selector only to owners, the API repeats the owner check, and a successful role change revokes the member's existing app sessions so the narrower or broader access takes effect on their next sign-in. + For local development, keep Google OAuth pinned to `http://localhost:5173`. The login screen blocks Google sign-in on other local ports or `127.0.0.1` so Supabase does not fall back to the deployed Site URL. ## Local Development diff --git a/docs/changelog.md b/docs/changelog.md index e6ca55f..c12f52d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,8 +1,58 @@ # Changelog +## 2026-08-03 — Phase-first work planning + +- Made the active delivery phase the primary scope for Work plan and Timeline, defaulting to Phase 1 during its window and allowing organizers to switch to another phase, No phase, or the entire conference. +- Recalculated task status, completion, ownership gaps, schedule health, and planning gaps from the selected phase instead of mixing a phase-filtered ledger with conference-wide statistics. +- Made the timeline runway selectable, changed its countdown to the selected phase end, scoped planning gaps to the selected phase, and preselected the active phase when creating a task from its work plan. +- Consolidated phase context, completion, status, owner filtering, and filter clearing into one Work Plan control panel and removed the separate task search strip. +- Replaced the ledger's internal scrolling with six-row pagination so larger phase plans stay bounded without extending the task table down the page. +- Teleported the phase and owner dropdown menus outside the merged panel so its rounded overflow boundary cannot clip open options. +- Removed the Timeline's workstream-progress, delivery-threat, and upcoming-deadline panels, leaving phase summary, runway, and planning gaps as its focused operating view. +- Replaced the Timeline's planning-gap card lanes with one compact eight-row table showing each task, owner, status, phase, and target date, with missing values highlighted in place, direct editing, and simple pagination. + +## 2026-08-03 — Initial Phase 1 responsibilities + +- Assigned the confirmed keynote, venue, website/registration, sponsorship, graphics, media coverage, and conference-call work to Phase 1 without adding duplicate umbrella tasks. +- Moved Venue accountability to Elijah, left graphics and call ownership unassigned where requested, and retained the existing specific design, media, and call tasks. +- Added Volunteer recruitment as a separate unassigned Phase 1 task for finding new volunteers and promoting the volunteer call. + +## 2026-08-03 — Conference health layout refinement + +- Removed the standalone Phase Health panel and placed delivery threats and upcoming deadlines in its column as two stacked, action-oriented panels beside workstream progress. +- Removed presentation-only task examples from those panels, limited each to two live tasks, prevented near-term tasks from repeating in later deadlines, and added 30-second plus window-focus work-plan refresh so organizer updates automatically change the visible rows. +- Made the Accra-local day value refresh every minute and whenever the window regains focus, keeping the conference countdown, Today marker, overdue state, due-soon state, and preview dates current across midnight. +- Simplified annual-conference owner and collaborator selectors to display organizer names while preserving email-backed values, summarized longer selections as “+N more” without a duplicate count badge, and replaced yellow/black multi-select checks with soft pink selected rows and pink check controls. +- Kept the collaborator menu within the task drawer's content inset by sizing it to its field instead of forcing it wider toward the left edge. +- Joined adjacent selected collaborators into a single softly divided selection group, removing the scalloped corners between consecutive rows while retaining rounded outer edges. +- Consolidated the duplicated annual-conference edition label and selector into one compact header control, moved workspace navigation to a dedicated row, and restyled future-edition creation as a secondary action. +- Fixed the shared date picker so its teleported calendar opens above task drawers and other application overlays instead of appearing unresponsive behind them. +- Replaced the long planning-gap repair list with a bounded three-lane exception board for tasks missing both fields, a phase, or a target date; each lane has independent scrolling and pagination, a header-level status filter, direct editing, and organizer display names instead of raw email labels. Removed the search bar and decorative lane labels/colors, and made the empty-board goal explicit. +- Made planning-gap lane titles self-contained and upgraded each clickable card with status, workstream, ownership, collaborator count, phase, target date, and conditional dependency context, plus restrained hover, focus, movement, and reduced-motion states. +- Simplified planning-gap cards by removing priority badges, animated left rails, accent hover borders, dependency side borders, and all card movement; hover motion is limited to the **Open task** arrow while keyboard focus remains explicit. +- Consolidated conference completion, countdown, current phase, planning confidence, overdue, blocked, and due-soon signals into one responsive summary, removing the six duplicate statistic cards and the repeated no-phase count. +- Tightened the conference summary into a clearer metric, status, progress, facts, and countdown hierarchy, and replaced planning-lane count pills with circular number-only counters. +- Kept the shared annual-conference navigation spatially fixed when switching between Overview, Work Plan, Timeline, and Volunteers by replacing the whole-page directional movement with a short opacity-only transition. +- Reserved a stable scrollbar gutter on the desktop application scroll container, preventing route content from changing width when switching between pages with and without vertical overflow. +- Replaced the oversized volunteer application cards with a compact, horizontally scrollable table for names, email addresses, X handles, Slack names, and sign-up times. +- Stabilized the pre-JavaScript workspace loading screen with system fonts so webfont loading cannot resize its message, reduced the loading headline's scale and weight, and softened the shared application heading/display weights from 700/800 to 600/700. + +## 2026-08-02 — Edition-scoped conference phases and timeline + +- Added a dynamic annual-conference edition switcher and planning-owner-controlled future-edition creation, with the next owner selected from active organizers or inherited from the previous edition. +- Added fixed 2026 Phase 1 and Phase 2 windows, unlimited phases for later editions, safe phase editing/reordering/deletion, and a No phase state that preserves all existing tasks. +- Added task phase assignment, target-date attention, and server/database validation preventing assigned dates from exceeding phase end dates. +- Added a conference-health Timeline with overall and workstream completion, planning confidence, phase time-versus-work comparisons, overdue/blocked/due-soon signals, upcoming deadlines, and a proportional phase runway. +- Kept unclassified and undated work actionable in a fixed-height planning-repair queue with search, status filtering, explicit missing-field labels, direct task editing, and ten-item pagination so planning gaps never lengthen the whole page. +- Refined the dashboard to a light app-native treatment without dark or yellow feature panels, sorted workstreams by completion percentage, replaced the native status select with the shared dropdown, and added clearly labelled non-persistent example rows to otherwise empty attention and deadline panels. + ## 2026-08-03 — Assigned-work access for Annual Conference volunteers - Added **Volunteer** to People & Access so owners and organizers can grant conference-only sign-in without organizer-wide privileges. +- Redesigned People & Access around a compact member directory, a focused side-panel invite form, concise role definitions, name-first rows, and a matching bounded loading skeleton. +- Added Navii avatars, hid directory emails by default, limited the email reveal control to signed-in owners, and made display names mandatory in both client and server validation. +- Added an owner-only inline role selector for switching active members between Organizer and Volunteer, with server-side owner enforcement, audit logging, and session revocation after a change. +- Removed the redundant global Annual Conference navigation tab and organizer-only ownership-gap counter from volunteer views, restricted full task editing to the edition planning owner or the task's accountable owner/collaborators, and replaced the black In progress badge with a restrained teal treatment. - Routed volunteer sessions directly into Annual Conference and limited navigation to the edition overview and work plan. - Filtered volunteer work-plan responses to tasks where their email is accountable or collaborating, removed organizer-only internal notes, and added a clear no-assignment state. - Allowed volunteers to update only the status of assigned tasks while keeping Events, hubs, access management, applicant records, task creation, reassignment, and task-detail changes server-denied. diff --git a/docs/decisions.md b/docs/decisions.md index bbfe949..20d9cdb 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,5 +1,27 @@ # Architectural Decisions +## ADR-041: Assignment-Scoped Conference Task Editing + +**Date:** 2026-08-03 +**Status:** Accepted +**Context:** Conference tasks have one accountable owner and optional collaborators, but the first organizer implementation allowed every owner or organizer to modify every task. That made assignment informational rather than an authorization boundary and allowed unrelated members to change another team's delivery details. +**Decision:** Treat normalized accountable-owner and collaborator emails as the task-edit boundary for every authenticated member. The edition planning owner, currently Ann for 2026 through `task_creator_email`, is the only member who may edit every task. Other owners and organizers retain conference-wide read access but may edit only assigned tasks. Volunteers continue seeing assigned tasks only and remain limited to status changes. Enforce the rule in the API before ownership or schedule processing, and mirror it in the task drawer so unavailable edits are not presented. Keep task creation, phase management, and next-edition creation with the same edition planning owner. +**Trade-offs:** Email-backed assignment remains the temporary identity link, and organizers can still read unrelated conference tasks even when they cannot edit them. Reassigning accountability can remove the former owner's edit capability immediately. Volunteers remain less capable than assigned organizers because they cannot change task details. +**Alternatives considered:** Preserve role-wide editing (too broad), hide edit buttons without API enforcement (bypassable), make platform owners global task editors (conflicts with assignment ownership), or limit all non-planning members to status-only updates (too restrictive for accountable organizers and collaborators). +**Revisit when:** Edition engagements replace platform membership emails, workstream leads need scoped authority, or individual fields require separate approval capabilities. + +--- + +## ADR-040: Edition-Owned Conference Phases and Target-Date Timeline + +**Date:** 2026-08-02 +**Decision:** Model delivery phases as edition-scoped relational records and keep task phase assignment nullable. Make phase the primary operating scope for Work plan and Timeline: default both views to the current phase, fall forward to the next phase outside an active window, and derive all visible metrics, workstreams, deadlines, risks, and planning gaps from the selected scope. Organizers may switch to another phase, **No phase**, or the entire conference. Reuse each edition's `task_creator_email` as its planning-owner capability for task creation, phase management, and next-edition creation. A new edition may select an active organizer or inherit the latest edition's owner. Keep target dates optional, but surface missing dates as timeline attention and reject an assigned date after its phase end at both the API and database boundaries. Derive conference-health signals from the task and phase records rather than persisting a second progress model: actual completion stays separate from planning confidence, and readiness remains **Needs planning** while any task in the selected scope lacks a phase or target date. +**Why:** The 2026 checklist must be classifiable without inventing assignments, while future conferences need a variable number of phases and a durable way to hand planning ownership forward. A separate phase table preserves edition history and makes the timeline derive from the same dates used to validate tasks. +**Tradeoffs:** Only confirmed kickoff work is initially assigned to Phase 1; every other task remains in **No phase**, so the timeline is intentionally incomplete until organizers finish classifying and dating the plan. Reusing the task creator as planning owner keeps one clear capability but does not yet support separate task-creation and phase-management delegates. The 2026 volunteer workflow remains edition-specific while overview, work plan, phases, and timeline become year-aware. +**Alternatives considered:** Hard-code two phase columns on tasks (cannot support later editions), store phase names directly on tasks (duplicates dates and breaks referential integrity), or require every target date immediately (would block safe migration of the existing checklist). + +--- + > ADR entries explain WHY — not what was built, but why it was built that way. --- @@ -9,7 +31,7 @@ **Date:** 2026-08-03 **Status:** Accepted as the first active-edition access slice **Context:** DevCongress volunteers need to sign in and work inside the Annual Conference without seeing year-round Events, attendance, feedback, access administration, private applicant records, or unrelated conference work. Existing authentication recognized only owners and organizers, and the global API middleware treated every authenticated membership as a potential organizer boundary. -**Decision:** Add a narrow `volunteer` membership role to the existing Google OAuth and app-owned session flow. Route that role to the active Annual Conference and allow only the overview and work-plan routes. At the API boundary, keep owner/organizer as the default allowlist and admit volunteers only to annual work-plan reads and individual task patches. Filter reads by normalized membership email matching the task's accountable owner or collaborators, remove `internal_note`, and allow patches only when the request contains exactly one `status` change for an assigned task. Owners and organizers retain full work-plan behavior and can assign active volunteers through the existing ownership controls. +**Decision:** Add a narrow `volunteer` membership role to the existing Google OAuth and app-owned session flow. Route that role to the active Annual Conference and allow only the overview and work-plan routes. At the API boundary, keep owner/organizer as the default allowlist and admit volunteers only to annual work-plan reads and individual task patches. Filter reads by normalized membership email matching the task's accountable owner or collaborators, remove `internal_note`, and allow patches only when the request contains exactly one `status` change for an assigned task. Owners and organizers can assign active volunteers through the existing ownership controls; ADR-041 subsequently narrows their task editing to assignment scope unless they are the edition planning owner. **Trade-offs:** The first slice represents active-edition volunteer access with a platform membership role because December 2026 is the only live annual workspace. That is simpler and immediately auditable, but it is not the final multi-edition people model. Task assignment depends on stable membership emails, volunteers cannot edit task details, and broader conference modules will require explicit capability rules before they are exposed. **Alternatives considered:** Hide organizer navigation only (does not protect APIs), give volunteers organizer access (excessive privilege), expose the whole conference plan read-only (violates assigned-work scope), create a second volunteer app (duplicates auth and conference foundations), or build the complete person/engagement/capability schema before any access (correct long term but larger than the active-edition need). **Revisit when:** A second annual edition is active, volunteers need workstream-wide rather than assignment-only access, speaker or sponsor collaborators sign in, task comments/files need separate visibility, or edition engagements can replace the temporary platform-level volunteer role. @@ -236,7 +258,7 @@ ## ADR-020: Relational Annual Work Plan With Named Task Creation **Date:** 2026-07-26 -**Status:** Accepted +**Status:** Partially superseded by ADR-040 for edition-owned planning responsibility. **Context:** The December 2026 conference was being tracked in a shared Excel file with inconsistent status labels, multiple names in one owner cell, dependencies buried in comments, and no durable multi-user edit history. Organizers want that workbook to be a one-time starting point, not an ongoing synchronization source. They also want broad task editing while keeping creation of new work controlled by one specifically named organizer. **Decision:** Store annual editions and tasks in relational Supabase tables, scoped by edition, and seed the 26 non-empty 2026 spreadsheet rows once. Normalize tasks to exactly `not_started`, `in_progress`, `blocked`, and `done`. The first named spreadsheet owner is accountable and later names are collaborators; `All`, `TBD`, and blank owners import as unassigned. Treat 19 December 2026 as provisional. Every authenticated organizer may edit every task, but the server permits task creation only when the session email is `angelateyvi@gmail.com`. Keep finance outside this slice as a later restricted module, and do not add reminders. Use a local JSON adapter only when Supabase is not configured. **Trade-offs:** The named-email creation rule is intentionally narrower than the existing owner role and must be changed deliberately if responsibility moves. Eleven imported tasks begin unassigned because the source workbook did not name a person. Every task edit now has a named Supabase organizer identity; no synthetic local owner can read or edit the plan. The one-time seed will not reflect later spreadsheet edits, which is intentional once the application becomes the source of truth. diff --git a/docs/implementation.md b/docs/implementation.md index 2689b0c..08f1bec 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -79,18 +79,20 @@ - Supabase persistence: `lib/supabase/feedback-campaigns.ts` - Mock DB fallback: `lib/mock-db/feedback.ts` - **December 2026 annual-conference workspace and volunteer intake** - - Active Vue pages: `src/views/admin/AdminAnnualConferenceView.vue`, `src/views/admin/AdminAnnualConferenceWorkPlanView.vue`, `src/views/VolunteerIntakeView.vue`, `src/views/admin/AdminVolunteerView.vue`, `src/views/admin/AdminVolunteerDisplayView.vue` - - Organizer workspace: `/organizer-console/annual-conference/2026`, with Work plan at `/organizer-console/annual-conference/2026/work-plan` and Volunteers at `/organizer-console/annual-conference/2026/volunteers` + - Active Vue pages: `src/views/admin/AdminAnnualConferenceView.vue`, `src/views/admin/AdminAnnualConferenceWorkPlanView.vue`, `src/views/admin/AdminAnnualConferenceTimelineView.vue`, `src/views/VolunteerIntakeView.vue`, `src/views/admin/AdminVolunteerView.vue`, `src/views/admin/AdminVolunteerDisplayView.vue` + - Organizer workspace: `/organizer-console/annual-conference/:year`, with edition-scoped Work plan and Timeline routes; Volunteers remains a 2026-specific compatibility workflow. The workspace header uses one edition selector beside the Annual Conference label, places module navigation on its own row, and exposes future-edition creation as a secondary action. - Overview composition: an unboxed editorial edition brief makes the provisional date the page heading, keeps delivery progress visible, and reserves the only filled action for Work Plan. Venue and keynote facts open from the anchored Planning notes disclosure; Volunteers stays a quiet utility route, and workstream detail remains in the Work Plan. - - Work-plan interface: compact status controls and an eight-workstream snapshot sit above a content-sized task ledger that becomes scrollable at its viewport cap. Filter changes use a reduced-motion-aware View Transition so the ledger can resize without page wobble; creating, opening, or editing a task uses one accessible right-side drawer. - - Work-plan API: authenticated `GET`, `POST`, and `PATCH` under `/api/annual-conference/:year/work-plan`; all organizers can edit, while task creation is server-limited to `angelateyvi@gmail.com`. New or changed ownership is normalized to active organizer emails, collaborators are deduplicated, and accountable/collaborator overlap is rejected without blocking unchanged legacy Excel assignments. - - Work-plan storage: relational Supabase `annual_conference_editions` and `annual_conference_tasks`, with `lib/mock-db/annual-conference-work-plan.ts` as the local JSON fallback - - Initial data: one-time 26-row Excel seed in `lib/annual-conference-work-plan.ts`; 19 December 2026 is provisional, the first named owner is accountable, remaining names collaborate, and `All`, `TBD`, or blank owners are unassigned + - Work-plan interface: a page-level phase selector defaults to the current or next delivery phase and scopes every downstream calculation; one consolidated phase panel combines phase context, completion, compact status controls, owner filtering, and filter clearing above an eight-workstream snapshot and a six-row paginated task ledger with no internal vertical scrollbar. Filter changes use a reduced-motion-aware View Transition so the ledger can resize without page wobble; the former task search strip is intentionally removed; creating, opening, or editing a task uses one accessible right-side drawer. Owner and collaborator controls display organizer names while retaining normalized email values in the submitted ownership contract; the collaborator summary shows at most two names plus a remaining count. + - Work-plan API: authenticated `GET`, `POST`, and `PATCH` under `/api/annual-conference/:year/work-plan`; the edition planning owner can edit every task, while every other owner or organizer can edit only tasks where their membership email is accountable or collaborating. Volunteers continue receiving assigned tasks only and may change only their statuses. Task creation follows the edition's `task_creator_email`. New or changed ownership is normalized to active organizer emails, collaborators are deduplicated, and accountable/collaborator overlap is rejected without blocking unchanged legacy Excel assignments. + - Edition and phase APIs: authenticated edition list/create plus phase create/update/delete routes. Only the latest edition's planning owner can create the next edition; each edition's owner manages its phases. New owners must be active organizers, and omission inherits the latest edition's owner. + - Work-plan storage: relational Supabase `annual_conference_editions`, `annual_conference_phases`, and `annual_conference_tasks`, with `lib/mock-db/annual-conference-work-plan.ts` as the local JSON fallback. Database triggers reject overlapping phase windows, cross-edition phase assignment, target dates after phase end, and phase-end changes that would invalidate assigned tasks. + - Conference-health contract: 2026 seeds Phase 1 for 1–31 August and Phase 2 for 1 September–19 December. Confirmed kickoff work is assigned to Phase 1 while the remaining tasks stay unclassified. Timeline defaults to the current or next phase and recalculates its completion, schedule facts, countdown, and planning gaps from the selected phase; organizers can explicitly switch to another phase, No phase, or the entire conference. The Timeline derives completion, phase elapsed-time comparisons, overdue/blocked/due-soon counts, and planning confidence from the same work-plan response. **Needs planning** remains the readiness signal while any task lacks a phase or target date, avoiding a false on-track forecast. The top summary owns completion and countdown, with current phase, planning confidence, overdue, blocked, and due-soon values consolidated into one supporting facts row instead of repeated statistic cards. The shared work-plan query refreshes every 30 seconds while the Timeline is open and immediately when its window regains focus, so task changes from another organizer update or remove the corresponding rows without a manual reload. One paginated planning-gap table lists each incomplete record exactly once and shows the task, missing fields, owner, status, phase, target date, and a direct editing action. The goal is zero rows; fully planned tasks leave this exception table and remain in the Work Plan. The Accra-local day value refreshes every minute and on window focus so the countdown and Today marker stay current across midnight. Future editions can use any number of phases; deleting one returns its tasks to **No phase**. + - Initial data: 26-row Excel seed plus the manual Volunteer recruitment task in `lib/annual-conference-work-plan.ts`; confirmed Phase 1 responsibility updates override the original sheet; 19 December 2026 is provisional, the first named owner is accountable, remaining names collaborate, and `All`, `TBD`, or blank owners are unassigned - Task contract: exactly `Not started`, `In progress`, `Blocked`, and `Done`; no reminders; finance remains a later restricted module - Organizer compatibility redirects: `/organizer-console/volunteers` and `/organizer-console/volunteer-display` - Public form: `/volunteer/december-mega-meetup`, intentionally preserved and standalone without organizer navigation or app chrome - APIs: `POST /api/volunteer-applications`, `GET /api/admin/volunteer-applications` - - Authenticated volunteer access: `admin_memberships.role = volunteer`; the SPA routes volunteers to Annual Conference, the API returns only tasks matched by accountable-owner/collaborator email, and volunteer mutations accept status-only changes on those assigned tasks + - Authenticated volunteer access: `admin_memberships.role = volunteer`; the SPA routes volunteers to Annual Conference without rendering the redundant global navigation tab, the API returns only tasks matched by accountable-owner/collaborator email, and volunteer mutations accept status-only changes on those assigned tasks - Security boundary: non-conference APIs retain the owner/organizer role gate, volunteer-applicant records remain organizer-only, and assigned-task responses redact `internal_note` - Storage: `lib/mock-db/volunteer-applications.ts` uses the existing `app_json_documents` Supabase compatibility store under the `volunteer-applications` key when server-side Supabase is enabled, with local JSON fallback for development - Abuse controls: mandatory production Turnstile using the `volunteer_intake` action, atomic cross-Worker client limits, and one application per campaign/email diff --git a/docs/reference/routes.md b/docs/reference/routes.md index 90cb9b0..7e8f201 100644 --- a/docs/reference/routes.md +++ b/docs/reference/routes.md @@ -37,7 +37,8 @@ There is no public-site header or organizer-link toggle in this deployment. | `/organizer-console/website-preview/events/:slug` | Authenticated, phone-safe preview of one published event rendered from `GET /api/public/meetups/:slug`, with a direct link to inspect the JSON | | `/organizer-console/annual-conference` | Redirects to the active annual-conference edition | | `/organizer-console/annual-conference/2026` | December 2026 annual-conference workspace overview; volunteer summaries contain only assigned work | -| `/organizer-console/annual-conference/2026/work-plan` | Shared conference task plan for organizers; volunteers receive only tasks where they are accountable or collaborating and may update status only | +| `/organizer-console/annual-conference/2026/work-plan` | Shared conference task plan; organizers receive complete planning controls while volunteers see assigned tasks and may update status only | +| `/organizer-console/annual-conference/:year/timeline` | Edition timeline with phase windows, target-dated tasks, No phase classification, and planning-owner phase controls | | `/organizer-console/annual-conference/2026/volunteers` | December 2026 volunteer-link sharing and private application review | | `/organizer-console/annual-conference/2026/volunteers/display` | Organizer-only TV-safe QR display for the December 2026 volunteer intake form | | `/organizer-console/organizers` | People & Access allowlist for owner, organizer, and restricted volunteer roles; volunteers cannot open this route | @@ -80,9 +81,10 @@ There is no public-site header or organizer-link toggle in this deployment. | `POST /api/volunteer-applications` | Public December 2026 volunteer application submission, protected by mandatory production Turnstile, atomic per-client limits, and email de-duplication | | `GET /api/admin/volunteer-applications` | Organizer-only December 2026 volunteer application read API | | `GET /api/annual-conference/:year/work-plan` | Organizers receive the annual edition and complete work plan; volunteers receive only assigned tasks with organizer-only internal notes removed | +| `GET/POST /api/annual-conference/editions` | List editions or let the latest edition's planning owner create a future edition with an inherited or selected active-organizer owner | +| `POST /api/annual-conference/:year/work-plan` | Add a task; server-restricted to the edition planning owner and requires one accountable owner | | `PATCH /api/annual-conference/:year/work-plan/:taskId` | Organizers may edit an existing task; volunteers may change only the status of a task assigned to them | -| `POST /api/annual-conference/:year/work-plan` | Add a task; server-restricted to `angelateyvi@gmail.com` and requires one accountable owner | -| `PATCH /api/annual-conference/:year/work-plan/:taskId` | Edit an existing task; available to every authenticated organizer | +| `POST/PATCH/DELETE /api/annual-conference/:year/phases*` | Planning-owner phase management with non-overlap and task target-date safeguards | | `/api/quiz*` | Separate quiz and System Design learning-room sessions, reviewed questions, protected presenter controls/state, and anonymous join/answer state | | `/api/public/meetups*` | Read-only website integration API | | `GET /api/public/events` | Read-only generic event feed containing published DevCongress events and approved, published external listings | diff --git a/docs/user-guides/organizer-guide.md b/docs/user-guides/organizer-guide.md index 8683496..5d600f7 100644 --- a/docs/user-guides/organizer-guide.md +++ b/docs/user-guides/organizer-guide.md @@ -28,13 +28,17 @@ Use the `System design` outline type for the monthly architecture scenario. The ## Plan the Annual Conference -Use **Annual Conference** in the primary navigation for the active December edition. The December 2026 overview establishes the conference workspace separately from regular monthly, quarterly, and special events. +Use **Annual Conference** in the primary navigation and select the required edition from the edition switcher. The December 2026 overview remains separate from regular monthly, quarterly, and special events. The current planning owner can create the next edition and either select an active organizer as its planning owner or inherit the previous edition's owner. The Overview keeps the provisional date and delivery progress visible without showing every planning note at once. Use **Edition details** to reveal the current venue and keynote notes, **Open work plan** for delivery ownership, or **Open volunteers** for the live intake form. -Open **Work plan** to see overall status and all eight workstreams before using the compact task ledger. Select a status or workstream to filter, search by task or owner, and open a row only when you need its notes, collaborators, or edit form; creating, viewing, and editing all use the same right-side drawer without resizing the ledger. Choose the one accountable owner from the active-organizer dropdown, then use the Collaborators multi-select for everyone else; names include email addresses so similarly named people remain distinguishable, and the accountable owner cannot also be selected as a collaborator. Choose target dates from the app calendar beside Priority; dates are displayed day-first. Tasks use exactly Not started, In progress, Blocked, or Done. Every organizer can edit tasks. Only Angela (`angelateyvi@gmail.com`) can add a new task. +Open **Work plan** to see overall status and all eight workstreams before using the compact task ledger. Filter by phase—including **No phase**—status, workstream, or owner, and open a row for its details or editor. Choose a phase and target date in the task drawer; dates remain optional but are called out on the Timeline, and an assigned target date cannot exceed the phase end. Every organizer can edit tasks, while the edition planning owner can add tasks. -Volunteer operations now live at **Annual Conference → December 2026 → Volunteers**. From there organizers can copy the existing public sign-up link, open the volunteer form, show its QR display, and review applications. Existing public QR codes and the `/volunteer/december-mega-meetup` link remain valid. +Owner and collaborator selectors show organizer names rather than email addresses. Emails remain the stable stored identity, so changing the presentation does not change task ownership. + +Open **Timeline** for the conference-health dashboard. Its unified top summary shows overall completion and the conference countdown, followed by current phase, planning confidence, overdue, blocked, and due-soon facts without repeating them in separate cards. **Threats to delivery** and **What is coming up?** show no more than two real tasks each; empty panels stay empty rather than showing sample tasks. The page checks the shared plan every 30 seconds and whenever you return to its window, so another organizer's status or date changes automatically update which tasks appear. The conference countdown and other day-sensitive signals refresh automatically using Accra time, including when the page remains open across midnight. **Needs planning** appears while tasks still lack phases or target dates, because the schedule cannot yet support a trustworthy on-track forecast. Use the bounded **Planning gaps** board to clear missing planning data: each task appears once under **Tasks missing a phase and target date**, **Dated tasks missing a phase**, or **Phased tasks missing a target date**. Filter all three lanes by status, scroll each lane independently, and use its pagination controls to move through six cards at a time. Cards show status, workstream, owner, collaborators, phase, target date, and any dependency before you open them. Select any card to open its editor directly; once both fields are present, the task leaves the board and remains in the Work Plan. The planning owner can manage phase names, dates, order, and deletion from the separate phase manager. + +Volunteer operations now live at **Annual Conference → December 2026 → Volunteers**. From there organizers can copy the existing public sign-up link, open the volunteer form, show its QR display, and review names, email addresses, X handles, Slack names, and sign-up times in a compact application table. Existing public QR codes and the `/volunteer/december-mega-meetup` link remain valid. The Overview treats 19 December 2026 as provisional and summarizes live work-plan progress. Finance is not part of this first release; it remains a later restricted module. diff --git a/lib/annual-conference-access.test.ts b/lib/annual-conference-access.test.ts index b357d65..f9dd649 100644 --- a/lib/annual-conference-access.test.ts +++ b/lib/annual-conference-access.test.ts @@ -13,6 +13,7 @@ function task(overrides: Partial = {}): AnnualConferenceTa title: 'Contact conference speakers', details: 'Send the approved invitation.', internal_note: 'Organizer-only negotiation context.', + phase_id: null, workstream: 'programme_speakers', accountable_owner: 'volunteer@example.com', collaborators: [], diff --git a/lib/annual-conference-access.ts b/lib/annual-conference-access.ts index 657683f..160a709 100644 --- a/lib/annual-conference-access.ts +++ b/lib/annual-conference-access.ts @@ -16,6 +16,18 @@ export function isAnnualConferenceTaskAssignedTo( || task.collaborators.some((collaborator) => normalizedIdentity(collaborator) === identity); } +export function canEditAnnualConferenceTask( + task: Pick, + memberEmail: string | null | undefined, + planningOwnerEmail: string | null | undefined, +): boolean { + const memberIdentity = normalizedIdentity(memberEmail); + if (!memberIdentity) return false; + + return memberIdentity === normalizedIdentity(planningOwnerEmail) + || isAnnualConferenceTaskAssignedTo(task, memberEmail); +} + export function annualConferenceTasksForMember( tasks: AnnualConferenceTask[], role: AdminRole, diff --git a/lib/annual-conference-work-plan.test.ts b/lib/annual-conference-work-plan.test.ts index 0e9ce2e..5bbc282 100644 --- a/lib/annual-conference-work-plan.test.ts +++ b/lib/annual-conference-work-plan.test.ts @@ -1,19 +1,26 @@ import { describe, expect, it } from 'vitest'; import { ANNUAL_CONFERENCE_2026_EDITION, + ANNUAL_CONFERENCE_2026_PHASES, ANNUAL_CONFERENCE_2026_SEED_TASKS, ANNUAL_CONFERENCE_TASK_CREATOR_EMAIL, ANNUAL_CONFERENCE_TASK_STATUSES, annualConferenceOwnershipNeedsActiveOrganizerLookup, + calculateAnnualConferenceHealth, canCreateAnnualConferenceTask, + canManageAnnualConferencePlanning, + defaultAnnualConferencePhaseScope, + filterAnnualConferenceTasksByPhase, summarizeAnnualConferenceWorkPlan, validateAnnualConferenceTaskOwnership, + validateAnnualConferencePhaseDates, + validateAnnualConferenceTaskSchedule, } from './annual-conference-work-plan'; describe('annual conference work plan', () => { - it('seeds the 26 non-empty spreadsheet rows once', () => { - expect(ANNUAL_CONFERENCE_2026_SEED_TASKS).toHaveLength(26); - expect(new Set(ANNUAL_CONFERENCE_2026_SEED_TASKS.map((task) => task.source_row)).size).toBe(26); + it('seeds the 26 spreadsheet rows and the Phase 1 volunteer recruitment task once', () => { + expect(ANNUAL_CONFERENCE_2026_SEED_TASKS).toHaveLength(27); + expect(new Set(ANNUAL_CONFERENCE_2026_SEED_TASKS.map((task) => task.source_row)).size).toBe(27); expect(ANNUAL_CONFERENCE_2026_EDITION.provisional_date).toBe('2026-12-19'); expect(ANNUAL_CONFERENCE_2026_EDITION.date_status).toBe('provisional'); }); @@ -27,14 +34,60 @@ describe('annual conference work plan', () => { ]); }); + it('seeds the fixed 2026 phases and the confirmed Phase 1 work', () => { + expect(ANNUAL_CONFERENCE_2026_PHASES.map((phase) => ({ + name: phase.name, + starts_on: phase.starts_on, + ends_on: phase.ends_on, + }))).toEqual([ + { name: 'Phase 1', starts_on: '2026-08-01', ends_on: '2026-08-31' }, + { name: 'Phase 2', starts_on: '2026-09-01', ends_on: '2026-12-19' }, + ]); + expect(ANNUAL_CONFERENCE_2026_SEED_TASKS + .filter((task) => task.phase_id === ANNUAL_CONFERENCE_2026_PHASES[0].id) + .map((task) => task.title)).toEqual([ + 'Venue', + 'Keynote speaker(s)', + 'Call for Speakers (announce)', + 'Call for Volunteers', + 'Flyer Designs', + 'Backdrop/Stage Designs', + 'Website/Registration Page', + 'Sponsorship Packages', + 'Call for sponsors and partners', + 'Photography', + 'Videography/Livestream', + 'Volunteer recruitment', + ]); + expect(ANNUAL_CONFERENCE_2026_SEED_TASKS.filter((task) => task.phase_id === null)).toHaveLength(15); + }); + + it('defaults phase-scoped views to the current delivery phase', () => { + expect(defaultAnnualConferencePhaseScope(ANNUAL_CONFERENCE_2026_PHASES, '2026-08-03')) + .toBe(ANNUAL_CONFERENCE_2026_PHASES[0].id); + expect(defaultAnnualConferencePhaseScope(ANNUAL_CONFERENCE_2026_PHASES, '2026-10-01')) + .toBe(ANNUAL_CONFERENCE_2026_PHASES[1].id); + expect(defaultAnnualConferencePhaseScope(ANNUAL_CONFERENCE_2026_PHASES, '2026-07-01')) + .toBe(ANNUAL_CONFERENCE_2026_PHASES[0].id); + }); + + it('uses the same phase scope for every page-level task calculation', () => { + expect(filterAnnualConferenceTasksByPhase( + ANNUAL_CONFERENCE_2026_SEED_TASKS, + ANNUAL_CONFERENCE_2026_PHASES[0].id, + )).toHaveLength(12); + expect(filterAnnualConferenceTasksByPhase(ANNUAL_CONFERENCE_2026_SEED_TASKS, 'unassigned')).toHaveLength(15); + expect(filterAnnualConferenceTasksByPhase(ANNUAL_CONFERENCE_2026_SEED_TASKS, 'all')).toHaveLength(27); + }); + it('makes the first listed owner accountable and the rest collaborators', () => { const venue = ANNUAL_CONFERENCE_2026_SEED_TASKS.find((task) => task.title === 'Venue'); const sponsorship = ANNUAL_CONFERENCE_2026_SEED_TASKS.find((task) => task.title === 'Sponsorship Packages'); const theme = ANNUAL_CONFERENCE_2026_SEED_TASKS.find((task) => task.title === 'Theme'); expect(venue).toMatchObject({ - accountable_owner: 'Angela', - collaborators: ['Elijah', 'Elvis'], + accountable_owner: 'Elijah', + collaborators: ['Elvis'], }); expect(sponsorship).toMatchObject({ accountable_owner: 'Dede', @@ -60,6 +113,26 @@ describe('annual conference work plan', () => { expect(canCreateAnnualConferenceTask(' ANGELATEYVI@GMAIL.COM ')).toBe(true); expect(canCreateAnnualConferenceTask('another-organizer@devcongress.org')).toBe(false); expect(canCreateAnnualConferenceTask(null)).toBe(false); + expect(canCreateAnnualConferenceTask('new-owner@devcongress.org', 'new-owner@devcongress.org')).toBe(true); + expect(canManageAnnualConferencePlanning('ANGELA@DEVCONGRESS.ORG', 'angela@devcongress.org')).toBe(true); + }); + + it('rejects overlapping phases and target dates beyond a selected phase', () => { + expect(validateAnnualConferencePhaseDates({ + starts_on: '2026-08-15', + ends_on: '2026-09-15', + }, ANNUAL_CONFERENCE_2026_PHASES)).toBe('Phase dates cannot overlap another phase.'); + + expect(validateAnnualConferenceTaskSchedule({ + phase_id: ANNUAL_CONFERENCE_2026_PHASES[0].id, + target_date: '2026-09-01', + }, ANNUAL_CONFERENCE_2026_PHASES)).toContain('on or before 2026-08-31'); + + expect(validateAnnualConferenceTaskSchedule({ + phase_id: ANNUAL_CONFERENCE_2026_PHASES[1].id, + target_date: null, + }, ANNUAL_CONFERENCE_2026_PHASES)).toBeNull(); + expect(validateAnnualConferenceTaskSchedule({ phase_id: null, target_date: null }, ANNUAL_CONFERENCE_2026_PHASES)).toBeNull(); }); it('normalizes ownership emails and deduplicates collaborators case-insensitively', () => { @@ -131,22 +204,22 @@ describe('annual conference work plan', () => { }); it('preserves unchanged legacy name assignments without an organizer lookup', () => { - const venue = ANNUAL_CONFERENCE_2026_SEED_TASKS.find((task) => task.title === 'Venue'); - expect(venue).toBeDefined(); + const sponsorship = ANNUAL_CONFERENCE_2026_SEED_TASKS.find((task) => task.title === 'Sponsorship Packages'); + expect(sponsorship).toBeDefined(); const update = { - title: 'Venue shortlist', - accountable_owner: ' angela ', - collaborators: ['ELVIS', 'Elijah', 'elvis'], + title: 'Sponsorship package shortlist', + accountable_owner: ' dede ', + collaborators: ['PHILIPA', 'Angela', 'philipa'], }; - expect(annualConferenceOwnershipNeedsActiveOrganizerLookup(update, venue)).toBe(false); - expect(validateAnnualConferenceTaskOwnership(update, [], venue)).toEqual({ + expect(annualConferenceOwnershipNeedsActiveOrganizerLookup(update, sponsorship)).toBe(false); + expect(validateAnnualConferenceTaskOwnership(update, [], sponsorship)).toEqual({ ok: true, value: { - title: 'Venue shortlist', - accountable_owner: 'Angela', - collaborators: ['Elijah', 'Elvis'], + title: 'Sponsorship package shortlist', + accountable_owner: 'Dede', + collaborators: ['Angela', 'Philipa'], }, }); }); @@ -175,31 +248,87 @@ describe('annual conference work plan', () => { }); it('allows a legacy collaborator to remain while another legacy collaborator is removed', () => { - const venue = ANNUAL_CONFERENCE_2026_SEED_TASKS.find((task) => task.title === 'Venue'); - expect(venue).toBeDefined(); + const sponsorship = ANNUAL_CONFERENCE_2026_SEED_TASKS.find((task) => task.title === 'Sponsorship Packages'); + expect(sponsorship).toBeDefined(); const update = { - collaborators: ['ELIJAH'], + collaborators: ['PHILIPA'], }; - expect(annualConferenceOwnershipNeedsActiveOrganizerLookup(update, venue)).toBe(false); - expect(validateAnnualConferenceTaskOwnership(update, [], venue)).toEqual({ + expect(annualConferenceOwnershipNeedsActiveOrganizerLookup(update, sponsorship)).toBe(false); + expect(validateAnnualConferenceTaskOwnership(update, [], sponsorship)).toEqual({ ok: true, value: { - collaborators: ['Elijah'], + collaborators: ['Philipa'], }, }); }); it('summarizes the imported starting point', () => { expect(summarizeAnnualConferenceWorkPlan(ANNUAL_CONFERENCE_2026_SEED_TASKS)).toEqual({ - total: 26, + total: 27, done: 2, in_progress: 0, blocked: 0, - not_started: 24, - unassigned: 11, - completion_percent: 8, + not_started: 25, + unassigned: 15, + completion_percent: 7, + }); + }); + + it('keeps readiness in planning mode while dates or phase assignments are missing', () => { + const snapshot = calculateAnnualConferenceHealth( + ANNUAL_CONFERENCE_2026_SEED_TASKS, + ANNUAL_CONFERENCE_2026_PHASES, + '2026-08-02', + ); + + expect(snapshot).toMatchObject({ + readiness: 'needs_planning', + total: 27, + done: 2, + completion_percent: 7, + scheduled: 0, + classified: 12, + planning_confidence_percent: 30, + overdue: 0, + due_soon: 0, + }); + expect(snapshot.phase_health).toEqual([ + { + phase_id: ANNUAL_CONFERENCE_2026_PHASES[0].id, + total: 12, + done: 0, + completion_percent: 0, + time_elapsed_percent: 6, + }, + { + phase_id: ANNUAL_CONFERENCE_2026_PHASES[1].id, + total: 0, + done: 0, + completion_percent: 0, + time_elapsed_percent: 0, + }, + ]); + }); + + it('marks a fully planned schedule off track when unfinished work is overdue', () => { + const tasks = ANNUAL_CONFERENCE_2026_SEED_TASKS.slice(0, 2).map((task, index) => ({ + ...task, + phase_id: ANNUAL_CONFERENCE_2026_PHASES[0].id, + accountable_owner: 'Angela', + target_date: index === 0 ? '2026-08-01' : '2026-08-08', + status: index === 0 ? 'not_started' as const : 'in_progress' as const, + })); + + expect(calculateAnnualConferenceHealth(tasks, ANNUAL_CONFERENCE_2026_PHASES, '2026-08-02')).toMatchObject({ + readiness: 'off_track', + scheduled: 2, + classified: 2, + assigned: 2, + planning_confidence_percent: 100, + overdue: 1, + due_soon: 1, }); }); }); diff --git a/lib/annual-conference-work-plan.ts b/lib/annual-conference-work-plan.ts index 416098b..378b441 100644 --- a/lib/annual-conference-work-plan.ts +++ b/lib/annual-conference-work-plan.ts @@ -45,6 +45,7 @@ export interface AnnualConferenceTask { title: string; details: string | null; internal_note: string | null; + phase_id: string | null; workstream: AnnualConferenceWorkstream; accountable_owner: string | null; collaborators: string[]; @@ -66,6 +67,7 @@ export interface AnnualConferenceTaskCreateInput { title: string; details?: string | null; internal_note?: string | null; + phase_id?: string | null; workstream: AnnualConferenceWorkstream; accountable_owner: string; collaborators?: string[]; @@ -81,6 +83,7 @@ export type AnnualConferenceTaskUpdateInput = Partial< | 'title' | 'details' | 'internal_note' + | 'phase_id' | 'workstream' | 'accountable_owner' | 'collaborators' @@ -101,6 +104,67 @@ export interface AnnualConferenceWorkPlanSummary { completion_percent: number; } +export type AnnualConferenceReadiness = + | 'complete' + | 'on_track' + | 'at_risk' + | 'off_track' + | 'needs_planning'; + +export interface AnnualConferencePhaseHealth { + phase_id: string; + total: number; + done: number; + completion_percent: number; + time_elapsed_percent: number; +} + +export interface AnnualConferenceHealthSnapshot { + readiness: AnnualConferenceReadiness; + total: number; + done: number; + completion_percent: number; + scheduled: number; + classified: number; + assigned: number; + planning_confidence_percent: number; + blocked: number; + overdue: number; + due_soon: number; + phase_health: AnnualConferencePhaseHealth[]; +} + +export interface AnnualConferencePhase { + id: string; + edition_id: string; + name: string; + starts_on: string; + ends_on: string; + sort_order: number; + created_by_email: string | null; + updated_by_email: string | null; + created_at: string; + updated_at: string; +} + +export interface AnnualConferenceEditionCreateInput { + year: number; + name: string; + label: string; + provisional_date: string; + task_creator_email?: string | null; +} + +export interface AnnualConferencePhaseCreateInput { + name: string; + starts_on: string; + ends_on: string; +} + +export type AnnualConferencePhaseUpdateInput = Partial< + Pick +>; + type AnnualConferenceTaskOwnershipInput = { accountable_owner?: string | null; collaborators?: string[]; @@ -153,7 +217,7 @@ export const ANNUAL_CONFERENCE_2026_EDITION: AnnualConferenceEdition = { type SeedTask = Omit< AnnualConferenceTask, - 'id' | 'edition_id' | 'source' | 'source_row' | 'sort_order' | 'created_by_email' | 'updated_by_email' | 'completed_at' | 'created_at' | 'updated_at' + 'id' | 'edition_id' | 'phase_id' | 'source' | 'source_row' | 'sort_order' | 'created_by_email' | 'updated_by_email' | 'completed_at' | 'created_at' | 'updated_at' > & { source_row: number; }; @@ -191,8 +255,8 @@ const seedTasks: SeedTask[] = [ details: 'Confirm the location, capacity, and breakout rooms.', internal_note: 'Current candidates: UPSA or Accra Digital Centre.', workstream: 'venue_production_logistics', - accountable_owner: 'Angela', - collaborators: ['Elijah', 'Elvis'], + accountable_owner: 'Elijah', + collaborators: ['Elvis'], priority: null, target_date: null, status: 'not_started', @@ -243,7 +307,7 @@ const seedTasks: SeedTask[] = [ details: 'Announce volunteer recruitment.', internal_note: null, workstream: 'volunteers', - accountable_owner: 'Elvis', + accountable_owner: null, collaborators: ['Ernest'], priority: null, target_date: null, @@ -308,7 +372,7 @@ const seedTasks: SeedTask[] = [ details: 'Prepare digital and print versions in the required social and email sizes.', internal_note: null, workstream: 'creative_marketing', - accountable_owner: 'Emmanuel', + accountable_owner: null, collaborators: [], priority: null, target_date: null, @@ -321,7 +385,7 @@ const seedTasks: SeedTask[] = [ details: 'Design the main stage, photo wall, and other branding elements.', internal_note: null, workstream: 'creative_marketing', - accountable_owner: 'Emmanuel', + accountable_owner: null, collaborators: [], priority: null, target_date: null, @@ -497,13 +561,33 @@ const seedTasks: SeedTask[] = [ status: 'not_started', dependency_note: null, }, + { + source_row: 28, + title: 'Volunteer recruitment', + details: 'Recruit new volunteers and coordinate promotion of the call for volunteers.', + internal_note: null, + workstream: 'volunteers', + accountable_owner: null, + collaborators: [], + priority: null, + target_date: null, + status: 'not_started', + dependency_note: 'Coordinate with the organizer responsible for conference calls and announcements.', + }, ]; +const ANNUAL_CONFERENCE_2026_PHASE_1_SOURCE_ROWS = new Set([ + 4, 5, 6, 8, 13, 14, 15, 16, 17, 19, 20, 28, +]); + export const ANNUAL_CONFERENCE_2026_SEED_TASKS: AnnualConferenceTask[] = seedTasks.map((task, index) => ({ ...task, id: `20260000-0000-4000-8000-${String(index + 2).padStart(12, '0')}`, edition_id: ANNUAL_CONFERENCE_2026_EDITION_ID, - source: 'excel_seed', + phase_id: ANNUAL_CONFERENCE_2026_PHASE_1_SOURCE_ROWS.has(task.source_row) + ? '20260000-0000-4000-8000-000000000101' + : null, + source: task.source_row === 28 ? 'manual' : 'excel_seed', sort_order: index + 1, created_by_email: null, updated_by_email: null, @@ -512,8 +596,96 @@ export const ANNUAL_CONFERENCE_2026_SEED_TASKS: AnnualConferenceTask[] = seedTas updated_at: SEEDED_AT, })); -export function canCreateAnnualConferenceTask(email: string | null | undefined): boolean { - return String(email ?? '').trim().toLowerCase() === ANNUAL_CONFERENCE_TASK_CREATOR_EMAIL; +export const ANNUAL_CONFERENCE_2026_PHASES: AnnualConferencePhase[] = [ + { + id: '20260000-0000-4000-8000-000000000101', + edition_id: ANNUAL_CONFERENCE_2026_EDITION_ID, + name: 'Phase 1', + starts_on: '2026-08-01', + ends_on: '2026-08-31', + sort_order: 1, + created_by_email: ANNUAL_CONFERENCE_TASK_CREATOR_EMAIL, + updated_by_email: ANNUAL_CONFERENCE_TASK_CREATOR_EMAIL, + created_at: SEEDED_AT, + updated_at: SEEDED_AT, + }, + { + id: '20260000-0000-4000-8000-000000000102', + edition_id: ANNUAL_CONFERENCE_2026_EDITION_ID, + name: 'Phase 2', + starts_on: '2026-09-01', + ends_on: '2026-12-19', + sort_order: 2, + created_by_email: ANNUAL_CONFERENCE_TASK_CREATOR_EMAIL, + updated_by_email: ANNUAL_CONFERENCE_TASK_CREATOR_EMAIL, + created_at: SEEDED_AT, + updated_at: SEEDED_AT, + }, +]; + +export function canManageAnnualConferencePlanning( + email: string | null | undefined, + taskCreatorEmail = ANNUAL_CONFERENCE_TASK_CREATOR_EMAIL, +): boolean { + return String(email ?? '').trim().toLowerCase() === taskCreatorEmail.trim().toLowerCase(); +} + +export function canCreateAnnualConferenceTask( + email: string | null | undefined, + taskCreatorEmail = ANNUAL_CONFERENCE_TASK_CREATOR_EMAIL, +): boolean { + return canManageAnnualConferencePlanning(email, taskCreatorEmail); +} + +export function defaultAnnualConferencePhaseScope( + phases: AnnualConferencePhase[], + today: string, +): string { + const orderedPhases = [...phases].sort( + (left, right) => left.sort_order - right.sort_order || left.starts_on.localeCompare(right.starts_on), + ); + const current = orderedPhases.find((phase) => today >= phase.starts_on && today <= phase.ends_on); + if (current) return current.id; + + const next = orderedPhases.find((phase) => phase.starts_on > today); + return next?.id ?? orderedPhases.at(-1)?.id ?? 'all'; +} + +export function filterAnnualConferenceTasksByPhase( + tasks: AnnualConferenceTask[], + phaseScope: string, +): AnnualConferenceTask[] { + if (phaseScope === 'all') return tasks; + if (phaseScope === 'unassigned') return tasks.filter((task) => !task.phase_id); + return tasks.filter((task) => task.phase_id === phaseScope); +} + +export function validateAnnualConferencePhaseDates( + input: Pick, + phases: AnnualConferencePhase[], + phaseId?: string, +): string | null { + if (input.ends_on < input.starts_on) return 'Phase end date cannot be before its start date.'; + + const overlaps = phases.some((phase) => ( + phase.id !== phaseId + && input.starts_on <= phase.ends_on + && input.ends_on >= phase.starts_on + )); + return overlaps ? 'Phase dates cannot overlap another phase.' : null; +} + +export function validateAnnualConferenceTaskSchedule( + input: Pick, + phases: AnnualConferencePhase[], +): string | null { + if (!input.phase_id) return null; + const phase = phases.find((item) => item.id === input.phase_id); + if (!phase) return 'The selected phase does not belong to this conference edition.'; + if (input.target_date && input.target_date > phase.ends_on) { + return `Target date must be on or before ${phase.ends_on}, the end of ${phase.name}.`; + } + return null; } function ownershipKey(value: string): string { @@ -701,3 +873,79 @@ export function annualConferenceWorkstreamCounts( return result; }, {} as Record); } + +function dateOrdinal(value: string): number { + return Date.parse(`${value}T12:00:00Z`) / 86_400_000; +} + +function percent(part: number, whole: number): number { + return whole === 0 ? 0 : Math.round((part / whole) * 100); +} + +export function calculateAnnualConferenceHealth( + tasks: AnnualConferenceTask[], + phases: AnnualConferencePhase[], + today: string, +): AnnualConferenceHealthSnapshot { + const total = tasks.length; + const done = tasks.filter((task) => task.status === 'done').length; + const scheduled = tasks.filter((task) => Boolean(task.target_date)).length; + const classified = tasks.filter((task) => Boolean(task.phase_id)).length; + const assigned = tasks.filter((task) => Boolean(task.accountable_owner)).length; + const incomplete = tasks.filter((task) => task.status !== 'done'); + const blocked = incomplete.filter((task) => task.status === 'blocked').length; + const overdue = incomplete.filter((task) => Boolean(task.target_date && task.target_date < today)).length; + const dueSoonLimit = dateOrdinal(today) + 7; + const dueSoon = incomplete.filter((task) => { + if (!task.target_date || task.target_date < today) return false; + return dateOrdinal(task.target_date) <= dueSoonLimit; + }).length; + + const phaseHealth = phases.map((phase) => { + const phaseTasks = tasks.filter((task) => task.phase_id === phase.id); + const phaseDone = phaseTasks.filter((task) => task.status === 'done').length; + const duration = Math.max(1, dateOrdinal(phase.ends_on) - dateOrdinal(phase.starts_on) + 1); + const elapsed = Math.min(duration, Math.max(0, dateOrdinal(today) - dateOrdinal(phase.starts_on) + 1)); + + return { + phase_id: phase.id, + total: phaseTasks.length, + done: phaseDone, + completion_percent: percent(phaseDone, phaseTasks.length), + time_elapsed_percent: percent(elapsed, duration), + }; + }); + + const currentPhaseHealth = phaseHealth.find((health) => { + const phase = phases.find((item) => item.id === health.phase_id); + return Boolean(phase && today >= phase.starts_on && today <= phase.ends_on); + }); + const planningConfidence = total === 0 + ? 100 + : percent(scheduled + classified + assigned, total * 3); + + let readiness: AnnualConferenceReadiness = 'on_track'; + if (total > 0 && done === total) readiness = 'complete'; + else if (scheduled < total || classified < total) readiness = 'needs_planning'; + else if (overdue > 0 || blocked > 0) readiness = 'off_track'; + else if ( + currentPhaseHealth + && currentPhaseHealth.total > 0 + && currentPhaseHealth.time_elapsed_percent - currentPhaseHealth.completion_percent >= 10 + ) readiness = 'at_risk'; + + return { + readiness, + total, + done, + completion_percent: percent(done, total), + scheduled, + classified, + assigned, + planning_confidence_percent: planningConfidence, + blocked, + overdue, + due_soon: dueSoon, + phase_health: phaseHealth, + }; +} diff --git a/lib/app-boot.ts b/lib/app-boot.ts index 36f4445..fa36716 100644 --- a/lib/app-boot.ts +++ b/lib/app-boot.ts @@ -1,13 +1,13 @@ export const APP_BOOT_STYLES = String.raw` -:root{color:#111;background:#f5f2e8;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +:root{color:#111;background:#f5f2e8;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",ui-sans-serif,system-ui,sans-serif} html,body,#app{width:100%;min-height:100%;margin:0} -.app-boot{box-sizing:border-box;display:flex;min-height:100vh;min-height:100svh;flex-direction:column;overflow:hidden;background:#f5f2e8} +.app-boot{box-sizing:border-box;display:flex;min-height:100vh;min-height:100svh;flex-direction:column;overflow:hidden;background:#f5f2e8;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",ui-sans-serif,system-ui,sans-serif} .app-boot *,.app-boot *::before,.app-boot *::after{box-sizing:border-box} .app-boot__content{display:grid;width:min(100% - 3rem,80rem);flex:1;align-content:center;margin-inline:auto;padding-block:clamp(3rem,12vh,8rem)} .app-boot__message{max-width:44rem} -.app-boot__eyebrow{margin:0 0 1rem;color:#e8117f;font-family:"IBM Plex Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.65rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase} -.app-boot__title{max-width:12ch;margin:0;font-size:clamp(2.75rem,7vw,6.5rem);font-weight:800;letter-spacing:-.045em;line-height:.92} -.app-boot__copy{max-width:32rem;margin:1.5rem 0 0;color:#555;font-size:clamp(1rem,1.5vw,1.2rem);line-height:1.6} +.app-boot__eyebrow{margin:0 0 .85rem;color:#e8117f;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.65rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase} +.app-boot__title{max-width:14ch;margin:0;font-size:clamp(2.25rem,5.5vw,4.75rem);font-weight:600;letter-spacing:-.035em;line-height:.98} +.app-boot__copy{max-width:32rem;margin:1.25rem 0 0;color:#555;font-size:clamp(.95rem,1.35vw,1.125rem);line-height:1.6} .app-boot__signal{display:grid;width:8rem;height:.35rem;grid-template-columns:4fr 2fr 1fr;gap:.25rem;margin-top:2rem} .app-boot__signal span{border-radius:999px;background:#f5e642} .app-boot__signal span:nth-child(2){background:#e8117f} diff --git a/lib/design-system.ts b/lib/design-system.ts index 22b0ad3..f211a41 100644 --- a/lib/design-system.ts +++ b/lib/design-system.ts @@ -46,8 +46,8 @@ export const designSystem = { body: 400, emphasis: 500, label: 600, - heading: 700, - display: 800, + heading: 600, + display: 700, }, // Common Styles diff --git a/lib/mock-db/annual-conference-work-plan.ts b/lib/mock-db/annual-conference-work-plan.ts index ee3c41d..9cf84a2 100644 --- a/lib/mock-db/annual-conference-work-plan.ts +++ b/lib/mock-db/annual-conference-work-plan.ts @@ -1,7 +1,12 @@ import { ANNUAL_CONFERENCE_2026_EDITION, + ANNUAL_CONFERENCE_2026_PHASES, ANNUAL_CONFERENCE_2026_SEED_TASKS, type AnnualConferenceEdition, + type AnnualConferenceEditionCreateInput, + type AnnualConferencePhase, + type AnnualConferencePhaseCreateInput, + type AnnualConferencePhaseUpdateInput, type AnnualConferenceTask, type AnnualConferenceTaskCreateInput, type AnnualConferenceTaskUpdateInput, @@ -10,26 +15,144 @@ import { readData, updateData } from '@/lib/mock-db'; import { generateId, now } from '@/lib/utils'; const FILE = 'annual-conference-tasks'; +const EDITIONS_FILE = 'annual-conference-editions'; +const PHASES_FILE = 'annual-conference-phases'; function seededTasks(tasks: AnnualConferenceTask[]): AnnualConferenceTask[] { const source = tasks.length > 0 ? tasks : ANNUAL_CONFERENCE_2026_SEED_TASKS; return source.map((task) => ({ ...task, + phase_id: task.phase_id ?? null, collaborators: [...task.collaborators], })); } export async function getMockAnnualConferenceWorkPlan( year: number, -): Promise<{ edition: AnnualConferenceEdition; tasks: AnnualConferenceTask[] } | undefined> { - if (year !== ANNUAL_CONFERENCE_2026_EDITION.year) return undefined; +): Promise<{ edition: AnnualConferenceEdition; phases: AnnualConferencePhase[]; tasks: AnnualConferenceTask[] } | undefined> { + const editions = await listMockAnnualConferenceEditions(); + const edition = editions.find((item) => item.year === year); + if (!edition) return undefined; + const storedPhases = await readData(PHASES_FILE); + const phases = storedPhases.length > 0 ? storedPhases : ANNUAL_CONFERENCE_2026_PHASES; return { - edition: { ...ANNUAL_CONFERENCE_2026_EDITION }, - tasks: seededTasks(await readData(FILE)), + edition: { ...edition }, + phases: phases.filter((phase) => phase.edition_id === edition.id).map((phase) => ({ ...phase })), + tasks: seededTasks(await readData(FILE)).filter((task) => task.edition_id === edition.id), }; } +export async function listMockAnnualConferenceEditions(): Promise { + const stored = await readData(EDITIONS_FILE); + const editions = stored.some((edition) => edition.id === ANNUAL_CONFERENCE_2026_EDITION.id) + ? stored + : [ANNUAL_CONFERENCE_2026_EDITION, ...stored]; + return editions.map((edition) => ({ ...edition })).sort((a, b) => b.year - a.year); +} + +export async function createMockAnnualConferenceEdition( + input: AnnualConferenceEditionCreateInput, + taskCreatorEmail: string, +): Promise { + return updateData(EDITIONS_FILE, (current) => { + const timestamp = now(); + const edition: AnnualConferenceEdition = { + id: generateId(), + year: input.year, + name: input.name, + label: input.label, + provisional_date: input.provisional_date, + date_status: 'provisional', + venue_note: null, + keynote_note: null, + task_creator_email: taskCreatorEmail, + created_at: timestamp, + updated_at: timestamp, + }; + return { data: [...current, edition], result: edition }; + }); +} + +export async function createMockAnnualConferencePhase( + editionId: string, + input: AnnualConferencePhaseCreateInput, + actorEmail: string, +): Promise { + return updateData(PHASES_FILE, (current) => { + const phases = current.length > 0 ? current : ANNUAL_CONFERENCE_2026_PHASES; + const timestamp = now(); + const editionPhases = phases.filter((phase) => phase.edition_id === editionId); + const phase: AnnualConferencePhase = { + id: generateId(), + edition_id: editionId, + name: input.name, + starts_on: input.starts_on, + ends_on: input.ends_on, + sort_order: Math.max(0, ...editionPhases.map((item) => item.sort_order)) + 1, + created_by_email: actorEmail, + updated_by_email: actorEmail, + created_at: timestamp, + updated_at: timestamp, + }; + return { data: [...phases, phase], result: phase }; + }); +} + +export async function updateMockAnnualConferencePhase( + editionId: string, + phaseId: string, + input: AnnualConferencePhaseUpdateInput, + actorEmail: string, +): Promise { + return updateData(PHASES_FILE, (current) => { + const phases = current.length > 0 ? current : ANNUAL_CONFERENCE_2026_PHASES; + const index = phases.findIndex((phase) => phase.edition_id === editionId && phase.id === phaseId); + if (index === -1) return { data: phases, result: undefined }; + const updated = { ...phases[index], ...input, updated_by_email: actorEmail, updated_at: now() }; + phases[index] = updated; + return { data: phases, result: updated }; + }); +} + +export async function deleteMockAnnualConferencePhase( + editionId: string, + phaseId: string, +): Promise { + const deleted = await updateData(PHASES_FILE, (current) => { + const phases = current.length > 0 ? current : ANNUAL_CONFERENCE_2026_PHASES; + const next = phases.filter((phase) => phase.edition_id !== editionId || phase.id !== phaseId); + return { data: next, result: next.length !== phases.length }; + }); + if (deleted) { + await updateData(FILE, (current) => ({ + data: seededTasks(current).map((task) => task.phase_id === phaseId ? { ...task, phase_id: null } : task), + result: null, + })); + } + return deleted; +} + +export async function reorderMockAnnualConferencePhases( + editionId: string, + phaseIds: string[], + actorEmail: string, +): Promise { + return updateData(PHASES_FILE, (current) => { + const phases = current.length > 0 ? current : ANNUAL_CONFERENCE_2026_PHASES; + const timestamp = now(); + const order = new Map(phaseIds.map((id, index) => [id, index + 1])); + const next = phases.map((phase) => phase.edition_id === editionId && order.has(phase.id) + ? { ...phase, sort_order: order.get(phase.id)!, updated_by_email: actorEmail, updated_at: timestamp } + : phase); + return { + data: next, + result: next.filter((phase) => phase.edition_id === editionId) + .sort((left, right) => left.sort_order - right.sort_order), + }; + }); +} + export async function createMockAnnualConferenceTask( edition: AnnualConferenceEdition, input: AnnualConferenceTaskCreateInput, @@ -45,6 +168,7 @@ export async function createMockAnnualConferenceTask( title: input.title, details: input.details ?? null, internal_note: input.internal_note ?? null, + phase_id: input.phase_id ?? null, workstream: input.workstream, accountable_owner: input.accountable_owner, collaborators: input.collaborators ?? [], diff --git a/lib/supabase/annual-conference-work-plan.ts b/lib/supabase/annual-conference-work-plan.ts index 51f1921..48ef7e4 100644 --- a/lib/supabase/annual-conference-work-plan.ts +++ b/lib/supabase/annual-conference-work-plan.ts @@ -1,6 +1,10 @@ import type { Context } from 'hono'; import type { AnnualConferenceEdition, + AnnualConferenceEditionCreateInput, + AnnualConferencePhase, + AnnualConferencePhaseCreateInput, + AnnualConferencePhaseUpdateInput, AnnualConferenceTask, AnnualConferenceTaskCreateInput, AnnualConferenceTaskUpdateInput, @@ -9,12 +13,16 @@ import { getSupabaseAdminClient, isSupabaseRuntimeEnabled } from '@/lib/supabase import type { Database } from '@/types/supabase'; type EditionRow = Database['public']['Tables']['annual_conference_editions']['Row']; +type EditionInsert = Database['public']['Tables']['annual_conference_editions']['Insert']; +type PhaseRow = Database['public']['Tables']['annual_conference_phases']['Row']; +type PhaseInsert = Database['public']['Tables']['annual_conference_phases']['Insert']; type TaskRow = Database['public']['Tables']['annual_conference_tasks']['Row']; type TaskInsert = Database['public']['Tables']['annual_conference_tasks']['Insert']; type TaskUpdate = Database['public']['Tables']['annual_conference_tasks']['Update']; export interface AnnualConferenceWorkPlanStoreResult { edition: AnnualConferenceEdition; + phases: AnnualConferencePhase[]; tasks: AnnualConferenceTask[]; } @@ -26,6 +34,45 @@ function toTask(row: TaskRow): AnnualConferenceTask { return row; } +function toPhase(row: PhaseRow): AnnualConferencePhase { + return row; +} + +export async function listSupabaseAnnualConferenceEditions( + c?: Context, +): Promise { + if (!isSupabaseRuntimeEnabled(c)) return null; + const result = await getSupabaseAdminClient(c) + .from('annual_conference_editions') + .select('*') + .order('year', { ascending: false }); + if (result.error) throw new Error(result.error.message); + return result.data.map(toEdition); +} + +export async function createSupabaseAnnualConferenceEdition( + input: AnnualConferenceEditionCreateInput, + taskCreatorEmail: string, + c?: Context, +): Promise { + if (!isSupabaseRuntimeEnabled(c)) return null; + const insert: EditionInsert = { + year: input.year, + name: input.name, + label: input.label, + provisional_date: input.provisional_date, + date_status: 'provisional', + task_creator_email: taskCreatorEmail, + }; + const result = await getSupabaseAdminClient(c) + .from('annual_conference_editions') + .insert(insert) + .select('*') + .single(); + if (result.error) throw new Error(result.error.message); + return toEdition(result.data); +} + export async function getSupabaseAnnualConferenceWorkPlan( year: number, c?: Context, @@ -51,12 +98,112 @@ export async function getSupabaseAnnualConferenceWorkPlan( if (taskResult.error) throw new Error(taskResult.error.message); + const phaseResult = await client + .from('annual_conference_phases') + .select('*') + .eq('edition_id', editionResult.data.id) + .order('sort_order', { ascending: true }) + .order('starts_on', { ascending: true }); + if (phaseResult.error) throw new Error(phaseResult.error.message); + return { edition: toEdition(editionResult.data), + phases: phaseResult.data.map(toPhase), tasks: taskResult.data.map(toTask), }; } +export async function createSupabaseAnnualConferencePhase( + editionId: string, + input: AnnualConferencePhaseCreateInput, + actorEmail: string, + c?: Context, +): Promise { + if (!isSupabaseRuntimeEnabled(c)) return null; + const client = getSupabaseAdminClient(c); + const latest = await client + .from('annual_conference_phases') + .select('sort_order') + .eq('edition_id', editionId) + .order('sort_order', { ascending: false }) + .limit(1) + .maybeSingle(); + if (latest.error) throw new Error(latest.error.message); + const insert: PhaseInsert = { + edition_id: editionId, + name: input.name, + starts_on: input.starts_on, + ends_on: input.ends_on, + sort_order: (latest.data?.sort_order ?? 0) + 1, + created_by_email: actorEmail, + updated_by_email: actorEmail, + }; + const result = await client.from('annual_conference_phases').insert(insert).select('*').single(); + if (result.error) throw new Error(result.error.message); + return toPhase(result.data); +} + +export async function updateSupabaseAnnualConferencePhase( + editionId: string, + phaseId: string, + input: AnnualConferencePhaseUpdateInput, + actorEmail: string, + c?: Context, +): Promise { + if (!isSupabaseRuntimeEnabled(c)) return null; + const result = await getSupabaseAdminClient(c) + .from('annual_conference_phases') + .update({ ...input, updated_by_email: actorEmail }) + .eq('edition_id', editionId) + .eq('id', phaseId) + .select('*') + .maybeSingle(); + if (result.error) throw new Error(result.error.message); + return result.data ? toPhase(result.data) : undefined; +} + +export async function deleteSupabaseAnnualConferencePhase( + editionId: string, + phaseId: string, + c?: Context, +): Promise { + if (!isSupabaseRuntimeEnabled(c)) return null; + const result = await getSupabaseAdminClient(c) + .from('annual_conference_phases') + .delete() + .eq('edition_id', editionId) + .eq('id', phaseId) + .select('id') + .maybeSingle(); + if (result.error) throw new Error(result.error.message); + return Boolean(result.data); +} + +export async function reorderSupabaseAnnualConferencePhases( + phases: AnnualConferencePhase[], + actorEmail: string, + c?: Context, +): Promise { + if (!isSupabaseRuntimeEnabled(c)) return null; + const rows: PhaseInsert[] = phases.map((phase, index) => ({ + id: phase.id, + edition_id: phase.edition_id, + name: phase.name, + starts_on: phase.starts_on, + ends_on: phase.ends_on, + sort_order: index + 1, + created_by_email: phase.created_by_email, + updated_by_email: actorEmail, + created_at: phase.created_at, + })); + const result = await getSupabaseAdminClient(c) + .from('annual_conference_phases') + .upsert(rows, { onConflict: 'id' }) + .select('*'); + if (result.error) throw new Error(result.error.message); + return result.data.map(toPhase).sort((left, right) => left.sort_order - right.sort_order); +} + export async function createSupabaseAnnualConferenceTask( edition: AnnualConferenceEdition, input: AnnualConferenceTaskCreateInput, @@ -82,6 +229,7 @@ export async function createSupabaseAnnualConferenceTask( title: input.title, details: input.details ?? null, internal_note: input.internal_note ?? null, + phase_id: input.phase_id ?? null, workstream: input.workstream, accountable_owner: input.accountable_owner, collaborators: input.collaborators ?? [], diff --git a/server/admin-api-access.test.ts b/server/admin-api-access.test.ts index c5392d7..608ba1e 100644 --- a/server/admin-api-access.test.ts +++ b/server/admin-api-access.test.ts @@ -14,6 +14,11 @@ describe('admin API role policy', () => { expect(adminRolesForApiRequest('/api/events', 'GET')).toEqual(['owner', 'organizer']); }); + it('keeps existing member role changes owner-only', () => { + expect(adminRolesForApiRequest('/api/admin/organizers/member-1/role', 'PATCH')).toEqual(['owner']); + expect(adminRolesForApiRequest('/api/admin/organizers/member-1/role', 'GET')).toEqual(['owner', 'organizer']); + }); + it('does not admit similarly prefixed or malformed paths', () => { expect(adminRolesForApiRequest('/api/annual-conference/2026/work-plan/export', 'GET')).not.toContain('volunteer'); expect(adminRolesForApiRequest('/api/annual-conference/current/work-plan', 'GET')).not.toContain('volunteer'); diff --git a/server/admin-api-access.ts b/server/admin-api-access.ts index 888af0c..dfb84a4 100644 --- a/server/admin-api-access.ts +++ b/server/admin-api-access.ts @@ -1,11 +1,17 @@ import type { AdminRole } from '@/lib/supabase/admin-auth'; const ORGANIZER_ROLES: AdminRole[] = ['owner', 'organizer']; +const OWNER_ROLES: AdminRole[] = ['owner']; const CONFERENCE_MEMBER_ROLES: AdminRole[] = ['owner', 'organizer', 'volunteer']; const ANNUAL_WORK_PLAN_PATH = /^\/api\/annual-conference\/\d{4}\/work-plan$/; const ANNUAL_TASK_PATH = /^\/api\/annual-conference\/\d{4}\/work-plan\/[^/]+$/; +const MEMBERSHIP_ROLE_PATH = /^\/api\/admin\/organizers\/[^/]+\/role$/; export function adminRolesForApiRequest(path: string, method: string): AdminRole[] { + if (method === 'PATCH' && MEMBERSHIP_ROLE_PATH.test(path)) { + return OWNER_ROLES; + } + if (method === 'GET' && ANNUAL_WORK_PLAN_PATH.test(path)) { return CONFERENCE_MEMBER_ROLES; } diff --git a/server/annual-conference-phases.test.ts b/server/annual-conference-phases.test.ts new file mode 100644 index 0000000..160d2ca --- /dev/null +++ b/server/annual-conference-phases.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ANNUAL_CONFERENCE_2026_EDITION, ANNUAL_CONFERENCE_2026_PHASES, ANNUAL_CONFERENCE_2026_SEED_TASKS } from '@/lib/annual-conference-work-plan'; + +const mocks = vi.hoisted(() => ({ + sessionEmail: 'organizer@devcongress.org', + createPhase: vi.fn(), + updateTask: vi.fn(), + audit: vi.fn(), +})); + +vi.mock('@/lib/supabase/admin-auth', async () => { + const actual = await vi.importActual('@/lib/supabase/admin-auth'); + return { + ...actual, + getAdminSession: vi.fn(async () => ({ + authenticated: true as const, + user_id: 'admin-1', + email: mocks.sessionEmail, + display_name: 'Organizer', + role: 'organizer' as const, + session_id: 'session-1', + expires_at: '2099-01-01T00:00:00.000Z', + })), + requireAdmin: vi.fn(async () => null), + recordAdminAudit: mocks.audit, + }; +}); + +vi.mock('@/lib/supabase/annual-conference-work-plan', async () => { + const actual = await vi.importActual('@/lib/supabase/annual-conference-work-plan'); + return { + ...actual, + getSupabaseAnnualConferenceWorkPlan: vi.fn(async () => ({ + edition: ANNUAL_CONFERENCE_2026_EDITION, + phases: ANNUAL_CONFERENCE_2026_PHASES, + tasks: ANNUAL_CONFERENCE_2026_SEED_TASKS, + })), + createSupabaseAnnualConferencePhase: mocks.createPhase, + updateSupabaseAnnualConferenceTask: mocks.updateTask, + }; +}); + +import app from './app'; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sessionEmail = 'organizer@devcongress.org'; + mocks.createPhase.mockResolvedValue(ANNUAL_CONFERENCE_2026_PHASES[0]); +}); + +describe('annual conference phase API', () => { + it('keeps phase management restricted to the edition planning owner', async () => { + const response = await app.request('/api/annual-conference/2026/phases', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Phase 3', starts_on: '2026-12-20', ends_on: '2026-12-31' }), + }); + + expect(response.status).toBe(403); + expect(mocks.createPhase).not.toHaveBeenCalled(); + }); + + it('rejects overlapping phase dates before persistence', async () => { + mocks.sessionEmail = 'angelateyvi@gmail.com'; + const response = await app.request('/api/annual-conference/2026/phases', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Overlap', starts_on: '2026-08-15', ends_on: '2026-09-15' }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'Phase dates cannot overlap another phase.' }); + expect(mocks.createPhase).not.toHaveBeenCalled(); + }); + + it('prevents an organizer from moving a task target beyond its phase end', async () => { + mocks.sessionEmail = 'angelateyvi@gmail.com'; + const task = ANNUAL_CONFERENCE_2026_SEED_TASKS[0]; + const response = await app.request(`/api/annual-conference/2026/work-plan/${task.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + phase_id: ANNUAL_CONFERENCE_2026_PHASES[0].id, + target_date: '2026-09-01', + }), + }); + + expect(response.status).toBe(400); + expect(mocks.updateTask).not.toHaveBeenCalled(); + }); +}); diff --git a/server/annual-conference-volunteer-access.test.ts b/server/annual-conference-volunteer-access.test.ts index d3441bc..499755a 100644 --- a/server/annual-conference-volunteer-access.test.ts +++ b/server/annual-conference-volunteer-access.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { AnnualConferenceTask } from '@/lib/annual-conference-work-plan'; +import type { AdminRole } from '@/types/supabase'; const mocks = vi.hoisted(() => ({ session: { @@ -8,7 +9,7 @@ const mocks = vi.hoisted(() => ({ membership_id: 'volunteer-membership', email: 'volunteer@example.com', display_name: 'Conference Volunteer', - role: 'volunteer' as const, + role: 'volunteer' as AdminRole, expires_at: '2099-01-01T00:00:00.000Z', }, updateTask: vi.fn(), @@ -20,6 +21,7 @@ const assignedTask: AnnualConferenceTask = { title: 'Send speaker invitations', details: 'Contact the approved speakers.', internal_note: 'Private organizer context.', + phase_id: null, workstream: 'programme_speakers', accountable_owner: 'volunteer@example.com', collaborators: [], @@ -86,10 +88,13 @@ vi.mock('@/lib/supabase/annual-conference-work-plan', () => ({ describe('annual conference volunteer API access', () => { beforeEach(() => { + mocks.session.email = 'volunteer@example.com'; + mocks.session.display_name = 'Conference Volunteer'; + mocks.session.role = 'volunteer'; mocks.updateTask.mockReset(); mocks.updateTask.mockImplementation(async (_editionId, _taskId, input) => ({ ...assignedTask, - status: input.status, + ...input, internal_note: 'Private organizer context.', })); }); @@ -105,6 +110,7 @@ describe('annual conference volunteer API access', () => { access_scope: 'assigned', can_create_tasks: false, can_edit_all_tasks: false, + can_edit_assigned_tasks: false, can_update_assigned_task_status: true, }, }); @@ -142,4 +148,55 @@ describe('annual conference volunteer API access', () => { expect(organizerResponse.status).toBe(403); expect(mocks.updateTask).not.toHaveBeenCalled(); }); + + it('rejects an organizer editing a task they do not own or collaborate on', async () => { + mocks.session.role = 'organizer'; + mocks.session.email = 'organizer@example.com'; + const { default: app } = await import('./app'); + + const response = await app.request('http://localhost/api/annual-conference/2026/work-plan/task-unrelated', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Changed title' }), + }); + + expect(response.status).toBe(403); + expect(mocks.updateTask).not.toHaveBeenCalled(); + }); + + it('allows an assigned organizer to edit their task details', async () => { + mocks.session.role = 'organizer'; + mocks.session.email = 'volunteer@example.com'; + const { default: app } = await import('./app'); + + const response = await app.request('http://localhost/api/annual-conference/2026/work-plan/task-assigned', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Updated speaker invitations' }), + }); + + expect(response.status).toBe(200); + expect(mocks.updateTask).toHaveBeenCalledWith( + 'edition-2026', + 'task-assigned', + expect.objectContaining({ title: 'Updated speaker invitations' }), + 'volunteer@example.com', + expect.anything(), + ); + }); + + it('allows the edition planning owner to edit any task', async () => { + mocks.session.role = 'owner'; + mocks.session.email = 'owner@example.com'; + const { default: app } = await import('./app'); + + const response = await app.request('http://localhost/api/annual-conference/2026/work-plan/task-assigned', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Planning owner update' }), + }); + + expect(response.status).toBe(200); + expect(mocks.updateTask).toHaveBeenCalled(); + }); }); diff --git a/server/app.ts b/server/app.ts index 7bab42c..5fde2c0 100644 --- a/server/app.ts +++ b/server/app.ts @@ -42,15 +42,23 @@ import { ANNUAL_CONFERENCE_WORKSTREAMS, annualConferenceOwnershipNeedsActiveOrganizerLookup, canCreateAnnualConferenceTask, + canManageAnnualConferencePlanning, summarizeAnnualConferenceWorkPlan, + validateAnnualConferencePhaseDates, validateAnnualConferenceTaskOwnership, + validateAnnualConferenceTaskSchedule, type AnnualConferenceEdition, + type AnnualConferenceEditionCreateInput, + type AnnualConferencePhase, + type AnnualConferencePhaseCreateInput, + type AnnualConferencePhaseUpdateInput, type AnnualConferenceTask, type AnnualConferenceTaskCreateInput, type AnnualConferenceTaskUpdateInput, } from '@/lib/annual-conference-work-plan'; import { annualConferenceTasksForMember, + canEditAnnualConferenceTask, volunteerCanUpdateAssignedTask, } from '@/lib/annual-conference-access'; import { createEventFormSchema, toCreateEventApiPayload } from '@/src/lib/event-form'; @@ -71,7 +79,7 @@ import { attendanceMonthForEvent, buildAttendanceInsights, buildAttendanceLedger import { getEventChecklist, setEventChecklistItemDisabled, updateEventChecklistItem } from '@/lib/mock-db/event-checklists'; import { createEvent as createMockEvent, deleteEvent as deleteMockEvent, getAllEvents as getAllMockEvents, getEventById as getMockEventById, updateEvent as updateMockEvent } from '@/lib/mock-db/events'; import { eventTestModeEnabled, markTestEventTitle } from '@/lib/event-test-mode'; -import { createMockAnnualConferenceTask, getMockAnnualConferenceWorkPlan, updateMockAnnualConferenceTask } from '@/lib/mock-db/annual-conference-work-plan'; +import { createMockAnnualConferenceEdition, createMockAnnualConferencePhase, createMockAnnualConferenceTask, deleteMockAnnualConferencePhase, getMockAnnualConferenceWorkPlan, listMockAnnualConferenceEditions, reorderMockAnnualConferencePhases, updateMockAnnualConferencePhase, updateMockAnnualConferenceTask } from '@/lib/mock-db/annual-conference-work-plan'; import { createDefaultFeedbackCampaign, createEventFeedbackSubmission, deleteFeedbackCampaignByEvent, getAllFeedbackCampaigns, getAllFeedbackSubmissions, getFeedbackCampaignByEvent, getFeedbackSubmissionByResponseToken, getFeedbackSubmissionsByEvent, getOrCreateFeedbackCampaign, updateFeedbackCampaign } from '@/lib/mock-db/feedback'; import { createQuestion, deleteQuestion, getQuestionById, getQuestionsBySession, reorderQuestions, updateQuestion } from '@/lib/mock-db/questions'; import { readData, writeData } from '@/lib/mock-db'; @@ -85,7 +93,7 @@ import { createVolunteerApplication, getVolunteerApplications } from '@/lib/mock import { addSpeaker, getSpeakerByEmail, getSpeakersByEvent, removeSpeaker } from '@/lib/mock-db/speakers'; import { getSupabaseAdminClient, isSupabaseRuntimeEnabled, isSupabaseServerConfigured } from '@/lib/supabase/server'; import { completeSupabaseAdminToken, configuredFrontendOrigins, defaultAdminRedirectPath, getAdminSession, isSupabaseAdminAuthConfigured, recordAdminAudit, requireAdmin, revokeAdminSession, revokeAdminSessionsForMembership, type AdminSession } from '@/lib/supabase/admin-auth'; -import { createSupabaseAnnualConferenceTask, getSupabaseAnnualConferenceWorkPlan, updateSupabaseAnnualConferenceTask } from '@/lib/supabase/annual-conference-work-plan'; +import { createSupabaseAnnualConferenceEdition, createSupabaseAnnualConferencePhase, createSupabaseAnnualConferenceTask, deleteSupabaseAnnualConferencePhase, getSupabaseAnnualConferenceWorkPlan, listSupabaseAnnualConferenceEditions, reorderSupabaseAnnualConferencePhases, updateSupabaseAnnualConferencePhase, updateSupabaseAnnualConferenceTask } from '@/lib/supabase/annual-conference-work-plan'; import { createSupabaseCommunityEvent, deleteSupabaseCommunityEvent, getSupabaseCommunityEventById, getSupabaseCommunityEventBySlug, getSupabaseCommunityEvents, getSupabasePublicEvents, getSupabasePublicMeetups, updateSupabaseCommunityEvent } from '@/lib/supabase/community-events'; import { approveEventSubmission, @@ -428,9 +436,12 @@ const systemDesignDraftRequestSchema = z.object({ }); const addOrganizerSchema = z.object({ email: z.string().trim().toLowerCase().email(), - display_name: z.string().trim().optional(), + display_name: z.string().trim().min(1).max(120), role: z.enum(['owner', 'organizer', 'volunteer']).default('organizer'), }); +const updateOrganizerRoleSchema = z.object({ + role: z.enum(['organizer', 'volunteer']), +}).strict(); const adminTokenExchangeSchema = z.object({ access_token: z.string().trim().min(20).max(8192), }).strict(); @@ -574,6 +585,7 @@ const annualConferenceTaskCreateSchema = z.object({ title: z.string().trim().min(1, 'Task title is required.').max(160), details: z.string().trim().max(2000).nullable().optional(), internal_note: z.string().trim().max(2000).nullable().optional(), + phase_id: z.string().uuid().nullable().optional(), workstream: z.enum(ANNUAL_CONFERENCE_WORKSTREAMS), accountable_owner: z.string().trim().min(1, 'An accountable owner is required.').max(120), collaborators: z.array(z.string().trim().min(1).max(120)).max(20).optional().default([]), @@ -586,6 +598,7 @@ const annualConferenceTaskUpdateSchema = z.object({ title: z.string().trim().min(1, 'Task title is required.').max(160).optional(), details: z.string().trim().max(2000).nullable().optional(), internal_note: z.string().trim().max(2000).nullable().optional(), + phase_id: z.string().uuid().nullable().optional(), workstream: z.enum(ANNUAL_CONFERENCE_WORKSTREAMS).optional(), accountable_owner: z.string().trim().min(1).max(120).nullable().optional(), collaborators: z.array(z.string().trim().min(1).max(120)).max(20).optional(), @@ -596,6 +609,29 @@ const annualConferenceTaskUpdateSchema = z.object({ }).strict().refine((value) => Object.keys(value).length > 0, { message: 'Provide at least one task change.', }); +const annualConferenceEditionCreateSchema = z.object({ + year: z.number().int().min(2000).max(2200), + name: z.string().trim().min(1).max(160), + label: z.string().trim().min(1).max(120), + provisional_date: z.string().date(), + task_creator_email: z.string().trim().toLowerCase().email().nullable().optional(), +}).strict(); +const annualConferencePhaseCreateSchema = z.object({ + name: z.string().trim().min(1).max(80), + starts_on: z.string().date(), + ends_on: z.string().date(), +}).strict(); +const annualConferencePhaseUpdateSchema = z.object({ + name: z.string().trim().min(1).max(80).optional(), + starts_on: z.string().date().optional(), + ends_on: z.string().date().optional(), + sort_order: z.number().int().min(0).optional(), +}).strict().refine((value) => Object.keys(value).length > 0, { + message: 'Provide at least one phase change.', +}); +const annualConferencePhaseOrderSchema = z.object({ + phase_ids: z.array(z.string().uuid()).min(1).max(100), +}).strict(); const STOP_WORDS = new Set([ 'about', 'above', @@ -1000,12 +1036,72 @@ async function createEventFeedbackSubmissionStore( async function getAnnualConferenceWorkPlanStore( year: number, c?: Context, -): Promise<{ edition: AnnualConferenceEdition; tasks: AnnualConferenceTask[] } | undefined> { +): Promise<{ edition: AnnualConferenceEdition; phases: AnnualConferencePhase[]; tasks: AnnualConferenceTask[] } | undefined> { const workPlan = await getSupabaseAnnualConferenceWorkPlan(year, c); if (workPlan !== null) return workPlan; return getMockAnnualConferenceWorkPlan(year); } +async function listAnnualConferenceEditionsStore(c?: Context): Promise { + const editions = await listSupabaseAnnualConferenceEditions(c); + if (editions !== null) return editions; + return listMockAnnualConferenceEditions(); +} + +async function createAnnualConferenceEditionStore( + input: AnnualConferenceEditionCreateInput, + taskCreatorEmail: string, + c?: Context, +): Promise { + const edition = await createSupabaseAnnualConferenceEdition(input, taskCreatorEmail, c); + if (edition) return edition; + return createMockAnnualConferenceEdition(input, taskCreatorEmail); +} + +async function createAnnualConferencePhaseStore( + editionId: string, + input: AnnualConferencePhaseCreateInput, + actorEmail: string, + c?: Context, +): Promise { + const phase = await createSupabaseAnnualConferencePhase(editionId, input, actorEmail, c); + if (phase) return phase; + return createMockAnnualConferencePhase(editionId, input, actorEmail); +} + +async function updateAnnualConferencePhaseStore( + editionId: string, + phaseId: string, + input: AnnualConferencePhaseUpdateInput, + actorEmail: string, + c?: Context, +): Promise { + const phase = await updateSupabaseAnnualConferencePhase(editionId, phaseId, input, actorEmail, c); + if (phase !== null) return phase; + return updateMockAnnualConferencePhase(editionId, phaseId, input, actorEmail); +} + +async function deleteAnnualConferencePhaseStore( + editionId: string, + phaseId: string, + c?: Context, +): Promise { + const deleted = await deleteSupabaseAnnualConferencePhase(editionId, phaseId, c); + if (deleted !== null) return deleted; + return deleteMockAnnualConferencePhase(editionId, phaseId); +} + +async function reorderAnnualConferencePhasesStore( + editionId: string, + phases: AnnualConferencePhase[], + actorEmail: string, + c?: Context, +): Promise { + const reordered = await reorderSupabaseAnnualConferencePhases(phases, actorEmail, c); + if (reordered !== null) return reordered; + return reorderMockAnnualConferencePhases(editionId, phases.map((phase) => phase.id), actorEmail); +} + async function createAnnualConferenceTaskStore( edition: AnnualConferenceEdition, input: AnnualConferenceTaskCreateInput, @@ -3079,6 +3175,51 @@ app.get('/api/admin/volunteer-applications', async (c) => { return c.json({ applications }); }); +app.get('/api/annual-conference/editions', async (c) => { + const adminError = await requireAdmin(c); + if (adminError) return adminError; + return c.json({ editions: await listAnnualConferenceEditionsStore(c) }); +}); + +app.post('/api/annual-conference/editions', async (c) => { + const adminError = await requireAdmin(c); + if (adminError) return adminError; + const parsed = annualConferenceEditionCreateSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: parsed.error.issues[0]?.message ?? 'Check the edition details.' }, 400); + + const editions = await listAnnualConferenceEditionsStore(c); + const latestEdition = editions[0]; + const session = await getAdminSession(c); + if (!session.authenticated || !session.email || !latestEdition + || !canManageAnnualConferencePlanning(session.email, latestEdition.task_creator_email)) { + return c.json({ error: 'Only the current annual-conference planning owner can create the next edition.' }, 403); + } + if (editions.some((edition) => edition.year === parsed.data.year)) { + return c.json({ error: `Annual conference ${parsed.data.year} already exists.` }, 409); + } + if (parsed.data.year <= latestEdition.year) { + return c.json({ error: `The next edition year must be after ${latestEdition.year}.` }, 400); + } + + const taskCreatorEmail = parsed.data.task_creator_email ?? latestEdition.task_creator_email; + if (parsed.data.task_creator_email) { + const activeOrganizerEmails = await getActiveOrganizerEmails(c); + if (!activeOrganizerEmails) return c.json({ error: 'Unable to verify the selected planning owner.' }, 500); + if (!activeOrganizerEmails.map((email) => email.trim().toLowerCase()).includes(taskCreatorEmail)) { + return c.json({ error: 'The selected planning owner must be an active organizer.' }, 400); + } + } + + const edition = await createAnnualConferenceEditionStore(parsed.data, taskCreatorEmail, c); + await auditAdminAction(c, { + action: 'annual_conference.edition.create', + targetType: 'annual_conference_edition', + targetId: edition.id, + metadata: { year: edition.year, task_creator_email: edition.task_creator_email }, + }); + return c.json(edition, 201); +}); + app.get('/api/annual-conference/:year/work-plan', async (c) => { const adminError = await requireAdmin(c, ['owner', 'organizer', 'volunteer']); if (adminError) return adminError; @@ -3098,7 +3239,12 @@ app.get('/api/annual-conference/:year/work-plan', async (c) => { const visibleTasks = annualConferenceTasksForMember(workPlan.tasks, session.role, session.email); const volunteerAccess = session.role === 'volunteer'; - const canCreateTasks = !volunteerAccess && canCreateAnnualConferenceTask(session.email); + const canEditAllTasks = canCreateAnnualConferenceTask( + session.email, + workPlan.edition.task_creator_email, + ); + const canCreateTasks = !volunteerAccess + && canEditAllTasks; return c.json({ ...workPlan, @@ -3106,7 +3252,9 @@ app.get('/api/annual-conference/:year/work-plan', async (c) => { summary: summarizeAnnualConferenceWorkPlan(visibleTasks), permissions: { can_create_tasks: canCreateTasks, - can_edit_all_tasks: !volunteerAccess, + can_manage_phases: canCreateTasks, + can_edit_all_tasks: !volunteerAccess && canEditAllTasks, + can_edit_assigned_tasks: !volunteerAccess, can_update_assigned_task_status: volunteerAccess, access_scope: volunteerAccess ? 'assigned' : 'all', task_creator_email: workPlan.edition.task_creator_email, @@ -3114,16 +3262,133 @@ app.get('/api/annual-conference/:year/work-plan', async (c) => { }); }); -app.post('/api/annual-conference/:year/work-plan', async (c) => { +app.post('/api/annual-conference/:year/phases', async (c) => { const adminError = await requireAdmin(c); if (adminError) return adminError; + const yearParam = c.req.param('year'); + if (!/^\d{4}$/.test(yearParam)) return c.json({ error: 'Conference year must use four digits.' }, 400); + const parsed = annualConferencePhaseCreateSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: parsed.error.issues[0]?.message ?? 'Check the phase details.' }, 400); + const workPlan = await getAnnualConferenceWorkPlanStore(Number(yearParam), c); + if (!workPlan) return c.json({ error: `Annual conference ${yearParam} was not found.` }, 404); + const session = await getAdminSession(c); + if (!session.authenticated || !session.email + || !canManageAnnualConferencePlanning(session.email, workPlan.edition.task_creator_email)) { + return c.json({ error: 'Only this edition’s planning owner can manage phases.' }, 403); + } + const dateError = validateAnnualConferencePhaseDates(parsed.data, workPlan.phases); + if (dateError) return c.json({ error: dateError }, 400); + const phase = await createAnnualConferencePhaseStore(workPlan.edition.id, parsed.data, session.email, c); + await auditAdminAction(c, { + action: 'annual_conference.phase.create', + targetType: 'annual_conference_phase', + targetId: phase.id, + metadata: { edition_year: Number(yearParam), name: phase.name }, + }); + return c.json(phase, 201); +}); +app.put('/api/annual-conference/:year/phases/order', async (c) => { + const adminError = await requireAdmin(c); + if (adminError) return adminError; + const yearParam = c.req.param('year'); + if (!/^\d{4}$/.test(yearParam)) return c.json({ error: 'Conference year must use four digits.' }, 400); + const parsed = annualConferencePhaseOrderSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: parsed.error.issues[0]?.message ?? 'Check the phase order.' }, 400); + const workPlan = await getAnnualConferenceWorkPlanStore(Number(yearParam), c); + if (!workPlan) return c.json({ error: `Annual conference ${yearParam} was not found.` }, 404); const session = await getAdminSession(c); - if (!session.authenticated || !session.email || !canCreateAnnualConferenceTask(session.email)) { - return c.json({ - error: 'Only Angela (angelateyvi@gmail.com) can add annual conference tasks. All organizers can edit existing tasks.', - }, 403); + if (!session.authenticated || !session.email + || !canManageAnnualConferencePlanning(session.email, workPlan.edition.task_creator_email)) { + return c.json({ error: 'Only this edition’s planning owner can manage phases.' }, 403); + } + const uniqueIds = new Set(parsed.data.phase_ids); + if (uniqueIds.size !== workPlan.phases.length + || workPlan.phases.some((phase) => !uniqueIds.has(phase.id))) { + return c.json({ error: 'Phase order must contain every phase exactly once.' }, 400); + } + const phaseById = new Map(workPlan.phases.map((phase) => [phase.id, phase])); + const ordered = parsed.data.phase_ids.map((id) => phaseById.get(id)!); + const phases = await reorderAnnualConferencePhasesStore(workPlan.edition.id, ordered, session.email, c); + await auditAdminAction(c, { + action: 'annual_conference.phase.reorder', + targetType: 'annual_conference_edition', + targetId: workPlan.edition.id, + metadata: { edition_year: Number(yearParam), phase_ids: parsed.data.phase_ids }, + }); + return c.json({ phases }); +}); + +app.patch('/api/annual-conference/:year/phases/:phaseId', async (c) => { + const adminError = await requireAdmin(c); + if (adminError) return adminError; + const yearParam = c.req.param('year'); + if (!/^\d{4}$/.test(yearParam)) return c.json({ error: 'Conference year must use four digits.' }, 400); + const parsed = annualConferencePhaseUpdateSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: parsed.error.issues[0]?.message ?? 'Check the phase changes.' }, 400); + const workPlan = await getAnnualConferenceWorkPlanStore(Number(yearParam), c); + if (!workPlan) return c.json({ error: `Annual conference ${yearParam} was not found.` }, 404); + const session = await getAdminSession(c); + if (!session.authenticated || !session.email + || !canManageAnnualConferencePlanning(session.email, workPlan.edition.task_creator_email)) { + return c.json({ error: 'Only this edition’s planning owner can manage phases.' }, 403); + } + const existing = workPlan.phases.find((phase) => phase.id === c.req.param('phaseId')); + if (!existing) return c.json({ error: 'Annual conference phase was not found.' }, 404); + const proposed = { + starts_on: parsed.data.starts_on ?? existing.starts_on, + ends_on: parsed.data.ends_on ?? existing.ends_on, + }; + const dateError = validateAnnualConferencePhaseDates(proposed, workPlan.phases, existing.id); + if (dateError) return c.json({ error: dateError }, 400); + if (workPlan.tasks.some((task) => task.phase_id === existing.id && task.target_date && task.target_date > proposed.ends_on)) { + return c.json({ error: 'Phase end date cannot be earlier than an assigned task target date.' }, 400); } + const phase = await updateAnnualConferencePhaseStore( + workPlan.edition.id, + existing.id, + parsed.data, + session.email, + c, + ); + if (!phase) return c.json({ error: 'Annual conference phase was not found.' }, 404); + await auditAdminAction(c, { + action: 'annual_conference.phase.update', + targetType: 'annual_conference_phase', + targetId: phase.id, + metadata: { edition_year: Number(yearParam), changed_fields: Object.keys(parsed.data) }, + }); + return c.json(phase); +}); + +app.delete('/api/annual-conference/:year/phases/:phaseId', async (c) => { + const adminError = await requireAdmin(c); + if (adminError) return adminError; + const yearParam = c.req.param('year'); + if (!/^\d{4}$/.test(yearParam)) return c.json({ error: 'Conference year must use four digits.' }, 400); + const workPlan = await getAnnualConferenceWorkPlanStore(Number(yearParam), c); + if (!workPlan) return c.json({ error: `Annual conference ${yearParam} was not found.` }, 404); + const session = await getAdminSession(c); + if (!session.authenticated || !session.email + || !canManageAnnualConferencePlanning(session.email, workPlan.edition.task_creator_email)) { + return c.json({ error: 'Only this edition’s planning owner can manage phases.' }, 403); + } + const phase = workPlan.phases.find((item) => item.id === c.req.param('phaseId')); + if (!phase) return c.json({ error: 'Annual conference phase was not found.' }, 404); + const deleted = await deleteAnnualConferencePhaseStore(workPlan.edition.id, phase.id, c); + if (!deleted) return c.json({ error: 'Annual conference phase was not found.' }, 404); + await auditAdminAction(c, { + action: 'annual_conference.phase.delete', + targetType: 'annual_conference_phase', + targetId: phase.id, + metadata: { edition_year: Number(yearParam), name: phase.name }, + }); + return c.json({ deleted: true, tasks_unassigned: workPlan.tasks.filter((task) => task.phase_id === phase.id).length }); +}); + +app.post('/api/annual-conference/:year/work-plan', async (c) => { + const adminError = await requireAdmin(c); + if (adminError) return adminError; const yearParam = c.req.param('year'); if (!/^\d{4}$/.test(yearParam)) { @@ -3140,6 +3405,15 @@ app.post('/api/annual-conference/:year/work-plan', async (c) => { return c.json({ error: `Annual conference ${yearParam} was not found.` }, 404); } + const session = await getAdminSession(c); + if (!session.authenticated || !session.email + || !canCreateAnnualConferenceTask(session.email, workPlan.edition.task_creator_email)) { + return c.json({ error: 'Only this edition’s planning owner can add annual conference tasks.' }, 403); + } + + const scheduleError = validateAnnualConferenceTaskSchedule(parsed.data, workPlan.phases); + if (scheduleError) return c.json({ error: scheduleError }, 400); + const activeOrganizerEmails = await getActiveOrganizerEmails(c); if (!activeOrganizerEmails) { return c.json({ @@ -3231,6 +3505,14 @@ app.patch('/api/annual-conference/:year/work-plan/:taskId', async (c) => { return c.json({ ...task, internal_note: null }); } + if (!canEditAnnualConferenceTask( + existingTask, + session.email, + workPlan.edition.task_creator_email, + )) { + return c.json({ error: 'Only the planning owner or a task owner/collaborator can edit this task.' }, 403); + } + let activeOrganizerEmails: string[] = []; if (annualConferenceOwnershipNeedsActiveOrganizerLookup(parsed.data, existingTask)) { const loadedOrganizerEmails = await getActiveOrganizerEmails(c); @@ -3251,6 +3533,13 @@ app.patch('/api/annual-conference/:year/work-plan/:taskId', async (c) => { return c.json({ error: ownership.error }, 400); } + const proposedSchedule = { + phase_id: 'phase_id' in parsed.data ? parsed.data.phase_id : existingTask.phase_id, + target_date: 'target_date' in parsed.data ? parsed.data.target_date : existingTask.target_date, + }; + const scheduleError = validateAnnualConferenceTaskSchedule(proposedSchedule, workPlan.phases); + if (scheduleError) return c.json({ error: scheduleError }, 400); + const task = await updateAnnualConferenceTaskStore( workPlan.edition.id, c.req.param('taskId'), @@ -3374,10 +3663,10 @@ app.post('/api/admin/organizers', async (c) => { const body = await c.req.json().catch(() => ({})); const parsed = addOrganizerSchema.safeParse(body); if (!parsed.success) { - return c.json({ error: 'Enter a valid email and role.' }, 400); + return c.json({ error: 'Enter a valid email, display name, and role.' }, 400); } const email = parsed.data.email; - const displayName = parsed.data.display_name || null; + const displayName = parsed.data.display_name; const role = parsed.data.role; const { data: existingMembership, error: existingMembershipError } = await getSupabaseAdminClient(c) @@ -3398,6 +3687,31 @@ app.post('/api/admin/organizers', async (c) => { if (existingMembership?.role === 'owner') { return c.json({ error: 'Only owners can update another owner.' }, 403); } + + if (existingMembership && existingMembership.role !== role) { + return c.json({ error: 'Only owners can change an existing member role.' }, 403); + } + } + + if (existingMembership && existingMembership.role !== role) { + if (existingMembership.id === session.membership_id) { + return c.json({ error: 'You cannot change your own role.' }, 400); + } + + if (existingMembership.role === 'owner') { + const { count: activeOwnerCount, error: ownerCountError } = await getSupabaseAdminClient(c) + .from('admin_memberships') + .select('id', { count: 'exact', head: true }) + .eq('role', 'owner') + .eq('status', 'active'); + + if (ownerCountError) { + return c.json({ error: 'Unable to verify owner coverage.' }, 500); + } + if ((activeOwnerCount ?? 0) <= 1) { + return c.json({ error: 'At least one active owner must remain.' }, 400); + } + } } const { data, error } = await getSupabaseAdminClient(c) @@ -3440,6 +3754,81 @@ app.post('/api/admin/organizers', async (c) => { return c.json(data, 201); }); +app.patch('/api/admin/organizers/:organizerId/role', async (c) => { + const adminError = await requireAdmin(c, ['owner']); + if (adminError) return adminError; + + if (!isSupabaseAdminAuthConfigured(c)) { + return c.json({ error: 'Organizer email management requires Supabase auth.' }, 503); + } + + const session = await getAdminSession(c); + if (!session.authenticated || session.role !== 'owner') { + return c.json({ error: 'Only owners can change member roles.' }, 403); + } + + const organizerId = c.req.param('organizerId'); + if (organizerId === session.membership_id) { + return c.json({ error: 'You cannot change your own role.' }, 400); + } + + const parsed = updateOrganizerRoleSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) { + return c.json({ error: 'Choose Organizer or Volunteer.' }, 400); + } + + const { data: existingMembership, error: existingMembershipError } = await getSupabaseAdminClient(c) + .from('admin_memberships') + .select('id, email, display_name, role, status, last_login_at, created_at') + .eq('id', organizerId) + .maybeSingle(); + + if (existingMembershipError) { + return c.json({ error: 'Unable to verify organizer access.' }, 500); + } + if (!existingMembership) { + return c.json({ error: 'Organizer was not found.' }, 404); + } + if (existingMembership.role === 'owner') { + return c.json({ error: 'Owner roles cannot be changed from this control.' }, 400); + } + if (existingMembership.role === parsed.data.role) { + return c.json(existingMembership); + } + + const { data, error } = await getSupabaseAdminClient(c) + .from('admin_memberships') + .update({ role: parsed.data.role }) + .eq('id', organizerId) + .select('id, email, display_name, role, status, last_login_at, created_at') + .maybeSingle(); + + if (error) { + return c.json({ error: 'Unable to change member role.' }, 500); + } + if (!data) { + return c.json({ error: 'Organizer was not found.' }, 404); + } + + try { + await revokeAdminSessionsForMembership(c, data.id); + } catch { + return c.json({ error: 'The role changed, but existing sessions could not be revoked.' }, 500); + } + + await recordAdminAudit(c, { + actor_user_id: session.user_id, + actor_email: session.email, + actor_role: session.role, + action: 'admin.organizer.role_change', + target_type: 'admin_membership', + target_id: data.id, + metadata: { email: data.email, previous_role: existingMembership.role, role: data.role }, + }); + + return c.json(data); +}); + app.delete('/api/admin/organizers/:organizerId', async (c) => { const adminError = await requireAdmin(c); if (adminError) return adminError; diff --git a/src/App.vue b/src/App.vue index 057ae06..268993b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -115,11 +115,15 @@ const showOrganizerAccessSurface = computed(() => ( const isOrganizerAuthenticated = computed(() => adminSessionQuery.data.value?.authenticated === true); const isConferenceVolunteer = computed(() => adminSessionQuery.data.value?.user?.role === 'volunteer'); const showAppHeader = computed(() => !isStandaloneRoute.value && isOrganizerAuthenticated.value); -const showPrimaryNavigation = computed(() => showAppHeader.value && isOrganizerAuthenticated.value); +const showPrimaryNavigation = computed(() => ( + showAppHeader.value + && isOrganizerAuthenticated.value + && !isConferenceVolunteer.value +)); const adminLinks = computed(() => { const session = adminSessionQuery.data.value; if (session?.authenticated && session.user?.role === 'volunteer') { - return [{ href: annualConferencePath(), label: 'Annual Conference' }]; + return []; } if (session?.authenticated && session.user?.role === 'owner') { @@ -216,6 +220,7 @@ const appMainStyle = computed(() => ({ '--admin-event-tabs-height': showAdminEventTabs.value ? `${adminEventTabsHeight.value}px` : '0px', })); const adminEventSectionOrder = ['', 'registrations', 'talks', 'speakers', 'attendance', 'quiz', 'feedback']; +const annualConferenceSectionOrder = ['', 'work-plan', 'timeline', 'volunteers']; function getAdminEventSection(path: string): { eventId: string; index: number } | null { const eventsBase = `${adminPath('events')}/`; @@ -231,12 +236,38 @@ function getAdminEventSection(path: string): { eventId: string; index: number } return { eventId, index }; } +function getAnnualConferenceSection(path: string): { year: string; index: number } | null { + const conferenceBase = `${adminPath('annual-conference')}/`; + if (!path.startsWith(conferenceBase)) return null; + + const [year, section = '', extraSegment] = path.slice(conferenceBase.length).split('/'); + if (!/^\d{4}$/.test(year) || extraSegment) return null; + + const index = annualConferenceSectionOrder.indexOf(section); + if (index === -1) return null; + + return { year, index }; +} + function updateRouteTransition(toPath: string, fromPath?: string) { if (!fromPath) { routeTransitionName.value = 'page'; return; } + const toConferenceSection = getAnnualConferenceSection(toPath); + const fromConferenceSection = getAnnualConferenceSection(fromPath); + + if ( + toConferenceSection + && fromConferenceSection + && toConferenceSection.year === fromConferenceSection.year + && toConferenceSection.index !== fromConferenceSection.index + ) { + routeTransitionName.value = 'page-stable'; + return; + } + const toSection = getAdminEventSection(toPath); const fromSection = getAdminEventSection(fromPath); diff --git a/src/annual-conference.ts b/src/annual-conference.ts index 02ef3c7..492d398 100644 --- a/src/annual-conference.ts +++ b/src/annual-conference.ts @@ -8,8 +8,8 @@ export const ACTIVE_ANNUAL_CONFERENCE_EDITION = { export const DECEMBER_2026_VOLUNTEER_PUBLIC_PATH = '/volunteer/december-mega-meetup'; -export function annualConferencePath(path = ''): string { - const editionPath = `annual-conference/${ACTIVE_ANNUAL_CONFERENCE_EDITION.year}`; +export function annualConferencePath(path = '', year: string = ACTIVE_ANNUAL_CONFERENCE_EDITION.year): string { + const editionPath = `annual-conference/${year}`; return adminPath(path ? `${editionPath}/${path.replace(/^\/+/, '')}` : editionPath); } diff --git a/src/components/AnnualConferenceNav.vue b/src/components/AnnualConferenceNav.vue index bc0edea..1d3361a 100644 --- a/src/components/AnnualConferenceNav.vue +++ b/src/components/AnnualConferenceNav.vue @@ -1,9 +1,19 @@ + + + + diff --git a/src/views/admin/AdminAnnualConferenceView.vue b/src/views/admin/AdminAnnualConferenceView.vue index 30672b6..5a0a786 100644 --- a/src/views/admin/AdminAnnualConferenceView.vue +++ b/src/views/admin/AdminAnnualConferenceView.vue @@ -1,18 +1,20 @@ @@ -322,7 +409,7 @@ function statusClass(status: AnnualConferenceTask['status']): string { id="annual-task-create-permission" class="block sm:ml-1 sm:inline" > - New tasks: Angela ({{ permissions.task_creator_email }}). All organizers can edit. + New tasks: planning owner {{ permissions.task_creator_email }}. All organizers can edit.

@@ -337,7 +424,7 @@ function statusClass(status: AnnualConferenceTask['status']): string { :aria-disabled="permissions?.can_create_tasks ? undefined : 'true'" :aria-describedby="permissions && !permissions.can_create_tasks ? 'annual-task-create-permission' : undefined" :title="permissions && !permissions.can_create_tasks - ? `Only Angela (${permissions.task_creator_email}) can add tasks.` + ? `Only this edition’s planning owner (${permissions.task_creator_email}) can add tasks.` : undefined" @click="requestCreateDrawer" > @@ -374,69 +461,32 @@ function statusClass(status: AnnualConferenceTask['status']): string { @@ -653,8 +716,10 @@ function statusClass(status: AnnualConferenceTask['status']): string { :open="showCreateForm || Boolean(selectedTask)" :mode="showCreateForm ? 'create' : editingTaskId ? 'edit' : 'details'" :task="showCreateForm ? null : selectedTask" + :phases="phases" + :default-phase-id="selectedPhase?.id ?? null" :organizer-labels="organizerLabels" - :can-edit="permissions?.can_edit_all_tasks === true" + :can-edit="canEditSelectedTask" :status-only="permissions?.can_update_assigned_task_status === true" :submitting="showCreateForm ? createMutation.isPending.value : updateMutation.isPending.value" @close="closeTaskDrawer" @@ -671,10 +736,6 @@ function statusClass(status: AnnualConferenceTask['status']): string { view-transition-name: annual-task-ledger; } -.annual-task-ledger__scroll { - overflow-anchor: none; -} - @supports (view-transition-name: annual-task-ledger) { :global(::view-transition-group(annual-task-ledger)) { animation-duration: 220ms; diff --git a/src/views/admin/AdminOrganizersView.vue b/src/views/admin/AdminOrganizersView.vue index 9ee19af..78194a9 100644 --- a/src/views/admin/AdminOrganizersView.vue +++ b/src/views/admin/AdminOrganizersView.vue @@ -3,18 +3,21 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'; import { computed, reactive, ref, watch } from 'vue'; import { z } from 'zod'; import AppDropdown from '@/src/components/AppDropdown.vue'; +import NaviiAvatar from '@/src/components/NaviiAvatar.vue'; import AdminOrganizersPageSkeleton from '@/src/components/ui/page-skeletons/AdminOrganizersPageSkeleton.vue'; import { fetchAdminOrganizers, fetchAdminSession, queryKeys, type OrganizerMembership, type OrganizerMembershipsResponse } from '@/src/lib/api'; import type { AdminRole } from '@/types/supabase'; const addOrganizerSchema = z.object({ email: z.string().trim().email('Enter a valid email.'), - display_name: z.string().trim().optional(), + display_name: z.string().trim().min(1, 'Enter a display name.').max(120, 'Keep the display name under 120 characters.'), role: z.enum(['owner', 'organizer', 'volunteer']), }); const queryClient = useQueryClient(); const actionError = ref(''); +const showEmails = ref(false); +const roleUpdatingId = ref(null); const form = reactive({ email: '', display_name: '', @@ -32,19 +35,23 @@ const adminSessionQuery = useQuery({ const organizers = computed(() => organizersQuery.data.value?.organizers ?? []); const currentUserRole = computed(() => adminSessionQuery.data.value?.user?.role ?? null); const currentUserEmail = computed(() => adminSessionQuery.data.value?.user?.email?.toLowerCase() ?? null); +const canRevealEmails = computed(() => currentUserRole.value === 'owner'); const activeOrganizers = computed(() => organizers.value.filter((organizer) => organizer.status === 'active')); const ownerCount = computed(() => activeOrganizers.value.filter((organizer) => organizer.role === 'owner').length); const loading = computed(() => organizersQuery.isPending.value); const error = computed(() => actionError.value || organizersQuery.error.value?.message || ''); const organizerPage = ref(1); -const organizersPerPage = 5; +const organizersPerPage = 10; const organizerPageCount = computed(() => Math.max(1, Math.ceil(organizers.value.length / organizersPerPage))); const organizerPageStart = computed(() => (organizerPage.value - 1) * organizersPerPage); const organizerPageEnd = computed(() => Math.min(organizers.value.length, organizerPageStart.value + organizersPerPage)); const paginatedOrganizers = computed(() => organizers.value.slice(organizerPageStart.value, organizerPageEnd.value)); -const emptyOrganizerSlots = computed(() => Math.max(0, organizersPerPage - paginatedOrganizers.value.length)); const addOrganizerValidation = computed(() => addOrganizerSchema.safeParse(form)); const canAddOrganizer = computed(() => addOrganizerValidation.value.success && !addOrganizerMutation.isPending.value); +const memberRoleOptions: Array<{ value: AdminRole; label: string }> = [ + { value: 'organizer', label: 'Organizer' }, + { value: 'volunteer', label: 'Volunteer' }, +]; const roleOptions = computed>(() => { if (currentUserRole.value === 'owner') { return [ @@ -64,6 +71,7 @@ watch(currentUserRole, (role) => { if (role !== 'owner' && form.role === 'owner') { form.role = 'organizer'; } + if (role !== 'owner') showEmails.value = false; }, { immediate: true }); watch(organizerPageCount, (pageCount) => { @@ -109,6 +117,48 @@ const addOrganizerMutation = useMutation({ }, }); +const updateOrganizerRoleMutation = useMutation({ + mutationFn: async ({ organizerId, role }: { organizerId: string; role: 'organizer' | 'volunteer' }) => { + const response = await fetch(`/api/admin/organizers/${organizerId}/role`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }), + }); + + if (!response.ok) { + throw new Error(await readError(response)); + } + + return response.json() as Promise; + }, + onMutate: async ({ organizerId, role }) => { + actionError.value = ''; + roleUpdatingId.value = organizerId; + await queryClient.cancelQueries({ queryKey: queryKeys.adminOrganizers }); + const previous = queryClient.getQueryData(queryKeys.adminOrganizers); + if (previous) { + queryClient.setQueryData(queryKeys.adminOrganizers, { + ...previous, + organizers: previous.organizers.map((organizer) => organizer.id === organizerId + ? { ...organizer, role } + : organizer), + }); + } + return { previous }; + }, + onError: (caught, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.adminOrganizers, context.previous); + } + actionError.value = caught instanceof Error ? caught.message : 'Unable to change member role.'; + }, + onSettled: () => { + roleUpdatingId.value = null; + void queryClient.invalidateQueries({ queryKey: queryKeys.adminOrganizers }); + }, +}); + const disableOrganizerMutation = useMutation({ mutationFn: async (organizerId: string) => { const response = await fetch(`/api/admin/organizers/${organizerId}`, { @@ -148,8 +198,11 @@ const disableOrganizerMutation = useMutation({ }); function submitOrganizer() { - if (!canAddOrganizer.value) { - actionError.value = 'Enter a valid email.'; + if (!addOrganizerValidation.value.success) { + actionError.value = addOrganizerValidation.value.error.issues[0]?.message ?? 'Check the access details.'; + return; + } + if (addOrganizerMutation.isPending.value) { return; } actionError.value = ''; @@ -172,6 +225,20 @@ function canDisableOrganizer(organizer: OrganizerMembership): boolean { return currentUserRole.value === 'owner' || currentUserRole.value === 'organizer'; } +function canChangeOrganizerRole(organizer: OrganizerMembership): boolean { + return currentUserRole.value === 'owner' + && organizer.status === 'active' + && organizer.role !== 'owner' + && organizer.email.toLowerCase() !== currentUserEmail.value; +} + +function changeOrganizerRole(organizer: OrganizerMembership, value: string | number) { + if (!canChangeOrganizerRole(organizer) || updateOrganizerRoleMutation.isPending.value) return; + if (value !== 'organizer' && value !== 'volunteer') return; + if (organizer.role === value) return; + updateOrganizerRoleMutation.mutate({ organizerId: organizer.id, role: value }); +} + function formatDateTime(value: string | null): string { if (!value) return 'Never'; return new Intl.DateTimeFormat('en', { @@ -188,136 +255,170 @@ function roleLabel(role: AdminRole): string { if (role === 'volunteer') return 'Volunteer'; return 'Organizer'; } + +function memberName(organizer: OrganizerMembership): string { + return organizer.display_name?.trim() || 'Unnamed member'; +} + +function unavailableActionLabel(organizer: OrganizerMembership): string { + if (organizer.status === 'disabled') return 'Disabled'; + if (currentUserEmail.value && organizer.email.toLowerCase() === currentUserEmail.value) return 'You'; + if (organizer.role === 'owner' && currentUserRole.value !== 'owner') return 'Owner only'; + if (organizer.role === 'owner' && ownerCount.value <= 1) return 'Protected'; + return 'Unavailable'; +} + +function toggleEmailVisibility() { + if (!canRevealEmails.value) return; + showEmails.value = !showEmails.value; +} diff --git a/src/views/admin/AdminVolunteerView.vue b/src/views/admin/AdminVolunteerView.vue index c8a8f1d..25be52d 100644 --- a/src/views/admin/AdminVolunteerView.vue +++ b/src/views/admin/AdminVolunteerView.vue @@ -88,16 +88,32 @@ function openVolunteerDisplay() {
Loading applications…
Unable to load volunteer applications.
No volunteer applications yet.
-
-
-
-

{{ application.name }}

- {{ application.email }} -
-

X: {{ application.x_handle }}

-

Slack: {{ application.slack_name }}

- -
+
+ + + + + + + + + + + + + + + + + + + + +
Volunteer applications
NameEmailXSlackSigned up
{{ application.name }} + {{ application.email }} + {{ application.x_handle }}{{ application.slack_name }} + +
diff --git a/supabase/migrations/20260802120000_annual_conference_phases.sql b/supabase/migrations/20260802120000_annual_conference_phases.sql new file mode 100644 index 0000000..325b287 --- /dev/null +++ b/supabase/migrations/20260802120000_annual_conference_phases.sql @@ -0,0 +1,137 @@ +create table public.annual_conference_phases ( + id uuid primary key default gen_random_uuid(), + edition_id uuid not null references public.annual_conference_editions(id) on delete cascade, + name text not null, + starts_on date not null, + ends_on date not null, + sort_order integer not null default 0, + created_by_email text, + updated_by_email text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint annual_conference_phases_name_not_blank check (length(trim(name)) > 0), + constraint annual_conference_phases_dates_check check (ends_on >= starts_on), + constraint annual_conference_phases_sort_order_check check (sort_order >= 0), + constraint annual_conference_phases_edition_name_unique unique (edition_id, name) +); + +alter table public.annual_conference_tasks + add column phase_id uuid references public.annual_conference_phases(id) on delete set null; + +create index annual_conference_phases_edition_order_idx + on public.annual_conference_phases (edition_id, sort_order, starts_on); + +create index annual_conference_tasks_edition_phase_idx + on public.annual_conference_tasks (edition_id, phase_id, target_date); + +create or replace function public.validate_annual_conference_phase_schedule() +returns trigger +language plpgsql +set search_path = public +as $$ +begin + if exists ( + select 1 + from public.annual_conference_phases phase + where phase.edition_id = new.edition_id + and phase.id <> new.id + and new.starts_on <= phase.ends_on + and new.ends_on >= phase.starts_on + ) then + raise exception 'Phase dates cannot overlap another phase.'; + end if; + + if exists ( + select 1 + from public.annual_conference_tasks task + where task.phase_id = new.id + and task.target_date is not null + and task.target_date > new.ends_on + ) then + raise exception 'Phase end date cannot precede an assigned task target date.'; + end if; + + return new; +end; +$$; + +create or replace function public.validate_annual_conference_task_phase() +returns trigger +language plpgsql +set search_path = public +as $$ +declare + selected_phase public.annual_conference_phases%rowtype; +begin + if new.phase_id is null then + return new; + end if; + + select * into selected_phase + from public.annual_conference_phases + where id = new.phase_id; + + if selected_phase.id is null or selected_phase.edition_id <> new.edition_id then + raise exception 'The selected phase does not belong to this conference edition.'; + end if; + + if new.target_date is not null and new.target_date > selected_phase.ends_on then + raise exception 'Task target date cannot be after the selected phase end date.'; + end if; + + return new; +end; +$$; + +create trigger validate_annual_conference_phase_schedule_trigger +before insert or update of edition_id, starts_on, ends_on +on public.annual_conference_phases +for each row +execute function public.validate_annual_conference_phase_schedule(); + +create trigger validate_annual_conference_task_phase_trigger +before insert or update of edition_id, phase_id, target_date +on public.annual_conference_tasks +for each row +execute function public.validate_annual_conference_task_phase(); + +create trigger set_annual_conference_phases_updated_at +before update on public.annual_conference_phases +for each row +execute function public.set_updated_at(); + +alter table public.annual_conference_phases enable row level security; + +grant select, insert, update, delete on public.annual_conference_phases to service_role; + +insert into public.annual_conference_phases ( + id, + edition_id, + name, + starts_on, + ends_on, + sort_order, + created_by_email, + updated_by_email +) values + ( + '20260000-0000-4000-8000-000000000101', + '20260000-0000-4000-8000-000000000001', + 'Phase 1', + '2026-08-01', + '2026-08-31', + 1, + 'angelateyvi@gmail.com', + 'angelateyvi@gmail.com' + ), + ( + '20260000-0000-4000-8000-000000000102', + '20260000-0000-4000-8000-000000000001', + 'Phase 2', + '2026-09-01', + '2026-12-19', + 2, + 'angelateyvi@gmail.com', + 'angelateyvi@gmail.com' + ) +on conflict (id) do nothing; diff --git a/supabase/migrations/20260803110000_annual_conference_phase_1_assignments.sql b/supabase/migrations/20260803110000_annual_conference_phase_1_assignments.sql new file mode 100644 index 0000000..dd78161 --- /dev/null +++ b/supabase/migrations/20260803110000_annual_conference_phase_1_assignments.sql @@ -0,0 +1,52 @@ +update public.annual_conference_tasks +set + phase_id = '20260000-0000-4000-8000-000000000101', + accountable_owner = case source_row + when 4 then 'Elijah' + when 8 then null + when 13 then null + when 14 then null + else accountable_owner + end, + collaborators = case source_row + when 4 then array['Elvis']::text[] + else collaborators + end, + updated_by_email = 'angelateyvi@gmail.com' +where edition_id = '20260000-0000-4000-8000-000000000001' + and source_row in (4, 5, 6, 8, 13, 14, 15, 16, 17, 19, 20); + +insert into public.annual_conference_tasks ( + id, + edition_id, + phase_id, + title, + details, + workstream, + accountable_owner, + collaborators, + status, + dependency_note, + source, + source_row, + sort_order, + created_by_email, + updated_by_email +) values ( + '20260000-0000-4000-8000-000000000028', + '20260000-0000-4000-8000-000000000001', + '20260000-0000-4000-8000-000000000101', + 'Volunteer recruitment', + 'Recruit new volunteers and coordinate promotion of the call for volunteers.', + 'volunteers', + null, + '{}', + 'not_started', + 'Coordinate with the organizer responsible for conference calls and announcements.', + 'manual', + 28, + 27, + 'angelateyvi@gmail.com', + 'angelateyvi@gmail.com' +) +on conflict (id) do nothing; diff --git a/types/supabase.ts b/types/supabase.ts index 7d919c1..1fcb22d 100644 --- a/types/supabase.ts +++ b/types/supabase.ts @@ -238,6 +238,53 @@ export interface Database { }; Relationships: []; }; + annual_conference_phases: { + Row: { + id: string; + edition_id: string; + name: string; + starts_on: string; + ends_on: string; + sort_order: number; + created_by_email: string | null; + updated_by_email: string | null; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + edition_id: string; + name: string; + starts_on: string; + ends_on: string; + sort_order?: number; + created_by_email?: string | null; + updated_by_email?: string | null; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + edition_id?: string; + name?: string; + starts_on?: string; + ends_on?: string; + sort_order?: number; + created_by_email?: string | null; + updated_by_email?: string | null; + created_at?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'annual_conference_phases_edition_id_fkey'; + columns: ['edition_id']; + isOneToOne: false; + referencedRelation: 'annual_conference_editions'; + referencedColumns: ['id']; + }, + ]; + }; annual_conference_tasks: { Row: { id: string; @@ -245,6 +292,7 @@ export interface Database { title: string; details: string | null; internal_note: string | null; + phase_id: string | null; workstream: AnnualConferenceWorkstream; accountable_owner: string | null; collaborators: string[]; @@ -267,6 +315,7 @@ export interface Database { title: string; details?: string | null; internal_note?: string | null; + phase_id?: string | null; workstream: AnnualConferenceWorkstream; accountable_owner?: string | null; collaborators?: string[]; @@ -289,6 +338,7 @@ export interface Database { title?: string; details?: string | null; internal_note?: string | null; + phase_id?: string | null; workstream?: AnnualConferenceWorkstream; accountable_owner?: string | null; collaborators?: string[]; @@ -313,6 +363,13 @@ export interface Database { referencedRelation: 'annual_conference_editions'; referencedColumns: ['id']; }, + { + foreignKeyName: 'annual_conference_tasks_phase_id_fkey'; + columns: ['phase_id']; + isOneToOne: false; + referencedRelation: 'annual_conference_phases'; + referencedColumns: ['id']; + }, ]; }; community_events: {