feat: refactor session retrieval logic to utilize generic session queries, enhancing code maintainability and reducing duplication across session types
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
|
||||
<CollaboratorDisplay
|
||||
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||
size="lg"
|
||||
showEmail
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -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<ReturnType<typeof getUserOKRsForPeriod>> = [];
|
||||
|
||||
// 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}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
|
||||
<CollaboratorDisplay collaborator={resolvedParticipant} size="lg" showEmail />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
|
||||
<CollaboratorDisplay
|
||||
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||
size="lg"
|
||||
showEmail
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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;
|
||||
|
||||
68
src/services/session-permissions.ts
Normal file
68
src/services/session-permissions.ts
Normal file
@@ -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<string | null>;
|
||||
|
||||
/** Prisma model delegate with count + findUnique (session-like models with userId + shares) */
|
||||
export type SessionLikeDelegate = {
|
||||
count: (args: { where: object }) => Promise<number>;
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const ownerId = await getOwnerId(sessionId);
|
||||
return ownerId !== null && (ownerId === userId || isAdminOfUser(ownerId, userId));
|
||||
}
|
||||
122
src/services/session-queries.ts
Normal file
122
src/services/session-queries.ts
Normal file
@@ -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<T> = {
|
||||
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<string, unknown> = Record<string, never>,
|
||||
>(
|
||||
fetchOwned: (userId: string) => Promise<T[]>,
|
||||
fetchShared: (userId: string) => Promise<SharedRecord<T>[]>,
|
||||
userId: string,
|
||||
resolveParticipant?: (session: T) => Promise<R>
|
||||
): 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<string, unknown> = Record<string, never>,
|
||||
>(
|
||||
fetchTeamSessions: (teamMemberIds: string[], userId: string) => Promise<T[]>,
|
||||
getTeamMemberIds: (userId: string) => Promise<string[]>,
|
||||
userId: string,
|
||||
resolveParticipant?: (session: T) => Promise<R>
|
||||
): 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<string, unknown> = Record<string, never>,
|
||||
>(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
fetchWithAccess: (sessionId: string, userId: string) => Promise<T | null>,
|
||||
fetchById: (sessionId: string) => Promise<T | null>,
|
||||
resolveParticipant?: (session: T) => Promise<R>
|
||||
): 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;
|
||||
}
|
||||
172
src/services/session-share-events.ts
Normal file
172
src/services/session-share-events.ts
Normal file
@@ -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<unknown>;
|
||||
deleteMany: (args: { where: { sessionId: string; userId: string } }) => Promise<unknown>;
|
||||
findMany: (args: {
|
||||
where: { sessionId: string };
|
||||
include: ShareInclude;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type EventDelegate = {
|
||||
create: (args: {
|
||||
data: { sessionId: string; userId: string; type: string; payload: string };
|
||||
}) => Promise<unknown>;
|
||||
findMany: (args: {
|
||||
where: { sessionId: string } | { sessionId: string; createdAt: { gt: Date } };
|
||||
include: EventInclude;
|
||||
orderBy: { createdAt: 'asc' };
|
||||
}) => Promise<unknown>;
|
||||
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<unknown>;
|
||||
};
|
||||
|
||||
export function createShareAndEventHandlers<TEventType extends string>(
|
||||
sessionModel: SessionDelegate,
|
||||
shareModel: ShareDelegate,
|
||||
eventModel: EventDelegate,
|
||||
canAccessSession: (sessionId: string, userId: string) => Promise<boolean>
|
||||
) {
|
||||
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<string, unknown>
|
||||
): Promise<SessionEventWithUser> {
|
||||
return eventModel.create({
|
||||
data: {
|
||||
sessionId,
|
||||
userId,
|
||||
type,
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
}) as Promise<SessionEventWithUser>;
|
||||
},
|
||||
|
||||
async getEvents(sessionId: string, since?: Date): Promise<SessionEventWithUser[]> {
|
||||
return eventModel.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
...(since && { createdAt: { gt: since } }),
|
||||
},
|
||||
include: {
|
||||
user: { select: userSelect },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}) as Promise<SessionEventWithUser[]>;
|
||||
},
|
||||
|
||||
async getLatestEventTimestamp(sessionId: string) {
|
||||
const event = await eventModel.findFirst({
|
||||
where: { sessionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
return event?.createdAt;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user