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 ## Stack Technique

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Workshop Manager', 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: { icons: {
icon: '/icon.svg', icon: '/icon.svg',
apple: '/rocket_blue_gradient_large_logo.jpg', apple: '/rocket_blue_gradient_large_logo.jpg',

View File

@@ -4,7 +4,7 @@ export default function manifest(): MetadataRoute.Manifest {
return { return {
name: 'Workshop Manager', name: 'Workshop Manager',
short_name: 'Workshop', 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: '/', start_url: '/',
display: 'standalone', display: 'standalone',
icons: [ icons: [

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops'; import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getMotivatorSessionById } from '@/services/moving-motivators'; 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 { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge, CollaboratorDisplay } from '@/components/ui'; import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableMotivatorTitle } from '@/components/ui'; import { EditableMotivatorTitle } from '@/components/ui';
@@ -19,7 +21,10 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
return null; 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) { if (!session) {
notFound(); notFound();
@@ -47,10 +52,14 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
<EditableMotivatorTitle <EditableMotivatorTitle
sessionId={session.id} sessionId={session.id}
initialTitle={session.title} initialTitle={session.title}
isOwner={session.isOwner} canEdit={session.canEdit}
/> />
<div className="mt-2"> <div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail /> <CollaboratorDisplay
collaborator={session.resolvedParticipant as ResolvedCollaborator}
size="lg"
showEmail
/>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -76,6 +85,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
shares={session.shares} shares={session.shares}
isOwner={session.isOwner} isOwner={session.isOwner}
canEdit={session.canEdit} canEdit={session.canEdit}
userTeams={userTeams}
> >
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} /> <MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
</MotivatorLiveWrapper> </MotivatorLiveWrapper>

View File

@@ -28,6 +28,7 @@ import {
const TYPE_TABS = [ const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' }, { 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 })), ...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
]; ];
@@ -64,6 +65,8 @@ interface SwotSession {
shares: Share[]; shares: Share[];
_count: { items: number; actions: number }; _count: { items: number; actions: number };
workshopType: 'swot'; workshopType: 'swot';
isTeamCollab?: true;
canEdit?: boolean;
} }
interface MotivatorSession { interface MotivatorSession {
@@ -78,6 +81,8 @@ interface MotivatorSession {
shares: Share[]; shares: Share[];
_count: { cards: number }; _count: { cards: number };
workshopType: 'motivators'; workshopType: 'motivators';
isTeamCollab?: true;
canEdit?: boolean;
} }
interface YearReviewSession { interface YearReviewSession {
@@ -93,6 +98,8 @@ interface YearReviewSession {
shares: Share[]; shares: Share[];
_count: { items: number }; _count: { items: number };
workshopType: 'year-review'; workshopType: 'year-review';
isTeamCollab?: true;
canEdit?: boolean;
} }
interface WeeklyCheckInSession { interface WeeklyCheckInSession {
@@ -108,6 +115,8 @@ interface WeeklyCheckInSession {
shares: Share[]; shares: Share[];
_count: { items: number }; _count: { items: number };
workshopType: 'weekly-checkin'; workshopType: 'weekly-checkin';
isTeamCollab?: true;
canEdit?: boolean;
} }
interface WeatherSession { interface WeatherSession {
@@ -121,6 +130,8 @@ interface WeatherSession {
shares: Share[]; shares: Share[];
_count: { entries: number }; _count: { entries: number };
workshopType: 'weather'; workshopType: 'weather';
isTeamCollab?: true;
canEdit?: boolean;
} }
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession; type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession;
@@ -131,6 +142,7 @@ interface WorkshopTabsProps {
yearReviewSessions: YearReviewSession[]; yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[]; weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[]; weatherSessions: WeatherSession[];
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
} }
// Helper to get resolved collaborator from any session // Helper to get resolved collaborator from any session
@@ -197,6 +209,7 @@ export function WorkshopTabs({
yearReviewSessions, yearReviewSessions,
weeklyCheckInSessions, weeklyCheckInSessions,
weatherSessions, weatherSessions,
teamCollabSessions = [],
}: WorkshopTabsProps) { }: WorkshopTabsProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -219,7 +232,7 @@ export function WorkshopTabs({
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`); 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[] = [ const allSessions: AnySession[] = [
...swotSessions, ...swotSessions,
...motivatorSessions, ...motivatorSessions,
@@ -232,7 +245,9 @@ export function WorkshopTabs({
const filteredSessions = const filteredSessions =
activeTab === 'all' || activeTab === 'byPerson' activeTab === 'all' || activeTab === 'byPerson'
? allSessions ? allSessions
: activeTab === 'swot' : activeTab === 'team'
? teamCollabSessions
: activeTab === 'swot'
? swotSessions ? swotSessions
: activeTab === 'motivators' : activeTab === 'motivators'
? motivatorSessions ? motivatorSessions
@@ -242,9 +257,11 @@ export function WorkshopTabs({
? weeklyCheckInSessions ? weeklyCheckInSessions
: weatherSessions; : weatherSessions;
// Separate by ownership // Separate by ownership (for non-team tab: owned, shared, teamCollab)
const ownedSessions = filteredSessions.filter((s) => s.isOwner); 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) // Group by person (all sessions - owned and shared)
const sessionsByPerson = groupByPerson(allSessions); const sessionsByPerson = groupByPerson(allSessions);
@@ -270,6 +287,15 @@ export function WorkshopTabs({
label="Par personne" label="Par personne"
count={sessionsByPerson.size} count={sessionsByPerson.size}
/> />
{teamCollabSessions.length > 0 && (
<TabButton
active={activeTab === 'team'}
onClick={() => setActiveTab('team')}
icon="🏢"
label="Équipe"
count={teamCollabSessions.length}
/>
)}
<TypeFilterDropdown <TypeFilterDropdown
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
@@ -281,6 +307,7 @@ export function WorkshopTabs({
'year-review': yearReviewSessions.length, 'year-review': yearReviewSessions.length,
'weekly-checkin': weeklyCheckInSessions.length, 'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length, weather: weatherSessions.length,
team: teamCollabSessions.length,
}} }}
/> />
</div> </div>
@@ -312,6 +339,28 @@ export function WorkshopTabs({
})} })}
</div> </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 ? ( ) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div> <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> </div>
</section> </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>
)} )}
</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 [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -562,11 +625,8 @@ function SessionCard({ session }: { session: AnySession }) {
const editParticipantLabel = workshop.participantLabel; const editParticipantLabel = workshop.participantLabel;
return ( const cardContent = (
<> <Card hover={!isTeamCollab} className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}>
<div className="relative group">
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
{/* Accent bar */} {/* Accent bar */}
<div <div
className="absolute top-0 left-0 right-0 h-1" className="absolute top-0 left-0 right-0 h-1"
@@ -677,10 +737,21 @@ function SessionCard({ session }: { session: AnySession }) {
</div> </div>
)} )}
</Card> </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> </Link>
{/* Action buttons - only for owner */} {/* Edit: owner, EDITOR, or team admin | Delete: owner or team admin only (not EDITOR) */}
{session.isOwner && ( {(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"> <div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => { onClick={(e) => {
@@ -700,6 +771,7 @@ function SessionCard({ session }: { session: AnySession }) {
/> />
</svg> </svg>
</button> </button>
{(session.isOwner || session.isTeamCollab) && (
<button <button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -718,6 +790,7 @@ function SessionCard({ session }: { session: AnySession }) {
/> />
</svg> </svg>
</button> </button>
)}
</div> </div>
)} )}
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops'; import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin'; import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin';
import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth';
import { getUserOKRsForPeriod } from '@/services/okrs'; import { getUserOKRsForPeriod } from '@/services/okrs';
import { getCurrentQuarterPeriod } from '@/lib/okr-utils'; import { getCurrentQuarterPeriod } from '@/lib/okr-utils';
import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin'; import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin';
@@ -22,7 +24,10 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
return null; 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) { if (!session) {
notFound(); notFound();
@@ -34,9 +39,10 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = []; let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = [];
// Only fetch OKRs if the participant is a recognized user (has matchedUser) // 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) // 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); currentQuarterOKRs = await getUserOKRsForPeriod(participantUserId, currentQuarterPeriod);
} }
@@ -62,10 +68,10 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
<EditableWeeklyCheckInTitle <EditableWeeklyCheckInTitle
sessionId={session.id} sessionId={session.id}
initialTitle={session.title} initialTitle={session.title}
isOwner={session.isOwner} canEdit={session.canEdit}
/> />
<div className="mt-2"> <div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail /> <CollaboratorDisplay collaborator={resolvedParticipant} size="lg" showEmail />
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -94,6 +100,7 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
shares={session.shares} shares={session.shares}
isOwner={session.isOwner} isOwner={session.isOwner}
canEdit={session.canEdit} canEdit={session.canEdit}
userTeams={userTeams}
> >
<WeeklyCheckInBoard sessionId={session.id} items={session.items} /> <WeeklyCheckInBoard sessionId={session.id} items={session.items} />
</WeeklyCheckInLiveWrapper> </WeeklyCheckInLiveWrapper>

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops'; import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getYearReviewSessionById } from '@/services/year-review'; 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 { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review';
import { Badge, CollaboratorDisplay } from '@/components/ui'; import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableYearReviewTitle } from '@/components/ui'; import { EditableYearReviewTitle } from '@/components/ui';
@@ -19,7 +21,10 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
return null; 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) { if (!session) {
notFound(); notFound();
@@ -47,10 +52,14 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
<EditableYearReviewTitle <EditableYearReviewTitle
sessionId={session.id} sessionId={session.id}
initialTitle={session.title} initialTitle={session.title}
isOwner={session.isOwner} canEdit={session.canEdit}
/> />
<div className="mt-2"> <div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail /> <CollaboratorDisplay
collaborator={session.resolvedParticipant as ResolvedCollaborator}
size="lg"
showEmail
/>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -75,6 +84,7 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
shares={session.shares} shares={session.shares}
isOwner={session.isOwner} isOwner={session.isOwner}
canEdit={session.canEdit} canEdit={session.canEdit}
userTeams={userTeams}
> >
<YearReviewBoard sessionId={session.id} items={session.items} /> <YearReviewBoard sessionId={session.id} items={session.items} />
</YearReviewLiveWrapper> </YearReviewLiveWrapper>

View File

@@ -4,9 +4,11 @@ import { useState, useCallback } from 'react';
import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive'; import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
import { LiveIndicator } from './LiveIndicator'; import { LiveIndicator } from './LiveIndicator';
import { ShareModal } from './ShareModal'; import { ShareModal } from './ShareModal';
import { shareSessionAction, removeShareAction } from '@/actions/share';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client'; import type { ShareRole } from '@prisma/client';
import type { TeamWithMembers } from '@/lib/share-utils';
interface ShareUser { interface ShareUser {
id: string; id: string;
@@ -28,6 +30,7 @@ interface SessionLiveWrapperProps {
shares: Share[]; shares: Share[];
isOwner: boolean; isOwner: boolean;
canEdit: boolean; canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode; children: React.ReactNode;
} }
@@ -38,6 +41,7 @@ export function SessionLiveWrapper({
shares, shares,
isOwner, isOwner,
canEdit, canEdit,
userTeams = [],
children, children,
}: SessionLiveWrapperProps) { }: SessionLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -122,10 +126,22 @@ export function SessionLiveWrapper({
<ShareModal <ShareModal
isOpen={shareModalOpen} isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)} onClose={() => setShareModalOpen(false)}
sessionId={sessionId} title="Partager la session"
sessionSubtitle="Session"
sessionTitle={sessionTitle} sessionTitle={sessionTitle}
shares={shares} shares={shares}
isOwner={isOwner} 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'; 'use client';
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import Link from 'next/link';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar'; 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'; import type { ShareRole } from '@prisma/client';
interface ShareUser { interface ShareUser {
@@ -19,39 +20,79 @@ interface Share {
id: string; id: string;
role: ShareRole; role: ShareRole;
user: ShareUser; user: ShareUser;
createdAt: Date; createdAt?: Date;
} }
type ShareTab = 'teamMember' | 'team' | 'email';
interface ShareModalProps { interface ShareModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
sessionId: string; title: string;
sessionSubtitle?: string;
sessionTitle: string; sessionTitle: string;
shares: Share[]; shares: Share[];
isOwner: boolean; 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({ export function ShareModal({
isOpen, isOpen,
onClose, onClose,
sessionId, title,
sessionSubtitle,
sessionTitle, sessionTitle,
shares, shares,
isOwner, isOwner,
userTeams = [],
currentUserId = '',
onShareWithEmail,
onShareWithTeam,
onRemoveShare,
helpText,
}: ShareModalProps) { }: ShareModalProps) {
const teamMembers = getTeamMembersForShare(userTeams, currentUserId);
const hasTeamShare = !!onShareWithTeam;
const [shareType, setShareType] = useState<ShareTab>('teamMember');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [selectedMemberId, setSelectedMemberId] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR'); const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const resetForm = () => {
setEmail('');
setTeamId('');
setSelectedMemberId('');
};
async function handleShare(e: React.FormEvent) { async function handleShare(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
startTransition(async () => { 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) { if (result.success) {
setEmail(''); resetForm();
} else { } else {
setError(result.error || 'Erreur lors du partage'); setError(result.error || 'Erreur lors du partage');
} }
@@ -60,53 +101,172 @@ export function ShareModal({
async function handleRemove(userId: string) { async function handleRemove(userId: string) {
startTransition(async () => { 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 ( return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session"> <Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="space-y-6"> <div className="space-y-6">
{/* Session info */}
<div> <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> <p className="font-medium text-foreground">{sessionTitle}</p>
</div> </div>
{/* Share form (only for owner) */}
{isOwner && ( {isOwner && (
<form onSubmit={handleShare} className="space-y-4"> <form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2"> <div className="flex gap-2 border-b border-border pb-3 flex-wrap">
<Input {tabs.map((tab) => (
type="email" <button
placeholder="Email de l'utilisateur" key={tab.value}
value={email} type="button"
onChange={(e) => setEmail(e.target.value)} onClick={() => {
className="flex-1" setShareType(tab.value);
required resetForm();
/> }}
<select className={`flex-1 min-w-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
value={role} shareType === tab.value
onChange={(e) => setRole(e.target.value as ShareRole)} ? 'bg-primary text-primary-foreground'
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground" : 'bg-card-hover text-muted hover:text-foreground'
> }`}
<option value="EDITOR">Éditeur</option> >
<option value="VIEWER">Lecteur</option> {tab.icon} {tab.label}
</select> </button>
))}
</div> </div>
{shareType === 'email' && (
<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={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>} {error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full"> <Button
{isPending ? 'Partage...' : 'Partager'} 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> </Button>
</form> </form>
)} )}
{/* Current shares */}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p> <p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? ( {shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p> <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>} {share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}> <Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'} {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" className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès" title="Retirer l'accès"
> >
<svg <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path <path
fillRule="evenodd" 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" 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> </div>
{/* Help text */} {helpText && <div className="rounded-lg bg-primary/5 p-3">{helpText}</div>}
<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>
</div> </div>
</Modal> </Modal>
); );

View File

@@ -3,7 +3,9 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive'; import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator'; 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 { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client'; import type { ShareRole } from '@prisma/client';
@@ -28,6 +30,7 @@ interface MotivatorLiveWrapperProps {
shares: Share[]; shares: Share[];
isOwner: boolean; isOwner: boolean;
canEdit: boolean; canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode; children: React.ReactNode;
} }
@@ -38,6 +41,7 @@ export function MotivatorLiveWrapper({
shares, shares,
isOwner, isOwner,
canEdit, canEdit,
userTeams = [],
children, children,
}: MotivatorLiveWrapperProps) { }: MotivatorLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -119,13 +123,25 @@ export function MotivatorLiveWrapper({
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div> <div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */} {/* Share Modal */}
<MotivatorShareModal <ShareModal
isOpen={shareModalOpen} isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)} onClose={() => setShareModalOpen(false)}
sessionId={sessionId} title="Partager la session"
sessionSubtitle="Session Moving Motivators"
sessionTitle={sessionTitle} sessionTitle={sessionTitle}
shares={shares} shares={shares}
isOwner={isOwner} 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 { MotivatorSummary } from './MotivatorSummary';
export { InfluenceZone } from './InfluenceZone'; export { InfluenceZone } from './InfluenceZone';
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper'; export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
export { MotivatorShareModal } from './MotivatorShareModal';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,8 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useWeatherLive, type WeatherLiveEvent } from '@/hooks/useWeatherLive'; import { useWeatherLive, type WeatherLiveEvent } from '@/hooks/useWeatherLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator'; 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 { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client'; import type { ShareRole } from '@prisma/client';
@@ -128,14 +129,26 @@ export function WeatherLiveWrapper({
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div> <div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */} {/* Share Modal */}
<WeatherShareModal <ShareModal
isOpen={shareModalOpen} isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)} onClose={() => setShareModalOpen(false)}
sessionId={sessionId} title="Partager la météo"
sessionSubtitle="Météo personnelle"
sessionTitle={sessionTitle} sessionTitle={sessionTitle}
shares={shares} shares={shares}
isOwner={isOwner} isOwner={isOwner}
userTeams={userTeams} 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 { WeatherBoard } from './WeatherBoard';
export { WeatherCard } from './WeatherCard'; export { WeatherCard } from './WeatherCard';
export { WeatherLiveWrapper } from './WeatherLiveWrapper'; export { WeatherLiveWrapper } from './WeatherLiveWrapper';
export { WeatherShareModal } from './WeatherShareModal';
export { WeatherInfoPanel } from './WeatherInfoPanel'; export { WeatherInfoPanel } from './WeatherInfoPanel';

View File

@@ -3,7 +3,9 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useWeeklyCheckInLive, type WeeklyCheckInLiveEvent } from '@/hooks/useWeeklyCheckInLive'; import { useWeeklyCheckInLive, type WeeklyCheckInLiveEvent } from '@/hooks/useWeeklyCheckInLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator'; 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 { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client'; import type { ShareRole } from '@prisma/client';
@@ -28,6 +30,7 @@ interface WeeklyCheckInLiveWrapperProps {
shares: Share[]; shares: Share[];
isOwner: boolean; isOwner: boolean;
canEdit: boolean; canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode; children: React.ReactNode;
} }
@@ -38,6 +41,7 @@ export function WeeklyCheckInLiveWrapper({
shares, shares,
isOwner, isOwner,
canEdit, canEdit,
userTeams = [],
children, children,
}: WeeklyCheckInLiveWrapperProps) { }: WeeklyCheckInLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -119,13 +123,25 @@ export function WeeklyCheckInLiveWrapper({
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div> <div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */} {/* Share Modal */}
<WeeklyCheckInShareModal <ShareModal
isOpen={shareModalOpen} isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)} onClose={() => setShareModalOpen(false)}
sessionId={sessionId} title="Options de partage"
sessionSubtitle="Check-in hebdomadaire"
sessionTitle={sessionTitle} sessionTitle={sessionTitle}
shares={shares} shares={shares}
isOwner={isOwner} 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 { WeeklyCheckInCard } from './WeeklyCheckInCard';
export { WeeklyCheckInSection } from './WeeklyCheckInSection'; export { WeeklyCheckInSection } from './WeeklyCheckInSection';
export { WeeklyCheckInLiveWrapper } from './WeeklyCheckInLiveWrapper'; export { WeeklyCheckInLiveWrapper } from './WeeklyCheckInLiveWrapper';
export { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
export { CurrentQuarterOKRs } from './CurrentQuarterOKRs'; export { CurrentQuarterOKRs } from './CurrentQuarterOKRs';

View File

@@ -3,7 +3,9 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useYearReviewLive, type YearReviewLiveEvent } from '@/hooks/useYearReviewLive'; import { useYearReviewLive, type YearReviewLiveEvent } from '@/hooks/useYearReviewLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator'; 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 { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client'; import type { ShareRole } from '@prisma/client';
@@ -28,6 +30,7 @@ interface YearReviewLiveWrapperProps {
shares: Share[]; shares: Share[];
isOwner: boolean; isOwner: boolean;
canEdit: boolean; canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode; children: React.ReactNode;
} }
@@ -38,6 +41,7 @@ export function YearReviewLiveWrapper({
shares, shares,
isOwner, isOwner,
canEdit, canEdit,
userTeams = [],
children, children,
}: YearReviewLiveWrapperProps) { }: YearReviewLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -119,13 +123,25 @@ export function YearReviewLiveWrapper({
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div> <div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */} {/* Share Modal */}
<YearReviewShareModal <ShareModal
isOpen={shareModalOpen} isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)} onClose={() => setShareModalOpen(false)}
sessionId={sessionId} title="Partager le bilan"
sessionSubtitle="Bilan annuel"
sessionTitle={sessionTitle} sessionTitle={sessionTitle}
shares={shares} shares={shares}
isOwner={isOwner} 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 { YearReviewCard } from './YearReviewCard';
export { YearReviewSection } from './YearReviewSection'; export { YearReviewSection } from './YearReviewSection';
export { YearReviewLiveWrapper } from './YearReviewLiveWrapper'; 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(); const year = date.getFullYear();
return `S${week.toString().padStart(2, '0')}-${year}`; 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'; 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 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[] = [ export const VALID_TAB_PARAMS: WorkshopTabType[] = [
'all', 'all',
...WORKSHOP_TYPE_IDS, ...WORKSHOP_TYPE_IDS,
'byPerson', 'byPerson',
'team',
]; ];
export interface WorkshopConfig { export interface WorkshopConfig {

View File

@@ -1,139 +1,93 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth'; 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 // Moving Motivators Session CRUD
// ============================================ // ============================================
export async function getMotivatorSessionsByUserId(userId: string) { export async function getMotivatorSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions return mergeSessionsByUserId(
const [owned, shared] = await Promise.all([ (uid) =>
prisma.movingMotivatorsSession.findMany({ prisma.movingMotivatorsSession.findMany({
where: { userId }, where: { userId: uid },
include: { include: motivatorInclude,
user: { select: { id: true, name: true, email: true } }, orderBy: { updatedAt: 'desc' },
shares: { }),
include: { (uid) =>
user: { select: { id: true, name: true, email: true } }, prisma.mMSessionShare.findMany({
}, where: { userId: uid },
}, include: { session: { include: motivatorInclude } },
_count: { }),
select: { userId,
cards: true, (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.mMSessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
cards: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
// 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) { export async function getMotivatorSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared return getSessionByIdGeneric(
const session = await prisma.movingMotivatorsSession.findFirst({ sessionId,
where: { userId,
id: sessionId, (sid, uid) =>
OR: [ prisma.movingMotivatorsSession.findFirst({
{ userId }, // Owner where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
{ shares: { some: { userId } } }, // Shared with user include: motivatorByIdInclude,
], }),
}, (sid) =>
include: { prisma.movingMotivatorsSession.findUnique({ where: { id: sid }, include: motivatorByIdInclude }),
user: { select: { id: true, name: true, email: true } }, (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
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 };
} }
// Check if user can access session (owner or shared) const motivatorPermissions = createSessionPermissionChecks(prisma.movingMotivatorsSession);
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;
}
// Check if user can edit session (owner or EDITOR role) const motivatorShareEvents = createShareAndEventHandlers<
export async function canEditMotivatorSession(sessionId: string, userId: string) { 'CARD_MOVED' | 'CARD_INFLUENCE_CHANGED' | 'CARDS_REORDERED' | 'SESSION_UPDATED'
const count = await prisma.movingMotivatorsSession.count({ >(
where: { prisma.movingMotivatorsSession,
id: sessionId, prisma.mMSessionShare,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], prisma.mMSessionEvent,
}, motivatorPermissions.canAccess
}); );
return count > 0;
} export const canAccessMotivatorSession = motivatorPermissions.canAccess;
export const canEditMotivatorSession = motivatorPermissions.canEdit;
export const canDeleteMotivatorSession = motivatorPermissions.canDelete;
const DEFAULT_MOTIVATOR_TYPES: MotivatorType[] = [ const DEFAULT_MOTIVATOR_TYPES: MotivatorType[] = [
'STATUS', 'STATUS',
@@ -178,15 +132,21 @@ export async function updateMotivatorSession(
userId: string, userId: string,
data: { title?: string; participant?: string } data: { title?: string; participant?: string }
) { ) {
if (!(await canEditMotivatorSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.movingMotivatorsSession.updateMany({ return prisma.movingMotivatorsSession.updateMany({
where: { id: sessionId, userId }, where: { id: sessionId },
data, data,
}); });
} }
export async function deleteMotivatorSession(sessionId: string, userId: string) { export async function deleteMotivatorSession(sessionId: string, userId: string) {
if (!(await canDeleteMotivatorSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.movingMotivatorsSession.deleteMany({ 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 // Session Sharing
// ============================================ // ============================================
export async function shareMotivatorSession( export const shareMotivatorSession = motivatorShareEvents.share;
sessionId: string, export const removeMotivatorShare = motivatorShareEvents.removeShare;
ownerId: string, export const getMotivatorSessionShares = motivatorShareEvents.getShares;
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 } },
},
});
}
// ============================================ // ============================================
// Session Events (for real-time sync) // Session Events (for real-time sync)
@@ -314,40 +202,6 @@ export type MMSessionEventType =
| 'CARDS_REORDERED' | 'CARDS_REORDERED'
| 'SESSION_UPDATED'; | 'SESSION_UPDATED';
export async function createMotivatorSessionEvent( export const createMotivatorSessionEvent = motivatorShareEvents.createEvent;
sessionId: string, export const getMotivatorSessionEvents = motivatorShareEvents.getEvents;
userId: string, export const getLatestMotivatorEventTimestamp = motivatorShareEvents.getLatestEventTimestamp;
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;
}

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 { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth'; 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'; 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 // Session CRUD
// ============================================ // ============================================
export async function getSessionsByUserId(userId: string) { export async function getSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions return mergeSessionsByUserId(
const [owned, shared] = await Promise.all([ (uid) =>
prisma.session.findMany({ prisma.session.findMany({
where: { userId }, where: { userId: uid },
include: { include: sessionInclude,
user: { select: { id: true, name: true, email: true } }, orderBy: { updatedAt: 'desc' },
shares: { }),
include: { (uid) =>
user: { select: { id: true, name: true, email: true } }, prisma.sessionShare.findMany({
}, where: { userId: uid },
}, include: { session: { include: sessionInclude } },
_count: { }),
select: { userId,
items: true, (s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r }))
actions: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.sessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
actions: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
// 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) { export async function getSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared return getSessionByIdGeneric(
const session = await prisma.session.findFirst({ sessionId,
where: { userId,
id: sessionId, (sid, uid) =>
OR: [ prisma.session.findFirst({
{ userId }, // Owner where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
{ shares: { some: { userId } } }, // Shared with user include: sessionByIdInclude,
], }),
}, (sid) => prisma.session.findUnique({ where: { id: sid }, include: sessionByIdInclude }),
include: { (s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r }))
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 };
} }
// Check if user can access session (owner or shared) const sessionPermissions = createSessionPermissionChecks(prisma.session);
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;
}
// Check if user can edit session (owner or EDITOR role) const sessionShareEvents = createShareAndEventHandlers<
export async function canEditSession(sessionId: string, userId: string) { | 'ITEM_CREATED'
const count = await prisma.session.count({ | 'ITEM_UPDATED'
where: { | 'ITEM_DELETED'
id: sessionId, | 'ITEM_MOVED'
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], | 'ACTION_CREATED'
}, | 'ACTION_UPDATED'
}); | 'ACTION_DELETED'
return count > 0; | '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 }) { export async function createSession(userId: string, data: { title: string; collaborator: string }) {
return prisma.session.create({ return prisma.session.create({
@@ -161,15 +113,21 @@ export async function updateSession(
userId: string, userId: string,
data: { title?: string; collaborator?: string } data: { title?: string; collaborator?: string }
) { ) {
if (!(await canEditSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.session.updateMany({ return prisma.session.updateMany({
where: { id: sessionId, userId }, where: { id: sessionId },
data, data,
}); });
} }
export async function deleteSession(sessionId: string, userId: string) { export async function deleteSession(sessionId: string, userId: string) {
if (!(await canDeleteSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.session.deleteMany({ 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 // Session Sharing
// ============================================ // ============================================
export async function shareSession( export const shareSession = sessionShareEvents.share;
sessionId: string, export const removeShare = sessionShareEvents.removeShare;
ownerId: string, export const getSessionShares = sessionShareEvents.getShares;
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 } },
},
});
}
// ============================================ // ============================================
// Session Events (for real-time sync) // Session Events (for real-time sync)
@@ -455,40 +345,6 @@ export type SessionEventType =
| 'ACTION_DELETED' | 'ACTION_DELETED'
| 'SESSION_UPDATED'; | 'SESSION_UPDATED';
export async function createSessionEvent( export const createSessionEvent = sessionShareEvents.createEvent;
sessionId: string, export const getSessionEvents = sessionShareEvents.getEvents;
userId: string, export const getLatestEventTimestamp = sessionShareEvents.getLatestEventTimestamp;
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;
}

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) { export async function getTeamMemberById(teamMemberId: string) {
return prisma.teamMember.findUnique({ return prisma.teamMember.findUnique({
where: { id: teamMemberId }, where: { id: teamMemberId },

View File

@@ -1,130 +1,93 @@
import { prisma } from '@/services/database'; 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'; 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 // Weather Session CRUD
// ============================================ // ============================================
export async function getWeatherSessionsByUserId(userId: string) { export async function getWeatherSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions return mergeSessionsByUserId(
const [owned, shared] = await Promise.all([ (uid) =>
prisma.weatherSession.findMany({ prisma.weatherSession.findMany({
where: { userId }, where: { userId: uid },
include: { include: weatherInclude,
user: { select: { id: true, name: true, email: true } }, orderBy: { updatedAt: 'desc' },
shares: { }),
include: { (uid) =>
user: { select: { id: true, name: true, email: true } }, prisma.weatherSessionShare.findMany({
}, where: { userId: uid },
}, include: { session: { include: weatherInclude } },
_count: { }),
select: { userId
entries: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.weatherSessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
entries: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
return allSessions;
} }
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
return fetchTeamCollaboratorSessions(
(teamMemberIds, uid) =>
prisma.weatherSession.findMany({
where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } },
include: weatherInclude,
orderBy: { updatedAt: 'desc' },
}),
getTeamMemberIdsForAdminTeams,
userId
);
}
const weatherByIdInclude = {
user: { select: { id: true, name: true, email: true } },
entries: {
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: { createdAt: 'asc' } as const,
},
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
};
export async function getWeatherSessionById(sessionId: string, userId: string) { export async function getWeatherSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared return getSessionByIdGeneric(
const session = await prisma.weatherSession.findFirst({ sessionId,
where: { userId,
id: sessionId, (sid, uid) =>
OR: [ prisma.weatherSession.findFirst({
{ userId }, // Owner where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
{ shares: { some: { userId } } }, // Shared with user include: weatherByIdInclude,
], }),
}, (sid) =>
include: { prisma.weatherSession.findUnique({ where: { id: sid }, include: weatherByIdInclude })
user: { select: { id: true, name: true, email: true } }, );
entries: {
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
},
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
if (!session) 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 };
} }
// Check if user can access session (owner or shared) const weatherPermissions = createSessionPermissionChecks(prisma.weatherSession);
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;
}
// Check if user can edit session (owner or EDITOR role) const weatherShareEvents = createShareAndEventHandlers<
export async function canEditWeatherSession(sessionId: string, userId: string) { 'ENTRY_CREATED' | 'ENTRY_UPDATED' | 'ENTRY_DELETED' | 'SESSION_UPDATED'
const count = await prisma.weatherSession.count({ >(
where: { prisma.weatherSession,
id: sessionId, prisma.weatherSessionShare,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], prisma.weatherSessionEvent,
}, weatherPermissions.canAccess
}); );
return count > 0;
} 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 }) { export async function createWeatherSession(userId: string, data: { title: string; date?: Date }) {
return prisma.weatherSession.create({ return prisma.weatherSession.create({
@@ -148,15 +111,21 @@ export async function updateWeatherSession(
userId: string, userId: string,
data: { title?: string; date?: Date } data: { title?: string; date?: Date }
) { ) {
if (!(await canEditWeatherSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weatherSession.updateMany({ return prisma.weatherSession.updateMany({
where: { id: sessionId, userId }, where: { id: sessionId },
data, data,
}); });
} }
export async function deleteWeatherSession(sessionId: string, userId: string) { export async function deleteWeatherSession(sessionId: string, userId: string) {
if (!(await canDeleteWeatherSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weatherSession.deleteMany({ 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 // Session Sharing
// ============================================ // ============================================
export async function shareWeatherSession( export const shareWeatherSession = weatherShareEvents.share;
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 async function shareWeatherSessionToTeam( export async function shareWeatherSessionToTeam(
sessionId: string, sessionId: string,
@@ -270,21 +197,43 @@ export async function shareWeatherSessionToTeam(
throw new Error('Session not found or not owned'); 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({ 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 }, where: { teamId },
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
}, },
}); });
if (teamMembers.length === 0) { if (teamMembersFull.length === 0) {
throw new Error('Team has no members'); throw new Error('Team has no members');
} }
// Share with all team members (except owner) // Share with all team members (except owner)
const shares = await Promise.all( const shares = await Promise.all(
teamMembers teamMembersFull
.filter((tm) => tm.userId !== ownerId) // Don't share with yourself .filter((tm) => tm.userId !== ownerId) // Don't share with yourself
.map((tm) => .map((tm) =>
prisma.weatherSessionShare.upsert({ prisma.weatherSessionShare.upsert({
@@ -307,37 +256,8 @@ export async function shareWeatherSessionToTeam(
return shares; return shares;
} }
export async function removeWeatherShare( export const removeWeatherShare = weatherShareEvents.removeShare;
sessionId: string, export const getWeatherSessionShares = weatherShareEvents.getShares;
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 } },
},
});
}
// ============================================ // ============================================
// Session Events (for real-time sync) // Session Events (for real-time sync)
@@ -349,40 +269,6 @@ export type WeatherSessionEventType =
| 'ENTRY_DELETED' | 'ENTRY_DELETED'
| 'SESSION_UPDATED'; | 'SESSION_UPDATED';
export async function createWeatherSessionEvent( export const createWeatherSessionEvent = weatherShareEvents.createEvent;
sessionId: string, export const getWeatherSessionEvents = weatherShareEvents.getEvents;
userId: string, export const getLatestWeatherEventTimestamp = weatherShareEvents.getLatestEventTimestamp;
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;
}

View File

@@ -1,139 +1,101 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth'; 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 // Weekly Check-in Session CRUD
// ============================================ // ============================================
export async function getWeeklyCheckInSessionsByUserId(userId: string) { export async function getWeeklyCheckInSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions return mergeSessionsByUserId(
const [owned, shared] = await Promise.all([ (uid) =>
prisma.weeklyCheckInSession.findMany({ prisma.weeklyCheckInSession.findMany({
where: { userId }, where: { userId: uid },
include: { include: weeklyCheckInInclude,
user: { select: { id: true, name: true, email: true } }, orderBy: { updatedAt: 'desc' },
shares: { }),
include: { (uid) =>
user: { select: { id: true, name: true, email: true } }, prisma.wCISessionShare.findMany({
}, where: { userId: uid },
}, include: { session: { include: weeklyCheckInInclude } },
_count: { }),
select: { userId,
items: true, (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.wCISessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
// 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) { export async function getWeeklyCheckInSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared return getSessionByIdGeneric(
const session = await prisma.weeklyCheckInSession.findFirst({ sessionId,
where: { userId,
id: sessionId, (sid, uid) =>
OR: [ prisma.weeklyCheckInSession.findFirst({
{ userId }, // Owner where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
{ shares: { some: { userId } } }, // Shared with user include: weeklyCheckInByIdInclude,
], }),
}, (sid) =>
include: { prisma.weeklyCheckInSession.findUnique({
user: { select: { id: true, name: true, email: true } }, where: { id: sid },
items: { include: weeklyCheckInByIdInclude,
orderBy: [{ category: 'asc' }, { order: 'asc' }], }),
}, (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
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 };
} }
// Check if user can access session (owner or shared) const weeklyCheckInPermissions = createSessionPermissionChecks(prisma.weeklyCheckInSession);
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;
}
// Check if user can edit session (owner or EDITOR role) const weeklyCheckInShareEvents = createShareAndEventHandlers<
export async function canEditWeeklyCheckInSession(sessionId: string, userId: string) { | 'ITEM_CREATED'
const count = await prisma.weeklyCheckInSession.count({ | 'ITEM_UPDATED'
where: { | 'ITEM_DELETED'
id: sessionId, | 'ITEM_MOVED'
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], | 'ITEMS_REORDERED'
}, | 'SESSION_UPDATED'
}); >(
return count > 0; 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( export async function createWeeklyCheckInSession(
userId: string, userId: string,
@@ -158,15 +120,21 @@ export async function updateWeeklyCheckInSession(
userId: string, userId: string,
data: { title?: string; participant?: string; date?: Date } data: { title?: string; participant?: string; date?: Date }
) { ) {
if (!(await canEditWeeklyCheckInSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weeklyCheckInSession.updateMany({ return prisma.weeklyCheckInSession.updateMany({
where: { id: sessionId, userId }, where: { id: sessionId },
data, data,
}); });
} }
export async function deleteWeeklyCheckInSession(sessionId: string, userId: string) { export async function deleteWeeklyCheckInSession(sessionId: string, userId: string) {
if (!(await canDeleteWeeklyCheckInSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weeklyCheckInSession.deleteMany({ return prisma.weeklyCheckInSession.deleteMany({
where: { id: sessionId, userId }, where: { id: sessionId },
}); });
} }
@@ -244,81 +212,9 @@ export async function reorderWeeklyCheckInItems(
// Session Sharing // Session Sharing
// ============================================ // ============================================
export async function shareWeeklyCheckInSession( export const shareWeeklyCheckInSession = weeklyCheckInShareEvents.share;
sessionId: string, export const removeWeeklyCheckInShare = weeklyCheckInShareEvents.removeShare;
ownerId: string, export const getWeeklyCheckInSessionShares = weeklyCheckInShareEvents.getShares;
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 } },
},
});
}
// ============================================ // ============================================
// Session Events (for real-time sync) // Session Events (for real-time sync)
@@ -332,40 +228,7 @@ export type WCISessionEventType =
| 'ITEMS_REORDERED' | 'ITEMS_REORDERED'
| 'SESSION_UPDATED'; | 'SESSION_UPDATED';
export async function createWeeklyCheckInSessionEvent( export const createWeeklyCheckInSessionEvent = weeklyCheckInShareEvents.createEvent;
sessionId: string, export const getWeeklyCheckInSessionEvents = weeklyCheckInShareEvents.getEvents;
userId: string, export const getLatestWeeklyCheckInEventTimestamp =
type: WCISessionEventType, weeklyCheckInShareEvents.getLatestEventTimestamp;
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;
}

View File

@@ -1,139 +1,98 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth'; 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 // Year Review Session CRUD
// ============================================ // ============================================
export async function getYearReviewSessionsByUserId(userId: string) { export async function getYearReviewSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions return mergeSessionsByUserId(
const [owned, shared] = await Promise.all([ (uid) =>
prisma.yearReviewSession.findMany({ prisma.yearReviewSession.findMany({
where: { userId }, where: { userId: uid },
include: { include: yearReviewInclude,
user: { select: { id: true, name: true, email: true } }, orderBy: { updatedAt: 'desc' },
shares: { }),
include: { (uid) =>
user: { select: { id: true, name: true, email: true } }, prisma.yRSessionShare.findMany({
}, where: { userId: uid },
}, include: { session: { include: yearReviewInclude } },
_count: { }),
select: { userId,
items: true, (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.yRSessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
// 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) { export async function getYearReviewSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared return getSessionByIdGeneric(
const session = await prisma.yearReviewSession.findFirst({ sessionId,
where: { userId,
id: sessionId, (sid, uid) =>
OR: [ prisma.yearReviewSession.findFirst({
{ userId }, // Owner where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
{ shares: { some: { userId } } }, // Shared with user include: yearReviewByIdInclude,
], }),
}, (sid) =>
include: { prisma.yearReviewSession.findUnique({ where: { id: sid }, include: yearReviewByIdInclude }),
user: { select: { id: true, name: true, email: true } }, (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
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 };
} }
// Check if user can access session (owner or shared) const yearReviewPermissions = createSessionPermissionChecks(prisma.yearReviewSession);
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;
}
// Check if user can edit session (owner or EDITOR role) const yearReviewShareEvents = createShareAndEventHandlers<
export async function canEditYearReviewSession(sessionId: string, userId: string) { | 'ITEM_CREATED'
const count = await prisma.yearReviewSession.count({ | 'ITEM_UPDATED'
where: { | 'ITEM_DELETED'
id: sessionId, | 'ITEM_MOVED'
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], | 'ITEMS_REORDERED'
}, | 'SESSION_UPDATED'
}); >(
return count > 0; 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( export async function createYearReviewSession(
userId: string, userId: string,
@@ -157,15 +116,21 @@ export async function updateYearReviewSession(
userId: string, userId: string,
data: { title?: string; participant?: string; year?: number } data: { title?: string; participant?: string; year?: number }
) { ) {
if (!(await canEditYearReviewSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.yearReviewSession.updateMany({ return prisma.yearReviewSession.updateMany({
where: { id: sessionId, userId }, where: { id: sessionId },
data, data,
}); });
} }
export async function deleteYearReviewSession(sessionId: string, userId: string) { export async function deleteYearReviewSession(sessionId: string, userId: string) {
if (!(await canDeleteYearReviewSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.yearReviewSession.deleteMany({ return prisma.yearReviewSession.deleteMany({
where: { id: sessionId, userId }, where: { id: sessionId },
}); });
} }
@@ -242,81 +207,9 @@ export async function reorderYearReviewItems(
// Session Sharing // Session Sharing
// ============================================ // ============================================
export async function shareYearReviewSession( export const shareYearReviewSession = yearReviewShareEvents.share;
sessionId: string, export const removeYearReviewShare = yearReviewShareEvents.removeShare;
ownerId: string, export const getYearReviewSessionShares = yearReviewShareEvents.getShares;
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 } },
},
});
}
// ============================================ // ============================================
// Session Events (for real-time sync) // Session Events (for real-time sync)
@@ -330,41 +223,7 @@ export type YRSessionEventType =
| 'ITEMS_REORDERED' | 'ITEMS_REORDERED'
| 'SESSION_UPDATED'; | 'SESSION_UPDATED';
export async function createYearReviewSessionEvent( export const createYearReviewSessionEvent = yearReviewShareEvents.createEvent;
sessionId: string, export const getYearReviewSessionEvents = yearReviewShareEvents.getEvents;
userId: string, export const getLatestYearReviewEventTimestamp = yearReviewShareEvents.getLatestEventTimestamp;
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;
}