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) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { MentoringRequestCard } from '@/components/applicant/mentoring-request-c
|
|||||||
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
||||||
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
||||||
import { LunchBanner } from '@/components/applicant/lunch-banner'
|
import { LunchBanner } from '@/components/applicant/lunch-banner'
|
||||||
|
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
@@ -407,6 +408,9 @@ export default function ApplicantDashboardPage() {
|
|||||||
{/* Lunch banner (auto-hides when lunch event disabled or unconfigured) */}
|
{/* Lunch banner (auto-hides when lunch event disabled or unconfigured) */}
|
||||||
<LunchBanner programId={project.programId} />
|
<LunchBanner programId={project.programId} />
|
||||||
|
|
||||||
|
{/* External lunch attendees attached to this team (auto-hides if none) */}
|
||||||
|
<ExternalAttendeesStrip projectId={project.id} />
|
||||||
|
|
||||||
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
|
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
|
||||||
<AttendingMembersCard />
|
<AttendingMembersCard />
|
||||||
|
|
||||||
|
|||||||
25
src/components/applicant/external-attendees-strip.tsx
Normal file
25
src/components/applicant/external-attendees-strip.tsx
Normal file
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-2 py-3">
|
||||||
|
<UsersRound className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">External attendees joining your team:</span>
|
||||||
|
{data.map((e) => (
|
||||||
|
<Badge key={e.id} variant="outline">
|
||||||
|
{e.name}
|
||||||
|
{e.roleNote ? ` (${e.roleNote})` : ''}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -398,6 +398,29 @@ export const lunchRouter = router({
|
|||||||
return { ...event, changeDeadline }
|
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
|
* All picks for the caller's team. Within-team transparency: every team
|
||||||
* member sees their teammates' picks (lunch picks aren't sensitive).
|
* member sees their teammates' picks (lunch picks aren't sensitive).
|
||||||
|
|||||||
@@ -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', () => {
|
describe('lunch.updateEvent', () => {
|
||||||
it('patches an arbitrary subset of fields', async () => {
|
it('patches an arbitrary subset of fields', async () => {
|
||||||
const program = await createTestProgram({ name: `lunch-upd-${uid()}` })
|
const program = await createTestProgram({ name: `lunch-upd-${uid()}` })
|
||||||
|
|||||||
Reference in New Issue
Block a user