Compare commits
6 Commits
cc7e73ce7b
...
d05157d498
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d05157d498 | ||
|
|
4d04d3ede8 | ||
|
|
aad4b7f111 | ||
|
|
5e9ae0936f | ||
|
|
4e14112ffa | ||
|
|
7f3eabbdb2 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
SWOT Manager - CSS Variables Theme System
|
Workshop Manager - CSS Variables Theme System
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +245,8 @@ export function WorkshopTabs({
|
|||||||
const filteredSessions =
|
const filteredSessions =
|
||||||
activeTab === 'all' || activeTab === 'byPerson'
|
activeTab === 'all' || activeTab === 'byPerson'
|
||||||
? allSessions
|
? allSessions
|
||||||
|
: activeTab === 'team'
|
||||||
|
? teamCollabSessions
|
||||||
: activeTab === 'swot'
|
: activeTab === 'swot'
|
||||||
? swotSessions
|
? swotSessions
|
||||||
: activeTab === 'motivators'
|
: activeTab === 'motivators'
|
||||||
@@ -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'é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'équipe – non partagés ({teamCollabSessions.length})
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted mb-4">
|
||||||
|
En tant qu'admin d'é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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,14 +51,29 @@ 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,
|
||||||
|
motivatorSessions,
|
||||||
|
yearReviewSessions,
|
||||||
|
weeklyCheckInSessions,
|
||||||
|
weatherSessions,
|
||||||
|
teamSwotSessions,
|
||||||
|
teamMotivatorSessions,
|
||||||
|
teamYearReviewSessions,
|
||||||
|
teamWeeklyCheckInSessions,
|
||||||
|
teamWeatherSessions,
|
||||||
|
] = await Promise.all([
|
||||||
getSessionsByUserId(session.user.id),
|
getSessionsByUserId(session.user.id),
|
||||||
getMotivatorSessionsByUserId(session.user.id),
|
getMotivatorSessionsByUserId(session.user.id),
|
||||||
getYearReviewSessionsByUserId(session.user.id),
|
getYearReviewSessionsByUserId(session.user.id),
|
||||||
getWeeklyCheckInSessionsByUserId(session.user.id),
|
getWeeklyCheckInSessionsByUserId(session.user.id),
|
||||||
getWeatherSessionsByUserId(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
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,22 +101,47 @@ 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 border-b border-border pb-3 flex-wrap">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShareType(tab.value);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
className={`flex-1 min-w-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
shareType === tab.value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-card-hover text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon} {tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shareType === 'email' && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -85,28 +151,122 @@ export function ShareModal({
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<select
|
<select value={role} onChange={(e) => setRole(e.target.value as ShareRole)} className={SELECT_STYLE}>
|
||||||
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="EDITOR">Éditeur</option>
|
||||||
<option value="VIEWER">Lecteur</option>
|
<option value="VIEWER">Lecteur</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shareType === 'teamMember' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{teamMembers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Vous n'êtes membre d'aucune équipe ou vos équipes n'ont pas d'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'êtes membre d'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'êtes membre d'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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
37
src/lib/share-utils.ts
Normal 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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
cards: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
|
(uid) =>
|
||||||
prisma.mMSessionShare.findMany({
|
prisma.mMSessionShare.findMany({
|
||||||
where: { userId },
|
where: { userId: uid },
|
||||||
include: {
|
include: { session: { include: motivatorInclude } },
|
||||||
session: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
cards: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
userId,
|
||||||
|
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|||||||
68
src/services/session-permissions.ts
Normal file
68
src/services/session-permissions.ts
Normal 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));
|
||||||
|
}
|
||||||
122
src/services/session-queries.ts
Normal file
122
src/services/session-queries.ts
Normal 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;
|
||||||
|
}
|
||||||
172
src/services/session-share-events.ts
Normal file
172
src/services/session-share-events.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
items: true,
|
|
||||||
actions: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
|
(uid) =>
|
||||||
prisma.sessionShare.findMany({
|
prisma.sessionShare.findMany({
|
||||||
where: { userId },
|
where: { userId: uid },
|
||||||
include: {
|
include: { session: { include: sessionInclude } },
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
userId,
|
||||||
|
(s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r }))
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
entries: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
|
(uid) =>
|
||||||
prisma.weatherSessionShare.findMany({
|
prisma.weatherSessionShare.findMany({
|
||||||
where: { userId },
|
where: { userId: uid },
|
||||||
include: {
|
include: { session: { include: weatherInclude } },
|
||||||
session: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
entries: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
userId
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWeatherSessionById(sessionId: string, userId: string) {
|
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
|
||||||
// Check if user owns the session OR has it shared
|
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
|
||||||
const session = await prisma.weatherSession.findFirst({
|
return fetchTeamCollaboratorSessions(
|
||||||
where: {
|
(teamMemberIds, uid) =>
|
||||||
id: sessionId,
|
prisma.weatherSession.findMany({
|
||||||
OR: [
|
where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } },
|
||||||
{ userId }, // Owner
|
include: weatherInclude,
|
||||||
{ shares: { some: { userId } } }, // Shared with user
|
orderBy: { updatedAt: 'desc' },
|
||||||
],
|
}),
|
||||||
},
|
getTeamMemberIdsForAdminTeams,
|
||||||
include: {
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weatherByIdInclude = {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
entries: {
|
entries: {
|
||||||
include: {
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
user: { select: { id: true, name: true, email: true } },
|
orderBy: { createdAt: 'asc' } as const,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' },
|
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||||
},
|
};
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) return null;
|
export async function getWeatherSessionById(sessionId: string, userId: string) {
|
||||||
|
return getSessionByIdGeneric(
|
||||||
// Determine user's role
|
sessionId,
|
||||||
const isOwner = session.userId === userId;
|
userId,
|
||||||
const share = session.shares.find((s) => s.userId === userId);
|
(sid, uid) =>
|
||||||
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
|
prisma.weatherSession.findFirst({
|
||||||
const canEdit = isOwner || role === 'EDITOR';
|
where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
|
||||||
|
include: weatherByIdInclude,
|
||||||
return { ...session, isOwner, role, canEdit };
|
}),
|
||||||
|
(sid) =>
|
||||||
|
prisma.weatherSession.findUnique({ where: { id: sid }, include: weatherByIdInclude })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
items: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
|
(uid) =>
|
||||||
prisma.wCISessionShare.findMany({
|
prisma.wCISessionShare.findMany({
|
||||||
where: { userId },
|
where: { userId: uid },
|
||||||
include: {
|
include: { session: { include: weeklyCheckInInclude } },
|
||||||
session: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
items: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
userId,
|
||||||
|
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
items: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
|
(uid) =>
|
||||||
prisma.yRSessionShare.findMany({
|
prisma.yRSessionShare.findMany({
|
||||||
where: { userId },
|
where: { userId: uid },
|
||||||
include: {
|
include: { session: { include: yearReviewInclude } },
|
||||||
session: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
shares: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
items: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
userId,
|
||||||
|
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user