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

9.6 KiB

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:
      {
        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: Commitgit 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: Commitgit commit -am "feat(applicant): self-service visa nationality entry"

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: Commitgit 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: Commitgit 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.