Files
workshop-manager/src/services/session-share-events.ts
Froidefond Julien c828ab1a48
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m45s
perf: optimize DB queries, SSE polling, and client rendering
- 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>
2026-02-25 14:04:58 +01:00

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 } },
});
},
};
}