Files
MOPC-Portal/docs/superpowers/plans/2026-06-04-wave4-team-facing-my-logistics.md
2026-06-04 16:46:12 +02:00

102 lines
9.6 KiB
Markdown

# Wave 4 — Team-facing "My Logistics" + travel/visa UX fixes
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Close the loop for finalist teams. Today a confirmed team can see only their attendee roster + a visa badge; they have no view of their flights or hotel, can't self-enter passport nationality, and the submitter (non-TeamMember lead) can't even see the card. This wave adds a team-facing "My Logistics" view (flights + hotel + visa + nationality self-entry), fixes the submitter gap, links the confirm-success page to the dashboard, and adds the admin-side travel/visa correctness fixes (departure-after-arrival validation, CSV export).
**Architecture:** New read procedures on the applicant router (`getMyLogistics`) reusing the same caller→project resolution as the existing finalist procedures, plus a self-service `updateMyVisaNationality`. A new applicant dashboard card. Admin-side: input validation on `logistics.upsertFlightDetail` + CSV export buttons mirroring the lunch manifest.
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, shadcn/ui. No schema change.
**Key facts (verified 2026-06-04):**
- `applicant.getMyFinalistConfirmation` (`src/server/routers/applicant.ts:2748`) resolves the project via `teamMembers: { some: { userId } }` — MISSES a lead who submitted but has no TeamMember row, and has no role guard.
- `applicant.getMyVisaApplications` (`:2819`) returns visa rows when `program.visaStatusVisibleToMembers`, else null. Reuse its visibility logic.
- Admin-only: `logistics.getHotel`, `logistics.listFlightDetails` (`src/server/routers/logistics.ts`). No team-facing equivalents → teams never see hotel/flights.
- Applicant dashboard composes cards in `src/app/(applicant)/applicant/page.tsx` (~line 409): `LunchBanner`, `ExternalAttendeesStrip`, `AttendingMembersCard`. Add the new card here.
- Confirm-success copy at `src/app/(public)/finalist/confirm/[token]/page.tsx:183` promises "your project page" with no link.
- `logistics.upsertFlightDetail` input (`:145`) has no departure-after-arrival check. Lunch CSV export pattern: `src/components/admin/logistics/lunch-manifest.tsx:28-57`.
- Models: `Hotel{ name, address?, link?, notes? }` (programId 1:1); `FlightDetail{ arrival/departure At/FlightNumber/Airport, status, adminNotes }` (per AttendingMember); `VisaApplication{ status, nationality?, ... }`.
---
## Task 1: Team-facing read endpoint + submitter fix
**Files:** `src/server/routers/applicant.ts`; test `tests/unit/applicant-my-logistics.test.ts`.
- [ ] **Step 1:** Fix `getMyFinalistConfirmation` resolution: change the `where` to
`{ OR: [{ submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }], finalistConfirmation: { isNot: null } }`
(verify `Project.submittedByUserId` is the correct field name in schema). Keep the rest. (No role guard added — APPLICANT/lead may not have an explicit role; the project-scoping is the guard. If other procedures in this router use a role check, match it; otherwise leave project-scoping as the guard and note it.)
- [ ] **Step 2:** Add `getMyLogistics: protectedProcedure.query`:
- Resolve the caller's confirmed-finalist project (same OR-resolution as above; return `null` if none or confirmation not CONFIRMED).
- Return:
```ts
{
projectTitle: string,
confirmationStatus: FinalistConfirmationStatus,
hotel: { name, address, link, notes } | null, // program Hotel (1:1)
myFlight: { arrivalAt, arrivalFlightNumber, arrivalAirport, departureAt, departureFlightNumber, departureAirport, status } | null, // the caller's own AttendingMember.flightDetail
visaVisible: boolean, // program.visaStatusVisibleToMembers
myVisa: { status, nationality } | null, // caller's own VisaApplication, only when visaVisible
}
```
- `myFlight`: find the caller's `AttendingMember` for this confirmation (`where confirmationId + userId`), include `flightDetail`. `hotel`: `prisma.hotel.findUnique({ where: { programId } })`. `myVisa`: only when `visaVisible` and the caller's AttendingMember has a `visaApplication`.
- [ ] **Step 3: Test** — set up a CONFIRMED finalist with the caller as an AttendingMember, a Hotel, a FlightDetail (CONFIRMED), `visaStatusVisibleToMembers:true`, a VisaApplication GRANTED. Call `getMyLogistics` as that user → assert `hotel.name`, `myFlight.arrivalFlightNumber`, `visaVisible:true`, `myVisa.status==='GRANTED'`. Also: a non-finalist user → `null`. (Caller via `createCaller(applicantRouter, { id, email, role:'APPLICANT' })`.)
- [ ] **Step 4:** `npx vitest run tests/unit/applicant-my-logistics.test.ts` → pass; re-run any applicant tests touching these procedures. `npm run typecheck` → clean.
- [ ] **Step 5: Commit** — `git commit -am "feat(applicant): getMyLogistics (hotel+flight+visa) + submitter-match fix"`
---
## Task 2: Self-service nationality entry
**Files:** `src/server/routers/applicant.ts`; test in the same file.
- [ ] **Step 1:** Add `updateMyVisaNationality: protectedProcedure.input(z.object({ nationality: z.string().max(100) })).mutation`:
- Find the caller's `AttendingMember` whose program has `visaStatusVisibleToMembers:true` and which has a `VisaApplication`; if none → `TRPCError NOT_FOUND` ("No visa application to update").
- Update that `VisaApplication.nationality`. Audit `VISA_NATIONALITY_SELF_SET`. Return `{ ok: true }`.
- (Optional nicety: also copy to `User.nationality` if empty.)
- [ ] **Step 2: Test** — caller with a visible VisaApplication sets nationality → assert it persisted. Caller without one → rejects.
- [ ] **Step 3:** Run → pass. `npm run typecheck` → clean.
- [ ] **Step 4: Commit** — `git commit -am "feat(applicant): self-service visa nationality entry"`
---
## Task 3: "My Logistics" card + confirm-page link
**Files:** create `src/components/applicant/my-logistics-card.tsx`; modify `src/app/(applicant)/applicant/page.tsx`, `src/app/(public)/finalist/confirm/[token]/page.tsx`.
- [ ] **Step 1:** Build `MyLogisticsCard` (`'use client'`): `trpc.applicant.getMyLogistics.useQuery()`. If `null` or loading → render nothing / Skeleton. Otherwise a Card "Your grand-finale logistics" with sections:
- **Hotel** — name + address + link (if present), else "Hotel details coming soon."
- **Flights** — the caller's arrival/departure (flight no, airport, formatted date/time in Europe/Paris) + a status badge, else "Your flight details will appear here once arranged."
- **Visa** (only if `visaVisible`) — status badge + a nationality field: shows current `myVisa.nationality` or an inline editable input → `trpc.applicant.updateMyVisaNationality` (sonner toast + invalidate).
Follow the visual pattern of `attending-members-card.tsx`. Visible affordances only.
- [ ] **Step 2:** Render `<MyLogisticsCard />` in `src/app/(applicant)/applicant/page.tsx` near `AttendingMembersCard` (~line 415).
- [ ] **Step 3:** In the confirm-success block (`finalist/confirm/[token]/page.tsx:168-185`), replace the dead "your project page" sentence with a real link/button to `/applicant` ("Go to my dashboard"). (The confirm page is public; the link will hit auth and land them on their dashboard after login.)
- [ ] **Step 4:** `npm run typecheck` → clean.
- [ ] **Step 5: Commit** — `git commit -am "feat(applicant): My Logistics card (hotel/flights/visa+nationality) + confirm-page dashboard link"`
---
## Task 4: Admin travel/visa UX — validation + CSV export
**Files:** `src/server/routers/logistics.ts`, `src/components/admin/logistics/travel-tab.tsx`, `src/components/admin/logistics/visas-tab.tsx`; test `tests/unit/logistics-flight.test.ts` (extend).
- [ ] **Step 1:** Server validation in `logistics.upsertFlightDetail`: after building `data`, if both `arrivalAt` and `departureAt` are present and `departureAt < arrivalAt`, throw `TRPCError BAD_REQUEST` ("Departure must be after arrival"). Add a test asserting the rejection.
- [ ] **Step 2:** CSV export — add a "Download CSV" button to `travel-tab.tsx` (columns: Project, Attendee, Email, Arrival date/time, Arrival flight, Arrival airport, Departure date/time, Departure flight, Departure airport, Status, Needs visa) and to `visas-tab.tsx` (columns: Project, Attendee, Email, Nationality, Status, Invitation sent, Appointment, Decision, Notes). MIRROR the CSV builder in `src/components/admin/logistics/lunch-manifest.tsx:28-57` (Blob + anchor download; escape commas/quotes). Build from the data already loaded by each tab's query.
- [ ] **Step 3:** `npx vitest run tests/unit/logistics-flight.test.ts` → pass. `npm run typecheck` → clean.
- [ ] **Step 4: Commit** — `git commit -am "feat(logistics): departure-after-arrival validation + travel/visa CSV export"`
---
## Task 5: Verify
- [ ] **Step 1:** `npx vitest run` — full suite green.
- [ ] **Step 2:** `npm run typecheck` — clean. Stop dev, `rm -rf .next`, `npm run build` — clean.
- [ ] **Step 3:** Restart dev on :3001. Dev smoke: as admin, enroll a team (ADMIN_CONFIRM) and add flight + hotel + set a visa; then log in as that team's lead (seed user `matt@letsbe.solutions` is a MEMBER of "Revamp Flips" — or use the lead) and confirm the "My Logistics" card shows the hotel + flight + visa + nationality field. Verify the CSV export downloads. Clean up.
- [ ] **Step 4:** Summarize.
## Notes
- Deep timezone overhaul of the admin flight `datetime-local` inputs (storing explicit Europe/Paris) is a separate design decision — NOT in this wave; the validation + Europe/Paris display labels are the pragmatic improvement. Flag as remaining polish.
- This completes the 4-wave logistics overhaul.