All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
Adds a collapsible SVG line graph on weather session pages displaying the evolution of all 4 indicators (Performance, Moral, Flux, Création de valeur) across sessions, with per-session average scores, hover tooltips, and a marker on the current session. Also fixes pre-existing lint errors: non-null assertion on optional chain in Header and eslint-disable for intentional hydration pattern in ThemeToggle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
import { prisma } from '@/services/database';
|
|
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 { getEmojiScore } from '@/lib/weather-utils';
|
|
import type { ShareRole } from '@prisma/client';
|
|
|
|
export type WeatherHistoryPoint = {
|
|
sessionId: string;
|
|
title: string;
|
|
date: Date;
|
|
performance: number | null;
|
|
moral: number | null;
|
|
flux: number | null;
|
|
valueCreation: number | null;
|
|
};
|
|
|
|
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) {
|
|
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
|
|
);
|
|
}
|
|
|
|
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
|
|
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
|
|
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) {
|
|
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 })
|
|
);
|
|
}
|
|
|
|
const weatherPermissions = createSessionPermissionChecks(prisma.weatherSession);
|
|
|
|
const weatherShareEvents = createShareAndEventHandlers<
|
|
'ENTRY_CREATED' | 'ENTRY_UPDATED' | 'ENTRY_DELETED' | 'SESSION_UPDATED'
|
|
>(
|
|
prisma.weatherSession,
|
|
prisma.weatherSessionShare,
|
|
prisma.weatherSessionEvent,
|
|
weatherPermissions.canAccess
|
|
);
|
|
|
|
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({
|
|
data: {
|
|
...data,
|
|
date: data.date || new Date(),
|
|
userId,
|
|
},
|
|
include: {
|
|
entries: {
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function updateWeatherSession(
|
|
sessionId: string,
|
|
userId: string,
|
|
data: { title?: string; date?: Date }
|
|
) {
|
|
if (!(await canEditWeatherSession(sessionId, userId))) {
|
|
return { count: 0 };
|
|
}
|
|
return prisma.weatherSession.updateMany({
|
|
where: { id: sessionId },
|
|
data,
|
|
});
|
|
}
|
|
|
|
export async function deleteWeatherSession(sessionId: string, userId: string) {
|
|
if (!(await canDeleteWeatherSession(sessionId, userId))) {
|
|
return { count: 0 };
|
|
}
|
|
return prisma.weatherSession.deleteMany({
|
|
where: { id: sessionId },
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Weather Entry CRUD
|
|
// ============================================
|
|
|
|
export async function getWeatherEntry(sessionId: string, userId: string) {
|
|
return prisma.weatherEntry.findUnique({
|
|
where: {
|
|
sessionId_userId: { sessionId, userId },
|
|
},
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function createOrUpdateWeatherEntry(
|
|
sessionId: string,
|
|
userId: string,
|
|
data: {
|
|
performanceEmoji?: string | null;
|
|
moralEmoji?: string | null;
|
|
fluxEmoji?: string | null;
|
|
valueCreationEmoji?: string | null;
|
|
notes?: string | null;
|
|
}
|
|
) {
|
|
return prisma.weatherEntry.upsert({
|
|
where: {
|
|
sessionId_userId: { sessionId, userId },
|
|
},
|
|
update: data,
|
|
create: {
|
|
sessionId,
|
|
userId,
|
|
...data,
|
|
},
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function deleteWeatherEntry(sessionId: string, userId: string) {
|
|
return prisma.weatherEntry.deleteMany({
|
|
where: { sessionId, userId },
|
|
});
|
|
}
|
|
|
|
// Returns the most recent WeatherEntry per userId from any session BEFORE sessionDate,
|
|
// excluding the current session. Returned as a map userId → entry.
|
|
export async function getPreviousWeatherEntriesForUsers(
|
|
excludeSessionId: string,
|
|
sessionDate: Date,
|
|
userIds: string[]
|
|
): Promise<
|
|
Map<
|
|
string,
|
|
{
|
|
performanceEmoji: string | null;
|
|
moralEmoji: string | null;
|
|
fluxEmoji: string | null;
|
|
valueCreationEmoji: string | null;
|
|
}
|
|
>
|
|
> {
|
|
if (userIds.length === 0) return new Map();
|
|
|
|
const entries = await prisma.weatherEntry.findMany({
|
|
where: {
|
|
userId: { in: userIds },
|
|
sessionId: { not: excludeSessionId },
|
|
session: { date: { lt: sessionDate } },
|
|
},
|
|
select: {
|
|
userId: true,
|
|
performanceEmoji: true,
|
|
moralEmoji: true,
|
|
fluxEmoji: true,
|
|
valueCreationEmoji: true,
|
|
session: { select: { date: true } },
|
|
},
|
|
});
|
|
|
|
// Sort by session.date desc (Prisma orderBy on relation is unreliable with SQLite)
|
|
entries.sort((a, b) => {
|
|
const dateA = a.session.date.getTime();
|
|
const dateB = b.session.date.getTime();
|
|
if (dateB !== dateA) return dateB - dateA; // most recent first
|
|
return a.userId.localeCompare(b.userId);
|
|
});
|
|
|
|
// For each user, use the most recent previous value PER AXIS (fallback if latest session has null)
|
|
const map = new Map<
|
|
string,
|
|
{
|
|
performanceEmoji: string | null;
|
|
moralEmoji: string | null;
|
|
fluxEmoji: string | null;
|
|
valueCreationEmoji: string | null;
|
|
}
|
|
>();
|
|
for (const entry of entries) {
|
|
const existing = map.get(entry.userId);
|
|
const base = existing ?? {
|
|
performanceEmoji: null as string | null,
|
|
moralEmoji: null as string | null,
|
|
fluxEmoji: null as string | null,
|
|
valueCreationEmoji: null as string | null,
|
|
};
|
|
if (!existing) map.set(entry.userId, base);
|
|
if (base.performanceEmoji == null && entry.performanceEmoji != null)
|
|
base.performanceEmoji = entry.performanceEmoji;
|
|
if (base.moralEmoji == null && entry.moralEmoji != null) base.moralEmoji = entry.moralEmoji;
|
|
if (base.fluxEmoji == null && entry.fluxEmoji != null) base.fluxEmoji = entry.fluxEmoji;
|
|
if (base.valueCreationEmoji == null && entry.valueCreationEmoji != null)
|
|
base.valueCreationEmoji = entry.valueCreationEmoji;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// ============================================
|
|
// Session Sharing
|
|
// ============================================
|
|
|
|
export const shareWeatherSession = weatherShareEvents.share;
|
|
|
|
export async function shareWeatherSessionToTeam(
|
|
sessionId: string,
|
|
ownerId: string,
|
|
teamId: 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');
|
|
}
|
|
|
|
// Max 1 météo par équipe par semaine
|
|
const teamMembers = await prisma.teamMember.findMany({
|
|
where: { teamId },
|
|
select: { userId: true },
|
|
});
|
|
const teamMemberIds = teamMembers.map((tm) => tm.userId);
|
|
if (teamMemberIds.length > 0) {
|
|
const { start: weekStart, end: weekEnd } = getWeekBounds(session.date);
|
|
const existingCount = await prisma.weatherSession.count({
|
|
where: {
|
|
id: { not: sessionId },
|
|
date: { gte: weekStart, lte: weekEnd },
|
|
shares: {
|
|
some: { userId: { in: teamMemberIds } },
|
|
},
|
|
},
|
|
});
|
|
if (existingCount > 0) {
|
|
throw new Error('Cette équipe a déjà une météo pour cette semaine');
|
|
}
|
|
}
|
|
|
|
// Get team members (full)
|
|
const teamMembersFull = await prisma.teamMember.findMany({
|
|
where: { teamId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
});
|
|
|
|
if (teamMembersFull.length === 0) {
|
|
throw new Error('Team has no members');
|
|
}
|
|
|
|
// Share with all team members (except owner)
|
|
const shares = await Promise.all(
|
|
teamMembersFull
|
|
.filter((tm) => tm.userId !== ownerId) // Don't share with yourself
|
|
.map((tm) =>
|
|
prisma.weatherSessionShare.upsert({
|
|
where: {
|
|
sessionId_userId: { sessionId, userId: tm.userId },
|
|
},
|
|
update: { role },
|
|
create: {
|
|
sessionId,
|
|
userId: tm.userId,
|
|
role,
|
|
},
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
})
|
|
)
|
|
);
|
|
|
|
return shares;
|
|
}
|
|
|
|
export const removeWeatherShare = weatherShareEvents.removeShare;
|
|
export const getWeatherSessionShares = weatherShareEvents.getShares;
|
|
|
|
// ============================================
|
|
// Session Events (for real-time sync)
|
|
// ============================================
|
|
|
|
export type WeatherSessionEventType =
|
|
| 'ENTRY_CREATED'
|
|
| 'ENTRY_UPDATED'
|
|
| 'ENTRY_DELETED'
|
|
| 'SESSION_UPDATED';
|
|
|
|
export const createWeatherSessionEvent = weatherShareEvents.createEvent;
|
|
export const getWeatherSessionEvents = weatherShareEvents.getEvents;
|
|
export const getLatestWeatherEventTimestamp = weatherShareEvents.getLatestEventTimestamp;
|
|
|
|
// ============================================
|
|
// Weather History (for trend chart)
|
|
// ============================================
|
|
|
|
function avgScore(emojis: (string | null)[]): number | null {
|
|
const scores = emojis.map(getEmojiScore).filter((s): s is number => s !== null);
|
|
if (scores.length === 0) return null;
|
|
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
}
|
|
|
|
export async function getWeatherSessionsHistory(userId: string): Promise<WeatherHistoryPoint[]> {
|
|
const entrySelect = {
|
|
performanceEmoji: true,
|
|
moralEmoji: true,
|
|
fluxEmoji: true,
|
|
valueCreationEmoji: true,
|
|
} as const;
|
|
|
|
const [ownSessions, sharedRaw] = await Promise.all([
|
|
prisma.weatherSession.findMany({
|
|
where: { userId },
|
|
select: { id: true, title: true, date: true, entries: { select: entrySelect } },
|
|
}),
|
|
prisma.weatherSessionShare.findMany({
|
|
where: { userId },
|
|
select: {
|
|
session: {
|
|
select: { id: true, title: true, date: true, entries: { select: entrySelect } },
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const seen = new Set<string>();
|
|
const all: { id: string; title: string; date: Date; entries: typeof ownSessions[0]['entries'] }[] = [];
|
|
for (const s of [...ownSessions, ...sharedRaw.map((r) => r.session)]) {
|
|
if (!seen.has(s.id)) {
|
|
seen.add(s.id);
|
|
all.push(s);
|
|
}
|
|
}
|
|
|
|
all.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
|
|
return all.map((s) => ({
|
|
sessionId: s.id,
|
|
title: s.title,
|
|
date: s.date,
|
|
performance: avgScore(s.entries.map((e) => e.performanceEmoji)),
|
|
moral: avgScore(s.entries.map((e) => e.moralEmoji)),
|
|
flux: avgScore(s.entries.map((e) => e.fluxEmoji)),
|
|
valueCreation: avgScore(s.entries.map((e) => e.valueCreationEmoji)),
|
|
}));
|
|
}
|