From aad4b7f11169a61190172a4469b8872d14848a96 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 17 Feb 2026 14:20:40 +0100 Subject: [PATCH] feat: enhance session management by implementing edit permissions for team admins and updating session components to reflect new access controls --- src/actions/swot.ts | 24 +++++++ src/app/motivators/[id]/page.tsx | 2 +- src/app/sessions/WorkshopTabs.tsx | 33 ++++++---- src/app/sessions/[id]/page.tsx | 2 +- src/app/weather/[id]/page.tsx | 2 +- src/app/weekly-checkin/[id]/page.tsx | 2 +- src/app/year-review/[id]/page.tsx | 2 +- src/components/ui/EditableMotivatorTitle.tsx | 6 +- src/components/ui/EditableSessionTitle.tsx | 6 +- src/components/ui/EditableTitle.tsx | 6 +- src/components/ui/EditableWeatherTitle.tsx | 6 +- .../ui/EditableWeeklyCheckInTitle.tsx | 6 +- src/components/ui/EditableYearReviewTitle.tsx | 6 +- src/services/moving-motivators.ts | 61 +++++++++++++---- src/services/sessions.ts | 66 +++++++++++++++---- src/services/teams.ts | 7 ++ src/services/weather.ts | 64 ++++++++++++++---- src/services/weekly-checkin.ts | 61 +++++++++++++---- src/services/year-review.ts | 61 +++++++++++++---- 19 files changed, 333 insertions(+), 90 deletions(-) diff --git a/src/actions/swot.ts b/src/actions/swot.ts index b02eb56..0294e0a 100644 --- a/src/actions/swot.ts +++ b/src/actions/swot.ts @@ -17,6 +17,9 @@ export async function createSwotItem( if (!session?.user?.id) { return { success: false, error: 'Non autorisé' }; } + if (!(await sessionsService.canEditSession(sessionId, session.user.id))) { + return { success: false, error: 'Non autorisé' }; + } try { const item = await sessionsService.createSwotItem(sessionId, data); @@ -45,6 +48,9 @@ export async function updateSwotItem( if (!session?.user?.id) { return { success: false, error: 'Non autorisé' }; } + if (!(await sessionsService.canEditSession(sessionId, session.user.id))) { + return { success: false, error: 'Non autorisé' }; + } try { const item = await sessionsService.updateSwotItem(itemId, data); @@ -68,6 +74,9 @@ export async function deleteSwotItem(itemId: string, sessionId: string) { if (!session?.user?.id) { return { success: false, error: 'Non autorisé' }; } + if (!(await sessionsService.canEditSession(sessionId, session.user.id))) { + return { success: false, error: 'Non autorisé' }; + } try { await sessionsService.deleteSwotItem(itemId); @@ -90,6 +99,9 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) { if (!session?.user?.id) { return { success: false, error: 'Non autorisé' }; } + if (!(await sessionsService.canEditSession(sessionId, session.user.id))) { + return { success: false, error: 'Non autorisé' }; + } try { const item = await sessionsService.duplicateSwotItem(itemId); @@ -120,6 +132,9 @@ export async function moveSwotItem( if (!session?.user?.id) { return { success: false, error: 'Non autorisé' }; } + if (!(await sessionsService.canEditSession(sessionId, session.user.id))) { + return { success: false, error: 'Non autorisé' }; + } try { const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder); @@ -156,6 +171,9 @@ export async function createAction( if (!session?.user?.id) { return { success: false, error: 'Non autorisé' }; } + if (!(await sessionsService.canEditSession(sessionId, session.user.id))) { + return { success: false, error: 'Non autorisé' }; + } try { const action = await sessionsService.createAction(sessionId, data); @@ -190,6 +208,9 @@ export async function updateAction( if (!session?.user?.id) { return { success: false, error: 'Non autorisé' }; } + if (!(await sessionsService.canEditSession(sessionId, session.user.id))) { + return { success: false, error: 'Non autorisé' }; + } try { const action = await sessionsService.updateAction(actionId, data); @@ -213,6 +234,9 @@ export async function deleteAction(actionId: string, sessionId: string) { if (!session?.user?.id) { return { success: false, error: 'Non autorisé' }; } + if (!(await sessionsService.canEditSession(sessionId, session.user.id))) { + return { success: false, error: 'Non autorisé' }; + } try { await sessionsService.deleteAction(actionId); diff --git a/src/app/motivators/[id]/page.tsx b/src/app/motivators/[id]/page.tsx index 45248ca..5cd24c8 100644 --- a/src/app/motivators/[id]/page.tsx +++ b/src/app/motivators/[id]/page.tsx @@ -47,7 +47,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
diff --git a/src/app/sessions/WorkshopTabs.tsx b/src/app/sessions/WorkshopTabs.tsx index 2571c73..b941ac2 100644 --- a/src/app/sessions/WorkshopTabs.tsx +++ b/src/app/sessions/WorkshopTabs.tsx @@ -65,6 +65,8 @@ interface SwotSession { shares: Share[]; _count: { items: number; actions: number }; workshopType: 'swot'; + isTeamCollab?: true; + canEdit?: boolean; } interface MotivatorSession { @@ -79,6 +81,8 @@ interface MotivatorSession { shares: Share[]; _count: { cards: number }; workshopType: 'motivators'; + isTeamCollab?: true; + canEdit?: boolean; } interface YearReviewSession { @@ -94,6 +98,8 @@ interface YearReviewSession { shares: Share[]; _count: { items: number }; workshopType: 'year-review'; + isTeamCollab?: true; + canEdit?: boolean; } interface WeeklyCheckInSession { @@ -109,6 +115,8 @@ interface WeeklyCheckInSession { shares: Share[]; _count: { items: number }; workshopType: 'weekly-checkin'; + isTeamCollab?: true; + canEdit?: boolean; } interface WeatherSession { @@ -122,6 +130,8 @@ interface WeatherSession { shares: Share[]; _count: { entries: number }; workshopType: 'weather'; + isTeamCollab?: true; + canEdit?: boolean; } type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession; @@ -732,19 +742,16 @@ function SessionCard({ session, isTeamCollab = false }: { session: AnySession; i return ( <>
- {isTeamCollab ? ( -
- {cardContent} -
- ) : ( - {cardContent} - )} + + {cardContent} + - {/* Action buttons - only for owner */} - {session.isOwner && ( + {/* Edit: owner, EDITOR, or team admin | Delete: owner or team admin only (not EDITOR) */} + {(session.isOwner || session.role === 'EDITOR' || session.isTeamCollab) && (
+ {(session.isOwner || session.isTeamCollab) && ( + )}
)}
diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx index e77742b..e33d73e 100644 --- a/src/app/sessions/[id]/page.tsx +++ b/src/app/sessions/[id]/page.tsx @@ -48,7 +48,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
diff --git a/src/app/weekly-checkin/[id]/page.tsx b/src/app/weekly-checkin/[id]/page.tsx index 963137e..bcf7ac2 100644 --- a/src/app/weekly-checkin/[id]/page.tsx +++ b/src/app/weekly-checkin/[id]/page.tsx @@ -62,7 +62,7 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
diff --git a/src/app/year-review/[id]/page.tsx b/src/app/year-review/[id]/page.tsx index 3c55377..66ef024 100644 --- a/src/app/year-review/[id]/page.tsx +++ b/src/app/year-review/[id]/page.tsx @@ -47,7 +47,7 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
diff --git a/src/components/ui/EditableMotivatorTitle.tsx b/src/components/ui/EditableMotivatorTitle.tsx index 8f765fe..d989f50 100644 --- a/src/components/ui/EditableMotivatorTitle.tsx +++ b/src/components/ui/EditableMotivatorTitle.tsx @@ -6,19 +6,19 @@ import { updateMotivatorSession } from '@/actions/moving-motivators'; interface EditableMotivatorTitleProps { sessionId: string; initialTitle: string; - isOwner: boolean; + canEdit: boolean; } export function EditableMotivatorTitle({ sessionId, initialTitle, - isOwner, + canEdit, }: EditableMotivatorTitleProps) { return ( { const result = await updateMotivatorSession(id, { title }); return result; diff --git a/src/components/ui/EditableSessionTitle.tsx b/src/components/ui/EditableSessionTitle.tsx index c9df8c6..d5612e7 100644 --- a/src/components/ui/EditableSessionTitle.tsx +++ b/src/components/ui/EditableSessionTitle.tsx @@ -6,19 +6,19 @@ import { updateSessionTitle } from '@/actions/session'; interface EditableSessionTitleProps { sessionId: string; initialTitle: string; - isOwner: boolean; + canEdit: boolean; } export function EditableSessionTitle({ sessionId, initialTitle, - isOwner, + canEdit, }: EditableSessionTitleProps) { return ( { const result = await updateSessionTitle(id, title); return result; diff --git a/src/components/ui/EditableTitle.tsx b/src/components/ui/EditableTitle.tsx index 5799d35..4d05ec3 100644 --- a/src/components/ui/EditableTitle.tsx +++ b/src/components/ui/EditableTitle.tsx @@ -5,14 +5,14 @@ import { useState, useTransition, useRef, useEffect, useMemo } from 'react'; interface EditableTitleProps { sessionId: string; initialTitle: string; - isOwner: boolean; + canEdit: boolean; onUpdate: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>; } export function EditableTitle({ sessionId, initialTitle, - isOwner, + canEdit, onUpdate, }: EditableTitleProps) { const [isEditing, setIsEditing] = useState(false); @@ -65,7 +65,7 @@ export function EditableTitle({ } }; - if (!isOwner) { + if (!canEdit) { return

{title}

; } diff --git a/src/components/ui/EditableWeatherTitle.tsx b/src/components/ui/EditableWeatherTitle.tsx index 72c9018..83890db 100644 --- a/src/components/ui/EditableWeatherTitle.tsx +++ b/src/components/ui/EditableWeatherTitle.tsx @@ -6,19 +6,19 @@ import { updateWeatherSession } from '@/actions/weather'; interface EditableWeatherTitleProps { sessionId: string; initialTitle: string; - isOwner: boolean; + canEdit: boolean; } export function EditableWeatherTitle({ sessionId, initialTitle, - isOwner, + canEdit, }: EditableWeatherTitleProps) { return ( { const result = await updateWeatherSession(id, { title }); return result; diff --git a/src/components/ui/EditableWeeklyCheckInTitle.tsx b/src/components/ui/EditableWeeklyCheckInTitle.tsx index 3725393..995cad1 100644 --- a/src/components/ui/EditableWeeklyCheckInTitle.tsx +++ b/src/components/ui/EditableWeeklyCheckInTitle.tsx @@ -6,19 +6,19 @@ import { updateWeeklyCheckInSession } from '@/actions/weekly-checkin'; interface EditableWeeklyCheckInTitleProps { sessionId: string; initialTitle: string; - isOwner: boolean; + canEdit: boolean; } export function EditableWeeklyCheckInTitle({ sessionId, initialTitle, - isOwner, + canEdit, }: EditableWeeklyCheckInTitleProps) { return ( { const result = await updateWeeklyCheckInSession(id, { title }); return result; diff --git a/src/components/ui/EditableYearReviewTitle.tsx b/src/components/ui/EditableYearReviewTitle.tsx index 3dd887b..1d0a7a0 100644 --- a/src/components/ui/EditableYearReviewTitle.tsx +++ b/src/components/ui/EditableYearReviewTitle.tsx @@ -6,19 +6,19 @@ import { updateYearReviewSession } from '@/actions/year-review'; interface EditableYearReviewTitleProps { sessionId: string; initialTitle: string; - isOwner: boolean; + canEdit: boolean; } export function EditableYearReviewTitle({ sessionId, initialTitle, - isOwner, + canEdit, }: EditableYearReviewTitleProps) { return ( { const result = await updateYearReviewSession(id, { title }); return result; diff --git a/src/services/moving-motivators.ts b/src/services/moving-motivators.ts index bafab3f..892e713 100644 --- a/src/services/moving-motivators.ts +++ b/src/services/moving-motivators.ts @@ -1,6 +1,6 @@ import { prisma } from '@/services/database'; import { resolveCollaborator } from '@/services/auth'; -import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; import type { ShareRole, MotivatorType } from '@prisma/client'; // ============================================ @@ -104,6 +104,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { isOwner: false as const, role: 'VIEWER' as const, isTeamCollab: true as const, + canEdit: true as const, // Admin has full rights on team member sessions })); return Promise.all( @@ -115,8 +116,8 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { } export async function getMotivatorSessionById(sessionId: string, userId: string) { - // Check if user owns the session OR has it shared - const session = await prisma.movingMotivatorsSession.findFirst({ + // Check if user owns the session, has it shared, or is team admin of owner + let session = await prisma.movingMotivatorsSession.findFirst({ where: { id: sessionId, OR: [ @@ -137,13 +138,25 @@ export async function getMotivatorSessionById(sessionId: string, userId: string) }, }); - if (!session) return null; + if (!session) { + const raw = await prisma.movingMotivatorsSession.findUnique({ + where: { id: sessionId }, + include: { + user: { select: { id: true, name: true, email: true } }, + cards: { orderBy: { orderIndex: 'asc' } }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + }, + }); + if (!raw || !(await isAdminOfUser(raw.userId, userId))) return null; + session = raw; + } // Determine user's role const isOwner = session.userId === userId; const share = session.shares.find((s) => s.userId === userId); + const isAdminOfOwner = !isOwner && !share && (await isAdminOfUser(session.userId, userId)); const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); - const canEdit = isOwner || role === 'EDITOR'; + const canEdit = isOwner || role === 'EDITOR' || isAdminOfOwner; // Resolve participant to user if it's an email const resolvedParticipant = await resolveCollaborator(session.participant); @@ -151,7 +164,7 @@ export async function getMotivatorSessionById(sessionId: string, userId: string) return { ...session, isOwner, role, canEdit, resolvedParticipant }; } -// Check if user can access session (owner or shared) +// Check if user can access session (owner, shared, or team admin of owner) export async function canAccessMotivatorSession(sessionId: string, userId: string) { const count = await prisma.movingMotivatorsSession.count({ where: { @@ -159,10 +172,15 @@ export async function canAccessMotivatorSession(sessionId: string, userId: strin OR: [{ userId }, { shares: { some: { userId } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.movingMotivatorsSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; } -// Check if user can edit session (owner or EDITOR role) +// Check if user can edit session (owner, EDITOR role, or team admin of owner) export async function canEditMotivatorSession(sessionId: string, userId: string) { const count = await prisma.movingMotivatorsSession.count({ where: { @@ -170,7 +188,22 @@ export async function canEditMotivatorSession(sessionId: string, userId: string) OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.movingMotivatorsSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; +} + +// Check if user can delete session (owner or team admin only - NOT EDITOR) +export async function canDeleteMotivatorSession(sessionId: string, userId: string) { + const session = await prisma.movingMotivatorsSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + if (!session) return false; + return session.userId === userId || isAdminOfUser(session.userId, userId); } const DEFAULT_MOTIVATOR_TYPES: MotivatorType[] = [ @@ -216,15 +249,21 @@ export async function updateMotivatorSession( userId: string, data: { title?: string; participant?: string } ) { + if (!(await canEditMotivatorSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.movingMotivatorsSession.updateMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, data, }); } export async function deleteMotivatorSession(sessionId: string, userId: string) { + if (!(await canDeleteMotivatorSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.movingMotivatorsSession.deleteMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, }); } diff --git a/src/services/sessions.ts b/src/services/sessions.ts index 566dd9b..a1a6611 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -1,6 +1,6 @@ import { prisma } from '@/services/database'; import { resolveCollaborator } from '@/services/auth'; -import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; import type { SwotCategory, ShareRole } from '@prisma/client'; // ============================================ @@ -111,6 +111,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { isOwner: false as const, role: 'VIEWER' as const, isTeamCollab: true as const, + canEdit: true as const, // Admin has full rights on team member sessions })); return Promise.all( @@ -122,8 +123,8 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { } export async function getSessionById(sessionId: string, userId: string) { - // Check if user owns the session OR has it shared - const session = await prisma.session.findFirst({ + // Check if user owns the session, has it shared, or is team admin of owner + let session = await prisma.session.findFirst({ where: { id: sessionId, OR: [ @@ -154,13 +155,30 @@ export async function getSessionById(sessionId: string, userId: string) { }, }); - if (!session) return null; + if (!session) { + // Fallback: team admin viewing team member's session + const raw = await prisma.session.findUnique({ + where: { id: sessionId }, + include: { + user: { select: { id: true, name: true, email: true } }, + items: { orderBy: { order: 'asc' } }, + actions: { + include: { links: { include: { swotItem: true } } }, + orderBy: { createdAt: 'asc' }, + }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + }, + }); + if (!raw || !(await isAdminOfUser(raw.userId, userId))) return null; + session = raw; + } // Determine user's role const isOwner = session.userId === userId; const share = session.shares.find((s) => s.userId === userId); + const isAdminOfOwner = !isOwner && !share && (await isAdminOfUser(session.userId, userId)); const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); - const canEdit = isOwner || role === 'EDITOR'; + const canEdit = isOwner || role === 'EDITOR' || isAdminOfOwner; // Resolve collaborator to user if it's an email const resolvedCollaborator = await resolveCollaborator(session.collaborator); @@ -168,7 +186,7 @@ export async function getSessionById(sessionId: string, userId: string) { return { ...session, isOwner, role, canEdit, resolvedCollaborator }; } -// Check if user can access session (owner or shared) +// Check if user can access session (owner, shared, or team admin of owner) export async function canAccessSession(sessionId: string, userId: string) { const count = await prisma.session.count({ where: { @@ -176,10 +194,15 @@ export async function canAccessSession(sessionId: string, userId: string) { OR: [{ userId }, { shares: { some: { userId } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; } -// Check if user can edit session (owner or EDITOR role) +// Check if user can edit session (owner, EDITOR role, or team admin of owner) export async function canEditSession(sessionId: string, userId: string) { const count = await prisma.session.count({ where: { @@ -187,7 +210,22 @@ export async function canEditSession(sessionId: string, userId: string) { OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; +} + +// Check if user can delete session (owner or team admin only - NOT EDITOR) +export async function canDeleteSession(sessionId: string, userId: string) { + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + if (!session) return false; + return session.userId === userId || isAdminOfUser(session.userId, userId); } export async function createSession(userId: string, data: { title: string; collaborator: string }) { @@ -204,15 +242,21 @@ export async function updateSession( userId: string, data: { title?: string; collaborator?: string } ) { + if (!(await canEditSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.session.updateMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, data, }); } export async function deleteSession(sessionId: string, userId: string) { + if (!(await canDeleteSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.session.deleteMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, }); } diff --git a/src/services/teams.ts b/src/services/teams.ts index 5c3f753..5eecdc7 100644 --- a/src/services/teams.ts +++ b/src/services/teams.ts @@ -244,6 +244,13 @@ export async function getTeamMember(teamId: string, userId: string) { }); } +/** Returns true if adminUserId is ADMIN of any team that contains ownerUserId. */ +export async function isAdminOfUser(ownerUserId: string, adminUserId: string): Promise { + if (ownerUserId === adminUserId) return false; + const teamMemberIds = await getTeamMemberIdsForAdminTeams(adminUserId); + return teamMemberIds.includes(ownerUserId); +} + /** Returns user IDs of all members in teams where the given user is ADMIN (excluding self). */ export async function getTeamMemberIdsForAdminTeams(userId: string): Promise { const adminTeams = await prisma.teamMember.findMany({ diff --git a/src/services/weather.ts b/src/services/weather.ts index a95e73a..cc7159e 100644 --- a/src/services/weather.ts +++ b/src/services/weather.ts @@ -1,5 +1,5 @@ import { prisma } from '@/services/database'; -import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; import { getWeekBounds } from '@/lib/date-utils'; import type { ShareRole } from '@prisma/client'; @@ -96,12 +96,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { isOwner: false as const, role: 'VIEWER' as const, isTeamCollab: true as const, + canEdit: true as const, // Admin has full rights on team member sessions })); } export async function getWeatherSessionById(sessionId: string, userId: string) { - // Check if user owns the session OR has it shared - const session = await prisma.weatherSession.findFirst({ + // Check if user owns the session, has it shared, or is team admin of owner + let session = await prisma.weatherSession.findFirst({ where: { id: sessionId, OR: [ @@ -125,18 +126,33 @@ export async function getWeatherSessionById(sessionId: string, userId: string) { }, }); - if (!session) return null; + if (!session) { + const raw = await prisma.weatherSession.findUnique({ + where: { id: sessionId }, + include: { + user: { select: { id: true, name: true, email: true } }, + entries: { + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: { createdAt: 'asc' }, + }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + }, + }); + if (!raw || !(await isAdminOfUser(raw.userId, userId))) return null; + session = raw; + } // Determine user's role const isOwner = session.userId === userId; const share = session.shares.find((s) => s.userId === userId); + const isAdminOfOwner = !isOwner && !share && (await isAdminOfUser(session.userId, userId)); const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); - const canEdit = isOwner || role === 'EDITOR'; + const canEdit = isOwner || role === 'EDITOR' || isAdminOfOwner; return { ...session, isOwner, role, canEdit }; } -// Check if user can access session (owner or shared) +// Check if user can access session (owner, shared, or team admin of owner) export async function canAccessWeatherSession(sessionId: string, userId: string) { const count = await prisma.weatherSession.count({ where: { @@ -144,10 +160,15 @@ export async function canAccessWeatherSession(sessionId: string, userId: string) OR: [{ userId }, { shares: { some: { userId } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.weatherSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; } -// Check if user can edit session (owner or EDITOR role) +// Check if user can edit session (owner, EDITOR role, or team admin of owner) export async function canEditWeatherSession(sessionId: string, userId: string) { const count = await prisma.weatherSession.count({ where: { @@ -155,7 +176,22 @@ export async function canEditWeatherSession(sessionId: string, userId: string) { OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.weatherSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; +} + +// Check if user can delete session (owner or team admin only - NOT EDITOR) +export async function canDeleteWeatherSession(sessionId: string, userId: string) { + const session = await prisma.weatherSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + if (!session) return false; + return session.userId === userId || isAdminOfUser(session.userId, userId); } export async function createWeatherSession(userId: string, data: { title: string; date?: Date }) { @@ -180,15 +216,21 @@ export async function updateWeatherSession( userId: string, data: { title?: string; date?: Date } ) { + if (!(await canEditWeatherSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.weatherSession.updateMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, data, }); } export async function deleteWeatherSession(sessionId: string, userId: string) { + if (!(await canDeleteWeatherSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.weatherSession.deleteMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, }); } diff --git a/src/services/weekly-checkin.ts b/src/services/weekly-checkin.ts index 337c5e3..829b510 100644 --- a/src/services/weekly-checkin.ts +++ b/src/services/weekly-checkin.ts @@ -1,6 +1,6 @@ import { prisma } from '@/services/database'; import { resolveCollaborator } from '@/services/auth'; -import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; import type { ShareRole, WeeklyCheckInCategory, Emotion } from '@prisma/client'; // ============================================ @@ -104,6 +104,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { isOwner: false as const, role: 'VIEWER' as const, isTeamCollab: true as const, + canEdit: true as const, // Admin has full rights on team member sessions })); return Promise.all( @@ -115,8 +116,8 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { } export async function getWeeklyCheckInSessionById(sessionId: string, userId: string) { - // Check if user owns the session OR has it shared - const session = await prisma.weeklyCheckInSession.findFirst({ + // Check if user owns the session, has it shared, or is team admin of owner + let session = await prisma.weeklyCheckInSession.findFirst({ where: { id: sessionId, OR: [ @@ -137,13 +138,25 @@ export async function getWeeklyCheckInSessionById(sessionId: string, userId: str }, }); - if (!session) return null; + if (!session) { + const raw = await prisma.weeklyCheckInSession.findUnique({ + where: { id: sessionId }, + include: { + user: { select: { id: true, name: true, email: true } }, + items: { orderBy: [{ category: 'asc' }, { order: 'asc' }] }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + }, + }); + if (!raw || !(await isAdminOfUser(raw.userId, userId))) return null; + session = raw; + } // Determine user's role const isOwner = session.userId === userId; const share = session.shares.find((s) => s.userId === userId); + const isAdminOfOwner = !isOwner && !share && (await isAdminOfUser(session.userId, userId)); const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); - const canEdit = isOwner || role === 'EDITOR'; + const canEdit = isOwner || role === 'EDITOR' || isAdminOfOwner; // Resolve participant to user if it's an email const resolvedParticipant = await resolveCollaborator(session.participant); @@ -151,7 +164,7 @@ export async function getWeeklyCheckInSessionById(sessionId: string, userId: str return { ...session, isOwner, role, canEdit, resolvedParticipant }; } -// Check if user can access session (owner or shared) +// Check if user can access session (owner, shared, or team admin of owner) export async function canAccessWeeklyCheckInSession(sessionId: string, userId: string) { const count = await prisma.weeklyCheckInSession.count({ where: { @@ -159,10 +172,15 @@ export async function canAccessWeeklyCheckInSession(sessionId: string, userId: s OR: [{ userId }, { shares: { some: { userId } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.weeklyCheckInSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; } -// Check if user can edit session (owner or EDITOR role) +// Check if user can edit session (owner, EDITOR role, or team admin of owner) export async function canEditWeeklyCheckInSession(sessionId: string, userId: string) { const count = await prisma.weeklyCheckInSession.count({ where: { @@ -170,7 +188,22 @@ export async function canEditWeeklyCheckInSession(sessionId: string, userId: str OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.weeklyCheckInSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; +} + +// Check if user can delete session (owner or team admin only - NOT EDITOR) +export async function canDeleteWeeklyCheckInSession(sessionId: string, userId: string) { + const session = await prisma.weeklyCheckInSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + if (!session) return false; + return session.userId === userId || isAdminOfUser(session.userId, userId); } export async function createWeeklyCheckInSession( @@ -196,15 +229,21 @@ export async function updateWeeklyCheckInSession( userId: string, data: { title?: string; participant?: string; date?: Date } ) { + if (!(await canEditWeeklyCheckInSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.weeklyCheckInSession.updateMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, data, }); } export async function deleteWeeklyCheckInSession(sessionId: string, userId: string) { + if (!(await canDeleteWeeklyCheckInSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.weeklyCheckInSession.deleteMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, }); } diff --git a/src/services/year-review.ts b/src/services/year-review.ts index 40a187b..b0afa9a 100644 --- a/src/services/year-review.ts +++ b/src/services/year-review.ts @@ -1,6 +1,6 @@ import { prisma } from '@/services/database'; import { resolveCollaborator } from '@/services/auth'; -import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; import type { ShareRole, YearReviewCategory } from '@prisma/client'; // ============================================ @@ -104,6 +104,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { isOwner: false as const, role: 'VIEWER' as const, isTeamCollab: true as const, + canEdit: true as const, // Admin has full rights on team member sessions })); return Promise.all( @@ -115,8 +116,8 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { } export async function getYearReviewSessionById(sessionId: string, userId: string) { - // Check if user owns the session OR has it shared - const session = await prisma.yearReviewSession.findFirst({ + // Check if user owns the session, has it shared, or is team admin of owner + let session = await prisma.yearReviewSession.findFirst({ where: { id: sessionId, OR: [ @@ -137,13 +138,25 @@ export async function getYearReviewSessionById(sessionId: string, userId: string }, }); - if (!session) return null; + if (!session) { + const raw = await prisma.yearReviewSession.findUnique({ + where: { id: sessionId }, + include: { + user: { select: { id: true, name: true, email: true } }, + items: { orderBy: [{ category: 'asc' }, { order: 'asc' }] }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + }, + }); + if (!raw || !(await isAdminOfUser(raw.userId, userId))) return null; + session = raw; + } // Determine user's role const isOwner = session.userId === userId; const share = session.shares.find((s) => s.userId === userId); + const isAdminOfOwner = !isOwner && !share && (await isAdminOfUser(session.userId, userId)); const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); - const canEdit = isOwner || role === 'EDITOR'; + const canEdit = isOwner || role === 'EDITOR' || isAdminOfOwner; // Resolve participant to user if it's an email const resolvedParticipant = await resolveCollaborator(session.participant); @@ -151,7 +164,7 @@ export async function getYearReviewSessionById(sessionId: string, userId: string return { ...session, isOwner, role, canEdit, resolvedParticipant }; } -// Check if user can access session (owner or shared) +// Check if user can access session (owner, shared, or team admin of owner) export async function canAccessYearReviewSession(sessionId: string, userId: string) { const count = await prisma.yearReviewSession.count({ where: { @@ -159,10 +172,15 @@ export async function canAccessYearReviewSession(sessionId: string, userId: stri OR: [{ userId }, { shares: { some: { userId } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.yearReviewSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; } -// Check if user can edit session (owner or EDITOR role) +// Check if user can edit session (owner, EDITOR role, or team admin of owner) export async function canEditYearReviewSession(sessionId: string, userId: string) { const count = await prisma.yearReviewSession.count({ where: { @@ -170,7 +188,22 @@ export async function canEditYearReviewSession(sessionId: string, userId: string OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], }, }); - return count > 0; + if (count > 0) return true; + const session = await prisma.yearReviewSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + return session ? isAdminOfUser(session.userId, userId) : false; +} + +// Check if user can delete session (owner or team admin only - NOT EDITOR) +export async function canDeleteYearReviewSession(sessionId: string, userId: string) { + const session = await prisma.yearReviewSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + if (!session) return false; + return session.userId === userId || isAdminOfUser(session.userId, userId); } export async function createYearReviewSession( @@ -195,15 +228,21 @@ export async function updateYearReviewSession( userId: string, data: { title?: string; participant?: string; year?: number } ) { + if (!(await canEditYearReviewSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.yearReviewSession.updateMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, data, }); } export async function deleteYearReviewSession(sessionId: string, userId: string) { + if (!(await canDeleteYearReviewSession(sessionId, userId))) { + return { count: 0 }; + } return prisma.yearReviewSession.deleteMany({ - where: { id: sessionId, userId }, + where: { id: sessionId }, }); }