From 0c2b2d1f96166c139dc4c16829d5abc2c68ca221 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 16:00:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(user):=20context-aware=20default=20dashboa?= =?UTF-8?q?rd=20(=C2=A7D.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user.getDefaultDashboard returns the highest-priority role for which the user has actionable work right now — pending eval in active round, active mentoring assignment, applicant project in active round, etc. — falling back to static priority order if nothing is actionable. src/app/page.tsx now reads roles[] (multi-role array) instead of just the primary role, fixing the bug where mentor+juror users always landed on their primary role's dashboard. Uses static priority for simplicity in the server component; the context-aware procedure remains available for client surfaces. Tests cover six cases: super-admin, juror with active eval, juror+observer fallback, mentor+juror in mentoring round, both-active-priority-tiebreak, observer-only. Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md --- src/app/page.tsx | 29 +++--- src/server/routers/user.ts | 105 +++++++++++++++++++ tests/unit/default-dashboard.test.ts | 150 +++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 tests/unit/default-dashboard.test.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index c129c24..8abb6c3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,28 +4,27 @@ import Image from 'next/image' import { auth } from '@/lib/auth' import { redirect } from 'next/navigation' import type { Route } from 'next' +import type { UserRole } from '@prisma/client' export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' } export default async function HomePage() { const session = await auth() - // Redirect authenticated users to their appropriate dashboard + // Redirect authenticated users to their appropriate dashboard. + // Reads the multi-role array (roles[]) so a user who is e.g. JURY_MEMBER+MENTOR + // lands on /jury (their highest-priority role) rather than always falling + // through on the singular `role` field. The context-aware variant — + // user.getDefaultDashboard tRPC procedure — exists for surfaces that can call + // tRPC; page.tsx uses static priority for simplicity. if (session?.user) { - if ( - session.user.role === 'SUPER_ADMIN' || - session.user.role === 'PROGRAM_ADMIN' - ) { - redirect('/admin') - } else if (session.user.role === 'JURY_MEMBER') { - redirect('/jury') - } else if (session.user.role === 'MENTOR') { - redirect('/mentor' as Route) - } else if (session.user.role === 'OBSERVER') { - redirect('/observer') - } else if (session.user.role === 'APPLICANT') { - redirect('/applicant' as Route) - } + const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole] + if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin') + if (roles.includes('AWARD_MASTER')) redirect('/award-master') + if (roles.includes('JURY_MEMBER')) redirect('/jury') + if (roles.includes('MENTOR')) redirect('/mentor' as Route) + if (roles.includes('APPLICANT')) redirect('/applicant' as Route) + if (roles.includes('OBSERVER')) redirect('/observer') } return ( diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index d518d8e..7806e75 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -2039,4 +2039,109 @@ export const userRouter = router({ return { ended: true } }), + + /** + * Context-aware default dashboard. Returns the highest-priority role for + * which the user has actionable work right now, or the highest-priority + * role they hold (static fallback) if nothing is actionable. + * + * Priority order: SUPER_ADMIN > PROGRAM_ADMIN > AWARD_MASTER > JURY_MEMBER > + * MENTOR > APPLICANT > OBSERVER > AUDIENCE. + * + * Used by src/app/page.tsx to route users at login. + */ + getDefaultDashboard: protectedProcedure.query(async ({ ctx }) => { + const user = await ctx.prisma.user.findUniqueOrThrow({ + where: { id: ctx.user.id }, + select: { id: true, roles: true, role: true }, + }) + + const userRoles = new Set([...(user.roles ?? []), user.role]) + + type Entry = { role: UserRole; path: string; predicate: () => Promise | boolean } + const PRIORITY: Entry[] = [ + { role: 'SUPER_ADMIN', path: '/admin', predicate: () => true }, + { role: 'PROGRAM_ADMIN', path: '/admin', predicate: () => true }, + { + role: 'AWARD_MASTER', + path: '/award-master', + predicate: async () => { + const cnt = await ctx.prisma.awardJuror.count({ where: { userId: user.id } }) + return cnt > 0 + }, + }, + { + role: 'JURY_MEMBER', + path: '/jury', + predicate: async () => { + const cnt = await ctx.prisma.assignment.count({ + where: { + userId: user.id, + isCompleted: false, + round: { status: 'ROUND_ACTIVE' }, + }, + }) + return cnt > 0 + }, + }, + { + role: 'MENTOR', + path: '/mentor', + predicate: async () => { + const cnt = await ctx.prisma.mentorAssignment.count({ + where: { + mentorId: user.id, + workspaceEnabled: true, + project: { + projectRoundStates: { + some: { round: { status: 'ROUND_ACTIVE' } }, + }, + }, + }, + }) + return cnt > 0 + }, + }, + { + role: 'APPLICANT', + path: '/applicant', + predicate: async () => { + const cnt = await ctx.prisma.teamMember.count({ + where: { + userId: user.id, + project: { + projectRoundStates: { + some: { + round: { status: 'ROUND_ACTIVE' }, + state: { in: ['PENDING', 'IN_PROGRESS'] }, + }, + }, + }, + }, + }) + return cnt > 0 + }, + }, + { role: 'OBSERVER', path: '/observer', predicate: () => false }, + { role: 'AUDIENCE', path: '/applicant', predicate: () => false }, + ] + + // Walk priority. Return first role the user holds whose predicate is true. + for (const entry of PRIORITY) { + if (!userRoles.has(entry.role)) continue + const has = await entry.predicate() + if (has) { + return { role: entry.role, path: entry.path, reason: 'has-active-work' as const } + } + } + + // Static fallback: highest-priority role they hold. + for (const entry of PRIORITY) { + if (userRoles.has(entry.role)) { + return { role: entry.role, path: entry.path, reason: 'static-fallback' as const } + } + } + + return { role: 'APPLICANT' as UserRole, path: '/applicant', reason: 'static-fallback' as const } + }), }) diff --git a/tests/unit/default-dashboard.test.ts b/tests/unit/default-dashboard.test.ts new file mode 100644 index 0000000..b8d4e4f --- /dev/null +++ b/tests/unit/default-dashboard.test.ts @@ -0,0 +1,150 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + createTestCompetition, + createTestRound, + createTestProjectRoundState, + cleanupTestData, + uid, +} from '../helpers' +import { userRouter } from '../../src/server/routers/user' +import type { UserRole } from '@prisma/client' + +async function createUserWithRoles(primaryRole: UserRole, rolesArray: UserRole[]) { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: `Test ${primaryRole}`, + role: primaryRole, + roles: rolesArray, + status: 'ACTIVE', + }, + }) +} + +describe('user.getDefaultDashboard', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('returns /admin for SUPER_ADMIN unconditionally', async () => { + const u = await createUserWithRoles('SUPER_ADMIN', ['SUPER_ADMIN']) + userIds.push(u.id) + const caller = createCaller(userRouter, { id: u.id, email: u.email, role: 'SUPER_ADMIN' }) + const result = await caller.getDefaultDashboard() + expect(result.path).toBe('/admin') + expect(result.role).toBe('SUPER_ADMIN') + }) + + it('returns /jury for a juror with a pending assignment in an active round', async () => { + const u = await createUserWithRoles('JURY_MEMBER', ['JURY_MEMBER', 'OBSERVER']) + userIds.push(u.id) + const program = await createTestProgram({ name: `default-juror-active-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + const round = await createTestRound(competition.id, { + roundType: 'EVALUATION', + status: 'ROUND_ACTIVE', + }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + await prisma.assignment.create({ + data: { userId: u.id, projectId: project.id, roundId: round.id, method: 'MANUAL' }, + }) + const caller = createCaller(userRouter, { id: u.id, email: u.email, role: 'JURY_MEMBER' }) + const result = await caller.getDefaultDashboard() + expect(result.path).toBe('/jury') + }) + + it('falls back to /jury (static priority) when juror+observer has no active work', async () => { + const u = await createUserWithRoles('JURY_MEMBER', ['JURY_MEMBER', 'OBSERVER']) + userIds.push(u.id) + const caller = createCaller(userRouter, { id: u.id, email: u.email, role: 'JURY_MEMBER' }) + const result = await caller.getDefaultDashboard() + expect(result.path).toBe('/jury') + expect(result.reason).toBe('static-fallback') + }) + + it('returns /mentor for a mentor+juror with active mentoring but no jury work', async () => { + const u = await createUserWithRoles('MENTOR', ['MENTOR', 'JURY_MEMBER']) + userIds.push(u.id) + const program = await createTestProgram({ name: `default-mentor-active-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + const round = await createTestRound(competition.id, { + roundType: 'MENTORING', + status: 'ROUND_ACTIVE', + }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: u.id, + method: 'MANUAL', + workspaceEnabled: true, + }, + }) + const caller = createCaller(userRouter, { id: u.id, email: u.email, role: 'MENTOR' }) + const result = await caller.getDefaultDashboard() + expect(result.path).toBe('/mentor') + }) + + it('returns /jury when both jury and mentor work are active (jury wins on priority)', async () => { + const u = await createUserWithRoles('JURY_MEMBER', ['JURY_MEMBER', 'MENTOR']) + userIds.push(u.id) + const program = await createTestProgram({ name: `default-both-active-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + const evalRound = await createTestRound(competition.id, { + roundType: 'EVALUATION', + status: 'ROUND_ACTIVE', + sortOrder: 0, + }) + const mentRound = await createTestRound(competition.id, { + roundType: 'MENTORING', + status: 'ROUND_ACTIVE', + sortOrder: 1, + }) + const projectE = await createTestProject(program.id) + const projectM = await createTestProject(program.id) + await createTestProjectRoundState(projectE.id, evalRound.id) + await createTestProjectRoundState(projectM.id, mentRound.id) + await prisma.assignment.create({ + data: { userId: u.id, projectId: projectE.id, roundId: evalRound.id, method: 'MANUAL' }, + }) + await prisma.mentorAssignment.create({ + data: { + projectId: projectM.id, + mentorId: u.id, + method: 'MANUAL', + workspaceEnabled: true, + }, + }) + const caller = createCaller(userRouter, { id: u.id, email: u.email, role: 'JURY_MEMBER' }) + const result = await caller.getDefaultDashboard() + expect(result.path).toBe('/jury') + }) + + it('returns /observer for an observer-only user (static fallback)', async () => { + const u = await createUserWithRoles('OBSERVER', ['OBSERVER']) + userIds.push(u.id) + const caller = createCaller(userRouter, { id: u.id, email: u.email, role: 'OBSERVER' }) + const result = await caller.getDefaultDashboard() + expect(result.path).toBe('/observer') + }) +})