diff --git a/src/app/motivators/[id]/page.tsx b/src/app/motivators/[id]/page.tsx index 5cd24c8..9ccffd8 100644 --- a/src/app/motivators/[id]/page.tsx +++ b/src/app/motivators/[id]/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops'; import { getMotivatorSessionById } from '@/services/moving-motivators'; +import type { ResolvedCollaborator } from '@/services/auth'; import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators'; import { Badge, CollaboratorDisplay } from '@/components/ui'; import { EditableMotivatorTitle } from '@/components/ui'; @@ -50,7 +51,11 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP canEdit={session.canEdit} />
- +
diff --git a/src/app/weekly-checkin/[id]/page.tsx b/src/app/weekly-checkin/[id]/page.tsx index bcf7ac2..fed2f61 100644 --- a/src/app/weekly-checkin/[id]/page.tsx +++ b/src/app/weekly-checkin/[id]/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops'; import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin'; +import type { ResolvedCollaborator } from '@/services/auth'; import { getUserOKRsForPeriod } from '@/services/okrs'; import { getCurrentQuarterPeriod } from '@/lib/okr-utils'; import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin'; @@ -34,9 +35,10 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn let currentQuarterOKRs: Awaited> = []; // Only fetch OKRs if the participant is a recognized user (has matchedUser) - if (session.resolvedParticipant.matchedUser) { + const resolvedParticipant = session.resolvedParticipant as ResolvedCollaborator; + if (resolvedParticipant.matchedUser) { // Use participant's ID, not session.userId (which is the creator's ID) - const participantUserId = session.resolvedParticipant.matchedUser.id; + const participantUserId = resolvedParticipant.matchedUser.id; currentQuarterOKRs = await getUserOKRsForPeriod(participantUserId, currentQuarterPeriod); } @@ -65,7 +67,7 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn canEdit={session.canEdit} />
- +
diff --git a/src/app/year-review/[id]/page.tsx b/src/app/year-review/[id]/page.tsx index 66ef024..7215316 100644 --- a/src/app/year-review/[id]/page.tsx +++ b/src/app/year-review/[id]/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops'; import { getYearReviewSessionById } from '@/services/year-review'; +import type { ResolvedCollaborator } from '@/services/auth'; import { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review'; import { Badge, CollaboratorDisplay } from '@/components/ui'; import { EditableYearReviewTitle } from '@/components/ui'; @@ -50,7 +51,11 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio canEdit={session.canEdit} />
- +
diff --git a/src/services/moving-motivators.ts b/src/services/moving-motivators.ts index 892e713..2969e08 100644 --- a/src/services/moving-motivators.ts +++ b/src/services/moving-motivators.ts @@ -1,210 +1,93 @@ import { prisma } from '@/services/database'; import { resolveCollaborator } from '@/services/auth'; -import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; -import type { ShareRole, MotivatorType } from '@prisma/client'; +import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { createSessionPermissionChecks } from '@/services/session-permissions'; +import { createShareAndEventHandlers } from '@/services/session-share-events'; +import { + mergeSessionsByUserId, + fetchTeamCollaboratorSessions, + getSessionByIdGeneric, +} from '@/services/session-queries'; +import type { MotivatorType } from '@prisma/client'; + +const motivatorInclude = { + user: { select: { id: true, name: true, email: true } }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + _count: { select: { cards: true } }, +}; // ============================================ // Moving Motivators Session CRUD // ============================================ export async function getMotivatorSessionsByUserId(userId: string) { - // Get owned sessions + shared sessions - const [owned, shared] = await Promise.all([ - prisma.movingMotivatorsSession.findMany({ - where: { userId }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - cards: true, - }, - }, - }, - orderBy: { updatedAt: 'desc' }, - }), - prisma.mMSessionShare.findMany({ - where: { userId }, - include: { - session: { - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - cards: true, - }, - }, - }, - }, - }, - }), - ]); - - // Mark owned sessions and merge with shared - const ownedWithRole = owned.map((s) => ({ - ...s, - isOwner: true as const, - role: 'OWNER' as const, - })); - const sharedWithRole = shared.map((s) => ({ - ...s.session, - isOwner: false as const, - role: s.role, - sharedAt: s.createdAt, - })); - - const allSessions = [...ownedWithRole, ...sharedWithRole].sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return mergeSessionsByUserId( + (uid) => + prisma.movingMotivatorsSession.findMany({ + where: { userId: uid }, + include: motivatorInclude, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.mMSessionShare.findMany({ + where: { userId: uid }, + include: { session: { include: motivatorInclude } }, + }), + userId, + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) ); - - // Resolve participants to users - const sessionsWithResolved = await Promise.all( - allSessions.map(async (s) => ({ - ...s, - resolvedParticipant: await resolveCollaborator(s.participant), - })) - ); - - return sessionsWithResolved; } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId); - if (teamMemberIds.length === 0) return []; - - const sessions = await prisma.movingMotivatorsSession.findMany({ - where: { - userId: { in: teamMemberIds }, - shares: { none: { userId } }, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { select: { cards: true } }, - }, - orderBy: { updatedAt: 'desc' }, - }); - - const withRole = sessions.map((s) => ({ - ...s, - 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( - withRole.map(async (s) => ({ - ...s, - resolvedParticipant: await resolveCollaborator(s.participant), - })) + return fetchTeamCollaboratorSessions( + (teamMemberIds, uid) => + prisma.movingMotivatorsSession.findMany({ + where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, + include: motivatorInclude, + orderBy: { updatedAt: 'desc' }, + }), + getTeamMemberIdsForAdminTeams, + userId, + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) ); } +const motivatorByIdInclude = { + user: { select: { id: true, name: true, email: true } }, + cards: { orderBy: { orderIndex: 'asc' } as const }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, +}; + export async function getMotivatorSessionById(sessionId: string, userId: string) { - // 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: [ - { userId }, // Owner - { shares: { some: { userId } } }, // Shared with user - ], - }, - include: { - user: { select: { id: true, name: true, email: true } }, - cards: { - orderBy: { orderIndex: 'asc' }, - }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - }, - }); - - 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' || isAdminOfOwner; - - // Resolve participant to user if it's an email - const resolvedParticipant = await resolveCollaborator(session.participant); - - return { ...session, isOwner, role, canEdit, resolvedParticipant }; + return getSessionByIdGeneric( + sessionId, + userId, + (sid, uid) => + prisma.movingMotivatorsSession.findFirst({ + where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] }, + include: motivatorByIdInclude, + }), + (sid) => + prisma.movingMotivatorsSession.findUnique({ where: { id: sid }, include: motivatorByIdInclude }), + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + ); } -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.movingMotivatorsSession.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const motivatorPermissions = createSessionPermissionChecks(prisma.movingMotivatorsSession); -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.movingMotivatorsSession.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const motivatorShareEvents = createShareAndEventHandlers< + 'CARD_MOVED' | 'CARD_INFLUENCE_CHANGED' | 'CARDS_REORDERED' | 'SESSION_UPDATED' +>( + prisma.movingMotivatorsSession, + prisma.mMSessionShare, + prisma.mMSessionEvent, + motivatorPermissions.canAccess +); -// 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); -} +export const canAccessMotivatorSession = motivatorPermissions.canAccess; +export const canEditMotivatorSession = motivatorPermissions.canEdit; +export const canDeleteMotivatorSession = motivatorPermissions.canDelete; const DEFAULT_MOTIVATOR_TYPES: MotivatorType[] = [ 'STATUS', @@ -305,81 +188,9 @@ export async function updateCardInfluence(cardId: string, influence: number) { // Session Sharing // ============================================ -export async function shareMotivatorSession( - sessionId: string, - ownerId: string, - targetEmail: string, - role: ShareRole = 'EDITOR' -) { - // Verify owner - const session = await prisma.movingMotivatorsSession.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - // Find target user - const targetUser = await prisma.user.findUnique({ - where: { email: targetEmail }, - }); - if (!targetUser) { - throw new Error('User not found'); - } - - // Can't share with yourself - if (targetUser.id === ownerId) { - throw new Error('Cannot share session with yourself'); - } - - // Create or update share - return prisma.mMSessionShare.upsert({ - where: { - sessionId_userId: { sessionId, userId: targetUser.id }, - }, - update: { role }, - create: { - sessionId, - userId: targetUser.id, - role, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} - -export async function removeMotivatorShare( - sessionId: string, - ownerId: string, - shareUserId: string -) { - // Verify owner - const session = await prisma.movingMotivatorsSession.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - return prisma.mMSessionShare.deleteMany({ - where: { sessionId, userId: shareUserId }, - }); -} - -export async function getMotivatorSessionShares(sessionId: string, userId: string) { - // Verify access - if (!(await canAccessMotivatorSession(sessionId, userId))) { - throw new Error('Access denied'); - } - - return prisma.mMSessionShare.findMany({ - where: { sessionId }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} +export const shareMotivatorSession = motivatorShareEvents.share; +export const removeMotivatorShare = motivatorShareEvents.removeShare; +export const getMotivatorSessionShares = motivatorShareEvents.getShares; // ============================================ // Session Events (for real-time sync) @@ -391,40 +202,6 @@ export type MMSessionEventType = | 'CARDS_REORDERED' | 'SESSION_UPDATED'; -export async function createMotivatorSessionEvent( - sessionId: string, - userId: string, - type: MMSessionEventType, - payload: Record -) { - return prisma.mMSessionEvent.create({ - data: { - sessionId, - userId, - type, - payload: JSON.stringify(payload), - }, - }); -} - -export async function getMotivatorSessionEvents(sessionId: string, since?: Date) { - return prisma.mMSessionEvent.findMany({ - where: { - sessionId, - ...(since && { createdAt: { gt: since } }), - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - orderBy: { createdAt: 'asc' }, - }); -} - -export async function getLatestMotivatorEventTimestamp(sessionId: string) { - const event = await prisma.mMSessionEvent.findFirst({ - where: { sessionId }, - orderBy: { createdAt: 'desc' }, - select: { createdAt: true }, - }); - return event?.createdAt; -} +export const createMotivatorSessionEvent = motivatorShareEvents.createEvent; +export const getMotivatorSessionEvents = motivatorShareEvents.getEvents; +export const getLatestMotivatorEventTimestamp = motivatorShareEvents.getLatestEventTimestamp; diff --git a/src/services/session-permissions.ts b/src/services/session-permissions.ts new file mode 100644 index 0000000..ad8eadf --- /dev/null +++ b/src/services/session-permissions.ts @@ -0,0 +1,68 @@ +/** + * Shared permission helpers for workshop sessions. + * Used by: sessions, moving-motivators, year-review, weekly-checkin, weather. + */ +import { isAdminOfUser } from '@/services/teams'; + +export type GetOwnerIdFn = (sessionId: string) => Promise; + +/** Prisma model delegate with count + findUnique (session-like models with userId + shares) */ +export type SessionLikeDelegate = { + count: (args: { where: object }) => Promise; + findUnique: (args: { + where: { id: string }; + select: { userId: true }; + }) => Promise<{ userId: string } | null>; +}; + +/** Shared where clauses for access/edit checks */ +const accessWhere = (sessionId: string, userId: string) => ({ + id: sessionId, + OR: [{ userId }, { shares: { some: { userId } } }], +}); + +const editWhere = (sessionId: string, userId: string) => ({ + id: sessionId, + OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' as const } } }], +}); + +/** Factory: creates canAccess, canEdit, canDelete for a session-like model */ +export function createSessionPermissionChecks(model: SessionLikeDelegate) { + const getOwnerId: GetOwnerIdFn = (sessionId) => + model.findUnique({ where: { id: sessionId }, select: { userId: true } }).then((s) => s?.userId ?? null); + + return { + canAccess: async (sessionId: string, userId: string) => { + const count = await model.count({ where: accessWhere(sessionId, userId) }); + return withAdminFallback(count > 0, getOwnerId, sessionId, userId); + }, + canEdit: async (sessionId: string, userId: string) => { + const count = await model.count({ where: editWhere(sessionId, userId) }); + return withAdminFallback(count > 0, getOwnerId, sessionId, userId); + }, + canDelete: async (sessionId: string, userId: string) => + canDeleteByOwner(getOwnerId, sessionId, userId), + }; +} + +/** Returns true if hasDirectAccess OR user is team admin of session owner */ +export async function withAdminFallback( + hasDirectAccess: boolean, + getOwnerId: GetOwnerIdFn, + sessionId: string, + userId: string +): Promise { + if (hasDirectAccess) return true; + const ownerId = await getOwnerId(sessionId); + return ownerId ? isAdminOfUser(ownerId, userId) : false; +} + +/** Returns true if userId is owner or team admin of owner. Use for delete permission (EDITOR cannot delete). */ +export async function canDeleteByOwner( + getOwnerId: GetOwnerIdFn, + sessionId: string, + userId: string +): Promise { + const ownerId = await getOwnerId(sessionId); + return ownerId !== null && (ownerId === userId || isAdminOfUser(ownerId, userId)); +} diff --git a/src/services/session-queries.ts b/src/services/session-queries.ts new file mode 100644 index 0000000..2d09ba0 --- /dev/null +++ b/src/services/session-queries.ts @@ -0,0 +1,122 @@ +/** + * Shared query patterns for workshop sessions. + * Used by: sessions, moving-motivators, year-review, weekly-checkin, weather. + */ +import { isAdminOfUser } from '@/services/teams'; + +type SessionWithUserAndShares = { + updatedAt: Date; + user: { id: string; name: string | null; email: string }; + shares: Array<{ user: { id: string; name: string | null; email: string } }>; +}; + +type SharedRecord = { + session: T; + role: string; + createdAt: Date; +}; + +/** Merge owned + shared sessions, sort by updatedAt, optionally resolve participant */ +export async function mergeSessionsByUserId< + T extends SessionWithUserAndShares, + R extends Record = Record, +>( + fetchOwned: (userId: string) => Promise, + fetchShared: (userId: string) => Promise[]>, + userId: string, + resolveParticipant?: (session: T) => Promise +): Promise<(T & { isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR' } & R)[]> { + const [owned, shared] = await Promise.all([fetchOwned(userId), fetchShared(userId)]); + + const ownedWithRole = owned.map((s) => ({ + ...s, + isOwner: true as const, + role: 'OWNER' as const, + })); + const sharedWithRole = shared.map((s) => ({ + ...s.session, + isOwner: false as const, + role: s.role, + sharedAt: s.createdAt, + })); + + const allSessions = [...ownedWithRole, ...sharedWithRole].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + + if (resolveParticipant) { + return Promise.all( + allSessions.map(async (s) => ({ ...s, ...(await resolveParticipant(s)) })) + ) as Promise<(T & { isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR' } & R)[]>; + } + return allSessions as (T & { isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR' } & R)[]; +} + +/** Fetch team member sessions (admin view), optionally resolve participant */ +export async function fetchTeamCollaboratorSessions< + T extends SessionWithUserAndShares, + R extends Record = Record, +>( + fetchTeamSessions: (teamMemberIds: string[], userId: string) => Promise, + getTeamMemberIds: (userId: string) => Promise, + userId: string, + resolveParticipant?: (session: T) => Promise +): Promise<(T & { isOwner: false; role: 'VIEWER'; isTeamCollab: true; canEdit: true } & R)[]> { + const teamMemberIds = await getTeamMemberIds(userId); + if (teamMemberIds.length === 0) return []; + + const sessions = await fetchTeamSessions(teamMemberIds, userId); + + const withRole = sessions.map((s) => ({ + ...s, + isOwner: false as const, + role: 'VIEWER' as const, + isTeamCollab: true as const, + canEdit: true as const, + })); + + if (resolveParticipant) { + return Promise.all( + withRole.map(async (s) => ({ ...s, ...(await resolveParticipant(s)) })) + ) as Promise<(T & { isOwner: false; role: 'VIEWER'; isTeamCollab: true; canEdit: true } & R)[]>; + } + return withRole as (T & { isOwner: false; role: 'VIEWER'; isTeamCollab: true; canEdit: true } & R)[]; +} + +type SessionWithShares = { + userId: string; + shares: Array<{ userId: string; role?: string }>; +}; + +/** Get session by ID with access check (owner, shared, or team admin). Fallback for admin viewing team member's session. */ +export async function getSessionByIdGeneric< + T extends SessionWithShares, + R extends Record = Record, +>( + sessionId: string, + userId: string, + fetchWithAccess: (sessionId: string, userId: string) => Promise, + fetchById: (sessionId: string) => Promise, + resolveParticipant?: (session: T) => Promise +): Promise<(T & { isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR'; canEdit: boolean } & R) | null> { + let session = await fetchWithAccess(sessionId, userId); + if (!session) { + const raw = await fetchById(sessionId); + if (!raw || !(await isAdminOfUser(raw.userId, userId))) return null; + session = raw; + } + 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' || isAdminOfOwner; + const base = { ...session, isOwner, role, canEdit }; + if (resolveParticipant) { + return { ...base, ...(await resolveParticipant(session)) } as T & { + isOwner: boolean; + role: 'OWNER' | 'VIEWER' | 'EDITOR'; + canEdit: boolean; + } & R; + } + return base as T & { isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR'; canEdit: boolean } & R; +} diff --git a/src/services/session-share-events.ts b/src/services/session-share-events.ts new file mode 100644 index 0000000..e7efbef --- /dev/null +++ b/src/services/session-share-events.ts @@ -0,0 +1,172 @@ +/** + * Shared share + realtime event logic for workshop sessions. + * Used by: sessions, moving-motivators, year-review, weekly-checkin, weather. + */ +import { prisma } from '@/services/database'; +import type { ShareRole } from '@prisma/client'; + +const userSelect = { id: true, name: true, email: true } as const; + +export type SessionEventWithUser = { + id: string; + sessionId: string; + userId: string; + type: string; + payload: string; + createdAt: Date; + user: { id: string; name: string | null; email: string }; +}; + +type ShareInclude = { user: { select: typeof userSelect } }; +type EventInclude = { user: { select: typeof userSelect } }; + +type ShareDelegate = { + upsert: (args: { + where: { sessionId_userId: { sessionId: string; userId: string } }; + update: { role: ShareRole }; + create: { sessionId: string; userId: string; role: ShareRole }; + include: ShareInclude; + }) => Promise; + deleteMany: (args: { where: { sessionId: string; userId: string } }) => Promise; + findMany: (args: { + where: { sessionId: string }; + include: ShareInclude; + }) => Promise; +}; + +type EventDelegate = { + create: (args: { + data: { sessionId: string; userId: string; type: string; payload: string }; + }) => Promise; + findMany: (args: { + where: { sessionId: string } | { sessionId: string; createdAt: { gt: Date } }; + include: EventInclude; + orderBy: { createdAt: 'asc' }; + }) => Promise; + findFirst: (args: { + where: { sessionId: string }; + orderBy: { createdAt: 'desc' }; + select: { createdAt: true }; + }) => Promise<{ createdAt: Date } | null>; +}; + +type SessionDelegate = { + findFirst: (args: { where: { id: string; userId: string } }) => Promise; +}; + +export function createShareAndEventHandlers( + sessionModel: SessionDelegate, + shareModel: ShareDelegate, + eventModel: EventDelegate, + canAccessSession: (sessionId: string, userId: string) => Promise +) { + return { + async share( + sessionId: string, + ownerId: string, + targetEmail: string, + role: ShareRole = 'EDITOR' + ) { + const session = await sessionModel.findFirst({ + where: { id: sessionId, userId: ownerId }, + }); + if (!session) { + throw new Error('Session not found or not owned'); + } + + const targetUser = await prisma.user.findUnique({ + where: { email: targetEmail }, + }); + if (!targetUser) { + throw new Error('User not found'); + } + + if (targetUser.id === ownerId) { + throw new Error('Cannot share session with yourself'); + } + + return shareModel.upsert({ + where: { + sessionId_userId: { sessionId, userId: targetUser.id }, + }, + update: { role }, + create: { + sessionId, + userId: targetUser.id, + role, + }, + include: { + user: { select: userSelect }, + }, + }); + }, + + async removeShare( + sessionId: string, + ownerId: string, + shareUserId: string + ) { + const session = await sessionModel.findFirst({ + where: { id: sessionId, userId: ownerId }, + }); + if (!session) { + throw new Error('Session not found or not owned'); + } + + return shareModel.deleteMany({ + where: { sessionId, userId: shareUserId }, + }); + }, + + async getShares(sessionId: string, userId: string) { + if (!(await canAccessSession(sessionId, userId))) { + throw new Error('Access denied'); + } + + return shareModel.findMany({ + where: { sessionId }, + include: { + user: { select: userSelect }, + }, + }); + }, + + async createEvent( + sessionId: string, + userId: string, + type: TEventType, + payload: Record + ): Promise { + return eventModel.create({ + data: { + sessionId, + userId, + type, + payload: JSON.stringify(payload), + }, + }) as Promise; + }, + + async getEvents(sessionId: string, since?: Date): Promise { + return eventModel.findMany({ + where: { + sessionId, + ...(since && { createdAt: { gt: since } }), + }, + include: { + user: { select: userSelect }, + }, + orderBy: { createdAt: 'asc' }, + }) as Promise; + }, + + async getLatestEventTimestamp(sessionId: string) { + const event = await eventModel.findFirst({ + where: { sessionId }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }); + return event?.createdAt; + }, + }; +} diff --git a/src/services/sessions.ts b/src/services/sessions.ts index a1a6611..b1a38f6 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -1,232 +1,103 @@ import { prisma } from '@/services/database'; import { resolveCollaborator } from '@/services/auth'; -import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; +import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { createSessionPermissionChecks } from '@/services/session-permissions'; +import { createShareAndEventHandlers } from '@/services/session-share-events'; +import { + mergeSessionsByUserId, + fetchTeamCollaboratorSessions, + getSessionByIdGeneric, +} from '@/services/session-queries'; import type { SwotCategory, ShareRole } from '@prisma/client'; +const sessionInclude = { + user: { select: { id: true, name: true, email: true } }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + _count: { select: { items: true, actions: true } }, +}; + // ============================================ // Session CRUD // ============================================ export async function getSessionsByUserId(userId: string) { - // Get owned sessions + shared sessions - const [owned, shared] = await Promise.all([ - prisma.session.findMany({ - where: { userId }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - items: true, - actions: true, - }, - }, - }, - orderBy: { updatedAt: 'desc' }, - }), - prisma.sessionShare.findMany({ - where: { userId }, - include: { - session: { - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - items: true, - actions: true, - }, - }, - }, - }, - }, - }), - ]); - - // Mark owned sessions and merge with shared - const ownedWithRole = owned.map((s) => ({ - ...s, - isOwner: true as const, - role: 'OWNER' as const, - })); - const sharedWithRole = shared.map((s) => ({ - ...s.session, - isOwner: false as const, - role: s.role, - sharedAt: s.createdAt, - })); - - const allSessions = [...ownedWithRole, ...sharedWithRole].sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return mergeSessionsByUserId( + (uid) => + prisma.session.findMany({ + where: { userId: uid }, + include: sessionInclude, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.sessionShare.findMany({ + where: { userId: uid }, + include: { session: { include: sessionInclude } }, + }), + userId, + (s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r })) ); - - // Resolve collaborators to users - const sessionsWithResolved = await Promise.all( - allSessions.map(async (s) => ({ - ...s, - resolvedCollaborator: await resolveCollaborator(s.collaborator), - })) - ); - - return sessionsWithResolved; } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId); - if (teamMemberIds.length === 0) return []; - - const sessions = await prisma.session.findMany({ - where: { - userId: { in: teamMemberIds }, - shares: { none: { userId } }, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - items: true, - actions: true, - }, - }, - }, - orderBy: { updatedAt: 'desc' }, - }); - - const withRole = sessions.map((s) => ({ - ...s, - 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( - withRole.map(async (s) => ({ - ...s, - resolvedCollaborator: await resolveCollaborator(s.collaborator), - })) + return fetchTeamCollaboratorSessions( + (teamMemberIds, uid) => + prisma.session.findMany({ + where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, + include: sessionInclude, + orderBy: { updatedAt: 'desc' }, + }), + getTeamMemberIdsForAdminTeams, + userId, + (s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r })) ); } +const sessionByIdInclude = { + user: { select: { id: true, name: true, email: true } }, + items: { orderBy: { order: 'asc' } as const }, + actions: { + include: { links: { include: { swotItem: true } } }, + orderBy: { createdAt: 'asc' } as const, + }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, +}; + export async function getSessionById(sessionId: string, userId: string) { - // 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: [ - { userId }, // Owner - { shares: { some: { userId } } }, // Shared with user - ], - }, - 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 (!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' || isAdminOfOwner; - - // Resolve collaborator to user if it's an email - const resolvedCollaborator = await resolveCollaborator(session.collaborator); - - return { ...session, isOwner, role, canEdit, resolvedCollaborator }; + return getSessionByIdGeneric( + sessionId, + userId, + (sid, uid) => + prisma.session.findFirst({ + where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] }, + include: sessionByIdInclude, + }), + (sid) => prisma.session.findUnique({ where: { id: sid }, include: sessionByIdInclude }), + (s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r })) + ); } -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.session.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const sessionPermissions = createSessionPermissionChecks(prisma.session); -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.session.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const sessionShareEvents = createShareAndEventHandlers< + | 'ITEM_CREATED' + | 'ITEM_UPDATED' + | 'ITEM_DELETED' + | 'ITEM_MOVED' + | 'ACTION_CREATED' + | 'ACTION_UPDATED' + | 'ACTION_DELETED' + | 'SESSION_UPDATED' +>( + prisma.session, + prisma.sessionShare, + prisma.sessionEvent, + sessionPermissions.canAccess +); -// 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 const canAccessSession = sessionPermissions.canAccess; +export const canEditSession = sessionPermissions.canEdit; +export const canDeleteSession = sessionPermissions.canDelete; export async function createSession(userId: string, data: { title: string; collaborator: string }) { return prisma.session.create({ @@ -456,77 +327,9 @@ export async function unlinkItemFromAction(actionId: string, swotItemId: string) // Session Sharing // ============================================ -export async function shareSession( - sessionId: string, - ownerId: string, - targetEmail: string, - role: ShareRole = 'EDITOR' -) { - // Verify owner - const session = await prisma.session.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - // Find target user - const targetUser = await prisma.user.findUnique({ - where: { email: targetEmail }, - }); - if (!targetUser) { - throw new Error('User not found'); - } - - // Can't share with yourself - if (targetUser.id === ownerId) { - throw new Error('Cannot share session with yourself'); - } - - // Create or update share - return prisma.sessionShare.upsert({ - where: { - sessionId_userId: { sessionId, userId: targetUser.id }, - }, - update: { role }, - create: { - sessionId, - userId: targetUser.id, - role, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} - -export async function removeShare(sessionId: string, ownerId: string, shareUserId: string) { - // Verify owner - const session = await prisma.session.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - return prisma.sessionShare.deleteMany({ - where: { sessionId, userId: shareUserId }, - }); -} - -export async function getSessionShares(sessionId: string, userId: string) { - // Verify access - if (!(await canAccessSession(sessionId, userId))) { - throw new Error('Access denied'); - } - - return prisma.sessionShare.findMany({ - where: { sessionId }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} +export const shareSession = sessionShareEvents.share; +export const removeShare = sessionShareEvents.removeShare; +export const getSessionShares = sessionShareEvents.getShares; // ============================================ // Session Events (for real-time sync) @@ -542,40 +345,6 @@ export type SessionEventType = | 'ACTION_DELETED' | 'SESSION_UPDATED'; -export async function createSessionEvent( - sessionId: string, - userId: string, - type: SessionEventType, - payload: Record -) { - return prisma.sessionEvent.create({ - data: { - sessionId, - userId, - type, - payload: JSON.stringify(payload), - }, - }); -} - -export async function getSessionEvents(sessionId: string, since?: Date) { - return prisma.sessionEvent.findMany({ - where: { - sessionId, - ...(since && { createdAt: { gt: since } }), - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - orderBy: { createdAt: 'asc' }, - }); -} - -export async function getLatestEventTimestamp(sessionId: string) { - const event = await prisma.sessionEvent.findFirst({ - where: { sessionId }, - orderBy: { createdAt: 'desc' }, - select: { createdAt: true }, - }); - return event?.createdAt; -} +export const createSessionEvent = sessionShareEvents.createEvent; +export const getSessionEvents = sessionShareEvents.getEvents; +export const getLatestEventTimestamp = sessionShareEvents.getLatestEventTimestamp; diff --git a/src/services/weather.ts b/src/services/weather.ts index cc7159e..3c6d804 100644 --- a/src/services/weather.ts +++ b/src/services/weather.ts @@ -1,198 +1,93 @@ import { prisma } from '@/services/database'; -import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; +import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { createSessionPermissionChecks } from '@/services/session-permissions'; +import { createShareAndEventHandlers } from '@/services/session-share-events'; +import { + mergeSessionsByUserId, + fetchTeamCollaboratorSessions, + getSessionByIdGeneric, +} from '@/services/session-queries'; import { getWeekBounds } from '@/lib/date-utils'; import type { ShareRole } from '@prisma/client'; +const weatherInclude = { + user: { select: { id: true, name: true, email: true } }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + _count: { select: { entries: true } }, +}; + // ============================================ // Weather Session CRUD // ============================================ export async function getWeatherSessionsByUserId(userId: string) { - // Get owned sessions + shared sessions - const [owned, shared] = await Promise.all([ - prisma.weatherSession.findMany({ - where: { userId }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - entries: true, - }, - }, - }, - orderBy: { updatedAt: 'desc' }, - }), - prisma.weatherSessionShare.findMany({ - where: { userId }, - include: { - session: { - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - entries: true, - }, - }, - }, - }, - }, - }), - ]); - - // Mark owned sessions and merge with shared - const ownedWithRole = owned.map((s) => ({ - ...s, - isOwner: true as const, - role: 'OWNER' as const, - })); - const sharedWithRole = shared.map((s) => ({ - ...s.session, - isOwner: false as const, - role: s.role, - sharedAt: s.createdAt, - })); - - const allSessions = [...ownedWithRole, ...sharedWithRole].sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return mergeSessionsByUserId( + (uid) => + prisma.weatherSession.findMany({ + where: { userId: uid }, + include: weatherInclude, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.weatherSessionShare.findMany({ + where: { userId: uid }, + include: { session: { include: weatherInclude } }, + }), + userId ); - - return allSessions; } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId); - if (teamMemberIds.length === 0) return []; - - const sessions = await prisma.weatherSession.findMany({ - where: { - userId: { in: teamMemberIds }, - shares: { none: { userId } }, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { select: { entries: true } }, - }, - orderBy: { updatedAt: 'desc' }, - }); - - return sessions.map((s) => ({ - ...s, - 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 fetchTeamCollaboratorSessions( + (teamMemberIds, uid) => + prisma.weatherSession.findMany({ + where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, + include: weatherInclude, + orderBy: { updatedAt: 'desc' }, + }), + getTeamMemberIdsForAdminTeams, + userId + ); } +const weatherByIdInclude = { + user: { select: { id: true, name: true, email: true } }, + entries: { + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: { createdAt: 'asc' } as const, + }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, +}; + export async function getWeatherSessionById(sessionId: string, userId: string) { - // 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: [ - { userId }, // Owner - { shares: { some: { userId } } }, // Shared with user - ], - }, - 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 (!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' || isAdminOfOwner; - - return { ...session, isOwner, role, canEdit }; + return getSessionByIdGeneric( + sessionId, + userId, + (sid, uid) => + prisma.weatherSession.findFirst({ + where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] }, + include: weatherByIdInclude, + }), + (sid) => + prisma.weatherSession.findUnique({ where: { id: sid }, include: weatherByIdInclude }) + ); } -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.weatherSession.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const weatherPermissions = createSessionPermissionChecks(prisma.weatherSession); -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.weatherSession.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const weatherShareEvents = createShareAndEventHandlers< + 'ENTRY_CREATED' | 'ENTRY_UPDATED' | 'ENTRY_DELETED' | 'SESSION_UPDATED' +>( + prisma.weatherSession, + prisma.weatherSessionShare, + prisma.weatherSessionEvent, + weatherPermissions.canAccess +); -// 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 const canAccessWeatherSession = weatherPermissions.canAccess; +export const canEditWeatherSession = weatherPermissions.canEdit; +export const canDeleteWeatherSession = weatherPermissions.canDelete; export async function createWeatherSession(userId: string, data: { title: string; date?: Date }) { return prisma.weatherSession.create({ @@ -286,49 +181,7 @@ export async function deleteWeatherEntry(sessionId: string, userId: string) { // Session Sharing // ============================================ -export async function shareWeatherSession( - sessionId: string, - ownerId: string, - targetEmail: string, - role: ShareRole = 'EDITOR' -) { - // Verify owner - const session = await prisma.weatherSession.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - // Find target user - const targetUser = await prisma.user.findUnique({ - where: { email: targetEmail }, - }); - if (!targetUser) { - throw new Error('User not found'); - } - - // Can't share with yourself - if (targetUser.id === ownerId) { - throw new Error('Cannot share session with yourself'); - } - - // Create or update share - return prisma.weatherSessionShare.upsert({ - where: { - sessionId_userId: { sessionId, userId: targetUser.id }, - }, - update: { role }, - create: { - sessionId, - userId: targetUser.id, - role, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} +export const shareWeatherSession = weatherShareEvents.share; export async function shareWeatherSessionToTeam( sessionId: string, @@ -403,37 +256,8 @@ export async function shareWeatherSessionToTeam( return shares; } -export async function removeWeatherShare( - sessionId: string, - ownerId: string, - shareUserId: string -) { - // Verify owner - const session = await prisma.weatherSession.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - return prisma.weatherSessionShare.deleteMany({ - where: { sessionId, userId: shareUserId }, - }); -} - -export async function getWeatherSessionShares(sessionId: string, userId: string) { - // Verify access - if (!(await canAccessWeatherSession(sessionId, userId))) { - throw new Error('Access denied'); - } - - return prisma.weatherSessionShare.findMany({ - where: { sessionId }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} +export const removeWeatherShare = weatherShareEvents.removeShare; +export const getWeatherSessionShares = weatherShareEvents.getShares; // ============================================ // Session Events (for real-time sync) @@ -445,40 +269,6 @@ export type WeatherSessionEventType = | 'ENTRY_DELETED' | 'SESSION_UPDATED'; -export async function createWeatherSessionEvent( - sessionId: string, - userId: string, - type: WeatherSessionEventType, - payload: Record -) { - return prisma.weatherSessionEvent.create({ - data: { - sessionId, - userId, - type, - payload: JSON.stringify(payload), - }, - }); -} - -export async function getWeatherSessionEvents(sessionId: string, since?: Date) { - return prisma.weatherSessionEvent.findMany({ - where: { - sessionId, - ...(since && { createdAt: { gt: since } }), - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - orderBy: { createdAt: 'asc' }, - }); -} - -export async function getLatestWeatherEventTimestamp(sessionId: string) { - const event = await prisma.weatherSessionEvent.findFirst({ - where: { sessionId }, - orderBy: { createdAt: 'desc' }, - select: { createdAt: true }, - }); - return event?.createdAt; -} +export const createWeatherSessionEvent = weatherShareEvents.createEvent; +export const getWeatherSessionEvents = weatherShareEvents.getEvents; +export const getLatestWeatherEventTimestamp = weatherShareEvents.getLatestEventTimestamp; diff --git a/src/services/weekly-checkin.ts b/src/services/weekly-checkin.ts index 829b510..7b68e6e 100644 --- a/src/services/weekly-checkin.ts +++ b/src/services/weekly-checkin.ts @@ -1,210 +1,101 @@ import { prisma } from '@/services/database'; import { resolveCollaborator } from '@/services/auth'; -import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; -import type { ShareRole, WeeklyCheckInCategory, Emotion } from '@prisma/client'; +import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { createSessionPermissionChecks } from '@/services/session-permissions'; +import { createShareAndEventHandlers } from '@/services/session-share-events'; +import { + mergeSessionsByUserId, + fetchTeamCollaboratorSessions, + getSessionByIdGeneric, +} from '@/services/session-queries'; +import type { WeeklyCheckInCategory, Emotion } from '@prisma/client'; + +const weeklyCheckInInclude = { + user: { select: { id: true, name: true, email: true } }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + _count: { select: { items: true } }, +}; // ============================================ // Weekly Check-in Session CRUD // ============================================ export async function getWeeklyCheckInSessionsByUserId(userId: string) { - // Get owned sessions + shared sessions - const [owned, shared] = await Promise.all([ - prisma.weeklyCheckInSession.findMany({ - where: { userId }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - items: true, - }, - }, - }, - orderBy: { updatedAt: 'desc' }, - }), - prisma.wCISessionShare.findMany({ - where: { userId }, - include: { - session: { - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - items: true, - }, - }, - }, - }, - }, - }), - ]); - - // Mark owned sessions and merge with shared - const ownedWithRole = owned.map((s) => ({ - ...s, - isOwner: true as const, - role: 'OWNER' as const, - })); - const sharedWithRole = shared.map((s) => ({ - ...s.session, - isOwner: false as const, - role: s.role, - sharedAt: s.createdAt, - })); - - const allSessions = [...ownedWithRole, ...sharedWithRole].sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return mergeSessionsByUserId( + (uid) => + prisma.weeklyCheckInSession.findMany({ + where: { userId: uid }, + include: weeklyCheckInInclude, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.wCISessionShare.findMany({ + where: { userId: uid }, + include: { session: { include: weeklyCheckInInclude } }, + }), + userId, + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) ); - - // Resolve participants to users - const sessionsWithResolved = await Promise.all( - allSessions.map(async (s) => ({ - ...s, - resolvedParticipant: await resolveCollaborator(s.participant), - })) - ); - - return sessionsWithResolved; } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId); - if (teamMemberIds.length === 0) return []; - - const sessions = await prisma.weeklyCheckInSession.findMany({ - where: { - userId: { in: teamMemberIds }, - shares: { none: { userId } }, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { select: { items: true } }, - }, - orderBy: { updatedAt: 'desc' }, - }); - - const withRole = sessions.map((s) => ({ - ...s, - 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( - withRole.map(async (s) => ({ - ...s, - resolvedParticipant: await resolveCollaborator(s.participant), - })) + return fetchTeamCollaboratorSessions( + (teamMemberIds, uid) => + prisma.weeklyCheckInSession.findMany({ + where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, + include: weeklyCheckInInclude, + orderBy: { updatedAt: 'desc' }, + }), + getTeamMemberIdsForAdminTeams, + userId, + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) ); } +const weeklyCheckInByIdInclude = { + user: { select: { id: true, name: true, email: true } }, + items: { orderBy: [...([{ category: 'asc' }, { order: 'asc' }] as const)] }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, +}; + export async function getWeeklyCheckInSessionById(sessionId: string, userId: string) { - // 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: [ - { userId }, // Owner - { shares: { some: { userId } } }, // Shared with user - ], - }, - 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 (!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' || isAdminOfOwner; - - // Resolve participant to user if it's an email - const resolvedParticipant = await resolveCollaborator(session.participant); - - return { ...session, isOwner, role, canEdit, resolvedParticipant }; + return getSessionByIdGeneric( + sessionId, + userId, + (sid, uid) => + prisma.weeklyCheckInSession.findFirst({ + where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] }, + include: weeklyCheckInByIdInclude, + }), + (sid) => + prisma.weeklyCheckInSession.findUnique({ + where: { id: sid }, + include: weeklyCheckInByIdInclude, + }), + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + ); } -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.weeklyCheckInSession.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const weeklyCheckInPermissions = createSessionPermissionChecks(prisma.weeklyCheckInSession); -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.weeklyCheckInSession.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const weeklyCheckInShareEvents = createShareAndEventHandlers< + | 'ITEM_CREATED' + | 'ITEM_UPDATED' + | 'ITEM_DELETED' + | 'ITEM_MOVED' + | 'ITEMS_REORDERED' + | 'SESSION_UPDATED' +>( + prisma.weeklyCheckInSession, + prisma.wCISessionShare, + prisma.wCISessionEvent, + weeklyCheckInPermissions.canAccess +); -// 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 const canAccessWeeklyCheckInSession = weeklyCheckInPermissions.canAccess; +export const canEditWeeklyCheckInSession = weeklyCheckInPermissions.canEdit; +export const canDeleteWeeklyCheckInSession = weeklyCheckInPermissions.canDelete; export async function createWeeklyCheckInSession( userId: string, @@ -321,81 +212,9 @@ export async function reorderWeeklyCheckInItems( // Session Sharing // ============================================ -export async function shareWeeklyCheckInSession( - sessionId: string, - ownerId: string, - targetEmail: string, - role: ShareRole = 'EDITOR' -) { - // Verify owner - const session = await prisma.weeklyCheckInSession.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - // Find target user - const targetUser = await prisma.user.findUnique({ - where: { email: targetEmail }, - }); - if (!targetUser) { - throw new Error('User not found'); - } - - // Can't share with yourself - if (targetUser.id === ownerId) { - throw new Error('Cannot share session with yourself'); - } - - // Create or update share - return prisma.wCISessionShare.upsert({ - where: { - sessionId_userId: { sessionId, userId: targetUser.id }, - }, - update: { role }, - create: { - sessionId, - userId: targetUser.id, - role, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} - -export async function removeWeeklyCheckInShare( - sessionId: string, - ownerId: string, - shareUserId: string -) { - // Verify owner - const session = await prisma.weeklyCheckInSession.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - return prisma.wCISessionShare.deleteMany({ - where: { sessionId, userId: shareUserId }, - }); -} - -export async function getWeeklyCheckInSessionShares(sessionId: string, userId: string) { - // Verify access - if (!(await canAccessWeeklyCheckInSession(sessionId, userId))) { - throw new Error('Access denied'); - } - - return prisma.wCISessionShare.findMany({ - where: { sessionId }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} +export const shareWeeklyCheckInSession = weeklyCheckInShareEvents.share; +export const removeWeeklyCheckInShare = weeklyCheckInShareEvents.removeShare; +export const getWeeklyCheckInSessionShares = weeklyCheckInShareEvents.getShares; // ============================================ // Session Events (for real-time sync) @@ -409,40 +228,7 @@ export type WCISessionEventType = | 'ITEMS_REORDERED' | 'SESSION_UPDATED'; -export async function createWeeklyCheckInSessionEvent( - sessionId: string, - userId: string, - type: WCISessionEventType, - payload: Record -) { - return prisma.wCISessionEvent.create({ - data: { - sessionId, - userId, - type, - payload: JSON.stringify(payload), - }, - }); -} - -export async function getWeeklyCheckInSessionEvents(sessionId: string, since?: Date) { - return prisma.wCISessionEvent.findMany({ - where: { - sessionId, - ...(since && { createdAt: { gt: since } }), - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - orderBy: { createdAt: 'asc' }, - }); -} - -export async function getLatestWeeklyCheckInEventTimestamp(sessionId: string) { - const event = await prisma.wCISessionEvent.findFirst({ - where: { sessionId }, - orderBy: { createdAt: 'desc' }, - select: { createdAt: true }, - }); - return event?.createdAt; -} +export const createWeeklyCheckInSessionEvent = weeklyCheckInShareEvents.createEvent; +export const getWeeklyCheckInSessionEvents = weeklyCheckInShareEvents.getEvents; +export const getLatestWeeklyCheckInEventTimestamp = + weeklyCheckInShareEvents.getLatestEventTimestamp; diff --git a/src/services/year-review.ts b/src/services/year-review.ts index b0afa9a..b652f42 100644 --- a/src/services/year-review.ts +++ b/src/services/year-review.ts @@ -1,210 +1,98 @@ import { prisma } from '@/services/database'; import { resolveCollaborator } from '@/services/auth'; -import { getTeamMemberIdsForAdminTeams, isAdminOfUser } from '@/services/teams'; -import type { ShareRole, YearReviewCategory } from '@prisma/client'; +import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; +import { createSessionPermissionChecks } from '@/services/session-permissions'; +import { createShareAndEventHandlers } from '@/services/session-share-events'; +import { + mergeSessionsByUserId, + fetchTeamCollaboratorSessions, + getSessionByIdGeneric, +} from '@/services/session-queries'; +import type { YearReviewCategory } from '@prisma/client'; + +const yearReviewInclude = { + user: { select: { id: true, name: true, email: true } }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + _count: { select: { items: true } }, +}; // ============================================ // Year Review Session CRUD // ============================================ export async function getYearReviewSessionsByUserId(userId: string) { - // Get owned sessions + shared sessions - const [owned, shared] = await Promise.all([ - prisma.yearReviewSession.findMany({ - where: { userId }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - items: true, - }, - }, - }, - orderBy: { updatedAt: 'desc' }, - }), - prisma.yRSessionShare.findMany({ - where: { userId }, - include: { - session: { - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { - select: { - items: true, - }, - }, - }, - }, - }, - }), - ]); - - // Mark owned sessions and merge with shared - const ownedWithRole = owned.map((s) => ({ - ...s, - isOwner: true as const, - role: 'OWNER' as const, - })); - const sharedWithRole = shared.map((s) => ({ - ...s.session, - isOwner: false as const, - role: s.role, - sharedAt: s.createdAt, - })); - - const allSessions = [...ownedWithRole, ...sharedWithRole].sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return mergeSessionsByUserId( + (uid) => + prisma.yearReviewSession.findMany({ + where: { userId: uid }, + include: yearReviewInclude, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.yRSessionShare.findMany({ + where: { userId: uid }, + include: { session: { include: yearReviewInclude } }, + }), + userId, + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) ); - - // Resolve participants to users - const sessionsWithResolved = await Promise.all( - allSessions.map(async (s) => ({ - ...s, - resolvedParticipant: await resolveCollaborator(s.participant), - })) - ); - - return sessionsWithResolved; } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId); - if (teamMemberIds.length === 0) return []; - - const sessions = await prisma.yearReviewSession.findMany({ - where: { - userId: { in: teamMemberIds }, - shares: { none: { userId } }, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - shares: { - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }, - _count: { select: { items: true } }, - }, - orderBy: { updatedAt: 'desc' }, - }); - - const withRole = sessions.map((s) => ({ - ...s, - 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( - withRole.map(async (s) => ({ - ...s, - resolvedParticipant: await resolveCollaborator(s.participant), - })) + return fetchTeamCollaboratorSessions( + (teamMemberIds, uid) => + prisma.yearReviewSession.findMany({ + where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, + include: yearReviewInclude, + orderBy: { updatedAt: 'desc' }, + }), + getTeamMemberIdsForAdminTeams, + userId, + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) ); } +const yearReviewByIdInclude = { + user: { select: { id: true, name: true, email: true } }, + items: { orderBy: [...([{ category: 'asc' }, { order: 'asc' }] as const)] }, + shares: { include: { user: { select: { id: true, name: true, email: true } } } }, +}; + export async function getYearReviewSessionById(sessionId: string, userId: string) { - // 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: [ - { userId }, // Owner - { shares: { some: { userId } } }, // Shared with user - ], - }, - 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 (!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' || isAdminOfOwner; - - // Resolve participant to user if it's an email - const resolvedParticipant = await resolveCollaborator(session.participant); - - return { ...session, isOwner, role, canEdit, resolvedParticipant }; + return getSessionByIdGeneric( + sessionId, + userId, + (sid, uid) => + prisma.yearReviewSession.findFirst({ + where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] }, + include: yearReviewByIdInclude, + }), + (sid) => + prisma.yearReviewSession.findUnique({ where: { id: sid }, include: yearReviewByIdInclude }), + (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + ); } -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.yearReviewSession.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const yearReviewPermissions = createSessionPermissionChecks(prisma.yearReviewSession); -// 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: { - id: sessionId, - OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], - }, - }); - if (count > 0) return true; - const session = await prisma.yearReviewSession.findUnique({ - where: { id: sessionId }, - select: { userId: true }, - }); - return session ? isAdminOfUser(session.userId, userId) : false; -} +const yearReviewShareEvents = createShareAndEventHandlers< + | 'ITEM_CREATED' + | 'ITEM_UPDATED' + | 'ITEM_DELETED' + | 'ITEM_MOVED' + | 'ITEMS_REORDERED' + | 'SESSION_UPDATED' +>( + prisma.yearReviewSession, + prisma.yRSessionShare, + prisma.yRSessionEvent, + yearReviewPermissions.canAccess +); -// 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 const canAccessYearReviewSession = yearReviewPermissions.canAccess; +export const canEditYearReviewSession = yearReviewPermissions.canEdit; +export const canDeleteYearReviewSession = yearReviewPermissions.canDelete; export async function createYearReviewSession( userId: string, @@ -319,81 +207,9 @@ export async function reorderYearReviewItems( // Session Sharing // ============================================ -export async function shareYearReviewSession( - sessionId: string, - ownerId: string, - targetEmail: string, - role: ShareRole = 'EDITOR' -) { - // Verify owner - const session = await prisma.yearReviewSession.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - // Find target user - const targetUser = await prisma.user.findUnique({ - where: { email: targetEmail }, - }); - if (!targetUser) { - throw new Error('User not found'); - } - - // Can't share with yourself - if (targetUser.id === ownerId) { - throw new Error('Cannot share session with yourself'); - } - - // Create or update share - return prisma.yRSessionShare.upsert({ - where: { - sessionId_userId: { sessionId, userId: targetUser.id }, - }, - update: { role }, - create: { - sessionId, - userId: targetUser.id, - role, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} - -export async function removeYearReviewShare( - sessionId: string, - ownerId: string, - shareUserId: string -) { - // Verify owner - const session = await prisma.yearReviewSession.findFirst({ - where: { id: sessionId, userId: ownerId }, - }); - if (!session) { - throw new Error('Session not found or not owned'); - } - - return prisma.yRSessionShare.deleteMany({ - where: { sessionId, userId: shareUserId }, - }); -} - -export async function getYearReviewSessionShares(sessionId: string, userId: string) { - // Verify access - if (!(await canAccessYearReviewSession(sessionId, userId))) { - throw new Error('Access denied'); - } - - return prisma.yRSessionShare.findMany({ - where: { sessionId }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - }); -} +export const shareYearReviewSession = yearReviewShareEvents.share; +export const removeYearReviewShare = yearReviewShareEvents.removeShare; +export const getYearReviewSessionShares = yearReviewShareEvents.getShares; // ============================================ // Session Events (for real-time sync) @@ -407,41 +223,7 @@ export type YRSessionEventType = | 'ITEMS_REORDERED' | 'SESSION_UPDATED'; -export async function createYearReviewSessionEvent( - sessionId: string, - userId: string, - type: YRSessionEventType, - payload: Record -) { - return prisma.yRSessionEvent.create({ - data: { - sessionId, - userId, - type, - payload: JSON.stringify(payload), - }, - }); -} - -export async function getYearReviewSessionEvents(sessionId: string, since?: Date) { - return prisma.yRSessionEvent.findMany({ - where: { - sessionId, - ...(since && { createdAt: { gt: since } }), - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - orderBy: { createdAt: 'asc' }, - }); -} - -export async function getLatestYearReviewEventTimestamp(sessionId: string) { - const event = await prisma.yRSessionEvent.findFirst({ - where: { sessionId }, - orderBy: { createdAt: 'desc' }, - select: { createdAt: true }, - }); - return event?.createdAt; -} +export const createYearReviewSessionEvent = yearReviewShareEvents.createEvent; +export const getYearReviewSessionEvents = yearReviewShareEvents.getEvents; +export const getLatestYearReviewEventTimestamp = yearReviewShareEvents.getLatestEventTimestamp;