From a2b07b3e19b6e50b3bfb2ddceebb3128929606fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Wed, 25 Feb 2026 16:24:45 +0300 Subject: [PATCH] feat: add approvals workflow and unified member invitation management across projects, workspaces, and videos --- app/(auth)/login/page.tsx | 17 +- app/(auth)/register/page.tsx | 69 ++- .../dashboard/dashboard-client.tsx | 4 +- app/(dashboard)/dashboard/page.tsx | 11 + app/(dashboard)/dashboard/project-filter.tsx | 31 +- .../projects/[projectId]/members/page.tsx | 341 +------------- .../[projectId]/project-content-client.tsx | 14 +- app/(dashboard)/settings/page.tsx | 10 + .../workspaces/[workspaceId]/members/page.tsx | 334 +------------ .../[workspaceId]/settings/page.tsx | 75 ++- app/api/approvals/[requestId]/cancel/route.ts | 93 ++++ .../approvals/[requestId]/decision/route.ts | 225 +++++++++ app/api/auth/register/route.ts | 78 +++- .../[projectId]/approval-candidates/route.ts | 34 ++ .../invitations/[invitationId]/route.ts | 70 +++ app/api/projects/[projectId]/members/route.ts | 114 +++-- .../[projectId]/videos/[videoId]/route.ts | 1 + app/api/settings/notifications/route.ts | 4 + .../versions/[versionId]/approvals/route.ts | 183 ++++++++ .../invitations/[invitationId]/route.ts | 70 +++ .../workspaces/[workspaceId]/members/route.ts | 91 ++-- app/api/workspaces/[workspaceId]/route.ts | 17 + app/invitations/accept/page.tsx | 47 ++ components/members-management-page.tsx | 442 ++++++++++++++++++ components/video-page-content.tsx | 84 ++++ .../video-page/approval-request-dialog.tsx | 164 +++++++ .../video-page/approval-requests-panel.tsx | 201 ++++++++ components/video-page/hooks/use-approvals.ts | 172 +++++++ components/video-page/types.ts | 41 ++ components/video-page/video-page-header.tsx | 95 ++-- lib/admin-stats.ts | 13 +- lib/approval-workflow.ts | 56 +++ lib/email-brand.ts | 100 ++++ lib/invitations.ts | 366 +++++++++++++++ lib/notifications.ts | 333 ++++++++----- prisma/schema.prisma | 104 +++++ 36 files changed, 3122 insertions(+), 982 deletions(-) create mode 100644 app/api/approvals/[requestId]/cancel/route.ts create mode 100644 app/api/approvals/[requestId]/decision/route.ts create mode 100644 app/api/projects/[projectId]/approval-candidates/route.ts create mode 100644 app/api/projects/[projectId]/members/invitations/[invitationId]/route.ts create mode 100644 app/api/versions/[versionId]/approvals/route.ts create mode 100644 app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route.ts create mode 100644 app/invitations/accept/page.tsx create mode 100644 components/members-management-page.tsx create mode 100644 components/video-page/approval-request-dialog.tsx create mode 100644 components/video-page/approval-requests-panel.tsx create mode 100644 components/video-page/hooks/use-approvals.ts create mode 100644 lib/approval-workflow.ts create mode 100644 lib/email-brand.ts create mode 100644 lib/invitations.ts diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index dbee0bc..9235251 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -10,6 +10,18 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { signIn } from 'next-auth/react'; +function getSafeCallbackUrl(value: string | null): string { + if (!value) return '/dashboard'; + try { + const baseOrigin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin; + const parsed = new URL(value, baseOrigin); + if (parsed.origin !== baseOrigin) return '/dashboard'; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return '/dashboard'; + } +} + function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); @@ -18,6 +30,7 @@ function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [showSuccess, setShowSuccess] = useState(false); + const callbackUrl = getSafeCallbackUrl(searchParams.get('callbackUrl')); useEffect(() => { if (searchParams.get('registered') === 'true') { @@ -35,6 +48,7 @@ function LoginForm() { email, password, redirect: false, + callbackUrl, }); if (result?.error) { @@ -42,7 +56,8 @@ function LoginForm() { return; } - router.push('/dashboard'); + const destination = getSafeCallbackUrl(result?.url || callbackUrl); + router.push(destination); router.refresh(); } catch { setError('Something went wrong. Please try again.'); diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 981e57d..8aad243 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Video, Loader2, KeyRound, UserPlus } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -11,6 +11,10 @@ import { Label } from '@/components/ui/label'; export default function RegisterPage() { const router = useRouter(); + const searchParams = useSearchParams(); + const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]); + const invitedEmail = useMemo(() => searchParams.get('email') || '', [searchParams]); + const isInvitationFlow = invitationToken.length > 0; const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [formData, setFormData] = useState({ @@ -21,6 +25,14 @@ export default function RegisterPage() { inviteCode: '', }); + useEffect(() => { + if (!invitedEmail) return; + setFormData((prev) => ({ + ...prev, + email: invitedEmail, + })); + }, [invitedEmail]); + const handleChange = (e: React.ChangeEvent) => { setFormData(prev => ({ ...prev, @@ -55,7 +67,8 @@ export default function RegisterPage() { name: formData.name, email: formData.email, password: formData.password, - inviteCode: formData.inviteCode, + inviteCode: formData.inviteCode || undefined, + invitationToken: invitationToken || undefined, }), }); @@ -97,28 +110,36 @@ export default function RegisterPage() {
{/* Invite Code - First and prominent */} -
- - -

- An invite code is required to create an account -

-
+ {isInvitationFlow ? ( +
+ You are registering via an invitation link. +
+ ) : ( + <> +
+ + +

+ An invite code is required to create an account +

+
-
+
+ + )}
diff --git a/app/(dashboard)/dashboard/dashboard-client.tsx b/app/(dashboard)/dashboard/dashboard-client.tsx index b53af83..0aeeefe 100644 --- a/app/(dashboard)/dashboard/dashboard-client.tsx +++ b/app/(dashboard)/dashboard/dashboard-client.tsx @@ -18,15 +18,17 @@ interface DashboardClientProps { serializedProjects: SerializedProject[]; workspaces: { id: string; name: string }[]; totalPages: number; + canCreateProjects: boolean; } -export function DashboardClient({ serializedProjects, workspaces, totalPages }: DashboardClientProps) { +export function DashboardClient({ serializedProjects, workspaces, totalPages, canCreateProjects }: DashboardClientProps) { return (
); diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 45239e3..b00b058 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -49,6 +49,16 @@ export default async function DashboardPage({ distinct: ['workspaceId'] }); + const creatableWorkspaces = await db.workspace.count({ + where: { + OR: [ + { ownerId: session.user.id }, + { members: { some: { userId: session.user.id, role: 'ADMIN' } } }, + ], + }, + }); + const canCreateProjects = creatableWorkspaces > 0; + const workspaceMap = new Map(); for (const project of accessibleProjects) { if (project.workspace) { @@ -105,6 +115,7 @@ export default async function DashboardPage({ serializedProjects={serializedProjects} workspaces={workspaces} totalPages={totalPages} + canCreateProjects={canCreateProjects} /> ); } diff --git a/app/(dashboard)/dashboard/project-filter.tsx b/app/(dashboard)/dashboard/project-filter.tsx index f594575..c9c5cb6 100644 --- a/app/(dashboard)/dashboard/project-filter.tsx +++ b/app/(dashboard)/dashboard/project-filter.tsx @@ -31,6 +31,7 @@ interface ProjectFilterProps { projects: SerializedProject[]; workspaces: { id: string; name: string }[]; totalPages: number; + canCreateProjects: boolean; } function formatRelativeTime(dateStr: string): string { @@ -61,7 +62,7 @@ function VisibilityIcon({ visibility }: { visibility: string }) { type SortOrder = 'desc' | 'asc'; -export function ProjectFilter({ projects, workspaces, totalPages }: ProjectFilterProps) { +export function ProjectFilter({ projects, workspaces, totalPages, canCreateProjects }: ProjectFilterProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -138,12 +139,14 @@ export function ProjectFilter({ projects, workspaces, totalPages }: ProjectFilte )} - + {canCreateProjects && ( + + )}
@@ -205,12 +208,14 @@ export function ProjectFilter({ projects, workspaces, totalPages }: ProjectFilte ? 'Create your first project to start collecting video feedback' : 'Create a project in this workspace to get started'}

- + {canCreateProjects && ( + + )} )} diff --git a/app/(dashboard)/projects/[projectId]/members/page.tsx b/app/(dashboard)/projects/[projectId]/members/page.tsx index 1dc69b7..4ec9487 100644 --- a/app/(dashboard)/projects/[projectId]/members/page.tsx +++ b/app/(dashboard)/projects/[projectId]/members/page.tsx @@ -1,335 +1,26 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { - ArrowLeft, - Plus, - Loader2, - Crown, - Shield, - MessageSquare, - Trash2, - UserPlus, -} from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; - -interface Member { - id: string; - role: 'ADMIN' | 'COMMENTATOR'; - userId: string; - user: { - id: string; - name: string | null; - email: string | null; - image: string | null; - }; -} - -interface Owner { - id: string; - name: string | null; - email: string | null; - image: string | null; -} +import { useParams } from 'next/navigation'; +import { MembersManagementPage } from '@/components/members-management-page'; export default function ProjectMembersPage() { const params = useParams(); - const router = useRouter(); const projectId = params.projectId as string; - const [members, setMembers] = useState([]); - const [owner, setOwner] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [inviteEmail, setInviteEmail] = useState(''); - const [inviteRole, setInviteRole] = useState<'ADMIN' | 'COMMENTATOR'>('COMMENTATOR'); - const [isInviting, setIsInviting] = useState(false); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); - - const fetchMembers = useCallback(async () => { - try { - const res = await fetch(`/api/projects/${projectId}/members`); - if (!res.ok) { - if (res.status === 403) router.push('/dashboard'); - return; - } - const data = await res.json(); - setMembers(data.data.members); - setOwner(data.data.owner); - } catch { - setError('Failed to load members'); - } finally { - setIsLoading(false); - } - }, [projectId, router]); - - useEffect(() => { - fetchMembers(); - }, [fetchMembers]); - - const handleInvite = async (e: React.FormEvent) => { - e.preventDefault(); - setIsInviting(true); - setError(''); - setSuccess(''); - - try { - const res = await fetch(`/api/projects/${projectId}/members`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: inviteEmail, role: inviteRole }), - }); - - const data = await res.json(); - - if (!res.ok) { - setError(data.error || 'Failed to invite member'); - return; - } - - if (data.user) { - setSuccess(`Invited ${data.user.name || data.user.email || inviteEmail} as ${inviteRole.toLowerCase()}`); - } else { - setSuccess(data.message || `Invitation sent to ${inviteEmail}`); - } - setInviteEmail(''); - fetchMembers(); - } catch { - setError('Something went wrong'); - } finally { - setIsInviting(false); - } - }; - - const handleRoleChange = async (memberId: string, newRole: string) => { - try { - const res = await fetch(`/api/projects/${projectId}/members/${memberId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: newRole }), - }); - - if (!res.ok) { - const data = await res.json(); - setError(data.error || 'Failed to update role'); - return; - } - - fetchMembers(); - } catch { - setError('Failed to update role'); - } - }; - - const handleRemove = async (memberId: string) => { - if (!confirm('Are you sure you want to remove this member?')) return; - - try { - const res = await fetch(`/api/projects/${projectId}/members/${memberId}`, { - method: 'DELETE', - }); - - if (!res.ok) { - const data = await res.json(); - setError(data.error || 'Failed to remove member'); - return; - } - - fetchMembers(); - } catch { - setError('Failed to remove member'); - } - }; - - if (isLoading) { - return ( -
- -
- ); - } - return ( -
-
- - - Back to Project - -
- -
-

Project Members

-

- Manage who has access to this project. Admins can manage settings and delete content. - Commentators can only view and leave comments. -

-
- - {/* Invite Form */} - - - - - Invite Member - - - Invite someone by email. They must have an account to be added. - - - - -
- - setInviteEmail(e.target.value)} - required - disabled={isInviting} - /> -
-
- - -
- - - - {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} -
-
- - {/* Members List */} - - - Current Members - - Admin — can manage project settings, members, and delete content.{' '} - Commentator — can view and comment only. - - - - {/* Owner */} - {owner && ( -
-
- - - {owner.name?.charAt(0).toUpperCase() ?? 'U'} - -
-

{owner.name || 'Unnamed'}

-

{owner.email}

-
-
- - - Owner - -
- )} - - {/* Members */} - {members.map((member) => ( -
-
- - - {member.user.name?.charAt(0).toUpperCase() ?? 'U'} - -
-

{member.user.name || 'Unnamed'}

-

{member.user.email}

-
-
-
- - -
-
- ))} - - {members.length === 0 && ( -

- No members yet. Invite someone above. -

- )} -
-
-
+ + Admin - can manage project settings, members, and delete content.{' '} + Commentator - can view and comment only. + + } + forbiddenRedirect="/dashboard" + /> ); } diff --git a/app/(dashboard)/projects/[projectId]/project-content-client.tsx b/app/(dashboard)/projects/[projectId]/project-content-client.tsx index 3a50a0a..1edf450 100644 --- a/app/(dashboard)/projects/[projectId]/project-content-client.tsx +++ b/app/(dashboard)/projects/[projectId]/project-content-client.tsx @@ -143,12 +143,14 @@ export function ProjectContentClient({ )} - + {canEdit && ( + + )} {(isOwner || project.members[0]?.role === 'ADMIN') && ( <> - - - {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} - - - - {/* Members List */} - - - Current Members - - Admins can manage projects and members. Commentators can view and comment only. - - - - {/* Owner */} - {owner && ( -
-
- - - {owner.name?.charAt(0).toUpperCase() ?? 'U'} - -
-

{owner.name || 'Unnamed'}

-

{owner.email}

-
-
- - - Owner - -
- )} - - {/* Members */} - {members.map((member) => ( -
-
- - - {member.user.name?.charAt(0).toUpperCase() ?? 'U'} - -
-

{member.user.name || 'Unnamed'}

-

{member.user.email}

-
-
-
- - -
-
- ))} - - {members.length === 0 && ( -

- No members yet. Invite someone above. -

- )} -
-
-
+ ); } diff --git a/app/(dashboard)/workspaces/[workspaceId]/settings/page.tsx b/app/(dashboard)/workspaces/[workspaceId]/settings/page.tsx index 9f71536..50916df 100644 --- a/app/(dashboard)/workspaces/[workspaceId]/settings/page.tsx +++ b/app/(dashboard)/workspaces/[workspaceId]/settings/page.tsx @@ -10,6 +10,17 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Separator } from '@/components/ui/separator'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; interface WorkspaceData { id: string; @@ -31,6 +42,7 @@ export default function WorkspaceSettingsPage() { const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [formData, setFormData] = useState({ name: '', description: '' }); + const [deleteConfirmation, setDeleteConfirmation] = useState(''); const fetchWorkspace = useCallback(async () => { try { @@ -85,8 +97,8 @@ export default function WorkspaceSettingsPage() { }; const handleDelete = async () => { - if (!confirm('Are you sure? This will NOT delete the projects inside, but they will be unlinked from this workspace.')) return; - if (!confirm('This action cannot be undone. Type the workspace name to confirm.')) return; + if (!workspace) return; + if (deleteConfirmation !== workspace.name) return; setIsDeleting(true); try { @@ -203,23 +215,52 @@ export default function WorkspaceSettingsPage() { - + + + + + Delete "{workspace.name}"? + +
+

+ This will permanently delete this workspace and everything inside it + (projects, videos, comments, images, and voice notes). This action cannot be undone. +

+
+ + setDeleteConfirmation(e.target.value)} + placeholder="Workspace name" + className="h-11" + /> +
+
+
+
+ + setDeleteConfirmation('')}> + Cancel + + + {isDeleting && } + Delete Workspace + + +
+
diff --git a/app/api/approvals/[requestId]/cancel/route.ts b/app/api/approvals/[requestId]/cancel/route.ts new file mode 100644 index 0000000..e534de7 --- /dev/null +++ b/app/api/approvals/[requestId]/cancel/route.ts @@ -0,0 +1,93 @@ +import { NextRequest } from 'next/server'; +import { Prisma } from '@prisma/client'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; + +type RouteParams = { params: Promise<{ requestId: string }> }; + +function isSerializableConflict(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034'; +} + +// POST /api/approvals/[requestId]/cancel +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'mutate'); + if (limited) return limited; + + const session = await auth(); + if (!session?.user?.id) return apiErrors.unauthorized(); + + const { requestId } = await params; + const approvalRequest = await db.approvalRequest.findUnique({ + where: { id: requestId }, + include: { + version: { + include: { + video: { + include: { + project: { select: { id: true, ownerId: true, workspaceId: true, visibility: true } }, + }, + }, + }, + }, + }, + }); + if (!approvalRequest) return apiErrors.notFound('Approval request'); + + const access = await checkProjectAccess(approvalRequest.version.video.project, session.user.id, { intent: 'manage' }); + const canCancel = approvalRequest.requestedById === session.user.id || access.canEdit; + if (!canCancel) return apiErrors.forbidden('Access denied'); + + if (approvalRequest.status !== 'PENDING') { + return apiErrors.conflict('Only pending approval requests can be canceled'); + } + + const updated = await db.$transaction(async (tx) => { + const current = await tx.approvalRequest.findUnique({ + where: { id: requestId }, + select: { status: true }, + }); + if (!current) throw new Error('__NOT_FOUND__'); + if (current.status !== 'PENDING') throw new Error('__NOT_PENDING__'); + + return tx.approvalRequest.update({ + where: { id: requestId }, + data: { + status: 'CANCELED', + canceledAt: new Date(), + canceledById: session.user.id, + }, + include: { + requestedBy: { select: { id: true, name: true, email: true, image: true } }, + canceledBy: { select: { id: true, name: true, email: true, image: true } }, + decisions: { + orderBy: { createdAt: 'asc' }, + include: { approver: { select: { id: true, name: true, email: true, image: true } } }, + }, + }, + }); + }, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + + const response = successResponse({ request: updated }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + if (error instanceof Error) { + if (error.message === '__NOT_PENDING__') { + return apiErrors.conflict('Only pending approval requests can be canceled'); + } + if (error.message === '__NOT_FOUND__') { + return apiErrors.notFound('Approval request'); + } + } + if (isSerializableConflict(error)) { + return apiErrors.conflict('Request state changed. Please try again.'); + } + console.error('Error canceling approval request:', error); + return apiErrors.internalError('Failed to cancel approval request'); + } +} diff --git a/app/api/approvals/[requestId]/decision/route.ts b/app/api/approvals/[requestId]/decision/route.ts new file mode 100644 index 0000000..ac40479 --- /dev/null +++ b/app/api/approvals/[requestId]/decision/route.ts @@ -0,0 +1,225 @@ +import { NextRequest } from 'next/server'; +import { Prisma } from '@prisma/client'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { notifyUsers } from '@/lib/notifications'; +import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; + +type RouteParams = { params: Promise<{ requestId: string }> }; + +function isSerializableConflict(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034'; +} + +// POST /api/approvals/[requestId]/decision +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'mutate'); + if (limited) return limited; + + const session = await auth(); + if (!session?.user?.id) return apiErrors.unauthorized(); + + const { requestId } = await params; + const body = await request.json().catch(() => ({})); + const decision = body.decision; + if (decision !== 'APPROVED' && decision !== 'REJECTED') { + return apiErrors.badRequest('Decision must be APPROVED or REJECTED'); + } + + const note = typeof body.note === 'string' ? body.note.trim() : ''; + if (note.length > 2000) { + return apiErrors.badRequest('Note must be 2000 characters or fewer'); + } + + const approvalRequest = await db.approvalRequest.findUnique({ + where: { id: requestId }, + include: { + version: { + include: { + video: { + include: { + project: { select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true } }, + }, + }, + }, + }, + decisions: { + where: { approverId: session.user.id }, + select: { id: true, status: true }, + }, + }, + }); + if (!approvalRequest) return apiErrors.notFound('Approval request'); + + const access = await checkProjectAccess(approvalRequest.version.video.project, session.user.id); + if (!access.hasAccess) return apiErrors.forbidden('Access denied'); + + const myDecision = approvalRequest.decisions[0]; + if (!myDecision) return apiErrors.forbidden('You are not an approver on this request'); + if (approvalRequest.status !== 'PENDING') { + return apiErrors.conflict('This approval request is no longer pending'); + } + if (myDecision.status !== 'PENDING') { + return apiErrors.conflict('You have already responded to this request'); + } + + const updated = await db.$transaction(async (tx) => { + const currentRequest = await tx.approvalRequest.findUnique({ + where: { id: requestId }, + include: { + decisions: { + orderBy: { createdAt: 'asc' }, + include: { + approver: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + requestedBy: { select: { id: true, name: true, email: true, image: true } }, + version: { + include: { + video: { + include: { + project: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + }); + if (!currentRequest) { + throw new Error('__NOT_FOUND__'); + } + if (currentRequest.status !== 'PENDING') { + throw new Error('__NOT_PENDING__'); + } + + const decisionRow = await tx.approvalDecision.findUnique({ + where: { requestId_approverId: { requestId, approverId: session.user.id } }, + select: { status: true }, + }); + if (!decisionRow) throw new Error('__NOT_APPROVER__'); + if (decisionRow.status !== 'PENDING') throw new Error('__ALREADY_RESPONDED__'); + + await tx.approvalDecision.update({ + where: { requestId_approverId: { requestId, approverId: session.user.id } }, + data: { + status: decision, + note: note || null, + respondedAt: new Date(), + }, + }); + + if (decision === 'REJECTED') { + await tx.approvalRequest.update({ + where: { id: requestId }, + data: { + status: 'REJECTED', + resolvedAt: new Date(), + }, + }); + } else { + const pendingCount = await tx.approvalDecision.count({ + where: { requestId, status: 'PENDING' }, + }); + const rejectedCount = await tx.approvalDecision.count({ + where: { requestId, status: 'REJECTED' }, + }); + if (pendingCount === 0 && rejectedCount === 0) { + await tx.approvalRequest.update({ + where: { id: requestId }, + data: { + status: 'APPROVED', + resolvedAt: new Date(), + }, + }); + } + } + + return tx.approvalRequest.findUnique({ + where: { id: requestId }, + include: { + requestedBy: { select: { id: true, name: true, email: true, image: true } }, + canceledBy: { select: { id: true, name: true, email: true, image: true } }, + decisions: { + orderBy: { createdAt: 'asc' }, + include: { + approver: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + version: { + include: { + video: { + include: { + project: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + }); + }, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + + if (!updated) return apiErrors.notFound('Approval request'); + + const actorName = session.user.name || 'A team member'; + const versionLabel = updated.version.versionLabel || `Version ${updated.version.versionNumber}`; + const baseUrl = process.env.NEXTAUTH_URL || ''; + const requestUrl = `${baseUrl}/projects/${updated.version.video.project.id}/videos/${updated.version.video.id}`; + + notifyUsers([updated.requestedById], { + type: 'approval_action', + projectName: updated.version.video.project.name, + videoTitle: updated.version.video.title, + versionLabel, + actorName, + action: decision === 'APPROVED' ? 'approved' : 'rejected', + note: note || undefined, + url: requestUrl, + }).catch((error) => { + console.error('Approval action notification failed:', error); + }); + + if (updated.status === 'APPROVED') { + notifyUsers([updated.requestedById], { + type: 'approval_completed', + projectName: updated.version.video.project.name, + videoTitle: updated.version.video.title, + versionLabel, + approvedByCount: updated.decisions.filter((item) => item.status === 'APPROVED').length, + url: requestUrl, + }).catch((error) => { + console.error('Approval completed notification failed:', error); + }); + } else if (updated.status === 'REJECTED') { + notifyUsers([updated.requestedById], { + type: 'approval_rejected', + projectName: updated.version.video.project.name, + videoTitle: updated.version.video.title, + versionLabel, + rejectedBy: actorName, + note: note || undefined, + url: requestUrl, + }).catch((error) => { + console.error('Approval rejected notification failed:', error); + }); + } + + const response = successResponse({ request: updated }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + if (error instanceof Error) { + if (error.message === '__NOT_PENDING__') return apiErrors.conflict('This approval request is no longer pending'); + if (error.message === '__ALREADY_RESPONDED__') return apiErrors.conflict('You have already responded to this request'); + if (error.message === '__NOT_APPROVER__') return apiErrors.forbidden('You are not an approver on this request'); + if (error.message === '__NOT_FOUND__') return apiErrors.notFound('Approval request'); + } + if (isSerializableConflict(error)) { + return apiErrors.conflict('Request state changed. Please try again.'); + } + console.error('Error responding to approval request:', error); + return apiErrors.internalError('Failed to respond to approval request'); + } +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index ffdcc7f..ec04117 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs'; +import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations'; import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; @@ -16,27 +17,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { name, email, password, inviteCode } = body; - - // Validate invite code using constant-time comparison to prevent timing attacks - const validInviteCode = process.env.INVITE_CODE; - if (!validInviteCode || !inviteCode) { - return apiErrors.forbidden('Invalid invite code'); - } - - // Constant-time comparison - const { timingSafeEqual } = await import('crypto'); - const validBuffer = Buffer.from(validInviteCode); - const providedBuffer = Buffer.from(String(inviteCode)); - - // Ensure same length for comparison (prevents length-based timing leak) - const isValidLength = validBuffer.length === providedBuffer.length; - const compareBuffer = isValidLength ? providedBuffer : validBuffer; - const isValidCode = isValidLength && timingSafeEqual(validBuffer, compareBuffer); - - if (!isValidCode) { - return apiErrors.forbidden('Invalid invite code'); - } + const { name, email, password, inviteCode, invitationToken } = body; // Validate required fields if (!name || typeof name !== 'string' || name.trim().length < 2) { @@ -46,20 +27,57 @@ export async function POST(request: NextRequest) { if (!email || typeof email !== 'string') { return apiErrors.badRequest('Email is required'); } + const normalizedEmail = email.toLowerCase().trim(); // Basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { + if (!emailRegex.test(normalizedEmail)) { return apiErrors.validationError('Invalid email format'); } + // Allow registration via a valid invitation token OR global invite code. + let invitationIsValid = false; + let validatedInvitationToken: string | null = null; + if (typeof invitationToken === 'string' && invitationToken.trim()) { + const normalizedToken = invitationToken.trim(); + const invitation = await getValidInvitationByToken(normalizedToken); + if (invitation && invitation.email === normalizedEmail) { + invitationIsValid = true; + validatedInvitationToken = normalizedToken; + } else { + return apiErrors.forbidden('Invalid or expired invitation token'); + } + } + + if (!invitationIsValid) { + // Validate invite code using constant-time comparison to prevent timing attacks + const validInviteCode = process.env.INVITE_CODE; + if (!validInviteCode || !inviteCode) { + return apiErrors.forbidden('Invalid invite code'); + } + + // Constant-time comparison + const { timingSafeEqual } = await import('crypto'); + const validBuffer = Buffer.from(validInviteCode); + const providedBuffer = Buffer.from(String(inviteCode)); + + // Ensure same length for comparison (prevents length-based timing leak) + const isValidLength = validBuffer.length === providedBuffer.length; + const compareBuffer = isValidLength ? providedBuffer : validBuffer; + const isValidCode = isValidLength && timingSafeEqual(validBuffer, compareBuffer); + + if (!isValidCode) { + return apiErrors.forbidden('Invalid invite code'); + } + } + if (!password || typeof password !== 'string' || password.length < 8) { return apiErrors.badRequest('Password must be at least 8 characters'); } // Check if email already exists const existingUser = await db.user.findUnique({ - where: { email: email.toLowerCase() }, + where: { email: normalizedEmail }, }); if (existingUser) { @@ -73,7 +91,7 @@ export async function POST(request: NextRequest) { const user = await db.user.create({ data: { name: name.trim(), - email: email.toLowerCase(), + email: normalizedEmail, password: hashedPassword, }, select: { @@ -84,6 +102,18 @@ export async function POST(request: NextRequest) { }, }); + if (validatedInvitationToken) { + const result = await acceptInvitationTokenForUser({ + token: validatedInvitationToken, + userId: user.id, + email: normalizedEmail, + }); + if (result !== 'accepted') { + await db.user.delete({ where: { id: user.id } }); + return apiErrors.conflict('Invitation could not be accepted. Please request a new invitation.'); + } + } + const response = successResponse( { message: 'Account created successfully', user }, 201 diff --git a/app/api/projects/[projectId]/approval-candidates/route.ts b/app/api/projects/[projectId]/approval-candidates/route.ts new file mode 100644 index 0000000..341ed56 --- /dev/null +++ b/app/api/projects/[projectId]/approval-candidates/route.ts @@ -0,0 +1,34 @@ +import { NextRequest } from 'next/server'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { getApprovalCandidatesForProject } from '@/lib/approval-workflow'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; + +type RouteParams = { params: Promise<{ projectId: string }> }; + +// GET /api/projects/[projectId]/approval-candidates +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + if (!session?.user?.id) return apiErrors.unauthorized(); + + const { projectId } = await params; + const project = await db.project.findUnique({ + where: { id: projectId }, + select: { id: true, ownerId: true, workspaceId: true, visibility: true }, + }); + if (!project) return apiErrors.notFound('Project'); + + const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' }); + if (!access.canEdit) return apiErrors.forbidden('Access denied'); + + const candidates = await getApprovalCandidatesForProject(projectId); + if (!candidates) return apiErrors.notFound('Project'); + + const response = successResponse({ candidates }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error fetching approval candidates:', error); + return apiErrors.internalError('Failed to fetch approval candidates'); + } +} diff --git a/app/api/projects/[projectId]/members/invitations/[invitationId]/route.ts b/app/api/projects/[projectId]/members/invitations/[invitationId]/route.ts new file mode 100644 index 0000000..6dd0dcb --- /dev/null +++ b/app/api/projects/[projectId]/members/invitations/[invitationId]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest } from 'next/server'; +import { InvitationStatus, ProjectMemberRole } from '@prisma/client'; +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; + +type RouteParams = { params: Promise<{ projectId: string; invitationId: string }> }; + +// DELETE /api/projects/[projectId]/members/invitations/[invitationId] - Cancel a pending invitation +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'manage-member'); + if (limited) return limited; + + const session = await auth(); + const { projectId, invitationId } = await params; + + if (!session?.user?.id) { + return apiErrors.unauthorized(); + } + + const project = await db.project.findUnique({ + where: { id: projectId }, + include: { members: { where: { userId: session.user.id } } }, + }); + + if (!project) { + return apiErrors.notFound('Project'); + } + + const isOwner = project.ownerId === session.user.id; + const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; + + if (!isOwner && !isAdmin) { + return apiErrors.forbidden('Only project owners and admins can cancel invitations'); + } + + const invitation = await db.invitation.findFirst({ + where: { + id: invitationId, + projectId, + scope: 'PROJECT', + }, + select: { + id: true, + status: true, + }, + }); + + if (!invitation) { + return apiErrors.notFound('Invitation'); + } + + if (invitation.status !== InvitationStatus.PENDING) { + return apiErrors.conflict('Only pending invitations can be canceled'); + } + + await db.invitation.update({ + where: { id: invitation.id }, + data: { status: InvitationStatus.CANCELED }, + }); + + const response = successResponse({ message: 'Invitation canceled' }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error canceling project invitation:', error); + return apiErrors.internalError('Failed to cancel invitation'); + } +} diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts index 927ae70..eb7bef1 100644 --- a/app/api/projects/[projectId]/members/route.ts +++ b/app/api/projects/[projectId]/members/route.ts @@ -1,8 +1,9 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; -import { ProjectMemberRole } from '@prisma/client'; +import { InvitationRole, ProjectMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; +import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -30,26 +31,51 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isOwner = project.ownerId === session.user.id; const isMember = project.members.length > 0; + const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; if (!isOwner && !isMember) { return apiErrors.forbidden('Access denied'); } - const members = await db.projectMember.findMany({ - where: { projectId }, - include: { - user: { select: { id: true, name: true, email: true, image: true } }, - }, - orderBy: { createdAt: 'asc' }, - }); + const now = new Date(); + const canViewPendingInvitations = isOwner || isAdmin; + const [members, owner, pendingInvitations] = await Promise.all([ + db.projectMember.findMany({ + where: { projectId }, + include: { + user: { select: { id: true, name: true, email: true, image: true } }, + }, + orderBy: { createdAt: 'asc' }, + }), + db.user.findUnique({ + where: { id: project.ownerId }, + select: { id: true, name: true, email: true, image: true }, + }), + canViewPendingInvitations + ? db.invitation.findMany({ + where: { + projectId, + scope: 'PROJECT', + status: 'PENDING', + expiresAt: { gt: now }, + }, + select: { + id: true, + email: true, + role: true, + createdAt: true, + expiresAt: true, + invitedBy: { + select: { id: true, name: true, email: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }) + : Promise.resolve([]), + ]); - const owner = await db.user.findUnique({ - where: { id: project.ownerId }, - select: { id: true, name: true, email: true, image: true }, - }); - - const response = successResponse({ members, owner }); - return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); + const response = successResponse({ members, owner, pendingInvitations }); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error fetching project members:', error); return apiErrors.internalError('Failed to fetch members'); @@ -93,45 +119,55 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.badRequest('Email is required'); } + const normalizedEmail = email.toLowerCase().trim(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(normalizedEmail)) { + return apiErrors.validationError('Invalid email format'); + } + // Validate role const validRoles = ['ADMIN', 'COMMENTATOR']; const memberRole = validRoles.includes(role) ? role : 'COMMENTATOR'; - // Find user by email + // If this email belongs to an existing user, validate owner/member conflicts. const userToInvite = await db.user.findUnique({ - where: { email: email.toLowerCase().trim() }, + where: { email: normalizedEmail }, + select: { id: true }, }); - if (!userToInvite) { - const response = successResponse({ message: 'If the user exists, an invitation has been sent.' }); - return withCacheControl(response, 'private, no-store'); - } - - if (userToInvite.id === project.ownerId) { + if (userToInvite?.id === project.ownerId) { return apiErrors.badRequest('Cannot invite the project owner as a member'); } - // Check if already a member - const existingMember = await db.projectMember.findUnique({ - where: { projectId_userId: { projectId, userId: userToInvite.id } }, - }); + if (userToInvite) { + const existingMember = await db.projectMember.findUnique({ + where: { projectId_userId: { projectId, userId: userToInvite.id } }, + }); - if (existingMember) { - return apiErrors.conflict('User is already a member of this project'); + if (existingMember) { + return apiErrors.conflict('User is already a member of this project'); + } } - const member = await db.projectMember.create({ - data: { - projectId, - userId: userToInvite.id, - role: memberRole as ProjectMemberRole, - }, - include: { - user: { select: { id: true, name: true, email: true, image: true } }, - }, + const invitation = await createOrRefreshInvitation({ + email: normalizedEmail, + scope: 'PROJECT', + role: memberRole as InvitationRole, + invitedById: session.user.id, + projectId, }); - const response = successResponse(member, 201); + const invitationUrl = buildInvitationUrl(invitation.token, normalizedEmail); + void sendInvitationEmail({ + to: normalizedEmail, + inviterName: session.user.name || 'A team member', + role: invitation.role, + scope: invitation.scope, + targetName: project.name, + invitationUrl, + }); + + const response = successResponse({ message: 'Invitation email sent.' }); return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error inviting project member:', error); diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index eccd23a..b4e9b9d 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -125,6 +125,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { canDownload: access.hasAccess, canManageTags: access.canEdit, canResolveComments: access.canEdit, + canRequestApproval: access.canEdit, }); return withCacheControl(response, 'private, no-cache'); diff --git a/app/api/settings/notifications/route.ts b/app/api/settings/notifications/route.ts index f3ea080..2820745 100644 --- a/app/api/settings/notifications/route.ts +++ b/app/api/settings/notifications/route.ts @@ -29,6 +29,7 @@ export async function GET() { onNewVersion: true, onNewComment: true, onNewReply: true, + onApprovalEvents: true, timezone: 'UTC', } ); @@ -61,6 +62,7 @@ export async function PUT(request: NextRequest) { onNewVersion, onNewComment, onNewReply, + onApprovalEvents, timezone, } = body; @@ -81,6 +83,7 @@ export async function PUT(request: NextRequest) { onNewVersion: onNewVersion ?? true, onNewComment: onNewComment ?? true, onNewReply: onNewReply ?? true, + onApprovalEvents: onApprovalEvents ?? true, timezone: timezone || 'UTC', }, update: { @@ -92,6 +95,7 @@ export async function PUT(request: NextRequest) { onNewVersion: onNewVersion ?? true, onNewComment: onNewComment ?? true, onNewReply: onNewReply ?? true, + onApprovalEvents: onApprovalEvents ?? true, timezone: timezone || 'UTC', }, }); diff --git a/app/api/versions/[versionId]/approvals/route.ts b/app/api/versions/[versionId]/approvals/route.ts new file mode 100644 index 0000000..43f5223 --- /dev/null +++ b/app/api/versions/[versionId]/approvals/route.ts @@ -0,0 +1,183 @@ +import { NextRequest } from 'next/server'; +import { Prisma } from '@prisma/client'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { getApprovalCandidatesForProject } from '@/lib/approval-workflow'; +import { notifyUsers } from '@/lib/notifications'; +import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; + +type RouteParams = { params: Promise<{ versionId: string }> }; + +function isSerializableConflict(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034'; +} + +// GET /api/versions/[versionId]/approvals +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + if (!session?.user?.id) return apiErrors.unauthorized(); + + const { versionId } = await params; + const version = await db.videoVersion.findUnique({ + where: { id: versionId }, + include: { + video: { + include: { + project: { select: { id: true, ownerId: true, workspaceId: true, visibility: true } }, + }, + }, + }, + }); + if (!version) return apiErrors.notFound('Version'); + + const access = await checkProjectAccess(version.video.project, session.user.id); + const hasMembership = access.isOwner || access.isProjectMember || access.isWorkspaceMember; + if (!hasMembership) return apiErrors.forbidden('Access denied'); + + const requests = await db.approvalRequest.findMany({ + where: { versionId }, + orderBy: { createdAt: 'desc' }, + include: { + requestedBy: { select: { id: true, name: true, email: true, image: true } }, + canceledBy: { select: { id: true, name: true, email: true, image: true } }, + decisions: { + orderBy: { createdAt: 'asc' }, + include: { + approver: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + }, + }); + + const response = successResponse({ requests }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error fetching approvals:', error); + return apiErrors.internalError('Failed to fetch approvals'); + } +} + +// POST /api/versions/[versionId]/approvals +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'mutate'); + if (limited) return limited; + + const session = await auth(); + if (!session?.user?.id) return apiErrors.unauthorized(); + + const { versionId } = await params; + const version = await db.videoVersion.findUnique({ + where: { id: versionId }, + include: { + video: { + include: { + project: { select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true } }, + }, + }, + }, + }); + if (!version) return apiErrors.notFound('Version'); + + const access = await checkProjectAccess(version.video.project, session.user.id, { intent: 'manage' }); + if (!access.canEdit) return apiErrors.forbidden('Access denied'); + + const body = await request.json().catch(() => ({})) as { approverIds?: unknown; message?: unknown }; + const message = typeof body.message === 'string' ? body.message.trim() : ''; + if (message.length > 2000) { + return apiErrors.badRequest('Message must be 2000 characters or fewer'); + } + + const rawApproverIds = Array.isArray(body.approverIds) ? body.approverIds : []; + const approverIds = Array.from(new Set( + rawApproverIds + .filter((approverId): approverId is string => typeof approverId === 'string' && approverId.trim().length > 0) + .map((approverId) => approverId.trim()) + )); + + if (approverIds.length === 0) { + return apiErrors.badRequest('At least one approver is required'); + } + + if (approverIds.includes(session.user.id)) { + return apiErrors.badRequest('Requester cannot be an approver'); + } + + const candidates = await getApprovalCandidatesForProject(version.video.project.id); + if (!candidates) return apiErrors.notFound('Project'); + const candidateIds = new Set(candidates.map((candidate) => candidate.id)); + + if (approverIds.some((id) => !candidateIds.has(id))) { + return apiErrors.badRequest('One or more approvers are not eligible for this project'); + } + + const created = await db.$transaction(async (tx) => { + const existingPending = await tx.approvalRequest.findFirst({ + where: { versionId, status: 'PENDING' }, + select: { id: true }, + }); + if (existingPending) { + throw new Error('__PENDING_REQUEST_EXISTS__'); + } + + return tx.approvalRequest.create({ + data: { + versionId, + requestedById: session.user.id, + message: message || null, + decisions: { + createMany: { + data: approverIds.map((approverId) => ({ + approverId, + status: 'PENDING', + })), + }, + }, + }, + include: { + requestedBy: { select: { id: true, name: true, email: true, image: true } }, + canceledBy: { select: { id: true, name: true, email: true, image: true } }, + decisions: { + orderBy: { createdAt: 'asc' }, + include: { + approver: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + }, + }); + }, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + + const requesterName = session.user.name || 'A team member'; + const versionLabel = version.versionLabel || `Version ${version.versionNumber}`; + const baseUrl = process.env.NEXTAUTH_URL || ''; + const requestUrl = `${baseUrl}/projects/${version.video.project.id}/videos/${version.video.id}`; + + notifyUsers(approverIds, { + type: 'approval_requested', + projectName: version.video.project.name, + videoTitle: version.video.title, + versionLabel, + requestedBy: requesterName, + message: message || undefined, + url: requestUrl, + }).catch((error) => { + console.error('Approval request notification failed:', error); + }); + + const response = successResponse({ request: created }, 201); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + if (error instanceof Error && error.message === '__PENDING_REQUEST_EXISTS__') { + return apiErrors.conflict('An approval request is already pending for this version'); + } + if (isSerializableConflict(error)) { + return apiErrors.conflict('Request state changed. Please try again.'); + } + console.error('Error creating approval request:', error); + return apiErrors.internalError('Failed to create approval request'); + } +} diff --git a/app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route.ts b/app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route.ts new file mode 100644 index 0000000..e512c55 --- /dev/null +++ b/app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest } from 'next/server'; +import { InvitationStatus, WorkspaceMemberRole } from '@prisma/client'; +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; + +type RouteParams = { params: Promise<{ workspaceId: string; invitationId: string }> }; + +// DELETE /api/workspaces/[workspaceId]/members/invitations/[invitationId] - Cancel a pending invitation +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'manage-member'); + if (limited) return limited; + + const session = await auth(); + const { workspaceId, invitationId } = await params; + + if (!session?.user?.id) { + return apiErrors.unauthorized(); + } + + const workspace = await db.workspace.findUnique({ + where: { id: workspaceId }, + include: { members: { where: { userId: session.user.id } } }, + }); + + if (!workspace) { + return apiErrors.notFound('Workspace'); + } + + const isOwner = workspace.ownerId === session.user.id; + const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; + + if (!isOwner && !isAdmin) { + return apiErrors.forbidden('Only workspace owners and admins can cancel invitations'); + } + + const invitation = await db.invitation.findFirst({ + where: { + id: invitationId, + workspaceId, + scope: 'WORKSPACE', + }, + select: { + id: true, + status: true, + }, + }); + + if (!invitation) { + return apiErrors.notFound('Invitation'); + } + + if (invitation.status !== InvitationStatus.PENDING) { + return apiErrors.conflict('Only pending invitations can be canceled'); + } + + await db.invitation.update({ + where: { id: invitation.id }, + data: { status: InvitationStatus.CANCELED }, + }); + + const response = successResponse({ message: 'Invitation canceled' }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error canceling workspace invitation:', error); + return apiErrors.internalError('Failed to cancel invitation'); + } +} diff --git a/app/api/workspaces/[workspaceId]/members/route.ts b/app/api/workspaces/[workspaceId]/members/route.ts index ca77b63..965c664 100644 --- a/app/api/workspaces/[workspaceId]/members/route.ts +++ b/app/api/workspaces/[workspaceId]/members/route.ts @@ -1,8 +1,9 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; -import { WorkspaceMemberRole } from '@prisma/client'; +import { InvitationRole, WorkspaceMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; +import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ workspaceId: string }> }; @@ -54,12 +55,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isOwner = workspace.ownerId === session.user.id; const isMember = workspace.members.length > 0; + const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; if (!isOwner && !isMember) { return apiErrors.forbidden('Access denied'); } - const [members, total] = await Promise.all([ + const now = new Date(); + const canViewPendingInvitations = isOwner || isAdmin; + const [members, total, pendingInvitations] = await Promise.all([ db.workspaceMember.findMany({ where: { workspaceId }, include: { @@ -72,6 +76,27 @@ export async function GET(request: NextRequest, { params }: RouteParams) { db.workspaceMember.count({ where: { workspaceId }, }), + canViewPendingInvitations + ? db.invitation.findMany({ + where: { + workspaceId, + scope: 'WORKSPACE', + status: 'PENDING', + expiresAt: { gt: now }, + }, + select: { + id: true, + email: true, + role: true, + createdAt: true, + expiresAt: true, + invitedBy: { + select: { id: true, name: true, email: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }) + : Promise.resolve([]), ]); // Include the owner as well @@ -81,7 +106,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); const response = successResponse( - { members, owner }, + { members, owner, pendingInvitations }, 200, { page, @@ -90,7 +115,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { totalPages: Math.ceil(total / limit), } ); - return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error fetching workspace members:', error); return apiErrors.internalError('Failed to fetch members'); @@ -134,45 +159,55 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.badRequest('Email is required'); } + const normalizedEmail = email.toLowerCase().trim(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(normalizedEmail)) { + return apiErrors.validationError('Invalid email format'); + } + // Validate role const validRoles = ['ADMIN', 'COMMENTATOR']; const memberRole = validRoles.includes(role) ? role : 'COMMENTATOR'; - // Find user by email + // If this email belongs to an existing user, validate owner/member conflicts. const userToInvite = await db.user.findUnique({ - where: { email: email.toLowerCase().trim() }, + where: { email: normalizedEmail }, + select: { id: true }, }); - if (!userToInvite) { - const response = successResponse({ message: 'If the user exists, an invitation has been sent.' }); - return withCacheControl(response, 'private, no-store'); - } - - if (userToInvite.id === workspace.ownerId) { + if (userToInvite?.id === workspace.ownerId) { return apiErrors.badRequest('Cannot invite the workspace owner as a member'); } - // Check if already a member - const existingMember = await db.workspaceMember.findUnique({ - where: { workspaceId_userId: { workspaceId, userId: userToInvite.id } }, - }); + if (userToInvite) { + const existingMember = await db.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId, userId: userToInvite.id } }, + }); - if (existingMember) { - return apiErrors.conflict('User is already a member of this workspace'); + if (existingMember) { + return apiErrors.conflict('User is already a member of this workspace'); + } } - const member = await db.workspaceMember.create({ - data: { - workspaceId, - userId: userToInvite.id, - role: memberRole as WorkspaceMemberRole, - }, - include: { - user: { select: { id: true, name: true, email: true, image: true } }, - }, + const invitation = await createOrRefreshInvitation({ + email: normalizedEmail, + scope: 'WORKSPACE', + role: memberRole as InvitationRole, + invitedById: session.user.id, + workspaceId, }); - const response = successResponse(member, 201); + const invitationUrl = buildInvitationUrl(invitation.token, normalizedEmail); + void sendInvitationEmail({ + to: normalizedEmail, + inviterName: session.user.name || 'A team member', + role: invitation.role, + scope: invitation.scope, + targetName: workspace.name, + invitationUrl, + }); + + const response = successResponse({ message: 'Invitation email sent.' }); return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error inviting workspace member:', error); diff --git a/app/api/workspaces/[workspaceId]/route.ts b/app/api/workspaces/[workspaceId]/route.ts index 5a12de5..d4bbbfe 100644 --- a/app/api/workspaces/[workspaceId]/route.ts +++ b/app/api/workspaces/[workspaceId]/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupWorkspaceMediaFiles } from '@/lib/r2-cleanup'; +import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ workspaceId: string }> }; @@ -164,6 +165,22 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { return apiErrors.forbidden('Only the workspace owner can delete it'); } + // Delete Bunny provider videos first to avoid orphaned external assets. + const workspaceVersionRefs = await db.videoVersion.findMany({ + where: { + video: { + project: { + workspaceId, + }, + }, + }, + select: { + providerId: true, + videoId: true, + }, + }); + await cleanupBunnyStreamVideos(workspaceVersionRefs); + // Clean up voice files from R2 before cascade delete removes comment rows await cleanupWorkspaceMediaFiles(workspaceId); diff --git a/app/invitations/accept/page.tsx b/app/invitations/accept/page.tsx new file mode 100644 index 0000000..8ad9119 --- /dev/null +++ b/app/invitations/accept/page.tsx @@ -0,0 +1,47 @@ +import { redirect } from 'next/navigation'; +import { auth } from '@/lib/auth'; +import { acceptInvitationTokenForUser } from '@/lib/invitations'; + +interface InvitationAcceptPageProps { + searchParams: Promise<{ + token?: string; + email?: string; + }>; +} + +export default async function InvitationAcceptPage({ searchParams }: InvitationAcceptPageProps) { + const resolvedSearchParams = await searchParams; + const token = resolvedSearchParams.token?.trim(); + + if (!token) { + redirect('/login?error=invalid_invitation'); + } + + const session = await auth(); + if (!session?.user?.id) { + const callbackUrl = `/invitations/accept?token=${encodeURIComponent(token)}`; + redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`); + } + + const userEmail = session.user.email?.toLowerCase().trim(); + if (!userEmail) { + redirect('/dashboard?invite=invalid_email'); + } + + const result = await acceptInvitationTokenForUser({ + token, + userId: session.user.id, + email: userEmail, + }); + + if (result === 'accepted') { + redirect('/dashboard?invite=accepted'); + } + if (result === 'expired') { + redirect('/dashboard?invite=expired'); + } + if (result === 'forbidden') { + redirect('/dashboard?invite=wrong_account'); + } + redirect('/dashboard?invite=not_found'); +} diff --git a/components/members-management-page.tsx b/components/members-management-page.tsx new file mode 100644 index 0000000..d53fdc6 --- /dev/null +++ b/components/members-management-page.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { ReactNode, useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + ArrowLeft, + Clock3, + Crown, + Loader2, + MailX, + MessageSquare, + Plus, + Shield, + Trash2, + UserPlus, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface Member { + id: string; + role: 'ADMIN' | 'COMMENTATOR'; + userId: string; + user: { + id: string; + name: string | null; + email: string | null; + image: string | null; + }; +} + +interface Owner { + id: string; + name: string | null; + email: string | null; + image: string | null; +} + +interface PendingInvitation { + id: string; + email: string; + role: 'ADMIN' | 'COMMENTATOR'; + createdAt: string; + expiresAt: string; + invitedBy: { + id: string; + name: string | null; + email: string | null; + }; +} + +interface MembersManagementPageProps { + apiBasePath: string; + backHref: string; + backLabel: string; + title: string; + subtitle: string; + membersDescription: ReactNode; + forbiddenRedirect: string; +} + +export function MembersManagementPage({ + apiBasePath, + backHref, + backLabel, + title, + subtitle, + membersDescription, + forbiddenRedirect, +}: MembersManagementPageProps) { + const router = useRouter(); + + const [members, setMembers] = useState([]); + const [owner, setOwner] = useState(null); + const [pendingInvitations, setPendingInvitations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState<'ADMIN' | 'COMMENTATOR'>('COMMENTATOR'); + const [isInviting, setIsInviting] = useState(false); + const [cancelingInvitationId, setCancelingInvitationId] = useState(null); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const fetchMembers = useCallback(async () => { + try { + const res = await fetch(`${apiBasePath}/members`, { + cache: 'no-store', + }); + if (!res.ok) { + if (res.status === 403) router.push(forbiddenRedirect); + return; + } + const data = await res.json(); + setMembers(data.data.members); + setOwner(data.data.owner); + setPendingInvitations(data.data.pendingInvitations || []); + } catch { + setError('Failed to load members'); + } finally { + setIsLoading(false); + } + }, [apiBasePath, forbiddenRedirect, router]); + + useEffect(() => { + fetchMembers(); + }, [fetchMembers]); + + useEffect(() => { + const interval = window.setInterval(() => { + void fetchMembers(); + }, 5000); + + return () => window.clearInterval(interval); + }, [fetchMembers]); + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + setIsInviting(true); + setError(''); + setSuccess(''); + + try { + const res = await fetch(`${apiBasePath}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: inviteEmail, role: inviteRole }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || 'Failed to invite member'); + return; + } + + if (data.user) { + setSuccess(`Invited ${data.user.name || data.user.email || inviteEmail} as ${inviteRole.toLowerCase()}`); + } else { + setSuccess(data.message || `Invitation sent to ${inviteEmail}`); + } + setInviteEmail(''); + fetchMembers(); + } catch { + setError('Something went wrong'); + } finally { + setIsInviting(false); + } + }; + + const handleRoleChange = async (memberId: string, newRole: string) => { + try { + const res = await fetch(`${apiBasePath}/members/${memberId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || 'Failed to update role'); + return; + } + + fetchMembers(); + } catch { + setError('Failed to update role'); + } + }; + + const handleRemove = async (memberId: string) => { + if (!confirm('Are you sure you want to remove this member?')) return; + + try { + const res = await fetch(`${apiBasePath}/members/${memberId}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || 'Failed to remove member'); + return; + } + + fetchMembers(); + } catch { + setError('Failed to remove member'); + } + }; + + const handleCancelInvitation = async (invitationId: string) => { + setCancelingInvitationId(invitationId); + setError(''); + setSuccess(''); + + try { + const res = await fetch(`${apiBasePath}/members/invitations/${invitationId}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || 'Failed to cancel invitation'); + return; + } + + setSuccess('Invitation canceled'); + fetchMembers(); + } catch { + setError('Failed to cancel invitation'); + } finally { + setCancelingInvitationId(null); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + {backLabel} + +
+ +
+

{title}

+

{subtitle}

+
+ + + + + + Invite Member + + + Invite someone by email. They must have an account to be added. + + + +
+
+ + setInviteEmail(e.target.value)} + required + disabled={isInviting} + /> +
+
+ + +
+ +
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} +
+
+ + + + Current Members + {membersDescription} + + + {owner && ( +
+
+ + + {owner.name?.charAt(0).toUpperCase() ?? 'U'} + +
+

{owner.name || 'Unnamed'}

+

{owner.email}

+
+
+ + + Owner + +
+ )} + + {members.map((member) => ( +
+
+ + + {member.user.name?.charAt(0).toUpperCase() ?? 'U'} + +
+

{member.user.name || 'Unnamed'}

+

{member.user.email}

+
+
+
+ + +
+
+ ))} + + {members.length === 0 && ( +

+ No members yet. Invite someone above. +

+ )} +
+
+ + + + + + Pending Invitations + + + Invitations that were sent but not accepted yet. + + + + {pendingInvitations.map((invitation) => ( +
+
+

{invitation.email}

+

+ {invitation.role === 'ADMIN' ? 'Admin' : 'Commentator'} · Sent by {invitation.invitedBy.name || invitation.invitedBy.email || 'Unknown'} +

+

+ Expires {new Date(invitation.expiresAt).toLocaleString()} +

+
+ +
+ ))} + + {pendingInvitations.length === 0 && ( +

+ No pending invitations. +

+ )} +
+
+
+ ); +} diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 6b59f42..e2f4554 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -23,6 +23,8 @@ import { useDownloadActions } from '@/components/video-page/hooks/use-download-a import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync'; import { CommentComposer } from '@/components/video-page/comment-composer'; import { CommentsPane } from '@/components/video-page/comments-pane'; +import { ApprovalRequestDialog } from '@/components/video-page/approval-request-dialog'; +import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel'; import type { CommentMarker, PlayerAdapter, @@ -31,6 +33,7 @@ import type { VideoPageComposerActions, VideoPageHeaderActions, } from '@/components/video-page/types'; +import { useApprovals } from '@/components/video-page/hooks/use-approvals'; function formatTime(seconds: number): string { const totalSeconds = Math.floor(seconds); @@ -111,6 +114,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi // Compare dialog state const [showCompareDialog, setShowCompareDialog] = useState(false); const [selectedCompareVersions, setSelectedCompareVersions] = useState>(new Set()); + const [showApprovalRequestDialog, setShowApprovalRequestDialog] = useState(false); + const [showApprovalsPanel, setShowApprovalsPanel] = useState(false); const router = useRouter(); const { @@ -185,6 +190,29 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const currentUserId = video?.currentUserId || null; const currentUserName = video?.currentUserName || null; const canResolveComments = !!video?.canResolveComments; + const canRequestApproval = !!video?.canRequestApproval; + + const { + requests: approvalRequests, + candidates: approvalCandidates, + isLoadingRequests: isLoadingApprovals, + isLoadingCandidates: isLoadingApprovalCandidates, + isSubmittingRequest: isSubmittingApprovalRequest, + isSubmittingDecision: isSubmittingApprovalDecision, + isCancelingRequest: isCancelingApprovalRequest, + activePendingRequest, + error: approvalError, + setError: setApprovalError, + fetchRequests: fetchApprovalRequests, + fetchCandidates: fetchApprovalCandidates, + createRequest: createApprovalRequest, + submitDecision: submitApprovalDecision, + cancelRequest: cancelApprovalRequest, + } = useApprovals({ + projectId, + activeVersionId, + currentUserId, + }); // Memoize active version lookup to avoid recalculating on every render const activeVersion = useMemo(() => { @@ -324,6 +352,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto'; }, [qualityOptions, selectedQualityLevel]); + useEffect(() => { + if (!activeVersionId || mode !== 'dashboard') return; + void fetchApprovalRequests(); + }, [activeVersionId, fetchApprovalRequests, mode]); + + useEffect(() => { + if (!showApprovalRequestDialog || mode !== 'dashboard') return; + void fetchApprovalCandidates(); + }, [fetchApprovalCandidates, mode, showApprovalRequestDialog]); + const { commentText, setCommentText, @@ -462,6 +500,17 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setShowCompareDialog(true); }, [activeVersionId]); + const handleOpenApprovalRequestDialog = useCallback(() => { + setApprovalError(''); + setShowApprovalRequestDialog(true); + }, [setApprovalError]); + + const handleOpenApprovalsPanel = useCallback(() => { + setApprovalError(''); + setShowApprovalsPanel(true); + void fetchApprovalRequests(); + }, [fetchApprovalRequests, setApprovalError]); + const toggleCompareVersion = useCallback((versionId: string) => { setSelectedCompareVersions((prev) => { const next = new Set(prev); @@ -605,6 +654,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi isCreatingVersion={isCreatingVersion} onCreateVersion={headerActions.onCreateVersion} onOpenCompare={headerActions.onOpenCompare} + canRequestApproval={canRequestApproval} + hasPendingApprovalRequest={!!activePendingRequest} + onOpenApprovalRequest={handleOpenApprovalRequestDialog} + onOpenApprovalsPanel={handleOpenApprovalsPanel} /> + + {mode === 'dashboard' ? ( + <> + + + + ) : null} ); } diff --git a/components/video-page/approval-request-dialog.tsx b/components/video-page/approval-request-dialog.tsx new file mode 100644 index 0000000..d401c46 --- /dev/null +++ b/components/video-page/approval-request-dialog.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, Loader2 } from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import type { ApprovalRequest } from '@/components/video-page/types'; +import type { ApprovalCandidate } from '@/components/video-page/hooks/use-approvals'; + +interface ApprovalRequestDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + candidates: ApprovalCandidate[]; + currentUserId: string | null; + activePendingRequest: ApprovalRequest | null; + isLoadingCandidates: boolean; + isSubmittingRequest: boolean; + error: string; + onRefreshCandidates: () => void; + onCreateRequest: (approverIds: string[], message?: string) => Promise; +} + +export function ApprovalRequestDialog({ + open, + onOpenChange, + candidates, + currentUserId, + activePendingRequest, + isLoadingCandidates, + isSubmittingRequest, + error, + onRefreshCandidates, + onCreateRequest, +}: ApprovalRequestDialogProps) { + const [selectedApproverIds, setSelectedApproverIds] = useState([]); + const [message, setMessage] = useState(''); + + const selectableCandidates = useMemo( + () => candidates.filter((candidate) => candidate.id !== currentUserId), + [candidates, currentUserId] + ); + + const toggleApprover = (userId: string) => { + setSelectedApproverIds((current) => ( + current.includes(userId) + ? current.filter((id) => id !== userId) + : [...current, userId] + )); + }; + + const handleCreate = async () => { + const success = await onCreateRequest(selectedApproverIds, message.trim() || undefined); + if (success) { + setSelectedApproverIds([]); + setMessage(''); + onOpenChange(false); + } + }; + + const isBlockedByPendingRequest = !!activePendingRequest; + + return ( + + + + Request Approval + + Select one or more approvers for this version. + + + + {isBlockedByPendingRequest ? ( +
+ A pending approval request already exists for this version. +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+

Approvers ({selectedApproverIds.length} selected)

+ +
+
+ {selectableCandidates.length === 0 ? ( +

No eligible approvers found.

+ ) : ( + selectableCandidates.map((candidate) => { + const selected = selectedApproverIds.includes(candidate.id); + return ( + + ); + }) + )} +
+
+ +
+

Message (optional)

+