feat: add approvals workflow and unified member invitation management across projects, workspaces, and videos

This commit is contained in:
Yusuf İpek
2026-02-25 16:24:45 +03:00
parent 0ae1becc1b
commit a2b07b3e19
36 changed files with 3122 additions and 982 deletions
+16 -1
View File
@@ -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.');
+45 -24
View File
@@ -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<HTMLInputElement>) => {
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() {
<CardContent>
<form onSubmit={handleRegister} className="space-y-4">
{/* Invite Code - First and prominent */}
<div className="space-y-2">
<Label htmlFor="inviteCode" className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-amber-500" />
Invite Code
</Label>
<Input
id="inviteCode"
name="inviteCode"
type="text"
placeholder="Enter your invite code"
value={formData.inviteCode}
onChange={handleChange}
required
disabled={isLoading}
className="border-amber-500/30 focus:border-amber-500"
/>
<p className="text-xs text-muted-foreground">
An invite code is required to create an account
</p>
</div>
{isInvitationFlow ? (
<div className="p-3 rounded-md bg-primary/10 text-sm">
You are registering via an invitation link.
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="inviteCode" className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-amber-500" />
Invite Code
</Label>
<Input
id="inviteCode"
name="inviteCode"
type="text"
placeholder="Enter your invite code"
value={formData.inviteCode}
onChange={handleChange}
required
disabled={isLoading}
className="border-amber-500/30 focus:border-amber-500"
/>
<p className="text-xs text-muted-foreground">
An invite code is required to create an account
</p>
</div>
<div className="h-px bg-border my-4" />
<div className="h-px bg-border my-4" />
</>
)}
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
@@ -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 (
<div className="px-6 lg:px-8 py-8 w-full">
<ProjectFilter
projects={serializedProjects}
workspaces={workspaces}
totalPages={totalPages}
canCreateProjects={canCreateProjects}
/>
</div>
);
+11
View File
@@ -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<string, string>();
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}
/>
);
}
+18 -13
View File
@@ -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
</>
)}
</Button>
<Button asChild>
<Link href="/projects/new">
<Plus className="h-4 w-4 mr-2" />
New Project
</Link>
</Button>
{canCreateProjects && (
<Button asChild>
<Link href="/projects/new">
<Plus className="h-4 w-4 mr-2" />
New Project
</Link>
</Button>
)}
</div>
</div>
@@ -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'}
</p>
<Button asChild>
<Link href="/projects/new">
<Plus className="h-4 w-4 mr-2" />
Create Project
</Link>
</Button>
{canCreateProjects && (
<Button asChild>
<Link href="/projects/new">
<Plus className="h-4 w-4 mr-2" />
Create Project
</Link>
</Button>
)}
</CardContent>
</Card>
)}
@@ -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<Member[]>([]);
const [owner, setOwner] = useState<Owner | null>(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 (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="px-6 lg:px-8 py-8 w-full max-w-4xl mx-auto">
<div className="mb-6">
<Link
href={`/projects/${projectId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Project
</Link>
</div>
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Project Members</h1>
<p className="text-muted-foreground mt-1">
Manage who has access to this project. Admins can manage settings and delete content.
Commentators can only view and leave comments.
</p>
</div>
{/* Invite Form */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Invite Member
</CardTitle>
<CardDescription>
Invite someone by email. They must have an account to be added.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="w-full sm:flex-1">
<Label htmlFor="email" className="mb-2 block">Email Address</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
required
disabled={isInviting}
/>
</div>
<div className="w-full sm:w-40">
<Label className="mb-2 block">Role</Label>
<Select value={inviteRole} onValueChange={(v) => setInviteRole(v as 'ADMIN' | 'COMMENTATOR')}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="COMMENTATOR">Commentator</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={isInviting} className="w-full sm:w-auto">
{isInviting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Invite
</>
)}
</Button>
</form>
{error && (
<div className="mt-3 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{success && (
<div className="mt-3 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
{success}
</div>
)}
</CardContent>
</Card>
{/* Members List */}
<Card>
<CardHeader>
<CardTitle>Current Members</CardTitle>
<CardDescription>
<strong>Admin</strong> can manage project settings, members, and delete content.{' '}
<strong>Commentator</strong> can view and comment only.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Owner */}
{owner && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg bg-accent/30">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={owner.image ?? undefined} />
<AvatarFallback>{owner.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{owner.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">{owner.email}</p>
</div>
</div>
<Badge variant="default" className="flex items-center gap-1">
<Crown className="h-3 w-3" />
Owner
</Badge>
</div>
)}
{/* Members */}
{members.map((member) => (
<div key={member.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg border">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={member.user.image ?? undefined} />
<AvatarFallback>{member.user.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{member.user.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">{member.user.email}</p>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<Select
value={member.role}
onValueChange={(v) => handleRoleChange(member.id, v)}
>
<SelectTrigger className="w-full sm:w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">
<span className="flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5" />
Admin
</span>
</SelectItem>
<SelectItem value="COMMENTATOR">
<span className="flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Commentator
</span>
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleRemove(member.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No members yet. Invite someone above.
</p>
)}
</CardContent>
</Card>
</div>
<MembersManagementPage
apiBasePath={`/api/projects/${projectId}`}
backHref={`/projects/${projectId}`}
backLabel="Back to Project"
title="Project Members"
subtitle="Manage who has access to this project. Admins can manage settings and delete content. Commentators can only view and leave comments."
membersDescription={
<>
<strong>Admin</strong> - can manage project settings, members, and delete content.{' '}
<strong>Commentator</strong> - can view and comment only.
</>
}
forbiddenRedirect="/dashboard"
/>
);
}
@@ -143,12 +143,14 @@ export function ProjectContentClient({
</>
)}
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share
</Link>
</Button>
{canEdit && (
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share
</Link>
</Button>
)}
{(isOwner || project.members[0]?.role === 'ADMIN') && (
<>
<Button variant="outline" size="sm" asChild>
+10
View File
@@ -29,6 +29,7 @@ interface NotificationSettings {
onNewVersion: boolean;
onNewComment: boolean;
onNewReply: boolean;
onApprovalEvents: boolean;
timezone: string;
}
@@ -87,6 +88,7 @@ export default function SettingsPage() {
onNewVersion: true,
onNewComment: true,
onNewReply: true,
onApprovalEvents: true,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
});
const [loading, setLoading] = useState(true);
@@ -283,6 +285,14 @@ export default function SettingsPage() {
label="New Reply"
description="When someone replies to a comment thread"
/>
<ToggleButton
enabled={settings.onApprovalEvents}
onToggle={() =>
setSettings((s) => ({ ...s, onApprovalEvents: !s.onApprovalEvents }))
}
label="Approval Workflow"
description="When approval requests are created, responded to, or finalized"
/>
</CardContent>
</Card>
@@ -1,333 +1,21 @@
'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 WorkspaceMembersPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params.workspaceId as string;
const [members, setMembers] = useState<Member[]>([]);
const [owner, setOwner] = useState<Owner | null>(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/workspaces/${workspaceId}/members`);
if (!res.ok) {
if (res.status === 403) router.push('/workspaces');
return;
}
const data = await res.json();
setMembers(data.data.members);
setOwner(data.data.owner);
} catch {
setError('Failed to load members');
} finally {
setIsLoading(false);
}
}, [workspaceId, router]);
useEffect(() => {
fetchMembers();
}, [fetchMembers]);
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
setIsInviting(true);
setError('');
setSuccess('');
try {
const res = await fetch(`/api/workspaces/${workspaceId}/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/workspaces/${workspaceId}/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/workspaces/${workspaceId}/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 (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="px-6 lg:px-8 py-8 w-full max-w-4xl mx-auto">
<div className="mb-6">
<Link
href={`/workspaces/${workspaceId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Workspace
</Link>
</div>
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Members</h1>
<p className="text-muted-foreground mt-1">
Manage who has access to this workspace and all its projects
</p>
</div>
{/* Invite Form */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Invite Member
</CardTitle>
<CardDescription>
Invite someone by email. They must have an account to be added.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="w-full sm:flex-1">
<Label htmlFor="email" className="mb-2 block">Email Address</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
required
disabled={isInviting}
/>
</div>
<div className="w-full sm:w-40">
<Label className="mb-2 block">Role</Label>
<Select value={inviteRole} onValueChange={(v) => setInviteRole(v as 'ADMIN' | 'COMMENTATOR')}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="COMMENTATOR">Commentator</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={isInviting} className="w-full sm:w-auto">
{isInviting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Invite
</>
)}
</Button>
</form>
{error && (
<div className="mt-3 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{success && (
<div className="mt-3 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
{success}
</div>
)}
</CardContent>
</Card>
{/* Members List */}
<Card>
<CardHeader>
<CardTitle>Current Members</CardTitle>
<CardDescription>
Admins can manage projects and members. Commentators can view and comment only.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Owner */}
{owner && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg bg-accent/30">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={owner.image ?? undefined} />
<AvatarFallback>{owner.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{owner.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">{owner.email}</p>
</div>
</div>
<Badge variant="default" className="flex items-center gap-1">
<Crown className="h-3 w-3" />
Owner
</Badge>
</div>
)}
{/* Members */}
{members.map((member) => (
<div key={member.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg border">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={member.user.image ?? undefined} />
<AvatarFallback>{member.user.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{member.user.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">{member.user.email}</p>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<Select
value={member.role}
onValueChange={(v) => handleRoleChange(member.id, v)}
>
<SelectTrigger className="w-full sm:w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">
<span className="flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5" />
Admin
</span>
</SelectItem>
<SelectItem value="COMMENTATOR">
<span className="flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Commentator
</span>
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleRemove(member.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No members yet. Invite someone above.
</p>
)}
</CardContent>
</Card>
</div>
<MembersManagementPage
apiBasePath={`/api/workspaces/${workspaceId}`}
backHref={`/workspaces/${workspaceId}`}
backLabel="Back to Workspace"
title="Members"
subtitle="Manage who has access to this workspace and all its projects"
membersDescription="Admins can manage projects and members. Commentators can view and comment only."
forbiddenRedirect="/workspaces"
/>
);
}
@@ -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() {
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete Workspace
</>
)}
</Button>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete &quot;{workspace.name}&quot;?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p>
This will permanently delete this workspace and everything inside it
(projects, videos, comments, images, and voice notes). This action cannot be undone.
</p>
<div className="space-y-2">
<Label htmlFor="delete-workspace-confirm">
Type <strong className="text-foreground">{workspace.name}</strong> to confirm
</Label>
<Input
id="delete-workspace-confirm"
value={deleteConfirmation}
onChange={(e) => setDeleteConfirmation(e.target.value)}
placeholder="Workspace name"
className="h-11"
/>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteConfirmation('')}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleteConfirmation !== workspace.name || isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Delete Workspace
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</div>
@@ -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');
}
}
@@ -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');
}
}
+54 -24
View File
@@ -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
@@ -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');
}
}
@@ -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');
}
}
+75 -39
View File
@@ -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);
@@ -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');
+4
View File
@@ -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',
},
});
@@ -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');
}
}
@@ -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');
}
}
@@ -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);
+17
View File
@@ -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);
+47
View File
@@ -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');
}
+442
View File
@@ -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<Member[]>([]);
const [owner, setOwner] = useState<Owner | null>(null);
const [pendingInvitations, setPendingInvitations] = useState<PendingInvitation[]>([]);
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<string | null>(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 (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="px-6 lg:px-8 py-8 w-full max-w-4xl mx-auto">
<div className="mb-6">
<Link
href={backHref}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
{backLabel}
</Link>
</div>
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground mt-1">{subtitle}</p>
</div>
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Invite Member
</CardTitle>
<CardDescription>
Invite someone by email. They must have an account to be added.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="w-full sm:flex-1">
<Label htmlFor="email" className="mb-2 block">Email Address</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
required
disabled={isInviting}
/>
</div>
<div className="w-full sm:w-40">
<Label className="mb-2 block">Role</Label>
<Select value={inviteRole} onValueChange={(v) => setInviteRole(v as 'ADMIN' | 'COMMENTATOR')}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="COMMENTATOR">Commentator</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={isInviting} className="w-full sm:w-auto">
{isInviting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Invite
</>
)}
</Button>
</form>
{error && (
<div className="mt-3 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{success && (
<div className="mt-3 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
{success}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current Members</CardTitle>
<CardDescription>{membersDescription}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{owner && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg bg-accent/30">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={owner.image ?? undefined} />
<AvatarFallback>{owner.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{owner.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">{owner.email}</p>
</div>
</div>
<Badge variant="default" className="flex items-center gap-1">
<Crown className="h-3 w-3" />
Owner
</Badge>
</div>
)}
{members.map((member) => (
<div key={member.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg border">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={member.user.image ?? undefined} />
<AvatarFallback>{member.user.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{member.user.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">{member.user.email}</p>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<Select
value={member.role}
onValueChange={(v) => handleRoleChange(member.id, v)}
>
<SelectTrigger className="w-full sm:w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">
<span className="flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5" />
Admin
</span>
</SelectItem>
<SelectItem value="COMMENTATOR">
<span className="flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Commentator
</span>
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleRemove(member.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No members yet. Invite someone above.
</p>
)}
</CardContent>
</Card>
<Card className="mt-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock3 className="h-5 w-5" />
Pending Invitations
</CardTitle>
<CardDescription>
Invitations that were sent but not accepted yet.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{pendingInvitations.map((invitation) => (
<div key={invitation.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 rounded-lg border">
<div>
<p className="text-sm font-medium">{invitation.email}</p>
<p className="text-xs text-muted-foreground">
{invitation.role === 'ADMIN' ? 'Admin' : 'Commentator'} · Sent by {invitation.invitedBy.name || invitation.invitedBy.email || 'Unknown'}
</p>
<p className="text-xs text-muted-foreground">
Expires {new Date(invitation.expiresAt).toLocaleString()}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleCancelInvitation(invitation.id)}
disabled={cancelingInvitationId === invitation.id}
>
{cancelingInvitationId === invitation.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<MailX className="h-4 w-4 mr-2" />
Cancel
</>
)}
</Button>
</div>
))}
{pendingInvitations.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No pending invitations.
</p>
)}
</CardContent>
</Card>
</div>
);
}
+84
View File
@@ -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<Set<string>>(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}
/>
<PlayerCore
@@ -782,6 +835,37 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
onToggleVersion={compareActions.onToggleVersion}
onCompare={compareActions.onCompare}
/>
{mode === 'dashboard' ? (
<>
<ApprovalRequestDialog
open={showApprovalRequestDialog}
onOpenChange={setShowApprovalRequestDialog}
candidates={approvalCandidates}
currentUserId={currentUserId}
activePendingRequest={activePendingRequest}
isLoadingCandidates={isLoadingApprovalCandidates}
isSubmittingRequest={isSubmittingApprovalRequest}
error={approvalError}
onRefreshCandidates={fetchApprovalCandidates}
onCreateRequest={createApprovalRequest}
/>
<ApprovalRequestsPanel
open={showApprovalsPanel}
onOpenChange={setShowApprovalsPanel}
requests={approvalRequests}
currentUserId={currentUserId}
canRequestApproval={canRequestApproval}
isLoadingRequests={isLoadingApprovals}
isSubmittingDecision={isSubmittingApprovalDecision}
isCancelingRequest={isCancelingApprovalRequest}
error={approvalError}
onRefresh={fetchApprovalRequests}
onSubmitDecision={submitApprovalDecision}
onCancelRequest={cancelApprovalRequest}
/>
</>
) : null}
</div >
);
}
@@ -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<boolean>;
}
export function ApprovalRequestDialog({
open,
onOpenChange,
candidates,
currentUserId,
activePendingRequest,
isLoadingCandidates,
isSubmittingRequest,
error,
onRefreshCandidates,
onCreateRequest,
}: ApprovalRequestDialogProps) {
const [selectedApproverIds, setSelectedApproverIds] = useState<string[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Request Approval</DialogTitle>
<DialogDescription>
Select one or more approvers for this version.
</DialogDescription>
</DialogHeader>
{isBlockedByPendingRequest ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
A pending approval request already exists for this version.
</div>
) : null}
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
) : null}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Approvers ({selectedApproverIds.length} selected)</p>
<Button size="sm" variant="ghost" onClick={onRefreshCandidates} disabled={isLoadingCandidates}>
{isLoadingCandidates ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Refresh'}
</Button>
</div>
<div className="max-h-56 overflow-y-auto rounded-md border p-2 space-y-1">
{selectableCandidates.length === 0 ? (
<p className="text-sm text-muted-foreground px-2 py-3">No eligible approvers found.</p>
) : (
selectableCandidates.map((candidate) => {
const selected = selectedApproverIds.includes(candidate.id);
return (
<button
key={candidate.id}
type="button"
className="w-full rounded-md border p-2 text-left hover:bg-accent/50 transition-colors"
onClick={() => toggleApprover(candidate.id)}
disabled={isBlockedByPendingRequest}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Avatar className="h-7 w-7">
<AvatarImage src={candidate.image ?? undefined} />
<AvatarFallback>{(candidate.name || candidate.email || 'U').charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{candidate.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground truncate">{candidate.email || 'No email'}</p>
</div>
</div>
{selected ? (
<Badge variant="default" className="gap-1">
<Check className="h-3 w-3" />
Selected
</Badge>
) : null}
</div>
</button>
);
})
)}
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Message (optional)</p>
<Textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Include context for the approvers..."
rows={3}
maxLength={2000}
disabled={isBlockedByPendingRequest}
/>
</div>
<DialogFooter>
<Button
onClick={handleCreate}
disabled={isSubmittingRequest || isBlockedByPendingRequest || selectedApproverIds.length === 0}
>
{isSubmittingRequest ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Create Request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,201 @@
'use client';
import { useMemo, useState } from 'react';
import { CheckCircle2, Clock3, Loader2, RefreshCcw, ShieldX, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import type { ApprovalRequest } from '@/components/video-page/types';
interface ApprovalRequestsPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
requests: ApprovalRequest[];
currentUserId: string | null;
canRequestApproval: boolean;
isLoadingRequests: boolean;
isSubmittingDecision: boolean;
isCancelingRequest: boolean;
error: string;
onRefresh: () => void;
onSubmitDecision: (requestId: string, decision: 'APPROVED' | 'REJECTED', note?: string) => Promise<boolean>;
onCancelRequest: (requestId: string) => Promise<boolean>;
}
function statusBadge(status: ApprovalRequest['status']) {
if (status === 'PENDING') {
return <Badge variant="secondary" className="gap-1"><Clock3 className="h-3 w-3" />Pending</Badge>;
}
if (status === 'APPROVED') {
return <Badge className="gap-1 bg-emerald-600 hover:bg-emerald-600"><CheckCircle2 className="h-3 w-3" />Approved</Badge>;
}
if (status === 'REJECTED') {
return <Badge variant="destructive" className="gap-1"><XCircle className="h-3 w-3" />Rejected</Badge>;
}
return <Badge variant="outline" className="gap-1"><ShieldX className="h-3 w-3" />Canceled</Badge>;
}
function decisionLabel(
requestStatus: ApprovalRequest['status'],
decisionStatus: 'PENDING' | 'APPROVED' | 'REJECTED'
) {
if (decisionStatus === 'APPROVED') return 'Approved';
if (decisionStatus === 'REJECTED') return 'Rejected';
if (requestStatus === 'CANCELED') return 'Canceled';
return 'Pending';
}
export function ApprovalRequestsPanel({
open,
onOpenChange,
requests,
currentUserId,
canRequestApproval,
isLoadingRequests,
isSubmittingDecision,
isCancelingRequest,
error,
onRefresh,
onSubmitDecision,
onCancelRequest,
}: ApprovalRequestsPanelProps) {
const [decisionNote, setDecisionNote] = useState('');
const pendingRequest = useMemo(
() => requests.find((request) => request.status === 'PENDING') || null,
[requests]
);
const myPendingDecision = useMemo(() => {
if (!currentUserId || !pendingRequest) return null;
return pendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null;
}, [currentUserId, pendingRequest]);
const canCancelPendingRequest = !!pendingRequest
&& !!currentUserId
&& (pendingRequest.requestedById === currentUserId || canRequestApproval);
const handleDecision = async (decision: 'APPROVED' | 'REJECTED') => {
if (!pendingRequest) return;
const success = await onSubmitDecision(pendingRequest.id, decision, decisionNote.trim() || undefined);
if (success) {
setDecisionNote('');
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-xl p-0">
<SheetHeader>
<SheetTitle>Approvals</SheetTitle>
<SheetDescription>Review request history and respond to pending approvals.</SheetDescription>
</SheetHeader>
<div className="px-4 pb-4 space-y-3 overflow-y-auto">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{requests.length} request(s)</p>
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={isLoadingRequests}>
{isLoadingRequests ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
</Button>
</div>
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
) : null}
{pendingRequest && myPendingDecision ? (
<div className="rounded-md border p-3 space-y-2">
<p className="text-sm font-medium">Your response is required</p>
<p className="text-xs text-muted-foreground">
{pendingRequest.requestedBy.name || pendingRequest.requestedBy.email || 'A user'} requested approval.
</p>
<Textarea
value={decisionNote}
onChange={(event) => setDecisionNote(event.target.value)}
placeholder="Optional note"
rows={3}
maxLength={2000}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleDecision('APPROVED')}
disabled={isSubmittingDecision}
>
{isSubmittingDecision ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Approve
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDecision('REJECTED')}
disabled={isSubmittingDecision}
>
{isSubmittingDecision ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Reject
</Button>
</div>
</div>
) : null}
{pendingRequest && canCancelPendingRequest ? (
<div className="rounded-md border p-3">
<Button
size="sm"
variant="outline"
onClick={() => onCancelRequest(pendingRequest.id)}
disabled={isCancelingRequest}
>
{isCancelingRequest ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Cancel Pending Request
</Button>
</div>
) : null}
<div className="space-y-2">
{requests.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No approval requests yet.</p>
) : (
requests.map((request) => (
<div key={request.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">
Requested by {request.requestedBy.name || request.requestedBy.email || 'Unknown'}
</p>
{statusBadge(request.status)}
</div>
{request.message ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{request.message}</p>
) : null}
<p className="text-xs text-muted-foreground">
{new Date(request.createdAt).toLocaleString()}
</p>
<div className="space-y-1">
{request.decisions.map((decision) => (
<div key={decision.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate">
{decision.approver.name || decision.approver.email || 'Unknown'}
</span>
<span className="text-muted-foreground">{decisionLabel(request.status, decision.status)}</span>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,172 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import type { ApprovalRequest } from '@/components/video-page/types';
export interface ApprovalCandidate {
id: string;
name: string | null;
email: string | null;
image: string | null;
}
interface UseApprovalsParams {
projectId?: string;
activeVersionId: string | null;
currentUserId: string | null;
}
export function useApprovals({ projectId, activeVersionId, currentUserId }: UseApprovalsParams) {
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
const [candidates, setCandidates] = useState<ApprovalCandidate[]>([]);
const [isLoadingRequests, setIsLoadingRequests] = useState(false);
const [isLoadingCandidates, setIsLoadingCandidates] = useState(false);
const [isSubmittingRequest, setIsSubmittingRequest] = useState(false);
const [isSubmittingDecision, setIsSubmittingDecision] = useState(false);
const [isCancelingRequest, setIsCancelingRequest] = useState(false);
const [error, setError] = useState('');
const fetchRequests = useCallback(async () => {
if (!activeVersionId) return;
setIsLoadingRequests(true);
setError('');
try {
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, { cache: 'no-store' });
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to fetch approval requests');
return;
}
setRequests(payload?.data?.requests || []);
} catch {
setError('Failed to fetch approval requests');
} finally {
setIsLoadingRequests(false);
}
}, [activeVersionId]);
const fetchCandidates = useCallback(async () => {
if (!projectId) return;
setIsLoadingCandidates(true);
setError('');
try {
const res = await fetch(`/api/projects/${projectId}/approval-candidates`, { cache: 'no-store' });
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to fetch approvers');
return;
}
setCandidates(payload?.data?.candidates || []);
} catch {
setError('Failed to fetch approvers');
} finally {
setIsLoadingCandidates(false);
}
}, [projectId]);
const createRequest = useCallback(async (approverIds: string[], message?: string) => {
if (!activeVersionId) return false;
setIsSubmittingRequest(true);
setError('');
try {
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approverIds, message: message || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to create approval request');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to create approval request');
return false;
} finally {
setIsSubmittingRequest(false);
}
}, [activeVersionId, fetchRequests]);
const submitDecision = useCallback(async (
requestId: string,
decision: 'APPROVED' | 'REJECTED',
note?: string
) => {
setIsSubmittingDecision(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note: note || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to submit approval decision');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to submit approval decision');
return false;
} finally {
setIsSubmittingDecision(false);
}
}, [fetchRequests]);
const cancelRequest = useCallback(async (requestId: string) => {
setIsCancelingRequest(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/cancel`, {
method: 'POST',
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to cancel approval request');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to cancel approval request');
return false;
} finally {
setIsCancelingRequest(false);
}
}, [fetchRequests]);
const activePendingRequest = useMemo(
() => requests.find((request) => request.status === 'PENDING') || null,
[requests]
);
const myPendingDecision = useMemo(() => {
if (!currentUserId || !activePendingRequest) return null;
return activePendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null;
}, [activePendingRequest, currentUserId]);
return {
requests,
candidates,
isLoadingRequests,
isLoadingCandidates,
isSubmittingRequest,
isSubmittingDecision,
isCancelingRequest,
activePendingRequest,
myPendingDecision,
error,
setError,
fetchRequests,
fetchCandidates,
createRequest,
submitDecision,
cancelRequest,
};
}
+41
View File
@@ -18,6 +18,46 @@ export interface CommentTag {
color: string;
}
export interface ApprovalDecision {
id: string;
approverId: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED';
note: string | null;
respondedAt: string | null;
createdAt: string;
approver: {
id: string;
name: string | null;
email: string | null;
image: string | null;
};
}
export interface ApprovalRequest {
id: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELED';
requestedById: string;
message: string | null;
resolvedAt: string | null;
canceledAt: string | null;
canceledById: string | null;
createdAt: string;
updatedAt: string;
requestedBy: {
id: string;
name: string | null;
email: string | null;
image: string | null;
};
canceledBy: {
id: string;
name: string | null;
email: string | null;
image: string | null;
} | null;
decisions: ApprovalDecision[];
}
export interface CommentReply {
id: string;
content: string | null;
@@ -70,6 +110,7 @@ export interface VideoData {
canDownload?: boolean;
canManageTags?: boolean;
canResolveComments?: boolean;
canRequestApproval?: boolean;
}
export interface BunnyQualityOption {
+53 -42
View File
@@ -2,7 +2,7 @@
import { memo } from 'react';
import Link from 'next/link';
import { ArrowLeft, ChevronDown, GitCompareArrows, MoreVertical, Plus, Share2, Trash2 } from 'lucide-react';
import { ArrowLeft, ChevronDown, GitCompareArrows, ListChecks, MoreVertical, Plus, Share2, ShieldCheck, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { DownloadControls, DownloadMenuItems } from '@/components/video-page/download-controls';
import { DownloadMenuItems } from '@/components/video-page/download-controls';
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
@@ -60,6 +60,10 @@ interface VideoPageHeaderProps {
isCreatingVersion: boolean;
onCreateVersion: () => void;
onOpenCompare: () => void;
canRequestApproval: boolean;
hasPendingApprovalRequest: boolean;
onOpenApprovalRequest: () => void;
onOpenApprovalsPanel: () => void;
}
export const VideoPageHeader = memo(function VideoPageHeader({
@@ -102,29 +106,34 @@ export const VideoPageHeader = memo(function VideoPageHeader({
isCreatingVersion,
onCreateVersion,
onOpenCompare,
canRequestApproval,
hasPendingApprovalRequest,
onOpenApprovalRequest,
onOpenApprovalsPanel,
}: VideoPageHeaderProps) {
return (
<div className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50',
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50 gap-3',
isFullscreenMode ? 'absolute top-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<Link
href={backHref}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
<Separator orientation="vertical" className="h-5" />
<div className="hidden sm:block min-w-0">
<span className="text-sm font-medium">{title}</span>
<span className="text-xs text-muted-foreground ml-2"> {projectName}</span>
<Separator orientation="vertical" className="h-5 shrink-0" />
<div className="hidden sm:flex min-w-0 items-center gap-2">
<span className="text-sm font-medium truncate">{title}</span>
<span className="text-xs text-muted-foreground shrink-0"></span>
<span className="text-xs text-muted-foreground truncate">{projectName}</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
@@ -175,24 +184,29 @@ export const VideoPageHeader = memo(function VideoPageHeader({
onDelete={onDeleteVersion}
/>
<DownloadControls
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
isDownloading={isDownloadingVideo}
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
{mode === 'dashboard' && (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-1" />
Share Video
</Link>
<Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex">
<Plus className="h-4 w-4 mr-1" />
New Version
</Button>
<div className="hidden sm:flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onOpenApprovalsPanel} className="hidden sm:inline-flex">
<ListChecks className="h-4 w-4 mr-1" />
Approvals
{hasPendingApprovalRequest ? (
<Badge variant="default" className="ml-2 hidden xl:inline-flex">Pending</Badge>
) : null}
</Button>
{versions.length >= 2 && (
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
<GitCompareArrows className="h-4 w-4 mr-1" />
Compare
</Button>
)}
<div className="hidden">
<VersionActionsDialog
open={showVersionDialog}
onOpenChange={setShowVersionDialog}
@@ -212,16 +226,9 @@ export const VideoPageHeader = memo(function VideoPageHeader({
versionsCount={versions.length}
onCreateVersion={onCreateVersion}
/>
{versions.length >= 2 && (
<Button variant="outline" size="sm" onClick={onOpenCompare}>
<GitCompareArrows className="h-4 w-4 mr-1" />
Compare
</Button>
)}
</div>
<div className="sm:hidden">
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
@@ -229,6 +236,20 @@ export const VideoPageHeader = memo(function VideoPageHeader({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share Video
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={onOpenApprovalRequest}
disabled={!canRequestApproval}
>
<ShieldCheck className="h-4 w-4 mr-2" />
Request Approval
</DropdownMenuItem>
<DropdownMenuSeparator />
<DownloadMenuItems
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
@@ -236,16 +257,6 @@ export const VideoPageHeader = memo(function VideoPageHeader({
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
New Version
</DropdownMenuItem>
{versions.length >= 2 && (
<DropdownMenuItem onSelect={onOpenCompare}>
<GitCompareArrows className="h-4 w-4 mr-2" />
Compare
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
+1 -12
View File
@@ -105,18 +105,7 @@ async function getR2StorageSnapshot(): Promise<R2StorageSnapshot> {
return globalForAdminStats.adminR2StorageSnapshot;
}
if (!globalForAdminStats.adminR2StorageSnapshotPromise) {
globalForAdminStats.adminR2StorageSnapshotPromise = buildR2StorageSnapshot()
.then((snapshot) => {
globalForAdminStats.adminR2StorageSnapshot = snapshot;
return snapshot;
})
.finally(() => {
globalForAdminStats.adminR2StorageSnapshotPromise = undefined;
});
}
return globalForAdminStats.adminR2StorageSnapshotPromise;
return Promise.reject(new Error('R2 storage snapshot is not available. Trigger a manual refresh from admin dashboard.'));
}
export async function refreshR2StorageSnapshot(): Promise<string> {
+56
View File
@@ -0,0 +1,56 @@
import { db } from '@/lib/db';
export interface ApprovalCandidate {
id: string;
name: string | null;
email: string | null;
image: string | null;
}
function addCandidate(map: Map<string, ApprovalCandidate>, user: ApprovalCandidate | null | undefined) {
if (!user) return;
map.set(user.id, user);
}
export async function getApprovalCandidatesForProject(projectId: string): Promise<ApprovalCandidate[] | null> {
const project = await db.project.findUnique({
where: { id: projectId },
select: {
owner: { select: { id: true, name: true, email: true, image: true } },
members: {
select: {
user: { select: { id: true, name: true, email: true, image: true } },
},
},
workspace: {
select: {
owner: { select: { id: true, name: true, email: true, image: true } },
members: {
select: {
user: { select: { id: true, name: true, email: true, image: true } },
},
},
},
},
},
});
if (!project) return null;
const map = new Map<string, ApprovalCandidate>();
addCandidate(map, project.owner);
addCandidate(map, project.workspace.owner);
for (const member of project.members) {
addCandidate(map, member.user);
}
for (const member of project.workspace.members) {
addCandidate(map, member.user);
}
return Array.from(map.values()).sort((a, b) => {
const aLabel = (a.name || a.email || '').toLowerCase();
const bLabel = (b.name || b.email || '').toLowerCase();
return aLabel.localeCompare(bLabel);
});
}
+100
View File
@@ -0,0 +1,100 @@
export const EMAIL_COLORS = {
bg: '#171717',
card: '#252525',
cardInner: '#2f2f2f',
border: '#3a3a3a',
accent: '#7aa7ff',
accentDark: '#243656',
text: '#f5f5f5',
textSecondary: '#c6c6cc',
textDim: '#8d8d95',
} as const;
function brandLogoSvg(): string {
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display:block;pointer-events:none;">
<rect x="2" y="6" width="14" height="12" rx="2" stroke="${EMAIL_COLORS.accent}" stroke-width="2" />
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" stroke="${EMAIL_COLORS.accent}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>`;
}
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function escapeAttr(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export function brandedEmailTemplate(
body: string,
options?: {
footerText?: string;
footerLinkText?: string;
footerLinkUrl?: string;
}
): string {
const footerText = options?.footerText || '';
const footerLinkText = options?.footerLinkText || '';
const footerLinkUrl = options?.footerLinkUrl || '';
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><meta name="color-scheme" content="dark"></head>
<body style="margin:0;padding:0;background-color:${EMAIL_COLORS.bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:${EMAIL_COLORS.text};">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:${EMAIL_COLORS.bg};padding:40px 16px;">
<tr><td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;">
<tr><td style="padding:0 0 24px;">
<table cellpadding="0" cellspacing="0"><tr>
<td style="padding-right:10px;vertical-align:middle;">${brandLogoSvg()}</td>
<td style="vertical-align:middle;font-size:16px;font-weight:700;color:${EMAIL_COLORS.text};letter-spacing:0.08em;">OpenFrame</td>
</tr></table>
</td></tr>
<tr><td style="background-color:${EMAIL_COLORS.card};border:1px solid ${EMAIL_COLORS.border};padding:0;">
${body}
</td></tr>
${(footerText || (footerLinkText && footerLinkUrl)) ? `
<tr><td style="padding:20px 0 0;text-align:center;">
${footerText ? `<p style="margin:0 0 6px;font-size:11px;color:${EMAIL_COLORS.textDim};">${footerText}</p>` : ''}
${(footerLinkText && footerLinkUrl) ? `<a href="${escapeAttr(footerLinkUrl)}" style="font-size:11px;color:${EMAIL_COLORS.accent};text-decoration:underline;">${escapeHtml(footerLinkText)}</a>` : ''}
</td></tr>` : ''}
</table>
</td></tr>
</table>
</body>
</html>`;
}
export function emailHeading(icon: string, title: string): string {
return `<td style="padding:16px 20px;border-bottom:1px solid ${EMAIL_COLORS.border};background-color:${EMAIL_COLORS.accentDark};">
<span style="font-size:14px;font-weight:600;color:${EMAIL_COLORS.accent};">${icon} &nbsp;${title}</span>
</td>`;
}
export function emailRow(label: string, value: string, isHighlight = false): string {
const valStyle = isHighlight
? `color:${EMAIL_COLORS.text};font-weight:600;`
: `color:${EMAIL_COLORS.textSecondary};`;
return `<tr>
<td style="padding:6px 16px 6px 0;color:${EMAIL_COLORS.textDim};font-size:13px;white-space:nowrap;vertical-align:top;">${label}</td>
<td style="padding:6px 0;font-size:13px;${valStyle}">${value}</td>
</tr>`;
}
export function emailButton(text: string, url: string): string {
return `<a href="${escapeAttr(url)}" style="display:inline-block;padding:9px 22px;background-color:${EMAIL_COLORS.accent};color:#0f1114;font-size:13px;font-weight:700;text-decoration:none;letter-spacing:0.2px;">${text}</a>`;
}
export function emailHighlight(text: string): string {
return `<div style="border:1px solid ${EMAIL_COLORS.border};padding:10px 12px;margin:0 0 16px;background-color:${EMAIL_COLORS.cardInner};color:${EMAIL_COLORS.text};font-size:13px;line-height:1.5;">${text}</div>`;
}
+366
View File
@@ -0,0 +1,366 @@
import { randomBytes } from 'crypto';
import nodemailer from 'nodemailer';
import { InvitationRole, InvitationScope, InvitationStatus, Prisma, ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { db } from '@/lib/db';
import {
brandedEmailTemplate,
emailButton,
emailHeading,
emailHighlight,
emailRow,
escapeHtml,
} from '@/lib/email-brand';
const INVITATION_TTL_DAYS = 7;
const MAX_INVITATION_RETRIES = 3;
function createSmtpTransport() {
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || '587');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
if (!host || !user || !pass) return null;
return nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
}
function roleLabel(role: InvitationRole): string {
return role === 'ADMIN' ? 'Admin' : 'Commentator';
}
function scopeLabel(scope: InvitationScope): string {
return scope === 'WORKSPACE' ? 'workspace' : 'project';
}
export function buildInvitationUrl(token: string, email: string): string {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const url = new URL('/invitations/accept', baseUrl);
url.searchParams.set('token', token);
url.searchParams.set('email', email);
return url.toString();
}
export async function sendInvitationEmail(input: {
to: string;
inviterName: string;
role: InvitationRole;
scope: InvitationScope;
targetName: string;
invitationUrl: string;
}): Promise<boolean> {
const transporter = createSmtpTransport();
if (!transporter) {
console.warn('SMTP not configured — skipping invitation email');
return false;
}
const fromAddress = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
const subject = `[OpenFrame] You were invited to a ${scopeLabel(input.scope)}: ${input.targetName}`;
const html = invitationEmailTemplate({
inviterName: input.inviterName,
role: roleLabel(input.role),
scope: scopeLabel(input.scope),
targetName: input.targetName,
invitationUrl: input.invitationUrl,
});
try {
await transporter.sendMail({
from: fromAddress,
to: input.to,
subject,
html,
});
return true;
} catch (error) {
console.error('Invitation email send failed:', error);
return false;
}
}
function invitationEmailTemplate(input: {
inviterName: string;
role: string;
scope: string;
targetName: string;
invitationUrl: string;
}): string {
return brandedEmailTemplate(
`
<tr>${emailHeading('&#10003;', `${escapeHtml(input.scope.charAt(0).toUpperCase() + input.scope.slice(1))} Invitation`)}</tr>
<tr><td style="padding:20px;">
${emailHighlight('You were invited to join OpenFrame.')}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Invited by', escapeHtml(input.inviterName), true)}
${emailRow('Target', `${escapeHtml(input.targetName)} (${escapeHtml(input.scope)})`, true)}
${emailRow('Role', escapeHtml(input.role))}
${emailRow('Expires', `${INVITATION_TTL_DAYS} days`)}
</table>
${emailHighlight('Create an account (or sign in with this email) to accept this invitation.')}
${emailButton('Accept Invitation &#8594;', input.invitationUrl)}
</td></tr>
`,
{
footerText: `This invitation expires in ${INVITATION_TTL_DAYS} days.`,
}
);
}
export async function createOrRefreshInvitation(params: {
email: string;
scope: InvitationScope;
role: InvitationRole;
invitedById: string;
workspaceId?: string;
projectId?: string;
}) {
const normalizedEmail = params.email.toLowerCase().trim();
for (let attempt = 1; attempt <= MAX_INVITATION_RETRIES; attempt++) {
const now = new Date();
const expiresAt = new Date(now.getTime() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
const token = randomBytes(32).toString('hex');
try {
return await db.$transaction(async (tx) => {
await tx.invitation.updateMany({
where: {
email: normalizedEmail,
scope: params.scope,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
status: InvitationStatus.PENDING,
expiresAt: { lte: now },
},
data: {
status: InvitationStatus.EXPIRED,
},
});
const existingPending = await tx.invitation.findFirst({
where: {
email: normalizedEmail,
scope: params.scope,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (existingPending) {
await tx.invitation.updateMany({
where: {
email: normalizedEmail,
scope: params.scope,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
status: InvitationStatus.PENDING,
id: { not: existingPending.id },
},
data: {
status: InvitationStatus.CANCELED,
},
});
return tx.invitation.update({
where: { id: existingPending.id },
data: {
role: params.role,
invitedById: params.invitedById,
token,
expiresAt,
},
});
}
return tx.invitation.create({
data: {
email: normalizedEmail,
scope: params.scope,
role: params.role,
invitedById: params.invitedById,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
token,
expiresAt,
status: InvitationStatus.PENDING,
},
});
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
} catch (error) {
const isSerializationFailure = error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034';
if (!isSerializationFailure || attempt === MAX_INVITATION_RETRIES) {
throw error;
}
}
}
throw new Error('Failed to create invitation after retrying');
}
export async function getValidInvitationByToken(token: string) {
const now = new Date();
return db.invitation.findFirst({
where: {
token,
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
});
}
async function acceptInvitation(tx: Prisma.TransactionClient, invitationId: string) {
await tx.invitation.update({
where: { id: invitationId },
data: {
status: InvitationStatus.ACCEPTED,
acceptedAt: new Date(),
},
});
}
async function applyInvitationMembership(tx: Prisma.TransactionClient, invitation: {
id: string;
role: InvitationRole;
scope: InvitationScope;
workspaceId: string | null;
projectId: string | null;
}, userId: string) {
if (invitation.scope === InvitationScope.WORKSPACE && invitation.workspaceId) {
const workspace = await tx.workspace.findUnique({
where: { id: invitation.workspaceId },
select: { ownerId: true },
});
if (!workspace) return;
if (workspace.ownerId !== userId) {
await tx.workspaceMember.upsert({
where: {
workspaceId_userId: {
workspaceId: invitation.workspaceId,
userId,
},
},
update: {
role: invitation.role === InvitationRole.ADMIN
? WorkspaceMemberRole.ADMIN
: WorkspaceMemberRole.COMMENTATOR,
},
create: {
workspaceId: invitation.workspaceId,
userId,
role: invitation.role === InvitationRole.ADMIN
? WorkspaceMemberRole.ADMIN
: WorkspaceMemberRole.COMMENTATOR,
},
});
}
await acceptInvitation(tx, invitation.id);
return;
}
if (invitation.scope === InvitationScope.PROJECT && invitation.projectId) {
const project = await tx.project.findUnique({
where: { id: invitation.projectId },
select: { ownerId: true },
});
if (!project) return;
if (project.ownerId !== userId) {
await tx.projectMember.upsert({
where: {
projectId_userId: {
projectId: invitation.projectId,
userId,
},
},
update: {
role: invitation.role === InvitationRole.ADMIN
? ProjectMemberRole.ADMIN
: ProjectMemberRole.COMMENTATOR,
},
create: {
projectId: invitation.projectId,
userId,
role: invitation.role === InvitationRole.ADMIN
? ProjectMemberRole.ADMIN
: ProjectMemberRole.COMMENTATOR,
},
});
}
await acceptInvitation(tx, invitation.id);
}
}
export async function acceptInvitationTokenForUser(input: {
token: string;
userId: string;
email: string;
}): Promise<'accepted' | 'not_found' | 'forbidden' | 'expired'> {
const normalizedEmail = input.email.toLowerCase().trim();
const now = new Date();
return db.$transaction(async (tx) => {
const invitation = await tx.invitation.findUnique({
where: { token: input.token },
});
if (!invitation) return 'not_found';
if (invitation.email !== normalizedEmail) return 'forbidden';
if (invitation.status !== InvitationStatus.PENDING) return 'not_found';
if (invitation.expiresAt <= now) {
await tx.invitation.update({
where: { id: invitation.id },
data: { status: InvitationStatus.EXPIRED },
});
return 'expired';
}
await applyInvitationMembership(tx, invitation, input.userId);
return 'accepted';
});
}
export async function acceptPendingInvitationsForUser(userId: string, email: string) {
const normalizedEmail = email.toLowerCase().trim();
const now = new Date();
await db.$transaction(async (tx) => {
await tx.invitation.updateMany({
where: {
email: normalizedEmail,
status: InvitationStatus.PENDING,
expiresAt: { lte: now },
},
data: {
status: InvitationStatus.EXPIRED,
},
});
const pendingInvitations = await tx.invitation.findMany({
where: {
email: normalizedEmail,
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
orderBy: { createdAt: 'asc' },
});
for (const invitation of pendingInvitations) {
await applyInvitationMembership(tx, invitation, userId);
}
});
}
+206 -127
View File
@@ -1,5 +1,14 @@
import { db } from '@/lib/db';
import nodemailer from 'nodemailer';
import {
EMAIL_COLORS,
brandedEmailTemplate,
emailButton,
emailHeading,
emailHighlight,
emailRow,
escapeHtml,
} from '@/lib/email-brand';
// ============================================
// NOTIFICATION CHANNELS
@@ -97,7 +106,11 @@ export type NotificationEvent =
| { type: 'new_video'; projectName: string; videoTitle: string; addedBy: string; url: string }
| { type: 'new_version'; projectName: string; videoTitle: string; versionLabel: string; addedBy: string; url: string }
| { type: 'new_comment'; projectName: string; videoTitle: string; commentAuthor: string; commentText: string; timestamp: string; url: string }
| { type: 'new_reply'; projectName: string; videoTitle: string; replyAuthor: string; replyText: string; parentAuthor: string; timestamp: string; url: string };
| { type: 'new_reply'; projectName: string; videoTitle: string; replyAuthor: string; replyText: string; parentAuthor: string; timestamp: string; url: string }
| { type: 'approval_requested'; projectName: string; videoTitle: string; versionLabel: string; requestedBy: string; message?: string; url: string }
| { type: 'approval_action'; projectName: string; videoTitle: string; versionLabel: string; actorName: string; action: 'approved' | 'rejected'; note?: string; url: string }
| { type: 'approval_completed'; projectName: string; videoTitle: string; versionLabel: string; approvedByCount: number; url: string }
| { type: 'approval_rejected'; projectName: string; videoTitle: string; versionLabel: string; rejectedBy: string; note?: string; url: string };
/** Structured Telegram message with text body + button label/URL */
interface TelegramMessage {
@@ -160,6 +173,57 @@ function formatTelegramMessage(event: NotificationEvent, timezone: string): Tele
buttonLabel: 'View Reply',
buttonUrl: event.url,
};
case 'approval_requested':
return {
text:
`✅ Approval Requested\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Requested by: ${event.requestedBy}\n` +
`${now}` +
(event.message ? `\n\n"${truncate(event.message, 200)}"` : ''),
buttonLabel: 'Review Request',
buttonUrl: event.url,
};
case 'approval_action':
return {
text:
`✅ Approval Update\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`${event.actorName} ${event.action}\n` +
`${now}` +
(event.note ? `\n\n"${truncate(event.note, 200)}"` : ''),
buttonLabel: 'Open Request',
buttonUrl: event.url,
};
case 'approval_completed':
return {
text:
`✅ Approval Completed\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Approved by: ${event.approvedByCount}\n` +
`${now}`,
buttonLabel: 'Open Version',
buttonUrl: event.url,
};
case 'approval_rejected':
return {
text:
`⛔ Approval Rejected\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Rejected by: ${event.rejectedBy}\n` +
`${now}` +
(event.note ? `\n\n"${truncate(event.note, 200)}"` : ''),
buttonLabel: 'Open Request',
buttonUrl: event.url,
};
}
}
@@ -167,82 +231,13 @@ function formatTelegramMessage(event: NotificationEvent, timezone: string): Tele
// EMAIL TEMPLATE
// ============================================
const BASE_URL = () => process.env.NEXTAUTH_URL || '';
// Theme colors (hex equivalents of oklch dark theme)
const COLORS = {
bg: '#111114', // page background (very dark)
card: '#1a1a20', // card background
cardInner: '#212128', // inner card / section bg
border: '#2a2a32', // subtle border
accent: '#2ec8d8', // primary/accent teal-cyan
accentDark: '#1a3a40',// accent background for headings
text: '#ebebeb', // primary text
textSecondary: '#9a9a9f', // muted text
textDim: '#6a6a72', // dimmer labels
} as const;
/**
* Wrap email body content in a branded template matching OpenFrame's dark theme.
* Square corners (radius:0), card-based layout, teal accent, unsubscribe footer.
*/
function emailTemplate(body: string): string {
const settingsUrl = `${BASE_URL()}/settings`;
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><meta name="color-scheme" content="dark"></head>
<body style="margin:0;padding:0;background-color:${COLORS.bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:${COLORS.text};">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:${COLORS.bg};padding:40px 16px;">
<tr><td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;">
<!-- Header -->
<tr><td style="padding:0 0 24px;">
<table cellpadding="0" cellspacing="0"><tr>
<td style="padding-right:10px;vertical-align:middle;color:${COLORS.accent};font-size:20px;">&#9654;</td>
<td style="vertical-align:middle;font-size:18px;font-weight:700;color:${COLORS.text};letter-spacing:-0.3px;">OpenFrame</td>
</tr></table>
</td></tr>
<!-- Main Card -->
<tr><td style="background-color:${COLORS.card};border:1px solid ${COLORS.border};padding:0;">
${body}
</td></tr>
<!-- Footer -->
<tr><td style="padding:20px 0 0;text-align:center;">
<p style="margin:0 0 6px;font-size:11px;color:${COLORS.textDim};">You received this because email notifications are enabled.</p>
<a href="${escapeAttr(settingsUrl)}" style="font-size:11px;color:${COLORS.accent};text-decoration:underline;">Unsubscribe &middot; Manage notification settings</a>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
/** Generates an info row for email detail tables */
function emailRow(label: string, value: string, isHighlight = false): string {
const valStyle = isHighlight
? `color:${COLORS.text};font-weight:600;`
: `color:${COLORS.textSecondary};`;
return `<tr>
<td style="padding:6px 16px 6px 0;color:${COLORS.textDim};font-size:13px;white-space:nowrap;vertical-align:top;">${label}</td>
<td style="padding:6px 0;font-size:13px;${valStyle}">${value}</td>
</tr>`;
}
/** Generates the accent-colored event type heading bar */
function emailHeading(icon: string, title: string): string {
return `<td style="padding:16px 20px;border-bottom:1px solid ${COLORS.border};background-color:${COLORS.accentDark};">
<span style="font-size:14px;font-weight:600;color:${COLORS.accent};">${icon} &nbsp;${title}</span>
</td>`;
}
/** Generates a CTA button */
function emailButton(text: string, url: string): string {
return `<a href="${escapeAttr(url)}" style="display:inline-block;padding:9px 22px;background-color:${COLORS.accent};color:#0f1114;font-size:13px;font-weight:600;text-decoration:none;letter-spacing:0.2px;">${text}</a>`;
const baseUrl = process.env.NEXTAUTH_URL || '';
return brandedEmailTemplate(body, {
footerText: 'You received this because email notifications are enabled.',
footerLinkText: 'Unsubscribe · Manage notification settings',
footerLinkUrl: `${baseUrl}/settings`,
});
}
/**
@@ -297,7 +292,7 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailRow('At', event.timestamp)}
${emailRow('When', now)}
</table>
<div style="border-left:2px solid ${COLORS.accent};padding:10px 14px;margin:0 0 20px;background-color:${COLORS.cardInner};color:${COLORS.textSecondary};font-size:13px;line-height:1.6;">
<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">
${escapeHtml(truncate(event.commentText, 300))}
</div>
${emailButton('View Comment &#8594;', event.url)}
@@ -313,16 +308,91 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('From', `<span style="color:${COLORS.text};font-weight:500;">${escapeHtml(event.replyAuthor)}</span> <span style="color:${COLORS.textDim};">&#8594;</span> ${escapeHtml(event.parentAuthor)}`)}
${emailRow('From', `<span style="color:${EMAIL_COLORS.text};font-weight:500;">${escapeHtml(event.replyAuthor)}</span> <span style="color:${EMAIL_COLORS.textDim};">&#8594;</span> ${escapeHtml(event.parentAuthor)}`)}
${emailRow('When', now)}
</table>
<div style="border-left:2px solid ${COLORS.accent};padding:10px 14px;margin:0 0 20px;background-color:${COLORS.cardInner};color:${COLORS.textSecondary};font-size:13px;line-height:1.6;">
<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">
${escapeHtml(truncate(event.replyText, 300))}
</div>
${emailButton('View Reply &#8594;', event.url)}
</td></tr>
`),
};
case 'approval_requested':
return {
subject: `[OpenFrame] Approval requested for ${event.versionLabel} in ${event.projectName}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Requested')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`A new approval request is waiting for your response.`)}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Requested by', escapeHtml(event.requestedBy))}
${emailRow('When', now)}
</table>
${event.message ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.message, 300))}</div>` : ''}
${emailButton('Review Request &#8594;', event.url)}
</td></tr>
`),
};
case 'approval_action':
return {
subject: `[OpenFrame] Approval ${event.action} by ${event.actorName}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Update')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`${escapeHtml(event.actorName)} ${escapeHtml(event.action)} this request.`)}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Action', escapeHtml(`${event.actorName} ${event.action}`))}
${emailRow('When', now)}
</table>
${event.note ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.note, 300))}</div>` : ''}
${emailButton('Open Request &#8594;', event.url)}
</td></tr>
`),
};
case 'approval_completed':
return {
subject: `[OpenFrame] Approval completed for ${event.versionLabel}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Completed')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`All approvers accepted this request.`)}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Approvals', String(event.approvedByCount))}
${emailRow('When', now)}
</table>
${emailButton('Open Version &#8594;', event.url)}
</td></tr>
`),
};
case 'approval_rejected':
return {
subject: `[OpenFrame] Approval rejected by ${event.rejectedBy}`,
html: emailTemplate(`
<tr>${emailHeading('&#9940;', 'Approval Rejected')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`${escapeHtml(event.rejectedBy)} rejected this request.`)}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Rejected by', escapeHtml(event.rejectedBy))}
${emailRow('When', now)}
</table>
${event.note ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.note, 300))}</div>` : ''}
${emailButton('Open Request &#8594;', event.url)}
</td></tr>
`),
};
}
}
@@ -333,8 +403,8 @@ export function testEmailHtml(): string {
return emailTemplate(`
<tr>${emailHeading('&#10003;', 'Test Notification')}</tr>
<tr><td style="padding:20px;">
<p style="margin:0 0 8px;font-size:14px;color:${COLORS.text};">Email notifications are working.</p>
<p style="margin:0;font-size:13px;color:${COLORS.textSecondary};">You&rsquo;ll receive emails when there&rsquo;s activity on your projects.</p>
<p style="margin:0 0 8px;font-size:14px;color:${EMAIL_COLORS.text};">Email notifications are working.</p>
<p style="margin:0;font-size:13px;color:${EMAIL_COLORS.textSecondary};">You&rsquo;ll receive emails when there&rsquo;s activity on your projects.</p>
</td></tr>
`);
}
@@ -348,44 +418,70 @@ export function testEmailHtml(): string {
* Looks up the owner's notification settings and dispatches to enabled channels.
* Best-effort — never throws, logs errors.
*/
export async function notifyProjectOwner(ownerId: string, event: NotificationEvent): Promise<void> {
function isApprovalEvent(event: NotificationEvent): boolean {
return event.type === 'approval_requested'
|| event.type === 'approval_action'
|| event.type === 'approval_completed'
|| event.type === 'approval_rejected';
}
function shouldSendEvent(settings: {
onNewVideo: boolean;
onNewVersion: boolean;
onNewComment: boolean;
onNewReply: boolean;
onApprovalEvents: boolean;
}, event: NotificationEvent): boolean {
if (event.type === 'new_video') return settings.onNewVideo;
if (event.type === 'new_version') return settings.onNewVersion;
if (event.type === 'new_comment') return settings.onNewComment;
if (event.type === 'new_reply') return settings.onNewReply;
if (isApprovalEvent(event)) return settings.onApprovalEvents;
return false;
}
export async function notifyUsers(userIds: string[], event: NotificationEvent): Promise<void> {
try {
const settings = await db.notificationSetting.findUnique({
where: { userId: ownerId },
const dedupedUserIds = Array.from(new Set(userIds.filter(Boolean)));
if (dedupedUserIds.length === 0) return;
const settingsList = await db.notificationSetting.findMany({
where: { userId: { in: dedupedUserIds } },
include: { user: { select: { email: true } } },
});
if (!settings) return; // No notification preferences configured
await Promise.allSettled(settingsList.map(async (settings) => {
if (!shouldSendEvent(settings, event)) return;
const shouldNotify =
(event.type === 'new_video' && settings.onNewVideo) ||
(event.type === 'new_version' && settings.onNewVersion) ||
(event.type === 'new_comment' && settings.onNewComment) ||
(event.type === 'new_reply' && settings.onNewReply);
const promises: Promise<boolean>[] = [];
const tz = settings.timezone || 'UTC';
if (!shouldNotify) return;
if (settings.telegramEnabled && settings.telegramBotToken && settings.telegramChatId) {
const msg = formatTelegramMessage(event, tz);
promises.push(sendTelegram(
settings.telegramBotToken,
settings.telegramChatId,
msg.text,
msg.buttonLabel,
msg.buttonUrl,
));
}
const promises: Promise<boolean>[] = [];
const tz = settings.timezone || 'UTC';
// Telegram
if (settings.telegramEnabled && settings.telegramBotToken && settings.telegramChatId) {
const msg = formatTelegramMessage(event, tz);
promises.push(sendTelegram(
settings.telegramBotToken,
settings.telegramChatId,
msg.text,
msg.buttonLabel,
msg.buttonUrl,
));
}
if (settings.emailEnabled && settings.user.email) {
const { subject, html } = formatEmail(event, tz);
promises.push(sendEmail(settings.user.email, subject, html));
}
// Email
if (settings.emailEnabled && settings.user.email) {
const { subject, html } = formatEmail(event, tz);
promises.push(sendEmail(settings.user.email, subject, html));
}
await Promise.allSettled(promises);
}));
} catch (err) {
console.error('Notification dispatch failed:', err);
}
}
await Promise.allSettled(promises);
export async function notifyProjectOwner(ownerId: string, event: NotificationEvent): Promise<void> {
try {
await notifyUsers([ownerId], event);
} catch (err) {
console.error('Notification dispatch failed:', err);
}
@@ -424,23 +520,6 @@ function formatNow(timezone: string): string {
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** Escape a URL for use inside an HTML href="..." attribute */
function escapeAttr(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function truncate(str: string, maxLen: number): string {
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
}
+104
View File
@@ -33,6 +33,10 @@ model User {
notificationSetting NotificationSetting?
watchProgress WatchProgress[]
feedbackEntries UserFeedback[]
requestedApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsRequestedBy")
canceledApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsCanceledBy")
approvalDecisions ApprovalDecision[]
sentInvitations Invitation[] @relation("InvitationsSentBy")
@@map("users")
}
@@ -143,6 +147,7 @@ model Workspace {
// Relations
members WorkspaceMember[]
projects Project[]
invitations Invitation[]
@@index([ownerId])
@@index([slug])
@@ -199,6 +204,7 @@ model Project {
members ProjectMember[]
shareLinks ShareLink[]
commentTags CommentTag[]
invitations Invitation[]
@@index([ownerId])
@@index([slug])
@@ -235,6 +241,50 @@ enum ProjectMemberRole {
COMMENTATOR // Can view and comment only
}
enum InvitationScope {
WORKSPACE
PROJECT
}
enum InvitationRole {
ADMIN
COMMENTATOR
}
enum InvitationStatus {
PENDING
ACCEPTED
CANCELED
EXPIRED
}
model Invitation {
id String @id @default(cuid())
token String @unique
email String
scope InvitationScope
role InvitationRole
status InvitationStatus @default(PENDING)
workspaceId String?
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
invitedById String
invitedBy User @relation("InvitationsSentBy", fields: [invitedById], references: [id], onDelete: Cascade)
acceptedAt DateTime?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email, status, expiresAt])
@@index([workspaceId, status, createdAt(sort: Desc)])
@@index([projectId, status, createdAt(sort: Desc)])
@@map("invitations")
}
model Video {
id String @id @default(cuid())
title String
@@ -287,6 +337,7 @@ model VideoVersion {
// Relations
comments Comment[]
watchProgress WatchProgress[]
approvalRequests ApprovalRequest[]
@@unique([videoParentId, versionNumber])
@@index([videoParentId])
@@ -452,6 +503,58 @@ enum SharePermission {
COMMENT // Can view and comment
}
enum ApprovalRequestStatus {
PENDING
APPROVED
REJECTED
CANCELED
}
enum ApprovalDecisionStatus {
PENDING
APPROVED
REJECTED
}
model ApprovalRequest {
id String @id @default(cuid())
versionId String
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
requestedById String
requestedBy User @relation("ApprovalRequestsRequestedBy", fields: [requestedById], references: [id], onDelete: Cascade)
message String? @db.Text
status ApprovalRequestStatus @default(PENDING)
resolvedAt DateTime?
canceledAt DateTime?
canceledById String?
canceledBy User? @relation("ApprovalRequestsCanceledBy", fields: [canceledById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
decisions ApprovalDecision[]
@@index([versionId, status, createdAt(sort: Desc)])
@@index([requestedById, createdAt(sort: Desc)])
@@map("approval_requests")
}
model ApprovalDecision {
id String @id @default(cuid())
requestId String
request ApprovalRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
approverId String
approver User @relation(fields: [approverId], references: [id], onDelete: Cascade)
status ApprovalDecisionStatus @default(PENDING)
note String? @db.Text
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([requestId, approverId])
@@index([approverId, status])
@@map("approval_decisions")
}
// ============================================
// NOTIFICATION SETTINGS
// ============================================
@@ -475,6 +578,7 @@ model NotificationSetting {
onNewVersion Boolean @default(true)
onNewComment Boolean @default(true)
onNewReply Boolean @default(true)
onApprovalEvents Boolean @default(true)
// User timezone for notification timestamps (IANA timezone identifier)
timezone String @default("UTC")