Compare commits

..

6 Commits

47 changed files with 1463 additions and 2132 deletions

View File

@@ -1,6 +1,6 @@
# SWOT Manager - Development Book
# Workshop Manager - Development Book
Application de gestion d'ateliers SWOT pour entretiens managériaux.
Application de gestion d'ateliers pour entretiens managériaux.
## Stack Technique

View File

@@ -17,6 +17,9 @@ export async function createSwotItem(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.createSwotItem(sessionId, data);
@@ -45,6 +48,9 @@ export async function updateSwotItem(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.updateSwotItem(itemId, data);
@@ -68,6 +74,9 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
await sessionsService.deleteSwotItem(itemId);
@@ -90,6 +99,9 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.duplicateSwotItem(itemId);
@@ -120,6 +132,9 @@ export async function moveSwotItem(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
@@ -156,6 +171,9 @@ export async function createAction(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const action = await sessionsService.createAction(sessionId, data);
@@ -190,6 +208,9 @@ export async function updateAction(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const action = await sessionsService.updateAction(actionId, data);
@@ -213,6 +234,9 @@ export async function deleteAction(actionId: string, sessionId: string) {
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
await sessionsService.deleteAction(actionId);

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { RocketIcon } from '@/components/ui';
export default function LoginPage() {
const router = useRouter();
@@ -44,8 +45,8 @@ export default function LoginPage() {
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-3xl">📊</span>
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
<RocketIcon className="h-8 w-8 shrink-0 text-primary" />
<span className="text-2xl font-bold text-foreground">Workshop Manager</span>
</Link>
<p className="mt-2 text-muted">Connectez-vous à votre compte</p>
</div>

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { RocketIcon } from '@/components/ui';
export default function RegisterPage() {
const router = useRouter();
@@ -73,8 +74,8 @@ export default function RegisterPage() {
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-3xl">📊</span>
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
<RocketIcon className="h-8 w-8 shrink-0 text-primary" />
<span className="text-2xl font-bold text-foreground">Workshop Manager</span>
</Link>
<p className="mt-2 text-muted">Créez votre compte</p>
</div>

View File

@@ -1,7 +1,7 @@
@import 'tailwindcss';
/* ============================================
SWOT Manager - CSS Variables Theme System
Workshop Manager - CSS Variables Theme System
============================================ */
:root {

View File

@@ -15,7 +15,7 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: 'Workshop Manager',
description: "Application de gestion d'ateliers SWOT pour entretiens managériaux",
description: "Application de gestion d'ateliers pour entretiens managériaux",
icons: {
icon: '/icon.svg',
apple: '/rocket_blue_gradient_large_logo.jpg',

View File

@@ -4,7 +4,7 @@ export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Workshop Manager',
short_name: 'Workshop',
description: "Application de gestion d'ateliers SWOT pour entretiens managériaux",
description: "Application de gestion d'ateliers pour entretiens managériaux",
start_url: '/',
display: 'standalone',
icons: [

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getMotivatorSessionById } from '@/services/moving-motivators';
import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth';
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableMotivatorTitle } from '@/components/ui';
@@ -19,7 +21,10 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
return null;
}
const session = await getMotivatorSessionById(id, authSession.user.id);
const [session, userTeams] = await Promise.all([
getMotivatorSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
@@ -47,10 +52,14 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
<EditableMotivatorTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
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">
@@ -76,6 +85,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
</MotivatorLiveWrapper>

View File

@@ -28,6 +28,7 @@ import {
const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' },
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
];
@@ -64,6 +65,8 @@ interface SwotSession {
shares: Share[];
_count: { items: number; actions: number };
workshopType: 'swot';
isTeamCollab?: true;
canEdit?: boolean;
}
interface MotivatorSession {
@@ -78,6 +81,8 @@ interface MotivatorSession {
shares: Share[];
_count: { cards: number };
workshopType: 'motivators';
isTeamCollab?: true;
canEdit?: boolean;
}
interface YearReviewSession {
@@ -93,6 +98,8 @@ interface YearReviewSession {
shares: Share[];
_count: { items: number };
workshopType: 'year-review';
isTeamCollab?: true;
canEdit?: boolean;
}
interface WeeklyCheckInSession {
@@ -108,6 +115,8 @@ interface WeeklyCheckInSession {
shares: Share[];
_count: { items: number };
workshopType: 'weekly-checkin';
isTeamCollab?: true;
canEdit?: boolean;
}
interface WeatherSession {
@@ -121,6 +130,8 @@ interface WeatherSession {
shares: Share[];
_count: { entries: number };
workshopType: 'weather';
isTeamCollab?: true;
canEdit?: boolean;
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession;
@@ -131,6 +142,7 @@ interface WorkshopTabsProps {
yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[];
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
}
// Helper to get resolved collaborator from any session
@@ -197,6 +209,7 @@ export function WorkshopTabs({
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
teamCollabSessions = [],
}: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -219,7 +232,7 @@ export function WorkshopTabs({
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
};
// Combine and sort all sessions
// Combine and sort all sessions (exclude team collab from main list - they're shown separately)
const allSessions: AnySession[] = [
...swotSessions,
...motivatorSessions,
@@ -232,6 +245,8 @@ export function WorkshopTabs({
const filteredSessions =
activeTab === 'all' || activeTab === 'byPerson'
? allSessions
: activeTab === 'team'
? teamCollabSessions
: activeTab === 'swot'
? swotSessions
: activeTab === 'motivators'
@@ -242,9 +257,11 @@ export function WorkshopTabs({
? weeklyCheckInSessions
: weatherSessions;
// Separate by ownership
// Separate by ownership (for non-team tab: owned, shared, teamCollab)
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter((s) => !s.isOwner);
const sharedSessions = filteredSessions.filter((s) => !s.isOwner && !(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab);
const teamCollabFiltered =
activeTab === 'all' ? teamCollabSessions : activeTab === 'team' ? teamCollabSessions : [];
// Group by person (all sessions - owned and shared)
const sessionsByPerson = groupByPerson(allSessions);
@@ -270,6 +287,15 @@ export function WorkshopTabs({
label="Par personne"
count={sessionsByPerson.size}
/>
{teamCollabSessions.length > 0 && (
<TabButton
active={activeTab === 'team'}
onClick={() => setActiveTab('team')}
icon="🏢"
label="Équipe"
count={teamCollabSessions.length}
/>
)}
<TypeFilterDropdown
activeTab={activeTab}
setActiveTab={setActiveTab}
@@ -281,6 +307,7 @@ export function WorkshopTabs({
'year-review': yearReviewSessions.length,
'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length,
team: teamCollabSessions.length,
}}
/>
</div>
@@ -312,6 +339,28 @@ export function WorkshopTabs({
})}
</div>
)
) : activeTab === 'team' ? (
teamCollabSessions.length === 0 ? (
<div className="text-center py-12 text-muted">
Aucun atelier de vos collaborateurs d&apos;équipe (non partagés avec vous)
</div>
) : (
<div className="space-y-8">
<section>
<h2 className="text-lg font-semibold text-muted mb-4">
🏢 Ateliers de l&apos;équipe non partagés ({teamCollabSessions.length})
</h2>
<p className="text-sm text-muted mb-4">
En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs qui ne vous sont pas encore partagés.
</p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teamCollabSessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab />
))}
</div>
</section>
</div>
)
) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
) : (
@@ -343,6 +392,20 @@ export function WorkshopTabs({
</div>
</section>
)}
{/* Team collab sessions (non-shared) - grayed out, admin view only */}
{activeTab === 'all' && teamCollabFiltered.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-muted mb-4">
🏢 Équipe non partagés ({teamCollabFiltered.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teamCollabFiltered.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab />
))}
</div>
</section>
)}
</div>
)}
</div>
@@ -470,7 +533,7 @@ function TabButton({
);
}
function SessionCard({ session }: { session: AnySession }) {
function SessionCard({ session, isTeamCollab = false }: { session: AnySession; isTeamCollab?: boolean }) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition();
@@ -562,11 +625,8 @@ function SessionCard({ session }: { session: AnySession }) {
const editParticipantLabel = workshop.participantLabel;
return (
<>
<div className="relative group">
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
const cardContent = (
<Card hover={!isTeamCollab} className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}>
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
@@ -677,10 +737,21 @@ function SessionCard({ session }: { session: AnySession }) {
</div>
)}
</Card>
);
return (
<>
<div className="relative group">
<Link
href={href}
className={isTeamCollab ? 'cursor-pointer' : ''}
title={isTeamCollab ? "Atelier de l'équipe éditable en tant qu'admin" : undefined}
>
{cardContent}
</Link>
{/* Action buttons - only for owner */}
{session.isOwner && (
{/* Edit: owner, EDITOR, or team admin | Delete: owner or team admin only (not EDITOR) */}
{(session.isOwner || session.role === 'EDITOR' || session.isTeamCollab) && (
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
@@ -700,6 +771,7 @@ function SessionCard({ session }: { session: AnySession }) {
/>
</svg>
</button>
{(session.isOwner || session.isTeamCollab) && (
<button
onClick={(e) => {
e.preventDefault();
@@ -718,6 +790,7 @@ function SessionCard({ session }: { session: AnySession }) {
/>
</svg>
</button>
)}
</div>
)}
</div>

View File

@@ -3,6 +3,7 @@ import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getSessionById } from '@/services/sessions';
import { getUserTeams } from '@/services/teams';
import { SwotBoard } from '@/components/swot/SwotBoard';
import { SessionLiveWrapper } from '@/components/collaboration';
import { EditableSessionTitle } from '@/components/ui';
@@ -20,7 +21,10 @@ export default async function SessionPage({ params }: SessionPageProps) {
return null;
}
const session = await getSessionById(id, authSession.user.id);
const [session, userTeams] = await Promise.all([
getSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
@@ -48,7 +52,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
<EditableSessionTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
/>
<div className="mt-2">
<CollaboratorDisplay
@@ -80,6 +84,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<SwotBoard sessionId={session.id} items={session.items} actions={session.actions} />
</SessionLiveWrapper>

View File

@@ -1,10 +1,25 @@
import { Suspense } from 'react';
import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
import { getWeatherSessionsByUserId } from '@/services/weather';
import {
getSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamSwotSessions,
} from '@/services/sessions';
import {
getMotivatorSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamMotivatorSessions,
} from '@/services/moving-motivators';
import {
getYearReviewSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamYearReviewSessions,
} from '@/services/year-review';
import {
getWeeklyCheckInSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeeklyCheckInSessions,
} from '@/services/weekly-checkin';
import {
getWeatherSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions,
} from '@/services/weather';
import { Card } from '@/components/ui';
import { withWorkshopType } from '@/lib/workshops';
import { WorkshopTabs } from './WorkshopTabs';
@@ -36,14 +51,29 @@ export default async function SessionsPage() {
return null;
}
// Fetch SWOT, Moving Motivators, Year Review, Weekly Check-in, and Weather sessions
const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions, weatherSessions] =
await Promise.all([
// Fetch sessions (owned + shared) and team collab sessions (for team admins, non-shared)
const [
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
teamSwotSessions,
teamMotivatorSessions,
teamYearReviewSessions,
teamWeeklyCheckInSessions,
teamWeatherSessions,
] = await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id),
getWeatherSessionsByUserId(session.user.id),
getTeamSwotSessions(session.user.id),
getTeamMotivatorSessions(session.user.id),
getTeamYearReviewSessions(session.user.id),
getTeamWeeklyCheckInSessions(session.user.id),
getTeamWeatherSessions(session.user.id),
]);
// Add workshopType to each session for unified display
@@ -53,6 +83,12 @@ export default async function SessionsPage() {
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin');
const allWeatherSessions = withWorkshopType(weatherSessions, 'weather');
const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot');
const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review');
const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin');
const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather');
// Combine and sort by updatedAt
const allSessions = [
...allSwotSessions,
@@ -99,6 +135,13 @@ export default async function SessionsPage() {
yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions}
weatherSessions={allWeatherSessions}
teamCollabSessions={[
...teamSwotWithType,
...teamMotivatorWithType,
...teamYearReviewWithType,
...teamWeeklyCheckInWithType,
...teamWeatherWithType,
]}
/>
</Suspense>
)}

View File

@@ -51,7 +51,7 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
<EditableWeatherTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
/>
</div>
<div className="flex items-center gap-3">

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin';
import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth';
import { getUserOKRsForPeriod } from '@/services/okrs';
import { getCurrentQuarterPeriod } from '@/lib/okr-utils';
import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin';
@@ -22,7 +24,10 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
return null;
}
const session = await getWeeklyCheckInSessionById(id, authSession.user.id);
const [session, userTeams] = await Promise.all([
getWeeklyCheckInSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
@@ -34,9 +39,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);
}
@@ -62,10 +68,10 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
<EditableWeeklyCheckInTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
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">
@@ -94,6 +100,7 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<WeeklyCheckInBoard sessionId={session.id} items={session.items} />
</WeeklyCheckInLiveWrapper>

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getYearReviewSessionById } from '@/services/year-review';
import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth';
import { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableYearReviewTitle } from '@/components/ui';
@@ -19,7 +21,10 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
return null;
}
const session = await getYearReviewSessionById(id, authSession.user.id);
const [session, userTeams] = await Promise.all([
getYearReviewSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
@@ -47,10 +52,14 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
<EditableYearReviewTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
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">
@@ -75,6 +84,7 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<YearReviewBoard sessionId={session.id} items={session.items} />
</YearReviewLiveWrapper>

View File

@@ -4,9 +4,11 @@ import { useState, useCallback } from 'react';
import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
import { LiveIndicator } from './LiveIndicator';
import { ShareModal } from './ShareModal';
import { shareSessionAction, removeShareAction } from '@/actions/share';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
import type { TeamWithMembers } from '@/lib/share-utils';
interface ShareUser {
id: string;
@@ -28,6 +30,7 @@ interface SessionLiveWrapperProps {
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
}
@@ -38,6 +41,7 @@ export function SessionLiveWrapper({
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: SessionLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -122,10 +126,22 @@ export function SessionLiveWrapper({
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
title="Partager la session"
sessionSubtitle="Session"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareSessionAction(sessionId, email, role)}
onRemoveShare={(userId) => removeShareAction(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier les items et actions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
);

View File

@@ -1,12 +1,13 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareSessionAction, removeShareAction } from '@/actions/share';
import { getTeamMembersForShare, type TeamWithMembers } from '@/lib/share-utils';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
@@ -19,39 +20,79 @@ interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
createdAt?: Date;
}
type ShareTab = 'teamMember' | 'team' | 'email';
interface ShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
title: string;
sessionSubtitle?: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
userTeams?: TeamWithMembers[];
currentUserId?: string;
onShareWithEmail: (email: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
onShareWithTeam?: (teamId: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
onRemoveShare: (userId: string) => Promise<unknown>;
helpText?: React.ReactNode;
}
const SELECT_STYLE =
'appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20';
export function ShareModal({
isOpen,
onClose,
sessionId,
title,
sessionSubtitle,
sessionTitle,
shares,
isOwner,
userTeams = [],
currentUserId = '',
onShareWithEmail,
onShareWithTeam,
onRemoveShare,
helpText,
}: ShareModalProps) {
const teamMembers = getTeamMembersForShare(userTeams, currentUserId);
const hasTeamShare = !!onShareWithTeam;
const [shareType, setShareType] = useState<ShareTab>('teamMember');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [selectedMemberId, setSelectedMemberId] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const resetForm = () => {
setEmail('');
setTeamId('');
setSelectedMemberId('');
};
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareSessionAction(sessionId, email, role);
let result: { success: boolean; error?: string };
if (shareType === 'team' && onShareWithTeam) {
result = await onShareWithTeam(teamId, role);
} else {
const targetEmail =
shareType === 'teamMember'
? teamMembers.find((m) => m.id === selectedMemberId)?.email ?? ''
: email;
result = await onShareWithEmail(targetEmail, role);
}
if (result.success) {
setEmail('');
resetForm();
} else {
setError(result.error || 'Erreur lors du partage');
}
@@ -60,22 +101,47 @@ export function ShareModal({
async function handleRemove(userId: string) {
startTransition(async () => {
await removeShareAction(sessionId, userId);
await onRemoveShare(userId);
});
}
const tabs: { value: ShareTab; label: string; icon: string }[] = [
{ value: 'teamMember', label: 'Membre', icon: '👥' },
...(hasTeamShare ? [{ value: 'team' as ShareTab, label: 'Équipe', icon: '🏢' }] : []),
{ value: 'email', label: 'Email', icon: '👤' },
];
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Session</p>
{sessionSubtitle && <p className="text-sm text-muted">{sessionSubtitle}</p>}
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2 border-b border-border pb-3 flex-wrap">
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => {
setShareType(tab.value);
resetForm();
}}
className={`flex-1 min-w-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === tab.value
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{shareType === 'email' && (
<div className="flex gap-2">
<Input
type="email"
@@ -85,28 +151,122 @@ export function ShareModal({
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<select value={role} onChange={(e) => setRole(e.target.value as ShareRole)} className={SELECT_STYLE}>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
)}
{shareType === 'teamMember' && (
<div className="space-y-2">
{teamMembers.length === 0 ? (
<p className="text-sm text-muted">
Vous n&apos;êtes membre d&apos;aucune équipe ou vos équipes n&apos;ont pas d&apos;autres membres.
Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline">
Équipes
</Link>
.
</p>
) : (
<div className="flex gap-2">
<div className="relative flex-1">
<select
value={selectedMemberId}
onChange={(e) => setSelectedMemberId(e.target.value)}
className={`w-full ${SELECT_STYLE}`}
required
>
<option value="">Sélectionner un membre</option>
{teamMembers.map((m) => (
<option key={m.id} value={m.id}>
{m.name || m.email} {m.name && `(${m.email})`}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg className="h-4 w-4 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<select value={role} onChange={(e) => setRole(e.target.value as ShareRole)} className={SELECT_STYLE}>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
)}
</div>
)}
{shareType === 'team' && hasTeamShare && (
<div className="space-y-2">
{userTeams.length === 0 ? (
<p className="text-sm text-muted">
Vous n&apos;êtes membre d&apos;aucune équipe. Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline">
Équipes
</Link>
.
</p>
) : (
<>
<div className="relative">
<select
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
className={`w-full ${SELECT_STYLE}`}
required
>
<option value="">Sélectionner une équipe</option>
{userTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name} {team.userRole === 'ADMIN' && '(Admin)'}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg className="h-4 w-4 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div className="relative">
<select value={role} onChange={(e) => setRole(e.target.value as ShareRole)} className={`w-full ${SELECT_STYLE}`}>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg className="h-4 w-4 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</>
)}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
<Button
type="submit"
disabled={
isPending ||
(shareType === 'email' && !email) ||
(shareType === 'teamMember' && !selectedMemberId) ||
(shareType === 'team' && !teamId)
}
className="w-full"
>
{isPending ? 'Partage...' : shareType === 'team' ? "Partager à l'équipe" : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
@@ -125,7 +285,6 @@ export function ShareModal({
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
@@ -137,12 +296,7 @@ export function ShareModal({
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
@@ -158,14 +312,7 @@ export function ShareModal({
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les items et actions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
{helpText && <div className="rounded-lg bg-primary/5 p-3">{helpText}</div>}
</div>
</Modal>
);

View File

@@ -3,7 +3,9 @@
import { useState, useCallback } from 'react';
import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { MotivatorShareModal } from './MotivatorShareModal';
import { ShareModal } from '@/components/collaboration/ShareModal';
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
import type { TeamWithMembers } from '@/lib/share-utils';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
@@ -28,6 +30,7 @@ interface MotivatorLiveWrapperProps {
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
}
@@ -38,6 +41,7 @@ export function MotivatorLiveWrapper({
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: MotivatorLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -119,13 +123,25 @@ export function MotivatorLiveWrapper({
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<MotivatorShareModal
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
title="Partager la session"
sessionSubtitle="Session Moving Motivators"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareMotivatorSession(sessionId, email, role)}
onRemoveShare={(userId) => removeMotivatorShare(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
);

View File

@@ -1,172 +0,0 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface MotivatorShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function MotivatorShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: MotivatorShareModalProps) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareMotivatorSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeMotivatorShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Session Moving Motivators</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -3,4 +3,3 @@ export { MotivatorCard, MotivatorCardStatic } from './MotivatorCard';
export { MotivatorSummary } from './MotivatorSummary';
export { InfluenceZone } from './InfluenceZone';
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
export { MotivatorShareModal } from './MotivatorShareModal';

View File

@@ -6,19 +6,19 @@ import { updateMotivatorSession } from '@/actions/moving-motivators';
interface EditableMotivatorTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
canEdit: boolean;
}
export function EditableMotivatorTitle({
sessionId,
initialTitle,
isOwner,
canEdit,
}: EditableMotivatorTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
canEdit={canEdit}
onUpdate={async (id, title) => {
const result = await updateMotivatorSession(id, { title });
return result;

View File

@@ -6,19 +6,19 @@ import { updateSessionTitle } from '@/actions/session';
interface EditableSessionTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
canEdit: boolean;
}
export function EditableSessionTitle({
sessionId,
initialTitle,
isOwner,
canEdit,
}: EditableSessionTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
canEdit={canEdit}
onUpdate={async (id, title) => {
const result = await updateSessionTitle(id, title);
return result;

View File

@@ -5,14 +5,14 @@ import { useState, useTransition, useRef, useEffect, useMemo } from 'react';
interface EditableTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
canEdit: boolean;
onUpdate: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
}
export function EditableTitle({
sessionId,
initialTitle,
isOwner,
canEdit,
onUpdate,
}: EditableTitleProps) {
const [isEditing, setIsEditing] = useState(false);
@@ -65,7 +65,7 @@ export function EditableTitle({
}
};
if (!isOwner) {
if (!canEdit) {
return <h1 className="text-3xl font-bold text-foreground">{title}</h1>;
}

View File

@@ -6,19 +6,19 @@ import { updateWeatherSession } from '@/actions/weather';
interface EditableWeatherTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
canEdit: boolean;
}
export function EditableWeatherTitle({
sessionId,
initialTitle,
isOwner,
canEdit,
}: EditableWeatherTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
canEdit={canEdit}
onUpdate={async (id, title) => {
const result = await updateWeatherSession(id, { title });
return result;

View File

@@ -6,19 +6,19 @@ import { updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
interface EditableWeeklyCheckInTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
canEdit: boolean;
}
export function EditableWeeklyCheckInTitle({
sessionId,
initialTitle,
isOwner,
canEdit,
}: EditableWeeklyCheckInTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
canEdit={canEdit}
onUpdate={async (id, title) => {
const result = await updateWeeklyCheckInSession(id, { title });
return result;

View File

@@ -6,19 +6,19 @@ import { updateYearReviewSession } from '@/actions/year-review';
interface EditableYearReviewTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
canEdit: boolean;
}
export function EditableYearReviewTitle({
sessionId,
initialTitle,
isOwner,
canEdit,
}: EditableYearReviewTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
canEdit={canEdit}
onUpdate={async (id, title) => {
const result = await updateYearReviewSession(id, { title });
return result;

View File

@@ -3,7 +3,8 @@
import { useState, useCallback } from 'react';
import { useWeatherLive, type WeatherLiveEvent } from '@/hooks/useWeatherLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { WeatherShareModal } from './WeatherShareModal';
import { ShareModal } from '@/components/collaboration/ShareModal';
import { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
@@ -128,14 +129,26 @@ export function WeatherLiveWrapper({
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<WeatherShareModal
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
title="Partager la météo"
sessionSubtitle="Météo personnelle"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareWeatherSession(sessionId, email, role)}
onShareWithTeam={(teamId, role) => shareWeatherSessionToTeam(sessionId, teamId, role)}
onRemoveShare={(userId) => removeWeatherShare(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier sa météo et voir celle des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
);

View File

@@ -1,307 +0,0 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
userTeams?: Team[];
}
export function WeatherShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
userTeams = [],
}: WeatherShareModalProps) {
const [shareType, setShareType] = useState<'user' | 'team'>('user');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
let result;
if (shareType === 'team') {
result = await shareWeatherSessionToTeam(sessionId, teamId, role);
} else {
result = await shareWeatherSession(sessionId, email, role);
}
if (result.success) {
setEmail('');
setTeamId('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeWeatherShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la météo">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Météo personnelle</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
{/* Share type selector */}
<div className="flex gap-2 border-b border-border pb-3">
<button
type="button"
onClick={() => {
setShareType('user');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👤 Utilisateur
</button>
<button
type="button"
onClick={() => {
setShareType('team');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'team'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👥 Équipe
</button>
</div>
{/* User share */}
{shareType === 'user' && (
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
)}
{/* Team share */}
{shareType === 'team' && (
<div className="space-y-2">
{userTeams.length === 0 ? (
<p className="text-sm text-muted">
Vous n&apos;êtes membre d&apos;aucune équipe. Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline">
Équipes
</Link>
.
</p>
) : (
<>
<div className="relative">
<select
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
required
>
<option value="">Sélectionner une équipe</option>
{userTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name} {team.userRole === 'ADMIN' && '(Admin)'}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</>
)}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
disabled={isPending || (shareType === 'user' && !email) || (shareType === 'team' && !teamId)}
className="w-full"
>
{isPending ? 'Partage...' : shareType === 'team' ? "Partager à l'équipe" : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier sa météo et voir celle des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -1,5 +1,4 @@
export { WeatherBoard } from './WeatherBoard';
export { WeatherCard } from './WeatherCard';
export { WeatherLiveWrapper } from './WeatherLiveWrapper';
export { WeatherShareModal } from './WeatherShareModal';
export { WeatherInfoPanel } from './WeatherInfoPanel';

View File

@@ -3,7 +3,9 @@
import { useState, useCallback } from 'react';
import { useWeeklyCheckInLive, type WeeklyCheckInLiveEvent } from '@/hooks/useWeeklyCheckInLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
import { ShareModal } from '@/components/collaboration/ShareModal';
import { shareWeeklyCheckInSession, removeWeeklyCheckInShare } from '@/actions/weekly-checkin';
import type { TeamWithMembers } from '@/lib/share-utils';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
@@ -28,6 +30,7 @@ interface WeeklyCheckInLiveWrapperProps {
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
}
@@ -38,6 +41,7 @@ export function WeeklyCheckInLiveWrapper({
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: WeeklyCheckInLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -119,13 +123,25 @@ export function WeeklyCheckInLiveWrapper({
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<WeeklyCheckInShareModal
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
title="Options de partage"
sessionSubtitle="Check-in hebdomadaire"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareWeeklyCheckInSession(sessionId, email, role)}
onRemoveShare={(userId) => removeWeeklyCheckInShare(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
);

View File

@@ -1,172 +0,0 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareWeeklyCheckInSession, removeWeeklyCheckInShare } from '@/actions/weekly-checkin';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface WeeklyCheckInShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function WeeklyCheckInShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: WeeklyCheckInShareModalProps) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareWeeklyCheckInSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeWeeklyCheckInShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Options de partage">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Check-in hebdomadaire</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -2,5 +2,4 @@ export { WeeklyCheckInBoard } from './WeeklyCheckInBoard';
export { WeeklyCheckInCard } from './WeeklyCheckInCard';
export { WeeklyCheckInSection } from './WeeklyCheckInSection';
export { WeeklyCheckInLiveWrapper } from './WeeklyCheckInLiveWrapper';
export { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
export { CurrentQuarterOKRs } from './CurrentQuarterOKRs';

View File

@@ -3,7 +3,9 @@
import { useState, useCallback } from 'react';
import { useYearReviewLive, type YearReviewLiveEvent } from '@/hooks/useYearReviewLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { YearReviewShareModal } from './YearReviewShareModal';
import { ShareModal } from '@/components/collaboration/ShareModal';
import { shareYearReviewSession, removeYearReviewShare } from '@/actions/year-review';
import type { TeamWithMembers } from '@/lib/share-utils';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
@@ -28,6 +30,7 @@ interface YearReviewLiveWrapperProps {
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
}
@@ -38,6 +41,7 @@ export function YearReviewLiveWrapper({
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: YearReviewLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -119,13 +123,25 @@ export function YearReviewLiveWrapper({
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<YearReviewShareModal
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
title="Partager le bilan"
sessionSubtitle="Bilan annuel"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareYearReviewSession(sessionId, email, role)}
onRemoveShare={(userId) => removeYearReviewShare(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
);

View File

@@ -1,173 +0,0 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareYearReviewSession, removeYearReviewShare } from '@/actions/year-review';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface YearReviewShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function YearReviewShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: YearReviewShareModalProps) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareYearReviewSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeYearReviewShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager le bilan">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Bilan annuel</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -2,4 +2,3 @@ export { YearReviewBoard } from './YearReviewBoard';
export { YearReviewCard } from './YearReviewCard';
export { YearReviewSection } from './YearReviewSection';
export { YearReviewLiveWrapper } from './YearReviewLiveWrapper';
export { YearReviewShareModal } from './YearReviewShareModal';

View File

@@ -19,3 +19,17 @@ export function getWeekYearLabel(date: Date = new Date()): string {
const year = date.getFullYear();
return `S${week.toString().padStart(2, '0')}-${year}`;
}
/** ISO week bounds (Monday 00:00:00 to Sunday 23:59:59.999) */
export function getWeekBounds(date: Date): { start: Date; end: Date } {
const d = new Date(date);
const dayNum = d.getDay() || 7; // Sunday = 7
const diff = d.getDate() - dayNum + (dayNum === 7 ? -6 : 1); // Monday
const monday = new Date(d);
monday.setDate(diff);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return { start: monday, end: sunday };
}

37
src/lib/share-utils.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Shared utilities for share modals across workshop types.
*/
export interface TeamMemberUser {
id: string;
email: string;
name: string | null;
}
export interface TeamWithMembers {
id: string;
name: string;
description: string | null;
userRole?: 'ADMIN' | 'MEMBER';
members?: { user: TeamMemberUser }[];
}
/**
* Flatten team members from all teams, dedupe by userId, exclude current user.
*/
export function getTeamMembersForShare(
userTeams: TeamWithMembers[],
currentUserId: string
): TeamMemberUser[] {
const seen = new Set<string>();
return (
userTeams
.flatMap((t) => t.members ?? [])
.map((m) => m.user)
.filter((u) => {
if (u.id === currentUserId || seen.has(u.id)) return false;
seen.add(u.id);
return true;
})
);
}

View File

@@ -1,5 +1,5 @@
// ============================================
// SWOT Manager - Type Definitions
// Workshop Manager - Type Definitions
// ============================================
export type SwotCategory = 'STRENGTH' | 'WEAKNESS' | 'OPPORTUNITY' | 'THREAT';

View File

@@ -13,12 +13,13 @@ export const WORKSHOP_TYPE_IDS = [
export type WorkshopTypeId = (typeof WORKSHOP_TYPE_IDS)[number];
export type WorkshopTabType = WorkshopTypeId | 'all' | 'byPerson';
export type WorkshopTabType = WorkshopTypeId | 'all' | 'byPerson' | 'team';
export const VALID_TAB_PARAMS: WorkshopTabType[] = [
'all',
...WORKSHOP_TYPE_IDS,
'byPerson',
'team',
];
export interface WorkshopConfig {

View File

@@ -1,139 +1,93 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
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([
return mergeSessionsByUserId(
(uid) =>
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,
},
},
},
where: { userId: uid },
include: motivatorInclude,
orderBy: { updatedAt: 'desc' },
}),
(uid) =>
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,
},
},
},
},
},
where: { userId: uid },
include: { session: { include: motivatorInclude } },
}),
]);
// 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()
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) {
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 OR has it shared
const 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) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
// 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 or shared)
export async function canAccessMotivatorSession(sessionId: string, userId: string) {
const count = await prisma.movingMotivatorsSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
const motivatorPermissions = createSessionPermissionChecks(prisma.movingMotivatorsSession);
// Check if user can edit session (owner or EDITOR role)
export async function canEditMotivatorSession(sessionId: string, userId: string) {
const count = await prisma.movingMotivatorsSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
const motivatorShareEvents = createShareAndEventHandlers<
'CARD_MOVED' | 'CARD_INFLUENCE_CHANGED' | 'CARDS_REORDERED' | 'SESSION_UPDATED'
>(
prisma.movingMotivatorsSession,
prisma.mMSessionShare,
prisma.mMSessionEvent,
motivatorPermissions.canAccess
);
export const canAccessMotivatorSession = motivatorPermissions.canAccess;
export const canEditMotivatorSession = motivatorPermissions.canEdit;
export const canDeleteMotivatorSession = motivatorPermissions.canDelete;
const DEFAULT_MOTIVATOR_TYPES: MotivatorType[] = [
'STATUS',
@@ -178,15 +132,21 @@ export async function updateMotivatorSession(
userId: string,
data: { title?: string; participant?: string }
) {
if (!(await canEditMotivatorSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.movingMotivatorsSession.updateMany({
where: { id: sessionId, userId },
where: { id: sessionId },
data,
});
}
export async function deleteMotivatorSession(sessionId: string, userId: string) {
if (!(await canDeleteMotivatorSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.movingMotivatorsSession.deleteMany({
where: { id: sessionId, userId },
where: { id: sessionId },
});
}
@@ -228,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)
@@ -314,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;

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

View 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;
}

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

View File

@@ -1,151 +1,103 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
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([
return mergeSessionsByUserId(
(uid) =>
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,
},
},
},
where: { userId: uid },
include: sessionInclude,
orderBy: { updatedAt: 'desc' },
}),
(uid) =>
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,
},
},
},
},
},
where: { userId: uid },
include: { session: { include: sessionInclude } },
}),
]);
// 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()
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) {
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 OR has it shared
const 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) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
// 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 or shared)
export async function canAccessSession(sessionId: string, userId: string) {
const count = await prisma.session.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
const sessionPermissions = createSessionPermissionChecks(prisma.session);
// Check if user can edit session (owner or EDITOR role)
export async function canEditSession(sessionId: string, userId: string) {
const count = await prisma.session.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
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
);
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({
@@ -161,15 +113,21 @@ export async function updateSession(
userId: string,
data: { title?: string; collaborator?: string }
) {
if (!(await canEditSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.session.updateMany({
where: { id: sessionId, userId },
where: { id: sessionId },
data,
});
}
export async function deleteSession(sessionId: string, userId: string) {
if (!(await canDeleteSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.session.deleteMany({
where: { id: sessionId, userId },
where: { id: sessionId },
});
}
@@ -369,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)
@@ -455,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;

View File

@@ -244,6 +244,35 @@ export async function getTeamMember(teamId: string, userId: string) {
});
}
/** Returns true if adminUserId is ADMIN of any team that contains ownerUserId. */
export async function isAdminOfUser(ownerUserId: string, adminUserId: string): Promise<boolean> {
if (ownerUserId === adminUserId) return false;
const teamMemberIds = await getTeamMemberIdsForAdminTeams(adminUserId);
return teamMemberIds.includes(ownerUserId);
}
/** Returns user IDs of all members in teams where the given user is ADMIN (excluding self). */
export async function getTeamMemberIdsForAdminTeams(userId: string): Promise<string[]> {
const adminTeams = await prisma.teamMember.findMany({
where: {
userId,
role: 'ADMIN',
},
select: { teamId: true },
});
if (adminTeams.length === 0) return [];
const members = await prisma.teamMember.findMany({
where: {
teamId: { in: adminTeams.map((t) => t.teamId) },
userId: { not: userId },
},
select: { userId: true },
distinct: ['userId'],
});
return members.map((m) => m.userId);
}
export async function getTeamMemberById(teamMemberId: string) {
return prisma.teamMember.findUnique({
where: { id: teamMemberId },

View File

@@ -1,130 +1,93 @@
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 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([
return mergeSessionsByUserId(
(uid) =>
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,
},
},
},
where: { userId: uid },
include: weatherInclude,
orderBy: { updatedAt: 'desc' },
}),
(uid) =>
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,
},
},
},
},
},
where: { userId: uid },
include: { session: { include: weatherInclude } },
}),
]);
// 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()
userId
);
return allSessions;
}
export async function getWeatherSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.weatherSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
/** 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 } },
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: { createdAt: 'asc' } as const,
},
orderBy: { createdAt: 'asc' },
},
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
};
if (!session) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
return { ...session, isOwner, role, canEdit };
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 })
);
}
// Check if user can access session (owner or shared)
export async function canAccessWeatherSession(sessionId: string, userId: string) {
const count = await prisma.weatherSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
const weatherPermissions = createSessionPermissionChecks(prisma.weatherSession);
// Check if user can edit session (owner or EDITOR role)
export async function canEditWeatherSession(sessionId: string, userId: string) {
const count = await prisma.weatherSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
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({
@@ -148,15 +111,21 @@ export async function updateWeatherSession(
userId: string,
data: { title?: string; date?: Date }
) {
if (!(await canEditWeatherSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weatherSession.updateMany({
where: { id: sessionId, userId },
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, userId },
where: { id: sessionId },
});
}
@@ -212,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,
@@ -270,21 +197,43 @@ export async function shareWeatherSessionToTeam(
throw new Error('Session not found or not owned');
}
// Get team members
// 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 (teamMembers.length === 0) {
if (teamMembersFull.length === 0) {
throw new Error('Team has no members');
}
// Share with all team members (except owner)
const shares = await Promise.all(
teamMembers
teamMembersFull
.filter((tm) => tm.userId !== ownerId) // Don't share with yourself
.map((tm) =>
prisma.weatherSessionShare.upsert({
@@ -307,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)
@@ -349,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;

View File

@@ -1,139 +1,101 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
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([
return mergeSessionsByUserId(
(uid) =>
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,
},
},
},
where: { userId: uid },
include: weeklyCheckInInclude,
orderBy: { updatedAt: 'desc' },
}),
(uid) =>
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,
},
},
},
},
},
where: { userId: uid },
include: { session: { include: weeklyCheckInInclude } },
}),
]);
// 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()
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) {
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 OR has it shared
const 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) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
// 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 or shared)
export async function canAccessWeeklyCheckInSession(sessionId: string, userId: string) {
const count = await prisma.weeklyCheckInSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
const weeklyCheckInPermissions = createSessionPermissionChecks(prisma.weeklyCheckInSession);
// Check if user can edit session (owner or EDITOR role)
export async function canEditWeeklyCheckInSession(sessionId: string, userId: string) {
const count = await prisma.weeklyCheckInSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
const weeklyCheckInShareEvents = createShareAndEventHandlers<
| 'ITEM_CREATED'
| 'ITEM_UPDATED'
| 'ITEM_DELETED'
| 'ITEM_MOVED'
| 'ITEMS_REORDERED'
| 'SESSION_UPDATED'
>(
prisma.weeklyCheckInSession,
prisma.wCISessionShare,
prisma.wCISessionEvent,
weeklyCheckInPermissions.canAccess
);
export const canAccessWeeklyCheckInSession = weeklyCheckInPermissions.canAccess;
export const canEditWeeklyCheckInSession = weeklyCheckInPermissions.canEdit;
export const canDeleteWeeklyCheckInSession = weeklyCheckInPermissions.canDelete;
export async function createWeeklyCheckInSession(
userId: string,
@@ -158,15 +120,21 @@ export async function updateWeeklyCheckInSession(
userId: string,
data: { title?: string; participant?: string; date?: Date }
) {
if (!(await canEditWeeklyCheckInSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weeklyCheckInSession.updateMany({
where: { id: sessionId, userId },
where: { id: sessionId },
data,
});
}
export async function deleteWeeklyCheckInSession(sessionId: string, userId: string) {
if (!(await canDeleteWeeklyCheckInSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weeklyCheckInSession.deleteMany({
where: { id: sessionId, userId },
where: { id: sessionId },
});
}
@@ -244,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)
@@ -332,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;

View File

@@ -1,139 +1,98 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
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([
return mergeSessionsByUserId(
(uid) =>
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,
},
},
},
where: { userId: uid },
include: yearReviewInclude,
orderBy: { updatedAt: 'desc' },
}),
(uid) =>
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,
},
},
},
},
},
where: { userId: uid },
include: { session: { include: yearReviewInclude } },
}),
]);
// 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()
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) {
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 OR has it shared
const 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) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
// 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 or shared)
export async function canAccessYearReviewSession(sessionId: string, userId: string) {
const count = await prisma.yearReviewSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
const yearReviewPermissions = createSessionPermissionChecks(prisma.yearReviewSession);
// Check if user can edit session (owner or EDITOR role)
export async function canEditYearReviewSession(sessionId: string, userId: string) {
const count = await prisma.yearReviewSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
const yearReviewShareEvents = createShareAndEventHandlers<
| 'ITEM_CREATED'
| 'ITEM_UPDATED'
| 'ITEM_DELETED'
| 'ITEM_MOVED'
| 'ITEMS_REORDERED'
| 'SESSION_UPDATED'
>(
prisma.yearReviewSession,
prisma.yRSessionShare,
prisma.yRSessionEvent,
yearReviewPermissions.canAccess
);
export const canAccessYearReviewSession = yearReviewPermissions.canAccess;
export const canEditYearReviewSession = yearReviewPermissions.canEdit;
export const canDeleteYearReviewSession = yearReviewPermissions.canDelete;
export async function createYearReviewSession(
userId: string,
@@ -157,15 +116,21 @@ export async function updateYearReviewSession(
userId: string,
data: { title?: string; participant?: string; year?: number }
) {
if (!(await canEditYearReviewSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.yearReviewSession.updateMany({
where: { id: sessionId, userId },
where: { id: sessionId },
data,
});
}
export async function deleteYearReviewSession(sessionId: string, userId: string) {
if (!(await canDeleteYearReviewSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.yearReviewSession.deleteMany({
where: { id: sessionId, userId },
where: { id: sessionId },
});
}
@@ -242,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)
@@ -330,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;