feat(user): context-aware default dashboard (§D.1)

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
This commit is contained in:
Matt
2026-04-28 16:00:56 +02:00
parent cedd188328
commit 0c2b2d1f96
3 changed files with 269 additions and 15 deletions

View File

@@ -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 (

View File

@@ -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<UserRole>([...(user.roles ?? []), user.role])
type Entry = { role: UserRole; path: string; predicate: () => Promise<boolean> | 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 }
}),
})