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;