All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m45s
- Fix resolveCollaborator N+1: replace full User table scan with findFirst - Fix getAllUsersWithStats N+1: use groupBy instead of per-user count queries - Cache getTeamMemberIdsForAdminTeams and isAdminOfUser with React.cache - Increase SSE poll interval from 1s to 2s across all 5 subscribe routes - Add cleanupOldEvents method to session-share-events for event table TTL - Add React.memo to all card components (Swot, Motivator, Weather, WeeklyCheckIn, YearReview) - Fix WeatherCard useEffect+setState lint error with idiomatic prop sync pattern - Add optimizePackageImports for DnD libs and poweredByHeader:false in next.config - Add inline theme script in layout.tsx to prevent dark mode FOUC - Remove unused Next.js template SVGs from public/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
5.0 KiB
TypeScript
184 lines
5.0 KiB
TypeScript
/**
|
|
* 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>;
|
|
deleteMany: (args: {
|
|
where: { createdAt: { lt: Date } };
|
|
}) => Promise<unknown>;
|
|
};
|
|
|
|
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;
|
|
},
|
|
|
|
/** Delete events older than the given number of hours (default: 24h) */
|
|
async cleanupOldEvents(maxAgeHours = 24) {
|
|
const cutoff = new Date(Date.now() - maxAgeHours * 60 * 60 * 1000);
|
|
return eventModel.deleteMany({
|
|
where: { createdAt: { lt: cutoff } },
|
|
});
|
|
},
|
|
};
|
|
}
|