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:
@@ -4,28 +4,27 @@ import Image from 'next/image'
|
|||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
import type { UserRole } from '@prisma/client'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
|
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await auth()
|
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) {
|
||||||
if (
|
const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole]
|
||||||
session.user.role === 'SUPER_ADMIN' ||
|
if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin')
|
||||||
session.user.role === 'PROGRAM_ADMIN'
|
if (roles.includes('AWARD_MASTER')) redirect('/award-master')
|
||||||
) {
|
if (roles.includes('JURY_MEMBER')) redirect('/jury')
|
||||||
redirect('/admin')
|
if (roles.includes('MENTOR')) redirect('/mentor' as Route)
|
||||||
} else if (session.user.role === 'JURY_MEMBER') {
|
if (roles.includes('APPLICANT')) redirect('/applicant' as Route)
|
||||||
redirect('/jury')
|
if (roles.includes('OBSERVER')) redirect('/observer')
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2039,4 +2039,109 @@ export const userRouter = router({
|
|||||||
|
|
||||||
return { ended: true }
|
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 }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
150
tests/unit/default-dashboard.test.ts
Normal file
150
tests/unit/default-dashboard.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user