From 31b98f6f1e15481f9793f9c6a778d35eb9ed82e4 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 02:50:15 +0200 Subject: [PATCH] feat: read-only external attendees strip on applicant dashboard Adds lunch.getProjectExternals (team-member guarded). Strip auto-hides when no externals attached to the team. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(applicant)/applicant/page.tsx | 4 ++ .../applicant/external-attendees-strip.tsx | 25 +++++++++++ src/server/routers/lunch.ts | 23 ++++++++++ tests/unit/lunch-router.test.ts | 43 +++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 src/components/applicant/external-attendees-strip.tsx diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 0f5fee8..19ba69b 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -20,6 +20,7 @@ import { MentoringRequestCard } from '@/components/applicant/mentoring-request-c import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card' import { AttendingMembersCard } from '@/components/applicant/attending-members-card' import { LunchBanner } from '@/components/applicant/lunch-banner' +import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip' import { AnimatedCard } from '@/components/shared/animated-container' import { ProjectLogoUpload } from '@/components/shared/project-logo-upload' import { Progress } from '@/components/ui/progress' @@ -407,6 +408,9 @@ export default function ApplicantDashboardPage() { {/* Lunch banner (auto-hides when lunch event disabled or unconfigured) */} + {/* External lunch attendees attached to this team (auto-hides if none) */} + + {/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */} diff --git a/src/components/applicant/external-attendees-strip.tsx b/src/components/applicant/external-attendees-strip.tsx new file mode 100644 index 0000000..fae22a5 --- /dev/null +++ b/src/components/applicant/external-attendees-strip.tsx @@ -0,0 +1,25 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { UsersRound } from 'lucide-react' + +export function ExternalAttendeesStrip({ projectId }: { projectId: string }) { + const { data } = trpc.lunch.getProjectExternals.useQuery({ projectId }) + if (!data || data.length === 0) return null + return ( + + + + External attendees joining your team: + {data.map((e) => ( + + {e.name} + {e.roleNote ? ` (${e.roleNote})` : ''} + + ))} + + + ) +} diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index d58a74f..e4c2c94 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -398,6 +398,29 @@ export const lunchRouter = router({ return { ...event, changeDeadline } }), + /** + * Read-only list of project-attached externals for a project. Visible to + * any team member of the project (so they know who's joining their lunch). + */ + getProjectExternals: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const userId = ctx.user.id + const role = ctx.user.role + const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN' + if (!isAdmin) { + const tm = await ctx.prisma.teamMember.findFirst({ + where: { projectId: input.projectId, userId }, + }) + if (!tm) throw new TRPCError({ code: 'FORBIDDEN' }) + } + return ctx.prisma.externalAttendee.findMany({ + where: { projectId: input.projectId }, + include: { dish: true }, + orderBy: { createdAt: 'asc' }, + }) + }), + /** * All picks for the caller's team. Within-team transparency: every team * member sees their teammates' picks (lunch picks aren't sensitive). diff --git a/tests/unit/lunch-router.test.ts b/tests/unit/lunch-router.test.ts index 501806e..4b95aaf 100644 --- a/tests/unit/lunch-router.test.ts +++ b/tests/unit/lunch-router.test.ts @@ -414,6 +414,49 @@ describe('lunch.exportManifestCsv', () => { }) }) +describe('lunch.getProjectExternals', () => { + it('returns project-attached externals to a team member', async () => { + const program = await createTestProgram({ name: `pe-ok-${uid()}` }) + programIds.push(program.id) + const lead = await createTestUser('APPLICANT') + userIds.push(lead.id) + const project = await createTestProject(program.id, { + title: `pe-${uid()}`, competitionCategory: 'STARTUP', + }) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, + }) + const event = await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: true }, + }) + await prisma.externalAttendee.create({ + data: { lunchEventId: event.id, projectId: project.id, name: 'Sponsor X' }, + }) + const caller = createCaller(lunchRouter, { + id: lead.id, email: lead.email, role: 'APPLICANT', + }) + const result = await caller.getProjectExternals({ projectId: project.id }) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Sponsor X') + }) + + it('rejects strangers', async () => { + const program = await createTestProgram({ name: `pe-rej-${uid()}` }) + programIds.push(program.id) + const stranger = await createTestUser('APPLICANT') + userIds.push(stranger.id) + const project = await createTestProject(program.id, { + title: `pe-rej-${uid()}`, competitionCategory: 'STARTUP', + }) + const caller = createCaller(lunchRouter, { + id: stranger.id, email: stranger.email, role: 'APPLICANT', + }) + await expect( + caller.getProjectExternals({ projectId: project.id }), + ).rejects.toThrow() + }) +}) + describe('lunch.updateEvent', () => { it('patches an arbitrary subset of fields', async () => { const program = await createTestProgram({ name: `lunch-upd-${uid()}` })