mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
refactor: eslint and prettier conflict will be resolved and formatted
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
.next/
|
||||||
|
node_modules/
|
||||||
|
prisma/migrations/
|
||||||
|
bun.lock
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": false,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
## Must-follow constraints
|
## Must-follow constraints
|
||||||
|
|
||||||
- Use `bun` only. Do not use `npm` or `pnpm`.
|
- Use `bun` only. Do not use `npm` or `pnpm`.
|
||||||
- Do not start the dev server (`bun run dev`); assume it is already running.
|
- Do not start the dev server (`bun run dev`); assume it is already running.
|
||||||
- If you change `prisma/schema.prisma`, run `bun run db:generate`.
|
- If you change `prisma/schema.prisma`, run `bun run db:generate`.
|
||||||
- In App Router dynamic routes, keep `params` typed as `Promise<...>` and `await params` in handlers/pages.
|
- In App Router dynamic routes, keep `params` typed as `Promise<...>` and `await params` in handlers/pages.
|
||||||
|
|
||||||
## Validation before finishing
|
## Validation before finishing
|
||||||
|
|
||||||
- Run `bun run check`.
|
- Run `bun run check`.
|
||||||
|
|
||||||
## Repo-specific conventions
|
## Repo-specific conventions
|
||||||
|
|
||||||
- Use `auth()` from `@/lib/auth` for server-side session reads.
|
- Use `auth()` from `@/lib/auth` for server-side session reads.
|
||||||
- Use `checkProjectAccess()` / `checkWorkspaceAccess()` for authorization instead of ad-hoc role checks.
|
- Use `checkProjectAccess()` / `checkWorkspaceAccess()` for authorization instead of ad-hoc role checks.
|
||||||
- For API responses, use `successResponse` / `apiErrors` from `@/lib/api-response`.
|
- For API responses, use `successResponse` / `apiErrors` from `@/lib/api-response`.
|
||||||
@@ -17,10 +20,12 @@
|
|||||||
- In Prisma raw SQL, use `$executeRaw` for statements that return no rows (e.g. `pg_advisory_xact_lock`). Using `$queryRaw` on void-returning functions causes a Prisma deserialization error (`Failed to deserialize column of type 'void'`).
|
- In Prisma raw SQL, use `$executeRaw` for statements that return no rows (e.g. `pg_advisory_xact_lock`). Using `$queryRaw` on void-returning functions causes a Prisma deserialization error (`Failed to deserialize column of type 'void'`).
|
||||||
|
|
||||||
## Important locations
|
## Important locations
|
||||||
|
|
||||||
- Custom SQL managed by Prisma migrations: `prisma/migrations/*/migration.sql`.
|
- Custom SQL managed by Prisma migrations: `prisma/migrations/*/migration.sql`.
|
||||||
- Shared API response helpers: `lib/api-response.ts`.
|
- Shared API response helpers: `lib/api-response.ts`.
|
||||||
- Auth + access-control helpers: `lib/auth.ts`.
|
- Auth + access-control helpers: `lib/auth.ts`.
|
||||||
|
|
||||||
## Change safety rules
|
## Change safety rules
|
||||||
|
|
||||||
- Prefer backward-compatible API changes unless explicitly asked to break contracts.
|
- Prefer backward-compatible API changes unless explicitly asked to break contracts.
|
||||||
- For multi-step DB writes, use Prisma transactions.
|
- For multi-step DB writes, use Prisma transactions.
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function AuthLayout({
|
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
// If already logged in, redirect to dashboard
|
// If already logged in, redirect to dashboard
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const ERROR_MESSAGES: Record<string, string> = {
|
|||||||
// Generic message — avoid confirming whether a credentials account exists for this email
|
// Generic message — avoid confirming whether a credentials account exists for this email
|
||||||
OAuthAccountNotLinked: 'Sign-in failed. Please try a different method or contact support.',
|
OAuthAccountNotLinked: 'Sign-in failed. Please try a different method or contact support.',
|
||||||
OAuthCallbackError: 'OAuth sign-in failed. Please try again.',
|
OAuthCallbackError: 'OAuth sign-in failed. Please try again.',
|
||||||
OAuthEmailNotVerified: 'Your OAuth account email is not verified. Please verify it with your provider and try again.',
|
OAuthEmailNotVerified:
|
||||||
|
'Your OAuth account email is not verified. Please verify it with your provider and try again.',
|
||||||
InvalidVerificationToken: 'The verification link is invalid or has expired.',
|
InvalidVerificationToken: 'The verification link is invalid or has expired.',
|
||||||
VerificationFailed: 'Email verification failed. Please try again.',
|
VerificationFailed: 'Email verification failed. Please try again.',
|
||||||
Default: 'Something went wrong. Please try again.',
|
Default: 'Something went wrong. Please try again.',
|
||||||
@@ -109,7 +110,8 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{showSuccess && (
|
{showSuccess && (
|
||||||
<div className="p-3 rounded-md bg-green-500/10 text-green-600 text-sm mb-4">
|
<div className="p-3 rounded-md bg-green-500/10 text-green-600 text-sm mb-4">
|
||||||
Account created successfully! Please check your email to verify your address before signing in.
|
Account created successfully! Please check your email to verify your address before
|
||||||
|
signing in.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -172,7 +174,12 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
|
|||||||
{oauthLoading === 'github' ? (
|
{oauthLoading === 'github' ? (
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">
|
<svg
|
||||||
|
className="h-4 w-4 mr-2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import { LoginForm, LoginFormSkeleton } from './login-form';
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const googleEnabled = Boolean(
|
const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET,
|
const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
);
|
|
||||||
const githubEnabled = Boolean(
|
|
||||||
process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
@@ -26,12 +22,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||||
By continuing, you agree to our{' '}
|
By continuing, you agree to our{' '}
|
||||||
<Link href="/terms" className="underline hover:text-foreground">Terms of Service</Link>
|
<Link href="/terms" className="underline hover:text-foreground">
|
||||||
{' '}and{' '}
|
Terms of Service
|
||||||
<Link href="/privacy" className="underline hover:text-foreground">Privacy Policy</Link>
|
</Link>{' '}
|
||||||
|
and{' '}
|
||||||
|
<Link href="/privacy" className="underline hover:text-foreground">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ import { isInviteCodeRequired } from '@/lib/feature-flags';
|
|||||||
import RegisterPageClient from './register-page-client';
|
import RegisterPageClient from './register-page-client';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const googleEnabled = Boolean(
|
const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET,
|
const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
);
|
|
||||||
const githubEnabled = Boolean(
|
|
||||||
process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RegisterPageClient
|
<RegisterPageClient
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ interface RegisterPageClientProps {
|
|||||||
githubEnabled: boolean;
|
githubEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterPageClient({ requireInviteCode, googleEnabled, githubEnabled }: RegisterPageClientProps) {
|
export default function RegisterPageClient({
|
||||||
|
requireInviteCode,
|
||||||
|
googleEnabled,
|
||||||
|
githubEnabled,
|
||||||
|
}: RegisterPageClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]);
|
const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]);
|
||||||
@@ -122,9 +126,7 @@ export default function RegisterPageClient({ requireInviteCode, googleEnabled, g
|
|||||||
<UserPlus className="h-5 w-5" />
|
<UserPlus className="h-5 w-5" />
|
||||||
Create Account
|
Create Account
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Join OpenFrame to collaborate on video projects</CardDescription>
|
||||||
Join OpenFrame to collaborate on video projects
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* OAuth Buttons */}
|
{/* OAuth Buttons */}
|
||||||
@@ -142,10 +144,22 @@ export default function RegisterPageClient({ requireInviteCode, googleEnabled, g
|
|||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
<path
|
||||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
fill="#4285F4"
|
||||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
Continue with Google
|
Continue with Google
|
||||||
@@ -162,7 +176,12 @@ export default function RegisterPageClient({ requireInviteCode, googleEnabled, g
|
|||||||
{oauthLoading === 'github' ? (
|
{oauthLoading === 'github' ? (
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">
|
<svg
|
||||||
|
className="h-4 w-4 mr-2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
@@ -297,9 +316,13 @@ export default function RegisterPageClient({ requireInviteCode, googleEnabled, g
|
|||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||||
By continuing, you agree to our{' '}
|
By continuing, you agree to our{' '}
|
||||||
<a href="/terms" className="underline hover:text-foreground">Terms of Service</a>
|
<a href="/terms" className="underline hover:text-foreground">
|
||||||
{' '}and{' '}
|
Terms of Service
|
||||||
<a href="/privacy" className="underline hover:text-foreground">Privacy Policy</a>
|
</a>{' '}
|
||||||
|
and{' '}
|
||||||
|
<a href="/privacy" className="underline hover:text-foreground">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ function VerifyEmailContent() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
We sent a verification link to{' '}
|
We sent a verification link to{' '}
|
||||||
{emailParam ? <strong>{emailParam}</strong> : 'your email address'}.
|
{emailParam ? <strong>{emailParam}</strong> : 'your email address'}. Click the link to
|
||||||
Click the link to activate your account.
|
activate your account.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -75,7 +75,9 @@ function VerifyEmailContent() {
|
|||||||
<span className="w-full border-t" />
|
<span className="w-full border-t" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
<span className="bg-card px-2 text-muted-foreground">Didn't receive it?</span>
|
<span className="bg-card px-2 text-muted-foreground">
|
||||||
|
Didn't receive it?
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/card"
|
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
function ProjectCardSkeleton() {
|
function ProjectCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +24,7 @@ function ProjectCardSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardLoading() {
|
export default function DashboardLoading() {
|
||||||
@@ -44,5 +44,5 @@ export default function DashboardLoading() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-134
@@ -2,162 +2,162 @@ import { auth } from '@/lib/auth';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access';
|
import {
|
||||||
|
hasCollaboratorBillingBackedAccess,
|
||||||
|
requireBillingAccessOrRedirect,
|
||||||
|
} from '@/lib/route-access';
|
||||||
import { DashboardClient } from './dashboard-client';
|
import { DashboardClient } from './dashboard-client';
|
||||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
export default async function DashboardPage({
|
export default async function DashboardPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ ws?: string; sort?: string; page?: string }>
|
searchParams: Promise<{ ws?: string; sort?: string; page?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCollaboratorAccess = await hasCollaboratorBillingBackedAccess(session.user.id);
|
const hasCollaboratorAccess = await hasCollaboratorBillingBackedAccess(session.user.id);
|
||||||
if (!hasCollaboratorAccess) {
|
if (!hasCollaboratorAccess) {
|
||||||
await requireBillingAccessOrRedirect({ userId: session.user.id });
|
await requireBillingAccessOrRedirect({ userId: session.user.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userOnboarding = await db.user.findUnique({
|
const userOnboarding = await db.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { onboardingCompletedAt: true },
|
select: { onboardingCompletedAt: true },
|
||||||
});
|
});
|
||||||
if (!userOnboarding?.onboardingCompletedAt) {
|
if (!userOnboarding?.onboardingCompletedAt) {
|
||||||
redirect('/onboarding');
|
redirect('/onboarding');
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const { ws, sort, page: pageParam } = resolvedSearchParams || {};
|
const { ws, sort, page: pageParam } = resolvedSearchParams || {};
|
||||||
|
|
||||||
const page = Number(pageParam) || 1;
|
const page = Number(pageParam) || 1;
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
const orderByDirection = sort === 'asc' ? 'asc' : 'desc';
|
const orderByDirection = sort === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
// Base permission where clause
|
// Base permission where clause
|
||||||
const baseWhere: Prisma.ProjectWhereInput = {
|
const baseWhere: Prisma.ProjectWhereInput = {
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: session.user.id },
|
{ ownerId: session.user.id },
|
||||||
{ members: { some: { userId: session.user.id } } },
|
{ members: { some: { userId: session.user.id } } },
|
||||||
{
|
{
|
||||||
workspace: {
|
|
||||||
owner: buildBillingAccessWhereInput(),
|
|
||||||
OR: [
|
|
||||||
{ ownerId: session.user.id },
|
|
||||||
{ members: { some: { userId: session.user.id } } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
workspace: {
|
workspace: {
|
||||||
owner: buildBillingAccessWhereInput(),
|
owner: buildBillingAccessWhereInput(),
|
||||||
|
OR: [{ ownerId: session.user.id }, { members: { some: { userId: session.user.id } } }],
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
],
|
||||||
|
workspace: {
|
||||||
|
owner: buildBillingAccessWhereInput(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Build unique workspace list for filter (Needs an unbounded list of accessible workspaces)
|
// Build unique workspace list for filter (Needs an unbounded list of accessible workspaces)
|
||||||
const accessibleProjects = await db.project.findMany({
|
const accessibleProjects = await db.project.findMany({
|
||||||
where: baseWhere,
|
where: baseWhere,
|
||||||
select: {
|
select: {
|
||||||
|
workspace: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
distinct: ['workspaceId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [creatableWorkspaces, editableProject] = await Promise.all([
|
||||||
|
db.workspace.count({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ ownerId: session.user.id },
|
||||||
|
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.project.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ ownerId: session.user.id },
|
||||||
|
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
|
||||||
|
{
|
||||||
workspace: {
|
workspace: {
|
||||||
select: { id: true, name: true }
|
OR: [
|
||||||
}
|
{ ownerId: session.user.id },
|
||||||
},
|
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
|
||||||
distinct: ['workspaceId']
|
],
|
||||||
});
|
|
||||||
|
|
||||||
const [creatableWorkspaces, editableProject] = await Promise.all([
|
|
||||||
db.workspace.count({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ ownerId: session.user.id },
|
|
||||||
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
db.project.findFirst({
|
],
|
||||||
where: {
|
},
|
||||||
OR: [
|
select: { id: true },
|
||||||
{ ownerId: session.user.id },
|
}),
|
||||||
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
|
]);
|
||||||
{
|
const canCreateProjects = creatableWorkspaces > 0;
|
||||||
workspace: {
|
const canUploadVideos = Boolean(editableProject);
|
||||||
OR: [
|
|
||||||
{ ownerId: session.user.id },
|
|
||||||
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const canCreateProjects = creatableWorkspaces > 0;
|
|
||||||
const canUploadVideos = Boolean(editableProject);
|
|
||||||
|
|
||||||
const workspaceMap = new Map<string, string>();
|
const workspaceMap = new Map<string, string>();
|
||||||
for (const project of accessibleProjects) {
|
for (const project of accessibleProjects) {
|
||||||
if (project.workspace) {
|
if (project.workspace) {
|
||||||
workspaceMap.set(project.workspace.id, project.workspace.name);
|
workspaceMap.set(project.workspace.id, project.workspace.name);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const workspaces = Array.from(workspaceMap, ([id, name]) => ({ id, name }));
|
}
|
||||||
|
const workspaces = Array.from(workspaceMap, ([id, name]) => ({ id, name }));
|
||||||
|
|
||||||
// Final query constraints
|
// Final query constraints
|
||||||
const queryWhere: Prisma.ProjectWhereInput = {
|
const queryWhere: Prisma.ProjectWhereInput = {
|
||||||
...baseWhere,
|
...baseWhere,
|
||||||
...(ws && ws !== 'all' ? { workspaceId: ws } : {})
|
...(ws && ws !== 'all' ? { workspaceId: ws } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [projects, totalProjects] = await Promise.all([
|
const [projects, totalProjects] = await Promise.all([
|
||||||
db.project.findMany({
|
db.project.findMany({
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
where: queryWhere,
|
where: queryWhere,
|
||||||
include: {
|
include: {
|
||||||
workspace: {
|
workspace: {
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
videos: true,
|
videos: true,
|
||||||
members: true,
|
members: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: orderByDirection },
|
orderBy: { updatedAt: orderByDirection },
|
||||||
}),
|
}),
|
||||||
db.project.count({
|
db.project.count({
|
||||||
where: queryWhere
|
where: queryWhere,
|
||||||
})
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalProjects / pageSize);
|
const totalPages = Math.ceil(totalProjects / pageSize);
|
||||||
|
|
||||||
const serializedProjects = projects.map((p) => ({
|
const serializedProjects = projects.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
visibility: p.visibility,
|
visibility: p.visibility,
|
||||||
updatedAt: p.updatedAt.toISOString(),
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
workspaceId: p.workspace?.id ?? null,
|
workspaceId: p.workspace?.id ?? null,
|
||||||
workspaceName: p.workspace?.name ?? null,
|
workspaceName: p.workspace?.name ?? null,
|
||||||
memberCount: p._count.members + 1,
|
memberCount: p._count.members + 1,
|
||||||
videoCount: p._count.videos,
|
videoCount: p._count.videos,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardClient
|
<DashboardClient
|
||||||
serializedProjects={serializedProjects}
|
serializedProjects={serializedProjects}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
canCreateProjects={canCreateProjects}
|
canCreateProjects={canCreateProjects}
|
||||||
canUploadVideos={canUploadVideos}
|
canUploadVideos={canUploadVideos}
|
||||||
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
|
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,18 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus, Building2, ArrowUp, ArrowDown } from 'lucide-react';
|
import {
|
||||||
|
Plus,
|
||||||
|
FolderOpen,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
UserPlus,
|
||||||
|
Building2,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -62,13 +73,18 @@ function VisibilityIcon({ visibility }: { visibility: string }) {
|
|||||||
|
|
||||||
type SortOrder = 'desc' | 'asc';
|
type SortOrder = 'desc' | 'asc';
|
||||||
|
|
||||||
export function ProjectFilter({ projects, workspaces, totalPages, canCreateProjects }: ProjectFilterProps) {
|
export function ProjectFilter({
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
totalPages,
|
||||||
|
canCreateProjects,
|
||||||
|
}: ProjectFilterProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const selectedWorkspace = searchParams.get('ws') || 'all';
|
const selectedWorkspace = searchParams.get('ws') || 'all';
|
||||||
const sortOrder = searchParams.get('sort') as SortOrder || 'desc';
|
const sortOrder = (searchParams.get('sort') as SortOrder) || 'desc';
|
||||||
const page = Number(searchParams.get('page')) || 1;
|
const page = Number(searchParams.get('page')) || 1;
|
||||||
|
|
||||||
const createQueryString = useCallback(
|
const createQueryString = useCallback(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
export default function DashboardError({
|
export default function DashboardError({
|
||||||
error,
|
error,
|
||||||
@@ -12,7 +12,7 @@ export default function DashboardError({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error("Dashboard error:", error);
|
console.error('Dashboard error:', error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,17 +23,13 @@ export default function DashboardError({
|
|||||||
<p className="text-muted-foreground max-w-md">
|
<p className="text-muted-foreground max-w-md">
|
||||||
Something went wrong loading the dashboard. Your projects and videos are safe.
|
Something went wrong loading the dashboard. Your projects and videos are safe.
|
||||||
</p>
|
</p>
|
||||||
{error.digest && (
|
{error.digest && <p className="text-muted-foreground text-xs">Error ID: {error.digest}</p>}
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Error ID: {error.digest}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={reset} variant="default">
|
<Button onClick={reset} variant="default">
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => window.location.href = "/dashboard"} variant="outline">
|
<Button onClick={() => (window.location.href = '/dashboard')} variant="outline">
|
||||||
Go to dashboard
|
Go to dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
type FeedbackCategory = 'BUG' | 'FEATURE' | 'OTHER';
|
type FeedbackCategory = 'BUG' | 'FEATURE' | 'OTHER';
|
||||||
type TabValue = 'feedback' | 'review';
|
type TabValue = 'feedback' | 'review';
|
||||||
@@ -70,7 +76,10 @@ export default function FeedbackPage() {
|
|||||||
for (const file of allowedFiles) {
|
for (const file of allowedFiles) {
|
||||||
const isImage = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type);
|
const isImage = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type);
|
||||||
if (!isImage) {
|
if (!isImage) {
|
||||||
setStatus({ type: 'error', message: 'Unsupported screenshot format. Use JPG, PNG, WEBP, or GIF.' });
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Unsupported screenshot format. Use JPG, PNG, WEBP, or GIF.',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
@@ -92,7 +101,9 @@ export default function FeedbackPage() {
|
|||||||
const targetUrl = feedbackScreenshotPreviewUrls[index];
|
const targetUrl = feedbackScreenshotPreviewUrls[index];
|
||||||
if (targetUrl) URL.revokeObjectURL(targetUrl);
|
if (targetUrl) URL.revokeObjectURL(targetUrl);
|
||||||
setFeedbackScreenshotFiles((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
|
setFeedbackScreenshotFiles((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
|
||||||
setFeedbackScreenshotPreviewUrls((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
|
setFeedbackScreenshotPreviewUrls((prev) =>
|
||||||
|
prev.filter((_, currentIndex) => currentIndex !== index)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearFeedbackScreenshots = () => {
|
const clearFeedbackScreenshots = () => {
|
||||||
@@ -169,7 +180,10 @@ export default function FeedbackPage() {
|
|||||||
setReviewMessage('');
|
setReviewMessage('');
|
||||||
setReviewRating('5');
|
setReviewRating('5');
|
||||||
setAllowShowcase(false);
|
setAllowShowcase(false);
|
||||||
setStatus({ type: 'success', message: 'Review submitted. Thank you for sharing your experience.' });
|
setStatus({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Review submitted. Thank you for sharing your experience.',
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setStatus({ type: 'error', message: 'Failed to submit review' });
|
setStatus({ type: 'error', message: 'Failed to submit review' });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -180,7 +194,10 @@ export default function FeedbackPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-4rem)] px-4 py-10">
|
<div className="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||||
<Link href="/dashboard" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
Back to Dashboard
|
Back to Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
@@ -189,7 +206,8 @@ export default function FeedbackPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Feedback & Review</CardTitle>
|
<CardTitle className="text-2xl">Feedback & Review</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Send product feedback, report bugs, or share a review we can feature on the landing page.
|
Send product feedback, report bugs, or share a review we can feature on the landing
|
||||||
|
page.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -205,7 +223,11 @@ export default function FeedbackPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabValue)} className="w-full">
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as TabValue)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="feedback" className="gap-1.5">
|
<TabsTrigger value="feedback" className="gap-1.5">
|
||||||
<Bug className="h-3.5 w-3.5" />
|
<Bug className="h-3.5 w-3.5" />
|
||||||
@@ -308,7 +330,11 @@ export default function FeedbackPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isSubmittingFeedback}>
|
<Button type="submit" disabled={isSubmittingFeedback}>
|
||||||
{isSubmittingFeedback ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ImageIcon className="mr-2 h-4 w-4" />}
|
{isSubmittingFeedback ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
Submit Feedback
|
Submit Feedback
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -332,7 +358,11 @@ export default function FeedbackPage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Rating</Label>
|
<Label>Rating</Label>
|
||||||
<Select value={reviewRating} onValueChange={setReviewRating} disabled={isSubmittingReview}>
|
<Select
|
||||||
|
value={reviewRating}
|
||||||
|
onValueChange={setReviewRating}
|
||||||
|
disabled={isSubmittingReview}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -369,11 +399,17 @@ export default function FeedbackPage() {
|
|||||||
onChange={(event) => setAllowShowcase(event.target.checked)}
|
onChange={(event) => setAllowShowcase(event.target.checked)}
|
||||||
disabled={isSubmittingReview}
|
disabled={isSubmittingReview}
|
||||||
/>
|
/>
|
||||||
<span>I allow OpenFrame to potentially showcase this review on the landing page.</span>
|
<span>
|
||||||
|
I allow OpenFrame to potentially showcase this review on the landing page.
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Button type="submit" disabled={isSubmittingReview}>
|
<Button type="submit" disabled={isSubmittingReview}>
|
||||||
{isSubmittingReview ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquareQuote className="mr-2 h-4 w-4" />}
|
{isSubmittingReview ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MessageSquareQuote className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
Submit Review
|
Submit Review
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { Header } from '@/components/layout';
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { hasAppNavigationAccess } from '@/lib/route-access';
|
import { hasAppNavigationAccess } from '@/lib/route-access';
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const showAppNavigation = session?.user?.id
|
const showAppNavigation = session?.user?.id
|
||||||
? await hasAppNavigationAccess(session.user.id)
|
? await hasAppNavigationAccess(session.user.id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from 'next/link';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { FileQuestion } from "lucide-react";
|
import { FileQuestion } from 'lucide-react';
|
||||||
|
|
||||||
export default function DashboardNotFound() {
|
export default function DashboardNotFound() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
function VideoCardSkeleton() {
|
function VideoCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +14,7 @@ function VideoCardSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectLoading() {
|
export default function ProjectLoading() {
|
||||||
@@ -49,5 +49,5 @@ export default function ProjectLoading() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from 'next/link';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { FolderX } from "lucide-react";
|
import { FolderX } from 'lucide-react';
|
||||||
|
|
||||||
export default function ProjectNotFound() {
|
export default function ProjectNotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -9,7 +9,8 @@ export default function ProjectNotFound() {
|
|||||||
<FolderX className="h-12 w-12 text-muted-foreground" />
|
<FolderX className="h-12 w-12 text-muted-foreground" />
|
||||||
<h1 className="text-2xl font-bold">Project Not Found</h1>
|
<h1 className="text-2xl font-bold">Project Not Found</h1>
|
||||||
<p className="text-muted-foreground max-w-md">
|
<p className="text-muted-foreground max-w-md">
|
||||||
The project you're looking for doesn't exist or you don't have access to it.
|
The project you're looking for doesn't exist or you don't have access to
|
||||||
|
it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import {
|
import { ArrowLeft } from 'lucide-react';
|
||||||
ArrowLeft,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { GuestGate } from '@/components/guest-gate';
|
import { GuestGate } from '@/components/guest-gate';
|
||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
@@ -120,8 +118,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.video.count({
|
db.video.count({
|
||||||
where: { projectId: project.id }
|
where: { projectId: project.id },
|
||||||
})
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalVideos / pageSize);
|
const totalPages = Math.ceil(totalVideos / pageSize);
|
||||||
@@ -132,7 +130,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
|||||||
return {
|
return {
|
||||||
id: video.id,
|
id: video.id,
|
||||||
title: video.title,
|
title: video.title,
|
||||||
thumbnailUrl: activeVersion?.thumbnailUrl || 'https://via.placeholder.com/320x180?text=No+Thumbnail',
|
thumbnailUrl:
|
||||||
|
activeVersion?.thumbnailUrl || 'https://via.placeholder.com/320x180?text=No+Thumbnail',
|
||||||
currentVersion: video._count.versions,
|
currentVersion: video._count.versions,
|
||||||
commentCount: activeVersion?._count.comments || 0,
|
commentCount: activeVersion?._count.comments || 0,
|
||||||
duration: formatDuration(activeVersion?.duration),
|
duration: formatDuration(activeVersion?.duration),
|
||||||
@@ -141,7 +140,12 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const canEdit = access.canEdit && (isOwner || project.members[0]?.role === 'ADMIN' || workspaceRole === 'OWNER' || workspaceRole === 'ADMIN');
|
const canEdit =
|
||||||
|
access.canEdit &&
|
||||||
|
(isOwner ||
|
||||||
|
project.members[0]?.role === 'ADMIN' ||
|
||||||
|
workspaceRole === 'OWNER' ||
|
||||||
|
workspaceRole === 'ADMIN');
|
||||||
const isAuthenticated = !!session?.user?.id;
|
const isAuthenticated = !!session?.user?.id;
|
||||||
|
|
||||||
const projectData = {
|
const projectData = {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function ProjectContentClient({
|
|||||||
canEdit,
|
canEdit,
|
||||||
isOwner,
|
isOwner,
|
||||||
totalPages,
|
totalPages,
|
||||||
currentPage
|
currentPage,
|
||||||
}: ProjectContentClientProps) {
|
}: ProjectContentClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -115,7 +115,10 @@ export function ProjectContentClient({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{project.workspace && (
|
{project.workspace && (
|
||||||
<Link href={`/workspaces/${project.workspace.id}`}>
|
<Link href={`/workspaces/${project.workspace.id}`}>
|
||||||
<Badge variant="secondary" className="flex items-center gap-1 hover:bg-accent transition-colors">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
<Building2 className="h-3 w-3" />
|
<Building2 className="h-3 w-3" />
|
||||||
{project.workspace.name}
|
{project.workspace.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -221,16 +224,13 @@ export function ProjectContentClient({
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-8 flex items-center justify-end space-x-2">
|
<div className="mt-8 flex items-center justify-end space-x-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" disabled={currentPage <= 1} asChild={currentPage > 1}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
asChild={currentPage > 1}
|
|
||||||
>
|
|
||||||
{currentPage > 1 ? (
|
{currentPage > 1 ? (
|
||||||
<Link href={`?${createQueryString('page', (currentPage - 1).toString())}`}>Previous</Link>
|
<Link href={`?${createQueryString('page', (currentPage - 1).toString())}`}>
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"Previous"
|
'Previous'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -245,7 +245,7 @@ export function ProjectContentClient({
|
|||||||
{currentPage < totalPages ? (
|
{currentPage < totalPages ? (
|
||||||
<Link href={`?${createQueryString('page', (currentPage + 1).toString())}`}>Next</Link>
|
<Link href={`?${createQueryString('page', (currentPage + 1).toString())}`}>Next</Link>
|
||||||
) : (
|
) : (
|
||||||
"Next"
|
'Next'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/card"
|
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
export default function ProjectSettingsLoading() {
|
export default function ProjectSettingsLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -63,5 +63,5 @@ export default function ProjectSettingsLoading() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,18 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Copy, Check, Loader2, UserPlus, Share2, Globe, Lock, Mail, X } from 'lucide-react';
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
UserPlus,
|
||||||
|
Share2,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -11,323 +22,323 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
|
||||||
interface ProjectMember {
|
interface ProjectMember {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
name: string | null;
|
||||||
user: {
|
email: string | null;
|
||||||
id: string;
|
};
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectSharePageProps {
|
interface ProjectSharePageProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectSharePageClient({ projectId }: ProjectSharePageProps) {
|
export default function ProjectSharePageClient({ projectId }: ProjectSharePageProps) {
|
||||||
const [projectName, setProjectName] = useState('');
|
const [projectName, setProjectName] = useState('');
|
||||||
const [projectVisibility, setProjectVisibility] = useState('');
|
const [projectVisibility, setProjectVisibility] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [members, setMembers] = useState<ProjectMember[]>([]);
|
const [members, setMembers] = useState<ProjectMember[]>([]);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
const [isInviting, setIsInviting] = useState(false);
|
const [isInviting, setIsInviting] = useState(false);
|
||||||
const [inviteSuccess, setInviteSuccess] = useState('');
|
const [inviteSuccess, setInviteSuccess] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/projects/${projectId}`)
|
fetch(`/api/projects/${projectId}`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setError(data.error);
|
setError(data.error);
|
||||||
} else {
|
} else {
|
||||||
const project = data.data;
|
const project = data.data;
|
||||||
setProjectName(project.name || '');
|
setProjectName(project.name || '');
|
||||||
setProjectVisibility(project.visibility || 'PRIVATE');
|
setProjectVisibility(project.visibility || 'PRIVATE');
|
||||||
setMembers(project.members || []);
|
setMembers(project.members || []);
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => setError('Failed to load project'))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDirectLink = () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return `${window.location.origin}/projects/${projectId}`;
|
|
||||||
}
|
}
|
||||||
return `/projects/${projectId}`;
|
})
|
||||||
};
|
.catch(() => setError('Failed to load project'))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
const handleInvite = async (e: React.FormEvent) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
e.preventDefault();
|
await navigator.clipboard.writeText(text);
|
||||||
if (!inviteEmail.trim()) return;
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
setIsInviting(true);
|
const getDirectLink = () => {
|
||||||
setError('');
|
if (typeof window !== 'undefined') {
|
||||||
setInviteSuccess('');
|
return `${window.location.origin}/projects/${projectId}`;
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: Implement invite API
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
setInviteSuccess(`Invitation sent to ${inviteEmail}`);
|
|
||||||
setInviteEmail('');
|
|
||||||
setTimeout(() => setInviteSuccess(''), 3000);
|
|
||||||
} catch {
|
|
||||||
setError('Failed to send invitation');
|
|
||||||
} finally {
|
|
||||||
setIsInviting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const VisibilityIcon = () => {
|
|
||||||
switch (projectVisibility) {
|
|
||||||
case 'PUBLIC':
|
|
||||||
return <Globe className="h-5 w-5" />;
|
|
||||||
case 'INVITE':
|
|
||||||
return <UserPlus className="h-5 w-5" />;
|
|
||||||
default:
|
|
||||||
return <Lock className="h-5 w-5" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getVisibilityColor = () => {
|
|
||||||
switch (projectVisibility) {
|
|
||||||
case 'PUBLIC':
|
|
||||||
return 'bg-green-500/10 text-green-500';
|
|
||||||
case 'INVITE':
|
|
||||||
return 'bg-blue-500/10 text-blue-500';
|
|
||||||
default:
|
|
||||||
return 'bg-orange-500/10 text-orange-500';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getVisibilityLabel = () => {
|
|
||||||
switch (projectVisibility) {
|
|
||||||
case 'PUBLIC':
|
|
||||||
return { title: 'Public', description: 'Anyone with the link can view this project' };
|
|
||||||
case 'INVITE':
|
|
||||||
return { title: 'Invite Only', description: 'Only people you invite can access' };
|
|
||||||
default:
|
|
||||||
return { title: 'Private', description: 'Only you can access this project' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return `/projects/${projectId}`;
|
||||||
|
};
|
||||||
|
|
||||||
const visibilityInfo = getVisibilityLabel();
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!inviteEmail.trim()) return;
|
||||||
|
|
||||||
|
setIsInviting(true);
|
||||||
|
setError('');
|
||||||
|
setInviteSuccess('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement invite API
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
setInviteSuccess(`Invitation sent to ${inviteEmail}`);
|
||||||
|
setInviteEmail('');
|
||||||
|
setTimeout(() => setInviteSuccess(''), 3000);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to send invitation');
|
||||||
|
} finally {
|
||||||
|
setIsInviting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const VisibilityIcon = () => {
|
||||||
|
switch (projectVisibility) {
|
||||||
|
case 'PUBLIC':
|
||||||
|
return <Globe className="h-5 w-5" />;
|
||||||
|
case 'INVITE':
|
||||||
|
return <UserPlus className="h-5 w-5" />;
|
||||||
|
default:
|
||||||
|
return <Lock className="h-5 w-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVisibilityColor = () => {
|
||||||
|
switch (projectVisibility) {
|
||||||
|
case 'PUBLIC':
|
||||||
|
return 'bg-green-500/10 text-green-500';
|
||||||
|
case 'INVITE':
|
||||||
|
return 'bg-blue-500/10 text-blue-500';
|
||||||
|
default:
|
||||||
|
return 'bg-orange-500/10 text-orange-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVisibilityLabel = () => {
|
||||||
|
switch (projectVisibility) {
|
||||||
|
case 'PUBLIC':
|
||||||
|
return { title: 'Public', description: 'Anyone with the link can view this project' };
|
||||||
|
case 'INVITE':
|
||||||
|
return { title: 'Invite Only', description: 'Only people you invite can access' };
|
||||||
|
default:
|
||||||
|
return { title: 'Private', description: 'Only you can access this project' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4">
|
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
|
||||||
<div className="w-full max-w-xl">
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
<div className="mb-8">
|
</div>
|
||||||
<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="space-y-6">
|
|
||||||
{/* Header Card */}
|
|
||||||
<Card className="border-border/50 shadow-lg">
|
|
||||||
<CardHeader className="text-center pb-2">
|
|
||||||
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
|
||||||
<Share2 className="h-7 w-7 text-primary" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-2xl">Share Project</CardTitle>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
Share "{projectName}" with your team or clients
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
{/* Visibility Status */}
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-xl ${getVisibilityColor()}`}>
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-current/10 flex items-center justify-center">
|
|
||||||
<VisibilityIcon />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">{visibilityInfo.title}</div>
|
|
||||||
<div className="text-sm opacity-80">{visibilityInfo.description}</div>
|
|
||||||
</div>
|
|
||||||
<Link href={`/projects/${projectId}/settings`}>
|
|
||||||
<Button variant="ghost" size="sm" className="text-current hover:bg-current/10">
|
|
||||||
Change
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Invite People - Only show for INVITE visibility */}
|
|
||||||
{projectVisibility === 'INVITE' && (
|
|
||||||
<Card className="border-border/50 shadow-lg">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Mail className="h-5 w-5 text-primary" />
|
|
||||||
Invite People
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Send email invitations to specific people
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<form onSubmit={handleInvite} className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={inviteEmail}
|
|
||||||
onChange={(e) => setInviteEmail(e.target.value)}
|
|
||||||
placeholder="[email protected]"
|
|
||||||
className="h-11 flex-1"
|
|
||||||
disabled={isInviting}
|
|
||||||
/>
|
|
||||||
<Button type="submit" disabled={isInviting || !inviteEmail.trim()} className="h-11">
|
|
||||||
{isInviting ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<UserPlus className="h-4 w-4 mr-2" />
|
|
||||||
Invite
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{inviteSuccess && (
|
|
||||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-green-500 text-sm">
|
|
||||||
{inviteSuccess}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current Members */}
|
|
||||||
{members.length > 0 && (
|
|
||||||
<div className="space-y-2 pt-2">
|
|
||||||
<Label className="text-sm text-muted-foreground">Project Members</Label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{members.map((member) => (
|
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
className="flex items-center justify-between p-3 rounded-xl border bg-card"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar className="h-9 w-9">
|
|
||||||
<AvatarFallback className="text-xs">
|
|
||||||
{member.user.name?.charAt(0) || member.user.email?.charAt(0) || '?'}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-sm">
|
|
||||||
{member.user.name || 'Unknown'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{member.user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="secondary" className="text-xs capitalize">
|
|
||||||
{member.role.toLowerCase()}
|
|
||||||
</Badge>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{members.length === 0 && (
|
|
||||||
<div className="text-center py-6 text-muted-foreground">
|
|
||||||
<UserPlus className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
||||||
<p className="text-sm">No members yet</p>
|
|
||||||
<p className="text-xs opacity-70">Invite people to collaborate on this project</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Public Link - Only show for PUBLIC visibility */}
|
|
||||||
{projectVisibility === 'PUBLIC' && (
|
|
||||||
<Card className="border-border/50 shadow-lg">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Globe className="h-5 w-5 text-primary" />
|
|
||||||
Public Link
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Share this link with anyone
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={getDirectLink()}
|
|
||||||
readOnly
|
|
||||||
className="font-mono text-sm h-11 bg-muted/50"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant={copied ? 'default' : 'outline'}
|
|
||||||
size="icon"
|
|
||||||
className="h-11 w-11 shrink-0"
|
|
||||||
onClick={() => copyToClipboard(getDirectLink())}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Private notice */}
|
|
||||||
{projectVisibility === 'PRIVATE' && (
|
|
||||||
<Card className="border-border/50 shadow-lg">
|
|
||||||
<CardContent className="py-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Lock className="h-8 w-8 text-muted-foreground/50" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-medium mb-1">This project is private</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Only you can access this project. Change visibility to share with others.
|
|
||||||
</p>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/projects/${projectId}/settings`}>
|
|
||||||
Change Visibility
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityInfo = getVisibilityLabel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4">
|
||||||
|
<div className="w-full max-w-xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<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="space-y-6">
|
||||||
|
{/* Header Card */}
|
||||||
|
<Card className="border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<Share2 className="h-7 w-7 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Share Project</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Share "{projectName}" with your team or clients
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{/* Visibility Status */}
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-xl ${getVisibilityColor()}`}>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-current/10 flex items-center justify-center">
|
||||||
|
<VisibilityIcon />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{visibilityInfo.title}</div>
|
||||||
|
<div className="text-sm opacity-80">{visibilityInfo.description}</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/projects/${projectId}/settings`}>
|
||||||
|
<Button variant="ghost" size="sm" className="text-current hover:bg-current/10">
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invite People - Only show for INVITE visibility */}
|
||||||
|
{projectVisibility === 'INVITE' && (
|
||||||
|
<Card className="border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5 text-primary" />
|
||||||
|
Invite People
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Send email invitations to specific people</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<form onSubmit={handleInvite} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
disabled={isInviting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isInviting || !inviteEmail.trim()}
|
||||||
|
className="h-11"
|
||||||
|
>
|
||||||
|
{isInviting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
|
Invite
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{inviteSuccess && (
|
||||||
|
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-green-500 text-sm">
|
||||||
|
{inviteSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Members */}
|
||||||
|
{members.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label className="text-sm text-muted-foreground">Project Members</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-xl border bg-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{member.user.name?.charAt(0) || member.user.email?.charAt(0) || '?'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{member.user.name || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{member.user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs capitalize">
|
||||||
|
{member.role.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{members.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-muted-foreground">
|
||||||
|
<UserPlus className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">No members yet</p>
|
||||||
|
<p className="text-xs opacity-70">
|
||||||
|
Invite people to collaborate on this project
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Public Link - Only show for PUBLIC visibility */}
|
||||||
|
{projectVisibility === 'PUBLIC' && (
|
||||||
|
<Card className="border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5 text-primary" />
|
||||||
|
Public Link
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Share this link with anyone</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={getDirectLink()}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm h-11 bg-muted/50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant={copied ? 'default' : 'outline'}
|
||||||
|
size="icon"
|
||||||
|
className="h-11 w-11 shrink-0"
|
||||||
|
onClick={() => copyToClipboard(getDirectLink())}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Private notice */}
|
||||||
|
{projectVisibility === 'PRIVATE' && (
|
||||||
|
<Card className="border-border/50 shadow-lg">
|
||||||
|
<CardContent className="py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Lock className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium mb-1">This project is private</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Only you can access this project. Change visibility to share with others.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/projects/${projectId}/settings`}>Change Visibility</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+213
-101
@@ -100,7 +100,13 @@ const isSafeUrl = (url: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CompareVersionsPageClient({ projectId, videoId }: { projectId: string; videoId: string }) {
|
export default function CompareVersionsPageClient({
|
||||||
|
projectId,
|
||||||
|
videoId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
videoId: string;
|
||||||
|
}) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [video, setVideo] = useState<VideoData | null>(null);
|
const [video, setVideo] = useState<VideoData | null>(null);
|
||||||
@@ -164,7 +170,9 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchVideo() {
|
async function fetchVideo() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}?includeComments=false`);
|
const res = await fetch(
|
||||||
|
`/api/projects/${projectId}/videos/${videoId}?includeComments=false`
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError('Failed to load video');
|
setError('Failed to load video');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -176,9 +184,9 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
|
|
||||||
const versionsParam = searchParams.get('versions');
|
const versionsParam = searchParams.get('versions');
|
||||||
if (versionsParam) {
|
if (versionsParam) {
|
||||||
const ids = versionsParam.split(',').filter((id) =>
|
const ids = versionsParam
|
||||||
data.versions.some((v: Version) => v.id === id)
|
.split(',')
|
||||||
);
|
.filter((id) => data.versions.some((v: Version) => v.id === id));
|
||||||
if (ids.length >= 2) {
|
if (ids.length >= 2) {
|
||||||
setPanelVersionIds(ids);
|
setPanelVersionIds(ids);
|
||||||
} else {
|
} else {
|
||||||
@@ -291,12 +299,23 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
const playing = state === window.YT?.PlayerState?.PLAYING;
|
const playing = state === window.YT?.PlayerState?.PLAYING;
|
||||||
|
|
||||||
if (playing) {
|
if (playing) {
|
||||||
players.forEach((p) => { try { p.pauseVideo(); } catch { /* */ } });
|
players.forEach((p) => {
|
||||||
|
try {
|
||||||
|
p.pauseVideo();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
});
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
} else {
|
} else {
|
||||||
const t = firstPlayer.getCurrentTime();
|
const t = firstPlayer.getCurrentTime();
|
||||||
players.forEach((p) => {
|
players.forEach((p) => {
|
||||||
try { p.seekTo(t, true); p.playVideo(); } catch { /* */ }
|
try {
|
||||||
|
p.seekTo(t, true);
|
||||||
|
p.playVideo();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
@@ -307,36 +326,48 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
|
|
||||||
const handleSeek = useCallback((time: number) => {
|
const handleSeek = useCallback((time: number) => {
|
||||||
const players = Array.from(playersRef.current.values());
|
const players = Array.from(playersRef.current.values());
|
||||||
players.forEach((p) => { try { p.seekTo(time, true); } catch { /* */ } });
|
players.forEach((p) => {
|
||||||
|
try {
|
||||||
|
p.seekTo(time, true);
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
});
|
||||||
setCurrentTime(time);
|
setCurrentTime(time);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleTimelineMouseDown = useCallback(
|
||||||
if (!timelineRef.current || durationRef.current <= 0) return;
|
(e: React.MouseEvent) => {
|
||||||
setIsDragging(true);
|
if (!timelineRef.current || durationRef.current <= 0) return;
|
||||||
const rect = timelineRef.current.getBoundingClientRect();
|
setIsDragging(true);
|
||||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
const rect = timelineRef.current.getBoundingClientRect();
|
||||||
const time = fraction * durationRef.current;
|
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
currentTimeRef.current = time;
|
const time = fraction * durationRef.current;
|
||||||
setCurrentTime(time);
|
currentTimeRef.current = time;
|
||||||
handleSeek(time);
|
setCurrentTime(time);
|
||||||
}, [handleSeek]);
|
handleSeek(time);
|
||||||
|
},
|
||||||
|
[handleSeek]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTimelineMouseMove = useCallback((e: React.MouseEvent) => {
|
const handleTimelineMouseMove = useCallback(
|
||||||
if (!isDragging || !timelineRef.current || durationRef.current <= 0) return;
|
(e: React.MouseEvent) => {
|
||||||
const rect = timelineRef.current.getBoundingClientRect();
|
if (!isDragging || !timelineRef.current || durationRef.current <= 0) return;
|
||||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
const rect = timelineRef.current.getBoundingClientRect();
|
||||||
const time = fraction * durationRef.current;
|
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
currentTimeRef.current = time;
|
const time = fraction * durationRef.current;
|
||||||
setCurrentTime(time);
|
currentTimeRef.current = time;
|
||||||
// Keep DOM in sync while the RAF loop is paused during drag
|
setCurrentTime(time);
|
||||||
const pct = fraction * 100;
|
// Keep DOM in sync while the RAF loop is paused during drag
|
||||||
if (progressBarRef.current) progressBarRef.current.style.width = `${pct}%`;
|
const pct = fraction * 100;
|
||||||
if (playheadRef.current) playheadRef.current.style.left = `calc(${pct}% - 2px)`;
|
if (progressBarRef.current) progressBarRef.current.style.width = `${pct}%`;
|
||||||
if (timecodeRef.current) {
|
if (playheadRef.current) playheadRef.current.style.left = `calc(${pct}% - 2px)`;
|
||||||
timecodeRef.current.textContent = `${formatTime(time)} / ${formatTime(durationRef.current)}`;
|
if (timecodeRef.current) {
|
||||||
}
|
timecodeRef.current.textContent = `${formatTime(time)} / ${formatTime(durationRef.current)}`;
|
||||||
}, [isDragging]);
|
}
|
||||||
|
},
|
||||||
|
[isDragging]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTimelineMouseUp = useCallback(() => {
|
const handleTimelineMouseUp = useCallback(() => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
@@ -399,7 +430,8 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
|
return;
|
||||||
|
|
||||||
const players = Array.from(playersRef.current.values());
|
const players = Array.from(playersRef.current.values());
|
||||||
if (players.length === 0) return;
|
if (players.length === 0) return;
|
||||||
@@ -430,8 +462,14 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
players.forEach((p) => {
|
players.forEach((p) => {
|
||||||
try {
|
try {
|
||||||
if (p.isMuted?.()) { p.unMute?.(); } else { p.mute?.(); }
|
if (p.isMuted?.()) {
|
||||||
} catch { /* */ }
|
p.unMute?.();
|
||||||
|
} else {
|
||||||
|
p.mute?.();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -442,28 +480,31 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
}, [handlePlayPause, handleSeek]);
|
}, [handlePlayPause, handleSeek]);
|
||||||
|
|
||||||
// Fetch comments for a version
|
// Fetch comments for a version
|
||||||
const toggleComments = useCallback(async (versionId: string) => {
|
const toggleComments = useCallback(
|
||||||
if (openCommentsPanel === versionId) {
|
async (versionId: string) => {
|
||||||
setOpenCommentsPanel(null);
|
if (openCommentsPanel === versionId) {
|
||||||
return;
|
setOpenCommentsPanel(null);
|
||||||
}
|
return;
|
||||||
setOpenCommentsPanel(versionId);
|
|
||||||
|
|
||||||
if (!commentsCache.has(versionId)) {
|
|
||||||
setCommentsLoading(versionId);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/versions/${versionId}/comments`);
|
|
||||||
const json = await res.json();
|
|
||||||
const data = json.data;
|
|
||||||
const commentsList = Array.isArray(data) ? data : (data?.comments ?? []);
|
|
||||||
setCommentsCache((prev) => new Map(prev).set(versionId, commentsList));
|
|
||||||
} catch {
|
|
||||||
setCommentsCache((prev) => new Map(prev).set(versionId, []));
|
|
||||||
} finally {
|
|
||||||
setCommentsLoading(null);
|
|
||||||
}
|
}
|
||||||
}
|
setOpenCommentsPanel(versionId);
|
||||||
}, [openCommentsPanel, commentsCache]);
|
|
||||||
|
if (!commentsCache.has(versionId)) {
|
||||||
|
setCommentsLoading(versionId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/comments`);
|
||||||
|
const json = await res.json();
|
||||||
|
const data = json.data;
|
||||||
|
const commentsList = Array.isArray(data) ? data : (data?.comments ?? []);
|
||||||
|
setCommentsCache((prev) => new Map(prev).set(versionId, commentsList));
|
||||||
|
} catch {
|
||||||
|
setCommentsCache((prev) => new Map(prev).set(versionId, []));
|
||||||
|
} finally {
|
||||||
|
setCommentsLoading(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[openCommentsPanel, commentsCache]
|
||||||
|
);
|
||||||
|
|
||||||
const handleChangeVersion = useCallback((panelIndex: number, newVersionId: string) => {
|
const handleChangeVersion = useCallback((panelIndex: number, newVersionId: string) => {
|
||||||
setPanelVersionIds((prev) => {
|
setPanelVersionIds((prev) => {
|
||||||
@@ -471,7 +512,11 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
const oldId = next[panelIndex];
|
const oldId = next[panelIndex];
|
||||||
const oldPlayer = playersRef.current.get(oldId);
|
const oldPlayer = playersRef.current.get(oldId);
|
||||||
if (oldPlayer) {
|
if (oldPlayer) {
|
||||||
try { oldPlayer.destroy(); } catch { /* */ }
|
try {
|
||||||
|
oldPlayer.destroy();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
playersRef.current.delete(oldId);
|
playersRef.current.delete(oldId);
|
||||||
}
|
}
|
||||||
next[panelIndex] = newVersionId;
|
next[panelIndex] = newVersionId;
|
||||||
@@ -619,11 +664,21 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
if (!player) return;
|
if (!player) return;
|
||||||
const isMuted = mutedPanels.has(versionId);
|
const isMuted = mutedPanels.has(versionId);
|
||||||
try {
|
try {
|
||||||
if (isMuted) { player.unMute(); } else { player.mute(); }
|
if (isMuted) {
|
||||||
} catch { /* */ }
|
player.unMute();
|
||||||
|
} else {
|
||||||
|
player.mute();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
setMutedPanels((prev) => {
|
setMutedPanels((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (isMuted) { next.delete(versionId); } else { next.add(versionId); }
|
if (isMuted) {
|
||||||
|
next.delete(versionId);
|
||||||
|
} else {
|
||||||
|
next.add(versionId);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -686,7 +741,11 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none',
|
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none',
|
||||||
isPlaying ? (cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100') : 'opacity-100'
|
isPlaying
|
||||||
|
? cursorIdle
|
||||||
|
? 'opacity-0'
|
||||||
|
: 'opacity-0 group-hover:opacity-100'
|
||||||
|
: 'opacity-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
|
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
|
||||||
@@ -710,7 +769,12 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
{panelComments.length}
|
{panelComments.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setOpenCommentsPanel(null)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setOpenCommentsPanel(null)}
|
||||||
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,20 +792,29 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
[...panelComments]
|
[...panelComments]
|
||||||
.sort((a, b) => a.timestamp - b.timestamp)
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
.map((comment) => {
|
.map((comment) => {
|
||||||
const authorName = comment.author?.name || comment.guestName || 'Anonymous';
|
const authorName =
|
||||||
|
comment.author?.name || comment.guestName || 'Anonymous';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
className={cn('rounded-lg border p-2 text-xs', comment.isResolved && 'opacity-60')}
|
className={cn(
|
||||||
|
'rounded-lg border p-2 text-xs',
|
||||||
|
comment.isResolved && 'opacity-60'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<Avatar className="h-4 w-4">
|
<Avatar className="h-4 w-4">
|
||||||
<AvatarImage src={comment.author?.image ?? undefined} />
|
<AvatarImage src={comment.author?.image ?? undefined} />
|
||||||
<AvatarFallback className="text-[8px]">{authorName.charAt(0)}</AvatarFallback>
|
<AvatarFallback className="text-[8px]">
|
||||||
|
{authorName.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="font-medium truncate">{authorName}</span>
|
<span className="font-medium truncate">{authorName}</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleSeek(comment.timestamp); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSeek(comment.timestamp);
|
||||||
|
}}
|
||||||
className="ml-auto flex items-center gap-0.5 text-primary bg-primary/10 px-1 py-0.5 rounded text-[10px] hover:bg-primary/20 transition-colors"
|
className="ml-auto flex items-center gap-0.5 text-primary bg-primary/10 px-1 py-0.5 rounded text-[10px] hover:bg-primary/20 transition-colors"
|
||||||
>
|
>
|
||||||
<Clock className="h-2.5 w-2.5" />
|
<Clock className="h-2.5 w-2.5" />
|
||||||
@@ -749,13 +822,18 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{comment.content && (
|
{comment.content && (
|
||||||
<p className="text-muted-foreground leading-relaxed">{comment.content}</p>
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{comment.tag && (
|
{comment.tag && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-1 text-[10px] px-1.5 py-0"
|
className="mt-1 text-[10px] px-1.5 py-0"
|
||||||
style={{ borderColor: comment.tag.color, color: comment.tag.color }}
|
style={{
|
||||||
|
borderColor: comment.tag.color,
|
||||||
|
color: comment.tag.color,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{comment.tag.name}
|
{comment.tag.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -873,7 +951,11 @@ function YouTubePanel({
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
onUnregister(version.id);
|
onUnregister(version.id);
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
try { playerRef.current.destroy(); } catch { /* */ }
|
try {
|
||||||
|
playerRef.current.destroy();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
playerRef.current = null;
|
playerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -882,7 +964,11 @@ function YouTubePanel({
|
|||||||
return () => {
|
return () => {
|
||||||
onUnregister(version.id);
|
onUnregister(version.id);
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
try { playerRef.current.destroy(); } catch { /* */ }
|
try {
|
||||||
|
playerRef.current.destroy();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
playerRef.current = null;
|
playerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -979,11 +1065,8 @@ function BunnyPanel({
|
|||||||
}
|
}
|
||||||
return cachedDuration;
|
return cachedDuration;
|
||||||
},
|
},
|
||||||
getPlayerState: () => (
|
getPlayerState: () =>
|
||||||
isPlaying
|
isPlaying ? (window.YT?.PlayerState?.PLAYING ?? 1) : (window.YT?.PlayerState?.PAUSED ?? 2),
|
||||||
? (window.YT?.PlayerState?.PLAYING ?? 1)
|
|
||||||
: (window.YT?.PlayerState?.PAUSED ?? 2)
|
|
||||||
),
|
|
||||||
setPlaybackRate: (rate: number) => {
|
setPlaybackRate: (rate: number) => {
|
||||||
videoEl.playbackRate = rate;
|
videoEl.playbackRate = rate;
|
||||||
},
|
},
|
||||||
@@ -997,7 +1080,11 @@ function BunnyPanel({
|
|||||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
videoEl.removeEventListener('error', onError);
|
videoEl.removeEventListener('error', onError);
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
try {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
hlsRef.current = null;
|
hlsRef.current = null;
|
||||||
}
|
}
|
||||||
videoEl.removeAttribute('src');
|
videoEl.removeAttribute('src');
|
||||||
@@ -1014,10 +1101,18 @@ function BunnyPanel({
|
|||||||
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onTimeUpdate = () => { cachedTime = videoEl.currentTime || 0; };
|
const onTimeUpdate = () => {
|
||||||
const onPlay = () => { isPlaying = true; };
|
cachedTime = videoEl.currentTime || 0;
|
||||||
const onPause = () => { isPlaying = false; };
|
};
|
||||||
const onEnded = () => { isPlaying = false; };
|
const onPlay = () => {
|
||||||
|
isPlaying = true;
|
||||||
|
};
|
||||||
|
const onPause = () => {
|
||||||
|
isPlaying = false;
|
||||||
|
};
|
||||||
|
const onEnded = () => {
|
||||||
|
isPlaying = false;
|
||||||
|
};
|
||||||
if (!bunnyCdnHostname) {
|
if (!bunnyCdnHostname) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1122,11 @@ function BunnyPanel({
|
|||||||
sourceMode = 'original';
|
sourceMode = 'original';
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
try {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
hlsRef.current = null;
|
hlsRef.current = null;
|
||||||
}
|
}
|
||||||
videoEl.src = getRetryUrl(originalUrl);
|
videoEl.src = getRetryUrl(originalUrl);
|
||||||
@@ -1075,24 +1174,30 @@ function BunnyPanel({
|
|||||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
const responseCode = (data as { response?: { code?: number } }).response?.code;
|
const responseCode = (data as { response?: { code?: number } }).response?.code;
|
||||||
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
|
const isManifestLoadFailure =
|
||||||
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR ||
|
||||||
const hasProcessingLikeStatus = responseCode === undefined
|
data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
||||||
|| responseCode === 0
|
const hasProcessingLikeStatus =
|
||||||
|| responseCode === 403
|
responseCode === undefined ||
|
||||||
|| responseCode === 404
|
responseCode === 0 ||
|
||||||
|| responseCode === 423
|
responseCode === 403 ||
|
||||||
|| responseCode === 429
|
responseCode === 404 ||
|
||||||
|| responseCode === 503;
|
responseCode === 423 ||
|
||||||
|
responseCode === 429 ||
|
||||||
|
responseCode === 503;
|
||||||
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
|
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
|
||||||
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR
|
const isNetworkPreMetadataProcessing =
|
||||||
&& hasProcessingLikeStatus
|
data.type === Hls.ErrorTypes.NETWORK_ERROR &&
|
||||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
hasProcessingLikeStatus &&
|
||||||
const isUnknownPreMetadataProcessing = !data.details
|
videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||||
&& !data.type
|
const isUnknownPreMetadataProcessing =
|
||||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
!data.details && !data.type && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||||
|
|
||||||
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
if (
|
||||||
|
isLikelyProcessing ||
|
||||||
|
isNetworkPreMetadataProcessing ||
|
||||||
|
isUnknownPreMetadataProcessing
|
||||||
|
) {
|
||||||
if (sourceMode === 'hls') {
|
if (sourceMode === 'hls') {
|
||||||
activateOriginalFallback();
|
activateOriginalFallback();
|
||||||
return;
|
return;
|
||||||
@@ -1132,13 +1237,20 @@ function BunnyPanel({
|
|||||||
}, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]);
|
}, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="relative w-full h-full group flex items-center justify-center bg-black"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex items-center justify-center bg-black',
|
'relative flex items-center justify-center bg-black',
|
||||||
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
|
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
|
||||||
)}
|
)}
|
||||||
style={isPortraitSource && portraitFrameWidth > 0 ? { width: `${portraitFrameWidth}px` } : undefined}
|
style={
|
||||||
|
isPortraitSource && portraitFrameWidth > 0
|
||||||
|
? { width: `${portraitFrameWidth}px` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -1156,5 +1268,5 @@ function BunnyPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
function PlayerPanelSkeleton() {
|
function PlayerPanelSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -12,7 +12,7 @@ function PlayerPanelSkeleton() {
|
|||||||
<Skeleton className="h-4 w-24 mx-auto" />
|
<Skeleton className="h-4 w-24 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CompareLoading() {
|
export default function CompareLoading() {
|
||||||
@@ -35,5 +35,5 @@ export default function CompareLoading() {
|
|||||||
<PlayerPanelSkeleton />
|
<PlayerPanelSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { AlertTriangle, Film } from "lucide-react";
|
import { AlertTriangle, Film } from 'lucide-react';
|
||||||
|
|
||||||
export default function VideoError({
|
export default function VideoError({
|
||||||
error,
|
error,
|
||||||
@@ -12,7 +12,7 @@ export default function VideoError({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error("Video player error:", error);
|
console.error('Video player error:', error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,13 +24,10 @@ export default function VideoError({
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold">Video Player Error</h1>
|
<h1 className="text-2xl font-bold">Video Player Error</h1>
|
||||||
<p className="text-muted-foreground max-w-md">
|
<p className="text-muted-foreground max-w-md">
|
||||||
Something went wrong with the video player. This could be due to a network issue or a problem with the video file.
|
Something went wrong with the video player. This could be due to a network issue or a
|
||||||
|
problem with the video file.
|
||||||
</p>
|
</p>
|
||||||
{error.digest && (
|
{error.digest && <p className="text-muted-foreground text-xs">Error ID: {error.digest}</p>}
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Error ID: {error.digest}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={reset} variant="default">
|
<Button onClick={reset} variant="default">
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
export default function VideoLayout({
|
export default function VideoLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
// This layout is empty - no header, no sidebar
|
// This layout is empty - no header, no sidebar
|
||||||
// The video page uses full screen space
|
// The video page uses full screen space
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
function CommentSkeleton() {
|
function CommentSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +14,7 @@ function CommentSkeleton() {
|
|||||||
<Skeleton className="h-4 w-full mb-1" />
|
<Skeleton className="h-4 w-full mb-1" />
|
||||||
<Skeleton className="h-4 w-2/3" />
|
<Skeleton className="h-4 w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoPlayerLoading() {
|
export default function VideoPlayerLoading() {
|
||||||
@@ -80,5 +80,5 @@ export default function VideoPlayerLoading() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from 'next/link';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Film } from "lucide-react";
|
import { Film } from 'lucide-react';
|
||||||
|
|
||||||
export default function VideoNotFound() {
|
export default function VideoNotFound() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+54
-15
@@ -2,7 +2,17 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Check, Copy, Link2, Loader2, RefreshCcw, ShieldOff, Lock, ShieldCheck } from 'lucide-react';
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
RefreshCcw,
|
||||||
|
ShieldOff,
|
||||||
|
Lock,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -46,7 +56,9 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, { cache: 'no-store' });
|
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
const payload = (await response.json()) as ShareResponse;
|
const payload = (await response.json()) as ShareResponse;
|
||||||
|
|
||||||
if (!response.ok || payload.error) {
|
if (!response.ok || payload.error) {
|
||||||
@@ -152,7 +164,10 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => null)) as ShareResponse | { error?: string } | null;
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| ShareResponse
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
|
if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
|
||||||
setError((payload as { error?: string } | null)?.error || 'Failed to update link security');
|
setError((payload as { error?: string } | null)?.error || 'Failed to update link security');
|
||||||
return;
|
return;
|
||||||
@@ -182,9 +197,14 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ allowDownloads: nextAllowDownloads }),
|
body: JSON.stringify({ allowDownloads: nextAllowDownloads }),
|
||||||
});
|
});
|
||||||
const payload = (await response.json().catch(() => null)) as ShareResponse | { error?: string } | null;
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| ShareResponse
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
|
if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
|
||||||
setError((payload as { error?: string } | null)?.error || 'Failed to update download setting');
|
setError(
|
||||||
|
(payload as { error?: string } | null)?.error || 'Failed to update download setting'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (payload as ShareResponse).data;
|
const data = (payload as ShareResponse).data;
|
||||||
@@ -237,18 +257,28 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={createShareLink} disabled={submitting} variant="outline">
|
<Button onClick={createShareLink} disabled={submitting} variant="outline">
|
||||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCcw className="h-4 w-4 mr-2" />}
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCcw className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
Regenerate Link
|
Regenerate Link
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={revokeShareLink} disabled={submitting} variant="destructive">
|
<Button onClick={revokeShareLink} disabled={submitting} variant="destructive">
|
||||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ShieldOff className="h-4 w-4 mr-2" />}
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ShieldOff className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
Revoke Link
|
Revoke Link
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-3 space-y-2">
|
<div className="rounded-lg border p-3 space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Video download</p>
|
<p className="text-sm font-medium">Video download</p>
|
||||||
<p className="text-xs text-muted-foreground">Allow viewers with this link to download</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Allow viewers with this link to download
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -270,13 +300,19 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
|||||||
|
|
||||||
<div className="rounded-lg border p-3 space-y-2">
|
<div className="rounded-lg border p-3 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
{hasPassword ? <ShieldCheck className="h-4 w-4 text-green-600" /> : <Lock className="h-4 w-4" />}
|
{hasPassword ? (
|
||||||
|
<ShieldCheck className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
)}
|
||||||
Link password
|
Link password
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={hasPassword ? 'Enter new password to replace current one' : 'Set a password'}
|
placeholder={
|
||||||
|
hasPassword ? 'Enter new password to replace current one' : 'Set a password'
|
||||||
|
}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
@@ -302,18 +338,21 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={createShareLink} disabled={submitting}>
|
<Button onClick={createShareLink} disabled={submitting}>
|
||||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Link2 className="h-4 w-4 mr-2" />}
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Link2 className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
Create Review Link
|
Create Review Link
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
This link allows guests to leave comments without an account. You can optionally protect it with a password.
|
This link allows guests to leave comments without an account. You can optionally
|
||||||
|
protect it with a password.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,27 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2, UploadCloud, FileVideo } from 'lucide-react';
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
Link as LinkIcon,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
UploadCloud,
|
||||||
|
FileVideo,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
import {
|
||||||
|
parseVideoUrl,
|
||||||
|
fetchVideoMetadata,
|
||||||
|
getThumbnailUrl,
|
||||||
|
type VideoSource,
|
||||||
|
} from '@/lib/video-providers';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import * as tus from 'tus-js-client';
|
import * as tus from 'tus-js-client';
|
||||||
|
|
||||||
@@ -60,7 +73,8 @@ export default function NewVideoPageClient({
|
|||||||
description: '',
|
description: '',
|
||||||
});
|
});
|
||||||
const isUploadingFile = isLoading && uploadMode === 'file';
|
const isUploadingFile = isLoading && uploadMode === 'file';
|
||||||
const leaveWarningMessage = 'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
const leaveWarningMessage =
|
||||||
|
'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
|
pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
|
||||||
@@ -70,45 +84,51 @@ export default function NewVideoPageClient({
|
|||||||
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
|
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
|
||||||
}, [pendingBunnyUploadToken]);
|
}, [pendingBunnyUploadToken]);
|
||||||
|
|
||||||
const cleanupPendingBunnyVideo = useCallback(async (videoId: string, uploadToken: string, keepalive = false) => {
|
const cleanupPendingBunnyVideo = useCallback(
|
||||||
try {
|
async (videoId: string, uploadToken: string, keepalive = false) => {
|
||||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ videoId, uploadToken }),
|
|
||||||
keepalive,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to cleanup pending Bunny upload:', error);
|
|
||||||
} finally {
|
|
||||||
if (pendingBunnyVideoIdRef.current === videoId) {
|
|
||||||
pendingBunnyVideoIdRef.current = null;
|
|
||||||
setPendingBunnyVideoId(null);
|
|
||||||
}
|
|
||||||
if (pendingBunnyUploadTokenRef.current === uploadToken) {
|
|
||||||
pendingBunnyUploadTokenRef.current = null;
|
|
||||||
setPendingBunnyUploadToken(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const abortAndCleanupPendingUpload = useCallback((keepalive = false) => {
|
|
||||||
const pendingVideoId = pendingBunnyVideoIdRef.current;
|
|
||||||
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
|
|
||||||
if (!pendingVideoId || !pendingUploadToken) return;
|
|
||||||
|
|
||||||
if (activeTusUploadRef.current) {
|
|
||||||
try {
|
try {
|
||||||
activeTusUploadRef.current.abort(true);
|
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||||
} catch {
|
method: 'DELETE',
|
||||||
// Ignore abort failures; we'll still attempt cleanup.
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ videoId, uploadToken }),
|
||||||
|
keepalive,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to cleanup pending Bunny upload:', error);
|
||||||
} finally {
|
} finally {
|
||||||
activeTusUploadRef.current = null;
|
if (pendingBunnyVideoIdRef.current === videoId) {
|
||||||
|
pendingBunnyVideoIdRef.current = null;
|
||||||
|
setPendingBunnyVideoId(null);
|
||||||
|
}
|
||||||
|
if (pendingBunnyUploadTokenRef.current === uploadToken) {
|
||||||
|
pendingBunnyUploadTokenRef.current = null;
|
||||||
|
setPendingBunnyUploadToken(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
|
||||||
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
|
const abortAndCleanupPendingUpload = useCallback(
|
||||||
}, [cleanupPendingBunnyVideo]);
|
(keepalive = false) => {
|
||||||
|
const pendingVideoId = pendingBunnyVideoIdRef.current;
|
||||||
|
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
|
||||||
|
if (!pendingVideoId || !pendingUploadToken) return;
|
||||||
|
|
||||||
|
if (activeTusUploadRef.current) {
|
||||||
|
try {
|
||||||
|
activeTusUploadRef.current.abort(true);
|
||||||
|
} catch {
|
||||||
|
// Ignore abort failures; we'll still attempt cleanup.
|
||||||
|
} finally {
|
||||||
|
activeTusUploadRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
|
||||||
|
},
|
||||||
|
[cleanupPendingBunnyVideo]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUploadingFile) return;
|
if (!isUploadingFile) return;
|
||||||
@@ -206,38 +226,47 @@ export default function NewVideoPageClient({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectedVideoFile = useCallback((file: File) => {
|
const setSelectedVideoFile = useCallback(
|
||||||
if (!isVideoFile(file)) {
|
(file: File) => {
|
||||||
setSubmitError('Please select a valid video file.');
|
if (!isVideoFile(file)) {
|
||||||
return;
|
setSubmitError('Please select a valid video file.');
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
|
|
||||||
if (!formData.title) {
|
if (!formData.title) {
|
||||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||||
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
||||||
}
|
}
|
||||||
}, [formData.title]);
|
},
|
||||||
|
[formData.title]
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileDragEnter = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
const handleFileDragEnter = useCallback(
|
||||||
event.preventDefault();
|
(event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
if (isLoading) return;
|
event.preventDefault();
|
||||||
fileDragDepthRef.current += 1;
|
if (isLoading) return;
|
||||||
if (Array.from(event.dataTransfer.types).includes('Files')) {
|
fileDragDepthRef.current += 1;
|
||||||
setIsFileDragOver(true);
|
if (Array.from(event.dataTransfer.types).includes('Files')) {
|
||||||
}
|
setIsFileDragOver(true);
|
||||||
}, [isLoading]);
|
}
|
||||||
|
},
|
||||||
|
[isLoading]
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileDragOver = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
const handleFileDragOver = useCallback(
|
||||||
event.preventDefault();
|
(event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
if (isLoading) return;
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
if (isLoading) return;
|
||||||
if (Array.from(event.dataTransfer.types).includes('Files')) {
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
setIsFileDragOver(true);
|
if (Array.from(event.dataTransfer.types).includes('Files')) {
|
||||||
}
|
setIsFileDragOver(true);
|
||||||
}, [isLoading]);
|
}
|
||||||
|
},
|
||||||
|
[isLoading]
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileDragLeave = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
const handleFileDragLeave = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -247,26 +276,35 @@ export default function NewVideoPageClient({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileDrop = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
const handleFileDrop = useCallback(
|
||||||
event.preventDefault();
|
(event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
fileDragDepthRef.current = 0;
|
event.preventDefault();
|
||||||
setIsFileDragOver(false);
|
fileDragDepthRef.current = 0;
|
||||||
if (isLoading) return;
|
setIsFileDragOver(false);
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
const file = Array.from(event.dataTransfer.files)[0];
|
const file = Array.from(event.dataTransfer.files)[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setSelectedVideoFile(file);
|
setSelectedVideoFile(file);
|
||||||
}, [isLoading, setSelectedVideoFile]);
|
},
|
||||||
|
[isLoading, setSelectedVideoFile]
|
||||||
|
);
|
||||||
|
|
||||||
const uploadToBunny = async (
|
const uploadToBunny = async (
|
||||||
file: File
|
file: File
|
||||||
): Promise<{ videoId: string; libraryId: string; providerId: string; url: string; uploadToken: string }> => {
|
): Promise<{
|
||||||
|
videoId: string;
|
||||||
|
libraryId: string;
|
||||||
|
providerId: string;
|
||||||
|
url: string;
|
||||||
|
uploadToken: string;
|
||||||
|
}> => {
|
||||||
// 1. Initialize Bunny Stream upload (creates video & gets signature)
|
// 1. Initialize Bunny Stream upload (creates video & gets signature)
|
||||||
setUploadStatus('Initializing upload...');
|
setUploadStatus('Initializing upload...');
|
||||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: formData.title || file.name })
|
body: JSON.stringify({ title: formData.title || file.name }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!initRes.ok) {
|
if (!initRes.ok) {
|
||||||
@@ -274,7 +312,9 @@ export default function NewVideoPageClient({
|
|||||||
throw new Error(data.error || 'Failed to initialize upload');
|
throw new Error(data.error || 'Failed to initialize upload');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: { videoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
|
const {
|
||||||
|
data: { videoId, libraryId, signature, expirationTime, uploadToken },
|
||||||
|
} = await initRes.json();
|
||||||
setPendingBunnyVideoId(videoId);
|
setPendingBunnyVideoId(videoId);
|
||||||
setPendingBunnyUploadToken(uploadToken);
|
setPendingBunnyUploadToken(uploadToken);
|
||||||
pendingBunnyVideoIdRef.current = videoId;
|
pendingBunnyVideoIdRef.current = videoId;
|
||||||
@@ -414,7 +454,10 @@ export default function NewVideoPageClient({
|
|||||||
console.error('Failed to add video:', error);
|
console.error('Failed to add video:', error);
|
||||||
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||||
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
|
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
|
||||||
await cleanupPendingBunnyVideo(pendingBunnyVideoIdRef.current, pendingBunnyUploadTokenRef.current);
|
await cleanupPendingBunnyVideo(
|
||||||
|
pendingBunnyVideoIdRef.current,
|
||||||
|
pendingBunnyUploadTokenRef.current
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
activeTusUploadRef.current = null;
|
activeTusUploadRef.current = null;
|
||||||
@@ -455,17 +498,26 @@ export default function NewVideoPageClient({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs value={uploadMode} onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6">
|
<Tabs
|
||||||
<TabsList className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
value={uploadMode}
|
||||||
<TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger>
|
onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')}
|
||||||
|
className="mb-6"
|
||||||
|
>
|
||||||
|
<TabsList
|
||||||
|
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||||
|
>
|
||||||
|
<TabsTrigger value="url" disabled={isLoading}>
|
||||||
|
Paste URL
|
||||||
|
</TabsTrigger>
|
||||||
{bunnyUploadsEnabled ? (
|
{bunnyUploadsEnabled ? (
|
||||||
<TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger>
|
<TabsTrigger value="file" disabled={isLoading}>
|
||||||
|
Direct Upload
|
||||||
|
</TabsTrigger>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|
||||||
{uploadMode === 'url' ? (
|
{uploadMode === 'url' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">Video URL</Label>
|
<Label htmlFor="url">Video URL</Label>
|
||||||
@@ -492,7 +544,9 @@ export default function NewVideoPageClient({
|
|||||||
{videoSource && (
|
{videoSource && (
|
||||||
<p className="text-sm text-green-600 flex items-center gap-1">
|
<p className="text-sm text-green-600 flex items-center gap-1">
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected
|
{videoSource.providerId.charAt(0).toUpperCase() +
|
||||||
|
videoSource.providerId.slice(1)}{' '}
|
||||||
|
video detected
|
||||||
{isFetchingMeta && ' — fetching metadata...'}
|
{isFetchingMeta && ' — fetching metadata...'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -519,7 +573,9 @@ export default function NewVideoPageClient({
|
|||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<>
|
<>
|
||||||
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
||||||
<p className="mb-2 text-sm text-foreground font-medium">{selectedFile.name}</p>
|
<p className="mb-2 text-sm text-foreground font-medium">
|
||||||
|
{selectedFile.name}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
|
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
@@ -534,7 +590,14 @@ export default function NewVideoPageClient({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input id="file" type="file" accept="video/*" className="hidden" onChange={handleFileChange} disabled={isLoading} />
|
<input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -561,7 +624,11 @@ export default function NewVideoPageClient({
|
|||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
placeholder={isFetchingMeta ? 'Fetching title...' : 'Video title (will auto-fill from video if empty)'}
|
placeholder={
|
||||||
|
isFetchingMeta
|
||||||
|
? 'Fetching title...'
|
||||||
|
: 'Video title (will auto-fill from video if empty)'
|
||||||
|
}
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -596,7 +663,10 @@ export default function NewVideoPageClient({
|
|||||||
<p className="text-sm text-muted-foreground">{uploadStatus}</p>
|
<p className="text-sm text-muted-foreground">{uploadStatus}</p>
|
||||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||||
<div className="w-full bg-secondary rounded-full h-2">
|
<div className="w-full bg-secondary rounded-full h-2">
|
||||||
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div>
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isUploadingFile && (
|
{isUploadingFile && (
|
||||||
@@ -608,11 +678,23 @@ export default function NewVideoPageClient({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button type="submit" disabled={isLoading || (uploadMode === 'url' && !videoSource) || (uploadMode === 'file' && !selectedFile)}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
(uploadMode === 'url' && !videoSource) ||
|
||||||
|
(uploadMode === 'file' && !selectedFile)
|
||||||
|
}
|
||||||
|
>
|
||||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
Add Video
|
Add Video
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isLoading}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ interface Workspace {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
const visibilityOptions: {
|
||||||
|
value: Visibility;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
value: 'PRIVATE',
|
value: 'PRIVATE',
|
||||||
label: 'Private',
|
label: 'Private',
|
||||||
@@ -71,7 +76,7 @@ export default function NewProjectPage() {
|
|||||||
setWorkspaces(workspacesData);
|
setWorkspaces(workspacesData);
|
||||||
// Auto-select if only one workspace and none preselected
|
// Auto-select if only one workspace and none preselected
|
||||||
if (!preselectedWorkspace && workspacesData.length === 1) {
|
if (!preselectedWorkspace && workspacesData.length === 1) {
|
||||||
setFormData(prev => ({ ...prev, workspaceId: workspacesData[0].id }));
|
setFormData((prev) => ({ ...prev, workspaceId: workspacesData[0].id }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -160,7 +165,7 @@ export default function NewProjectPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={formData.workspaceId}
|
value={formData.workspaceId}
|
||||||
onValueChange={(v) => setFormData(prev => ({ ...prev, workspaceId: v }))}
|
onValueChange={(v) => setFormData((prev) => ({ ...prev, workspaceId: v }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-11">
|
<SelectTrigger className="h-11">
|
||||||
<SelectValue placeholder="Select a workspace" />
|
<SelectValue placeholder="Select a workspace" />
|
||||||
@@ -187,7 +192,7 @@ export default function NewProjectPage() {
|
|||||||
id="name"
|
id="name"
|
||||||
placeholder="e.g. Product Demo Q1"
|
placeholder="e.g. Product Demo Q1"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
@@ -203,7 +208,9 @@ export default function NewProjectPage() {
|
|||||||
id="description"
|
id="description"
|
||||||
placeholder="Brief description of what this project is about..."
|
placeholder="Brief description of what this project is about..."
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||||
|
}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="resize-none"
|
className="resize-none"
|
||||||
@@ -217,29 +224,34 @@ export default function NewProjectPage() {
|
|||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFormData(prev => ({ ...prev, visibility: option.value }))}
|
onClick={() => setFormData((prev) => ({ ...prev, visibility: option.value }))}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${formData.visibility === option.value
|
className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
|
formData.visibility === option.value
|
||||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||||
: 'border-border hover:border-border/80 hover:bg-accent/50'
|
: 'border-border hover:border-border/80 hover:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${formData.visibility === option.value
|
<div
|
||||||
? 'bg-primary text-primary-foreground'
|
className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||||
: 'bg-muted text-muted-foreground'
|
formData.visibility === option.value
|
||||||
}`}>
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium">{option.label}</div>
|
<div className="font-medium">{option.label}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||||
{option.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${formData.visibility === option.value
|
<div
|
||||||
? 'border-primary bg-primary'
|
className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||||
: 'border-muted-foreground/30'
|
formData.visibility === option.value
|
||||||
}`}>
|
? 'border-primary bg-primary'
|
||||||
|
: 'border-muted-foreground/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{formData.visibility === option.value && (
|
{formData.visibility === option.value && (
|
||||||
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
|
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/card"
|
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
function SettingsCardSkeleton({ rows }: { rows: number }) {
|
function SettingsCardSkeleton({ rows }: { rows: number }) {
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +20,7 @@ function SettingsCardSkeleton({ rows }: { rows: number }) {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsLoading() {
|
export default function SettingsLoading() {
|
||||||
@@ -68,5 +68,5 @@ export default function SettingsLoading() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe, CreditCard, HardDrive } from 'lucide-react';
|
import {
|
||||||
|
Bell,
|
||||||
|
Send,
|
||||||
|
Mail,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
Globe,
|
||||||
|
CreditCard,
|
||||||
|
HardDrive,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -93,16 +103,12 @@ function ToggleButton({
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between w-full p-3 rounded-lg border transition-colors text-left',
|
'flex items-center justify-between w-full p-3 rounded-lg border transition-colors text-left',
|
||||||
enabled
|
enabled ? 'border-primary/50 bg-primary/5' : 'border-border hover:bg-accent/50'
|
||||||
? 'border-primary/50 bg-primary/5'
|
|
||||||
: 'border-border hover:bg-accent/50'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0 pr-4">
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
<span className="text-sm font-medium">{label}</span>
|
<span className="text-sm font-medium">{label}</span>
|
||||||
{description && (
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -336,9 +342,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
<CreditCard className="h-5 w-5" />
|
<CreditCard className="h-5 w-5" />
|
||||||
Billing
|
Billing
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Manage your paid plan and workspace creation access</CardDescription>
|
||||||
Manage your paid plan and workspace creation access
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{billingLoading || !billing ? (
|
{billingLoading || !billing ? (
|
||||||
@@ -349,22 +353,25 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
</div>
|
</div>
|
||||||
) : !billing.isEnabled ? (
|
) : !billing.isEnabled ? (
|
||||||
<div className="rounded-md border border-muted bg-muted/40 p-4 text-sm text-muted-foreground">
|
<div className="rounded-md border border-muted bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||||
Stripe billing is disabled by this host. Workspace creation is unrestricted in this environment.
|
Stripe billing is disabled by this host. Workspace creation is unrestricted in this
|
||||||
|
environment.
|
||||||
</div>
|
</div>
|
||||||
) : !billing.isConfigured ? (
|
) : !billing.isConfigured ? (
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400">
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400">
|
||||||
Stripe is not configured yet. Add your Stripe environment variables before using billing.
|
Stripe is not configured yet. Add your Stripe environment variables before using
|
||||||
|
billing.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!billing.subscription.hasActiveSubscription
|
{!billing.subscription.hasActiveSubscription &&
|
||||||
&& !billing.subscription.hasActiveTrial
|
!billing.subscription.hasActiveTrial &&
|
||||||
&& billing.subscription.isTrialEligible
|
billing.subscription.isTrialEligible &&
|
||||||
&& billing.checkoutAvailable ? (
|
billing.checkoutAvailable ? (
|
||||||
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
|
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
|
||||||
<p className="text-sm font-semibold">Start your 7-day free trial</p>
|
<p className="text-sm font-semibold">Start your 7-day free trial</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Get full access to all features — no charge until the trial ends. Cancel anytime.
|
Get full access to all features — no charge until the trial ends. Cancel
|
||||||
|
anytime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -382,7 +389,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
: billing.subscription.hasActiveTrial
|
: billing.subscription.hasActiveTrial
|
||||||
? 'Trial access is active.'
|
? 'Trial access is active.'
|
||||||
: billing.subscription.isTrialEligible
|
: billing.subscription.isTrialEligible
|
||||||
? 'You haven\'t started your free trial yet.'
|
? "You haven't started your free trial yet."
|
||||||
: 'Billing access has ended.'}
|
: 'Billing access has ended.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,35 +400,37 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{billing.subscription.hasActiveTrial
|
{billing.subscription.hasActiveTrial &&
|
||||||
&& billing.subscription.trialEndsAt
|
billing.subscription.trialEndsAt &&
|
||||||
&& hasScheduledCancellation ? (
|
hasScheduledCancellation ? (
|
||||||
<p
|
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive"
|
Access ends on {new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.
|
||||||
>
|
|
||||||
Access ends on {' '}
|
|
||||||
{new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{billing.subscription.currentPeriodEnd ? (
|
{billing.subscription.currentPeriodEnd ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{hasScheduledCancellation ? 'Your subscription ends on ' : 'Current billing period ends on '}
|
{hasScheduledCancellation
|
||||||
|
? 'Your subscription ends on '
|
||||||
|
: 'Current billing period ends on '}
|
||||||
{new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}.
|
{new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasScheduledCancellation && billing.subscription.cancelAt ? (
|
{hasScheduledCancellation && billing.subscription.cancelAt ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Cancellation was scheduled on {new Date(billing.subscription.cancelAt).toLocaleDateString()}.
|
Cancellation was scheduled on{' '}
|
||||||
|
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!billing.subscription.hasBillingAccess
|
{!billing.subscription.hasBillingAccess &&
|
||||||
&& billing.subscription.billingAccessEndedAt
|
billing.subscription.billingAccessEndedAt &&
|
||||||
&& billing.subscription.storageCleanupEligibleAt ? (
|
billing.subscription.storageCleanupEligibleAt ? (
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
<p className="text-sm text-amber-700 dark:text-amber-400">
|
||||||
Stored media cleanup is scheduled after {new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()} unless billing is restored first.
|
Stored media cleanup is scheduled after{' '}
|
||||||
|
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()}{' '}
|
||||||
|
unless billing is restored first.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -473,346 +482,332 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{billing?.subscription.hasBillingAccess && (
|
{billing?.subscription.hasBillingAccess && (
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<HardDrive className="h-5 w-5" />
|
<HardDrive className="h-5 w-5" />
|
||||||
Storage
|
Storage
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Combined usage across video files and media attachments (200 GB limit)
|
Combined usage across video files and media attachments (200 GB limit)
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{storageLoading || !storageInfo ? (
|
{storageLoading || !storageInfo ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-4 w-48" />
|
<Skeleton className="h-4 w-48" />
|
||||||
<Skeleton className="h-2 w-full rounded-full" />
|
<Skeleton className="h-2 w-full rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{formatBytes(storageInfo.usedBytes)} used of {formatBytes(storageInfo.limitBytes)}
|
{formatBytes(storageInfo.usedBytes)} used of{' '}
|
||||||
</span>
|
{formatBytes(storageInfo.limitBytes)}
|
||||||
<span
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
storageInfo.percentage >= 90
|
||||||
|
? 'text-destructive font-medium'
|
||||||
|
: storageInfo.percentage >= 75
|
||||||
|
? 'text-amber-600 dark:text-amber-400 font-medium'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{storageInfo.percentage < 0.1
|
||||||
|
? '<0.1%'
|
||||||
|
: `${storageInfo.percentage.toFixed(1)}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={storageInfo.percentage}
|
||||||
className={
|
className={
|
||||||
storageInfo.percentage >= 90
|
storageInfo.percentage >= 90
|
||||||
? 'text-destructive font-medium'
|
? '[&>div]:bg-destructive'
|
||||||
: storageInfo.percentage >= 75
|
: storageInfo.percentage >= 75
|
||||||
? 'text-amber-600 dark:text-amber-400 font-medium'
|
? '[&>div]:bg-amber-500'
|
||||||
: 'text-muted-foreground'
|
: ''
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
{storageInfo.percentage < 0.1 ? '<0.1%' : `${storageInfo.percentage.toFixed(1)}%`}
|
{storageInfo.percentage >= 90 && (
|
||||||
</span>
|
<p className="text-xs text-destructive">
|
||||||
</div>
|
Storage is almost full. Delete unused files or contact support.
|
||||||
<Progress
|
</p>
|
||||||
value={storageInfo.percentage}
|
)}
|
||||||
className={
|
</>
|
||||||
storageInfo.percentage >= 90
|
)}
|
||||||
? '[&>div]:bg-destructive'
|
</CardContent>
|
||||||
: storageInfo.percentage >= 75
|
</Card>
|
||||||
? '[&>div]:bg-amber-500'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{storageInfo.percentage >= 90 && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
Storage is almost full. Delete unused files or contact support.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!billingOnly && (
|
{!billingOnly && (
|
||||||
<>
|
<>
|
||||||
{/* Event Subscriptions */}
|
{/* Event Subscriptions */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
Notification Events
|
Notification Events
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Choose which events trigger notifications</CardDescription>
|
||||||
Choose which events trigger notifications
|
</CardHeader>
|
||||||
</CardDescription>
|
<CardContent className="space-y-4">
|
||||||
</CardHeader>
|
<ToggleButton
|
||||||
<CardContent className="space-y-4">
|
enabled={settings.onNewVideo}
|
||||||
<ToggleButton
|
onToggle={() => setSettings((s) => ({ ...s, onNewVideo: !s.onNewVideo }))}
|
||||||
enabled={settings.onNewVideo}
|
label="New Video Added"
|
||||||
onToggle={() =>
|
description="When a new video is added to one of your projects"
|
||||||
setSettings((s) => ({ ...s, onNewVideo: !s.onNewVideo }))
|
/>
|
||||||
}
|
<ToggleButton
|
||||||
label="New Video Added"
|
enabled={settings.onNewVersion}
|
||||||
description="When a new video is added to one of your projects"
|
onToggle={() => setSettings((s) => ({ ...s, onNewVersion: !s.onNewVersion }))}
|
||||||
/>
|
label="New Version Added"
|
||||||
<ToggleButton
|
description="When a new version is added to an existing video"
|
||||||
enabled={settings.onNewVersion}
|
/>
|
||||||
onToggle={() =>
|
<ToggleButton
|
||||||
setSettings((s) => ({ ...s, onNewVersion: !s.onNewVersion }))
|
enabled={settings.onNewComment}
|
||||||
}
|
onToggle={() => setSettings((s) => ({ ...s, onNewComment: !s.onNewComment }))}
|
||||||
label="New Version Added"
|
label="New Comment"
|
||||||
description="When a new version is added to an existing video"
|
description="When someone leaves a comment on your videos"
|
||||||
/>
|
/>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
enabled={settings.onNewComment}
|
enabled={settings.onNewReply}
|
||||||
onToggle={() =>
|
onToggle={() => setSettings((s) => ({ ...s, onNewReply: !s.onNewReply }))}
|
||||||
setSettings((s) => ({ ...s, onNewComment: !s.onNewComment }))
|
label="New Reply"
|
||||||
}
|
description="When someone replies to a comment thread"
|
||||||
label="New Comment"
|
/>
|
||||||
description="When someone leaves a comment on your videos"
|
<ToggleButton
|
||||||
/>
|
enabled={settings.onApprovalEvents}
|
||||||
<ToggleButton
|
onToggle={() =>
|
||||||
enabled={settings.onNewReply}
|
setSettings((s) => ({ ...s, onApprovalEvents: !s.onApprovalEvents }))
|
||||||
onToggle={() =>
|
}
|
||||||
setSettings((s) => ({ ...s, onNewReply: !s.onNewReply }))
|
label="Approval Workflow"
|
||||||
}
|
description="When approval requests are created, responded to, or finalized"
|
||||||
label="New Reply"
|
/>
|
||||||
description="When someone replies to a comment thread"
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Telegram */}
|
{/* Telegram */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Send className="h-5 w-5" />
|
<Send className="h-5 w-5" />
|
||||||
Telegram
|
Telegram
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={settings.telegramEnabled ? 'default' : 'secondary'}>
|
<Badge variant={settings.telegramEnabled ? 'default' : 'secondary'}>
|
||||||
{settings.telegramEnabled ? 'Enabled' : 'Disabled'}
|
{settings.telegramEnabled ? 'Enabled' : 'Disabled'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Get instant notifications via Telegram</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-md border bg-muted/40 p-3 space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">Setup instructions</p>
|
||||||
|
<ol className="space-y-1.5 list-decimal list-inside">
|
||||||
|
<li>
|
||||||
|
Message{' '}
|
||||||
|
<a
|
||||||
|
href="https://t.me/UserInfeBot"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
@UserInfeBot
|
||||||
|
</a>{' '}
|
||||||
|
on Telegram and send{' '}
|
||||||
|
<code className="bg-muted px-1 rounded text-xs">/start</code> to get your Chat
|
||||||
|
ID
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Start{' '}
|
||||||
|
<a
|
||||||
|
href="https://t.me/openframe_bot"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
@openframe_bot
|
||||||
|
</a>{' '}
|
||||||
|
and send <code className="bg-muted px-1 rounded text-xs">/start</code> so it can
|
||||||
|
message you
|
||||||
|
</li>
|
||||||
|
<li>Paste your Chat ID below and enable notifications</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="telegram-chat-id">Your Chat ID</Label>
|
||||||
|
<Input
|
||||||
|
id="telegram-chat-id"
|
||||||
|
placeholder="123456789"
|
||||||
|
value={telegramChatId}
|
||||||
|
onChange={(e) => setTelegramChatId(e.target.value)}
|
||||||
|
className="mt-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToggleButton
|
||||||
|
enabled={settings.telegramEnabled}
|
||||||
|
onToggle={() => setSettings((s) => ({ ...s, telegramEnabled: !s.telegramEnabled }))}
|
||||||
|
label="Enable Telegram notifications"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTest('telegram')}
|
||||||
|
disabled={!telegramChatId || testing === 'telegram'}
|
||||||
|
>
|
||||||
|
{testing === 'telegram' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Send Test Message
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Email
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant={settings.emailEnabled ? 'default' : 'secondary'}>
|
||||||
|
{settings.emailEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Receive notification emails to your account email address
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ToggleButton
|
||||||
|
enabled={settings.emailEnabled}
|
||||||
|
onToggle={() => setSettings((s) => ({ ...s, emailEnabled: !s.emailEnabled }))}
|
||||||
|
label="Enable email notifications"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTest('email')}
|
||||||
|
disabled={!settings.emailEnabled || testing === 'email'}
|
||||||
|
>
|
||||||
|
{testing === 'email' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Send Test Email
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Timezone */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
Timezone
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Timestamps in notifications will use this timezone</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Select
|
||||||
|
value={settings.timezone}
|
||||||
|
onValueChange={(value) => setSettings((s) => ({ ...s, timezone: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select timezone" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Americas</SelectLabel>
|
||||||
|
<SelectItem value="America/New_York">Eastern Time (New York)</SelectItem>
|
||||||
|
<SelectItem value="America/Chicago">Central Time (Chicago)</SelectItem>
|
||||||
|
<SelectItem value="America/Denver">Mountain Time (Denver)</SelectItem>
|
||||||
|
<SelectItem value="America/Los_Angeles">Pacific Time (Los Angeles)</SelectItem>
|
||||||
|
<SelectItem value="America/Anchorage">Alaska (Anchorage)</SelectItem>
|
||||||
|
<SelectItem value="Pacific/Honolulu">Hawaii (Honolulu)</SelectItem>
|
||||||
|
<SelectItem value="America/Toronto">Toronto</SelectItem>
|
||||||
|
<SelectItem value="America/Vancouver">Vancouver</SelectItem>
|
||||||
|
<SelectItem value="America/Mexico_City">Mexico City</SelectItem>
|
||||||
|
<SelectItem value="America/Sao_Paulo">São Paulo</SelectItem>
|
||||||
|
<SelectItem value="America/Argentina/Buenos_Aires">Buenos Aires</SelectItem>
|
||||||
|
<SelectItem value="America/Bogota">Bogotá</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Europe</SelectLabel>
|
||||||
|
<SelectItem value="Europe/London">London (GMT/BST)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Paris">Paris (CET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Berlin">Berlin (CET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Amsterdam">Amsterdam (CET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Madrid">Madrid (CET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Rome">Rome (CET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Zurich">Zurich (CET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Stockholm">Stockholm (CET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Helsinki">Helsinki (EET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Athens">Athens (EET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Istanbul">Istanbul (TRT)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Moscow">Moscow (MSK)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Kiev">Kyiv (EET)</SelectItem>
|
||||||
|
<SelectItem value="Europe/Warsaw">Warsaw (CET)</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Asia & Pacific</SelectLabel>
|
||||||
|
<SelectItem value="Asia/Dubai">Dubai (GST)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Kolkata">India (IST)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Bangkok">Bangkok (ICT)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Singapore">Singapore (SGT)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Hong_Kong">Hong Kong (HKT)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Shanghai">Shanghai (CST)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Tokyo">Tokyo (JST)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Seoul">Seoul (KST)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Taipei">Taipei (CST)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Jakarta">Jakarta (WIB)</SelectItem>
|
||||||
|
<SelectItem value="Australia/Sydney">Sydney (AEST)</SelectItem>
|
||||||
|
<SelectItem value="Australia/Melbourne">Melbourne (AEST)</SelectItem>
|
||||||
|
<SelectItem value="Australia/Perth">Perth (AWST)</SelectItem>
|
||||||
|
<SelectItem value="Pacific/Auckland">Auckland (NZST)</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Africa & Middle East</SelectLabel>
|
||||||
|
<SelectItem value="Africa/Cairo">Cairo (EET)</SelectItem>
|
||||||
|
<SelectItem value="Africa/Lagos">Lagos (WAT)</SelectItem>
|
||||||
|
<SelectItem value="Africa/Johannesburg">Johannesburg (SAST)</SelectItem>
|
||||||
|
<SelectItem value="Africa/Nairobi">Nairobi (EAT)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Riyadh">Riyadh (AST)</SelectItem>
|
||||||
|
<SelectItem value="Asia/Tehran">Tehran (IRST)</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Other</SelectLabel>
|
||||||
|
<SelectItem value="UTC">UTC</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator className="my-6" />
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Settings'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
|
||||||
Get instant notifications via Telegram
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="rounded-md border bg-muted/40 p-3 space-y-2 text-sm text-muted-foreground">
|
|
||||||
<p className="font-medium text-foreground">Setup instructions</p>
|
|
||||||
<ol className="space-y-1.5 list-decimal list-inside">
|
|
||||||
<li>
|
|
||||||
Message{' '}
|
|
||||||
<a
|
|
||||||
href="https://t.me/UserInfeBot"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary underline underline-offset-2"
|
|
||||||
>
|
|
||||||
@UserInfeBot
|
|
||||||
</a>
|
|
||||||
{' '}on Telegram and send <code className="bg-muted px-1 rounded text-xs">/start</code> to get your Chat ID
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Start{' '}
|
|
||||||
<a
|
|
||||||
href="https://t.me/openframe_bot"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary underline underline-offset-2"
|
|
||||||
>
|
|
||||||
@openframe_bot
|
|
||||||
</a>
|
|
||||||
{' '}and send <code className="bg-muted px-1 rounded text-xs">/start</code> so it can message you
|
|
||||||
</li>
|
|
||||||
<li>Paste your Chat ID below and enable notifications</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="telegram-chat-id">Your Chat ID</Label>
|
|
||||||
<Input
|
|
||||||
id="telegram-chat-id"
|
|
||||||
placeholder="123456789"
|
|
||||||
value={telegramChatId}
|
|
||||||
onChange={(e) => setTelegramChatId(e.target.value)}
|
|
||||||
className="mt-1 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToggleButton
|
|
||||||
enabled={settings.telegramEnabled}
|
|
||||||
onToggle={() =>
|
|
||||||
setSettings((s) => ({ ...s, telegramEnabled: !s.telegramEnabled }))
|
|
||||||
}
|
|
||||||
label="Enable Telegram notifications"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleTest('telegram')}
|
|
||||||
disabled={!telegramChatId || testing === 'telegram'}
|
|
||||||
>
|
|
||||||
{testing === 'telegram' ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Send className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Send Test Message
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Mail className="h-5 w-5" />
|
|
||||||
Email
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant={settings.emailEnabled ? 'default' : 'secondary'}>
|
|
||||||
{settings.emailEnabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
Receive notification emails to your account email address
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<ToggleButton
|
|
||||||
enabled={settings.emailEnabled}
|
|
||||||
onToggle={() =>
|
|
||||||
setSettings((s) => ({ ...s, emailEnabled: !s.emailEnabled }))
|
|
||||||
}
|
|
||||||
label="Enable email notifications"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleTest('email')}
|
|
||||||
disabled={!settings.emailEnabled || testing === 'email'}
|
|
||||||
>
|
|
||||||
{testing === 'email' ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Mail className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Send Test Email
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Timezone */}
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Globe className="h-5 w-5" />
|
|
||||||
Timezone
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Timestamps in notifications will use this timezone
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Select
|
|
||||||
value={settings.timezone}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setSettings((s) => ({ ...s, timezone: value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Select timezone" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Americas</SelectLabel>
|
|
||||||
<SelectItem value="America/New_York">Eastern Time (New York)</SelectItem>
|
|
||||||
<SelectItem value="America/Chicago">Central Time (Chicago)</SelectItem>
|
|
||||||
<SelectItem value="America/Denver">Mountain Time (Denver)</SelectItem>
|
|
||||||
<SelectItem value="America/Los_Angeles">Pacific Time (Los Angeles)</SelectItem>
|
|
||||||
<SelectItem value="America/Anchorage">Alaska (Anchorage)</SelectItem>
|
|
||||||
<SelectItem value="Pacific/Honolulu">Hawaii (Honolulu)</SelectItem>
|
|
||||||
<SelectItem value="America/Toronto">Toronto</SelectItem>
|
|
||||||
<SelectItem value="America/Vancouver">Vancouver</SelectItem>
|
|
||||||
<SelectItem value="America/Mexico_City">Mexico City</SelectItem>
|
|
||||||
<SelectItem value="America/Sao_Paulo">São Paulo</SelectItem>
|
|
||||||
<SelectItem value="America/Argentina/Buenos_Aires">Buenos Aires</SelectItem>
|
|
||||||
<SelectItem value="America/Bogota">Bogotá</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Europe</SelectLabel>
|
|
||||||
<SelectItem value="Europe/London">London (GMT/BST)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Paris">Paris (CET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Berlin">Berlin (CET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Amsterdam">Amsterdam (CET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Madrid">Madrid (CET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Rome">Rome (CET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Zurich">Zurich (CET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Stockholm">Stockholm (CET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Helsinki">Helsinki (EET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Athens">Athens (EET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Istanbul">Istanbul (TRT)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Moscow">Moscow (MSK)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Kiev">Kyiv (EET)</SelectItem>
|
|
||||||
<SelectItem value="Europe/Warsaw">Warsaw (CET)</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Asia & Pacific</SelectLabel>
|
|
||||||
<SelectItem value="Asia/Dubai">Dubai (GST)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Kolkata">India (IST)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Bangkok">Bangkok (ICT)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Singapore">Singapore (SGT)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Hong_Kong">Hong Kong (HKT)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Shanghai">Shanghai (CST)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Tokyo">Tokyo (JST)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Seoul">Seoul (KST)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Taipei">Taipei (CST)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Jakarta">Jakarta (WIB)</SelectItem>
|
|
||||||
<SelectItem value="Australia/Sydney">Sydney (AEST)</SelectItem>
|
|
||||||
<SelectItem value="Australia/Melbourne">Melbourne (AEST)</SelectItem>
|
|
||||||
<SelectItem value="Australia/Perth">Perth (AWST)</SelectItem>
|
|
||||||
<SelectItem value="Pacific/Auckland">Auckland (NZST)</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Africa & Middle East</SelectLabel>
|
|
||||||
<SelectItem value="Africa/Cairo">Cairo (EET)</SelectItem>
|
|
||||||
<SelectItem value="Africa/Lagos">Lagos (WAT)</SelectItem>
|
|
||||||
<SelectItem value="Africa/Johannesburg">Johannesburg (SAST)</SelectItem>
|
|
||||||
<SelectItem value="Africa/Nairobi">Nairobi (EAT)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Riyadh">Riyadh (AST)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Tehran">Tehran (IRST)</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Other</SelectLabel>
|
|
||||||
<SelectItem value="UTC">UTC</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Separator className="my-6" />
|
|
||||||
|
|
||||||
{/* Save button */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Save Settings'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,9 +60,8 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
|
|||||||
|
|
||||||
const pageParam = resolvedSearchParams?.page;
|
const pageParam = resolvedSearchParams?.page;
|
||||||
const parsedPage = pageParam ? Number(pageParam) : 1;
|
const parsedPage = pageParam ? Number(pageParam) : 1;
|
||||||
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0 && parsedPage <= MAX_PAGE
|
const page =
|
||||||
? parsedPage
|
Number.isSafeInteger(parsedPage) && parsedPage > 0 && parsedPage <= MAX_PAGE ? parsedPage : 1;
|
||||||
: 1;
|
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
@@ -94,7 +93,10 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
|
|||||||
const membership = workspace.members[0];
|
const membership = workspace.members[0];
|
||||||
const isMember = !!membership;
|
const isMember = !!membership;
|
||||||
const isAdmin = isOwner || membership?.role === 'ADMIN';
|
const isAdmin = isOwner || membership?.role === 'ADMIN';
|
||||||
const access = await checkWorkspaceAccess({ id: workspace.id, ownerId: workspace.ownerId }, session.user.id);
|
const access = await checkWorkspaceAccess(
|
||||||
|
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
if (!access.hasAccess || (!isOwner && !isMember)) {
|
if (!access.hasAccess || (!isOwner && !isMember)) {
|
||||||
redirect('/dashboard');
|
redirect('/dashboard');
|
||||||
@@ -213,7 +215,12 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Page {page} of {totalPages}
|
Page {page} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" disabled={page >= totalPages} asChild={page < totalPages}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
asChild={page < totalPages}
|
||||||
|
>
|
||||||
{page < totalPages ? (
|
{page < totalPages ? (
|
||||||
<Link href={`/workspaces/${workspaceId}?page=${page + 1}`}>Next</Link>
|
<Link href={`/workspaces/${workspaceId}?page=${page + 1}`}>Next</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+7
-3
@@ -12,7 +12,12 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
|
|
||||||
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
||||||
|
|
||||||
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
const visibilityOptions: {
|
||||||
|
value: Visibility;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
value: 'PRIVATE',
|
value: 'PRIVATE',
|
||||||
label: 'Private',
|
label: 'Private',
|
||||||
@@ -111,8 +116,7 @@ export default function NewWorkspaceProjectPageClient({ workspaceId }: { workspa
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description" className="text-sm font-medium">
|
<Label htmlFor="description" className="text-sm font-medium">
|
||||||
Description{' '}
|
Description <span className="text-muted-foreground font-normal">(optional)</span>
|
||||||
<span className="text-muted-foreground font-normal">(optional)</span>
|
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
|
|||||||
+6
-8
@@ -148,9 +148,7 @@ export default function WorkspaceSettingsPageClient({
|
|||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Workspace Settings</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Workspace Settings</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">Manage workspace configuration</p>
|
||||||
Manage workspace configuration
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mb-8">
|
<Card className="mb-8">
|
||||||
@@ -215,9 +213,7 @@ export default function WorkspaceSettingsPageClient({
|
|||||||
<Card className="border-destructive/50">
|
<Card className="border-destructive/50">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Irreversible actions. Proceed with caution.</CardDescription>
|
||||||
Irreversible actions. Proceed with caution.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
@@ -234,11 +230,13 @@ export default function WorkspaceSettingsPageClient({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>
|
<p>
|
||||||
This will permanently delete this workspace and everything inside it
|
This will permanently delete this workspace and everything inside it
|
||||||
(projects, videos, comments, images, and voice notes). This action cannot be undone.
|
(projects, videos, comments, images, and voice notes). This action cannot
|
||||||
|
be undone.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="delete-workspace-confirm">
|
<Label htmlFor="delete-workspace-confirm">
|
||||||
Type <strong className="text-foreground">{workspace.name}</strong> to confirm
|
Type <strong className="text-foreground">{workspace.name}</strong> to
|
||||||
|
confirm
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="delete-workspace-confirm"
|
id="delete-workspace-confirm"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/card"
|
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
function WorkspaceCardSkeleton() {
|
function WorkspaceCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +20,7 @@ function WorkspaceCardSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkspacesLoading() {
|
export default function WorkspacesLoading() {
|
||||||
@@ -40,5 +40,5 @@ export default function WorkspacesLoading() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,9 +95,7 @@ export default function NewWorkspacePage({
|
|||||||
id="name"
|
id="name"
|
||||||
placeholder="e.g., My Studio"
|
placeholder="e.g., My Studio"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
setFormData({ ...formData, name: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -112,9 +110,7 @@ export default function NewWorkspacePage({
|
|||||||
id="description"
|
id="description"
|
||||||
placeholder="What is this workspace for?"
|
placeholder="What is this workspace for?"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
setFormData({ ...formData, description: e.target.value })
|
|
||||||
}
|
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -140,7 +136,8 @@ export default function NewWorkspacePage({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You can still create and manage projects inside workspaces where you are already a member.
|
You can still create and manage projects inside workspaces where you are already a
|
||||||
|
member.
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="w-full">
|
<Button asChild className="w-full">
|
||||||
<Link href="/settings">Open Billing Settings</Link>
|
<Link href="/settings">Open Billing Settings</Link>
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { auth } from '@/lib/auth';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
|
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
|
||||||
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access';
|
import {
|
||||||
|
hasCollaboratorBillingBackedAccess,
|
||||||
|
requireBillingAccessOrRedirect,
|
||||||
|
} from '@/lib/route-access';
|
||||||
import { WorkspacesClient } from './workspaces-client';
|
import { WorkspacesClient } from './workspaces-client';
|
||||||
|
|
||||||
export default async function WorkspacesPage({
|
export default async function WorkspacesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ page?: string }>
|
searchParams: Promise<{ page?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -52,7 +55,7 @@ export default async function WorkspacesPage({
|
|||||||
{ ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
|
{ ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
|
||||||
{ members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
|
{ members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
getBillingOverview(session.user.id),
|
getBillingOverview(session.user.id),
|
||||||
]);
|
]);
|
||||||
@@ -64,7 +67,7 @@ export default async function WorkspacesPage({
|
|||||||
name: w.name,
|
name: w.name,
|
||||||
description: w.description,
|
description: w.description,
|
||||||
updatedAt: w.updatedAt.toISOString(),
|
updatedAt: w.updatedAt.toISOString(),
|
||||||
_count: w._count
|
_count: w._count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ export function WorkspacesClient({
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Workspaces</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Workspaces</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">Manage your workspaces and their projects</p>
|
||||||
Manage your workspaces and their projects
|
|
||||||
</p>
|
|
||||||
{!workspaceCreation.canCreateWorkspace && workspaceCreation.reason ? (
|
{!workspaceCreation.canCreateWorkspace && workspaceCreation.reason ? (
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-2">
|
<p className="text-sm text-amber-700 dark:text-amber-400 mt-2">
|
||||||
{workspaceCreation.reason}
|
{workspaceCreation.reason}
|
||||||
@@ -73,9 +71,7 @@ export function WorkspacesClient({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button asChild className="w-full sm:w-auto">
|
<Button asChild className="w-full sm:w-auto">
|
||||||
<Link href="/settings">
|
<Link href="/settings">Upgrade to Create Workspace</Link>
|
||||||
Upgrade to Create Workspace
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,25 +20,29 @@ export default async function AdminFeedbackDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { feedbackId } = await params;
|
const { feedbackId } = await params;
|
||||||
const userFeedbackDelegate = (db as unknown as {
|
const userFeedbackDelegate = (
|
||||||
userFeedback?: {
|
db as unknown as {
|
||||||
findUnique: (args?: unknown) => Promise<{
|
userFeedback?: {
|
||||||
id: string;
|
findUnique: (args?: unknown) => Promise<{
|
||||||
type: string;
|
id: string;
|
||||||
category: string | null;
|
type: string;
|
||||||
status: string;
|
category: string | null;
|
||||||
rating: number | null;
|
status: string;
|
||||||
title: string;
|
rating: number | null;
|
||||||
message: string;
|
title: string;
|
||||||
screenshotUrl: string | null;
|
message: string;
|
||||||
createdAt: Date;
|
screenshotUrl: string | null;
|
||||||
user: { name: string | null; email: string | null };
|
createdAt: Date;
|
||||||
screenshots: Array<{ id: string; url: string }>;
|
user: { name: string | null; email: string | null };
|
||||||
} | null>;
|
screenshots: Array<{ id: string; url: string }>;
|
||||||
};
|
} | null>;
|
||||||
}).userFeedback;
|
};
|
||||||
|
}
|
||||||
|
).userFeedback;
|
||||||
|
|
||||||
let entry = null as Awaited<ReturnType<NonNullable<typeof userFeedbackDelegate>['findUnique']>> | null;
|
let entry = null as Awaited<
|
||||||
|
ReturnType<NonNullable<typeof userFeedbackDelegate>['findUnique']>
|
||||||
|
> | null;
|
||||||
if (userFeedbackDelegate) {
|
if (userFeedbackDelegate) {
|
||||||
try {
|
try {
|
||||||
entry = await userFeedbackDelegate.findUnique({
|
entry = await userFeedbackDelegate.findUnique({
|
||||||
@@ -63,7 +67,7 @@ export default async function AdminFeedbackDetailPage({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '';
|
const message = error instanceof Error ? error.message : '';
|
||||||
if (message.includes('Unknown field `screenshots`')) {
|
if (message.includes('Unknown field `screenshots`')) {
|
||||||
entry = await userFeedbackDelegate.findUnique({
|
entry = (await userFeedbackDelegate.findUnique({
|
||||||
where: { id: feedbackId },
|
where: { id: feedbackId },
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
@@ -74,7 +78,7 @@ export default async function AdminFeedbackDetailPage({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}) as typeof entry;
|
})) as typeof entry;
|
||||||
|
|
||||||
if (entry && !Array.isArray(entry.screenshots)) {
|
if (entry && !Array.isArray(entry.screenshots)) {
|
||||||
entry = {
|
entry = {
|
||||||
@@ -95,9 +99,9 @@ export default async function AdminFeedbackDetailPage({
|
|||||||
const screenshotItems =
|
const screenshotItems =
|
||||||
entry.screenshots.length > 0
|
entry.screenshots.length > 0
|
||||||
? entry.screenshots
|
? entry.screenshots
|
||||||
: (entry.screenshotUrl
|
: entry.screenshotUrl
|
||||||
? [{ id: `${entry.id}-legacy`, url: entry.screenshotUrl }]
|
? [{ id: `${entry.id}-legacy`, url: entry.screenshotUrl }]
|
||||||
: []);
|
: [];
|
||||||
const submittedAtText = format(new Date(entry.createdAt), 'MMM dd, yyyy HH:mm');
|
const submittedAtText = format(new Date(entry.createdAt), 'MMM dd, yyyy HH:mm');
|
||||||
const submitterName = entry.user.name || 'there';
|
const submitterName = entry.user.name || 'there';
|
||||||
const feedbackTypeLabel = entry.type.toLowerCase();
|
const feedbackTypeLabel = entry.type.toLowerCase();
|
||||||
@@ -107,14 +111,14 @@ export default async function AdminFeedbackDetailPage({
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
const mailtoHref = entry.user.email
|
const mailtoHref = entry.user.email
|
||||||
? `mailto:${entry.user.email}?subject=${encodeURIComponent(
|
? `mailto:${entry.user.email}?subject=${encodeURIComponent(
|
||||||
`[OpenFrame ${entry.type}] Re: ${entry.title}`
|
`[OpenFrame ${entry.type}] Re: ${entry.title}`
|
||||||
)}&body=${encodeURIComponent(
|
)}&body=${encodeURIComponent(
|
||||||
`Hi ${submitterName},\n\nThanks for your ${feedbackTypeLabel}.\n\n` +
|
`Hi ${submitterName},\n\nThanks for your ${feedbackTypeLabel}.\n\n` +
|
||||||
`I reviewed your submission:\n` +
|
`I reviewed your submission:\n` +
|
||||||
`Title: ${entry.title}\n` +
|
`Title: ${entry.title}\n` +
|
||||||
`Submitted: ${submittedAtText}\n\n` +
|
`Submitted: ${submittedAtText}\n\n` +
|
||||||
`Your message:\n${quotedMessage}\n\n`
|
`Your message:\n${quotedMessage}\n\n`
|
||||||
)}`
|
)}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,7 +173,9 @@ export default async function AdminFeedbackDetailPage({
|
|||||||
Screenshots ({screenshotItems.length})
|
Screenshots ({screenshotItems.length})
|
||||||
</h3>
|
</h3>
|
||||||
{screenshotItems.length === 0 ? (
|
{screenshotItems.length === 0 ? (
|
||||||
<div className="rounded-md border p-4 text-sm text-muted-foreground">No screenshots attached.</div>
|
<div className="rounded-md border p-4 text-sm text-muted-foreground">
|
||||||
|
No screenshots attached.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{screenshotItems.map((screenshot, index) => (
|
{screenshotItems.map((screenshot, index) => (
|
||||||
|
|||||||
+114
-34
@@ -18,7 +18,14 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
type SortBy = 'submittedAt' | 'type' | 'status' | 'rating' | 'user' | 'allowShowcase' | 'showOnLanding';
|
type SortBy =
|
||||||
|
| 'submittedAt'
|
||||||
|
| 'type'
|
||||||
|
| 'status'
|
||||||
|
| 'rating'
|
||||||
|
| 'user'
|
||||||
|
| 'allowShowcase'
|
||||||
|
| 'showOnLanding';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
type TypeFilter = 'ALL' | FeedbackEntryType;
|
type TypeFilter = 'ALL' | FeedbackEntryType;
|
||||||
type StatusFilter = 'ALL' | FeedbackStatus;
|
type StatusFilter = 'ALL' | FeedbackStatus;
|
||||||
@@ -39,7 +46,15 @@ type AdminFeedbackEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function parseSortBy(value: string | undefined): SortBy {
|
function parseSortBy(value: string | undefined): SortBy {
|
||||||
const accepted: SortBy[] = ['submittedAt', 'type', 'status', 'rating', 'user', 'allowShowcase', 'showOnLanding'];
|
const accepted: SortBy[] = [
|
||||||
|
'submittedAt',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'rating',
|
||||||
|
'user',
|
||||||
|
'allowShowcase',
|
||||||
|
'showOnLanding',
|
||||||
|
];
|
||||||
return accepted.includes(value as SortBy) ? (value as SortBy) : 'submittedAt';
|
return accepted.includes(value as SortBy) ? (value as SortBy) : 'submittedAt';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +73,11 @@ function parseStatusFilter(value: string | undefined): StatusFilter {
|
|||||||
return 'ALL';
|
return 'ALL';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSortIndicator(column: SortBy, activeSortBy: SortBy, activeSortDirection: SortDirection): string {
|
function getSortIndicator(
|
||||||
|
column: SortBy,
|
||||||
|
activeSortBy: SortBy,
|
||||||
|
activeSortDirection: SortDirection
|
||||||
|
): string {
|
||||||
if (column !== activeSortBy) return '↕';
|
if (column !== activeSortBy) return '↕';
|
||||||
return activeSortDirection === 'asc' ? '↑' : '↓';
|
return activeSortDirection === 'asc' ? '↑' : '↓';
|
||||||
}
|
}
|
||||||
@@ -127,12 +146,14 @@ export default async function AdminFeedbackPage({
|
|||||||
};
|
};
|
||||||
const orderBy = getOrderBy(sortBy, sortDirection);
|
const orderBy = getOrderBy(sortBy, sortDirection);
|
||||||
|
|
||||||
const userFeedbackDelegate = (db as unknown as {
|
const userFeedbackDelegate = (
|
||||||
userFeedback?: {
|
db as unknown as {
|
||||||
count: (args?: unknown) => Promise<number>;
|
userFeedback?: {
|
||||||
findMany: (args?: unknown) => Promise<AdminFeedbackEntry[]>;
|
count: (args?: unknown) => Promise<number>;
|
||||||
};
|
findMany: (args?: unknown) => Promise<AdminFeedbackEntry[]>;
|
||||||
}).userFeedback;
|
};
|
||||||
|
}
|
||||||
|
).userFeedback;
|
||||||
|
|
||||||
let totalEntries = 0;
|
let totalEntries = 0;
|
||||||
let page = requestedPage;
|
let page = requestedPage;
|
||||||
@@ -174,7 +195,7 @@ export default async function AdminFeedbackPage({
|
|||||||
const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize));
|
const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize));
|
||||||
page = Math.min(requestedPage, totalPages);
|
page = Math.min(requestedPage, totalPages);
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
const fallbackEntries = await userFeedbackDelegate.findMany({
|
const fallbackEntries = (await userFeedbackDelegate.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
@@ -188,7 +209,11 @@ export default async function AdminFeedbackPage({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy,
|
orderBy,
|
||||||
}) as Array<Omit<AdminFeedbackEntry, 'screenshots'> & { screenshots?: Array<{ id: string; url: string }> }>;
|
})) as Array<
|
||||||
|
Omit<AdminFeedbackEntry, 'screenshots'> & {
|
||||||
|
screenshots?: Array<{ id: string; url: string }>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
entries = fallbackEntries.map((entry) => ({
|
entries = fallbackEntries.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
@@ -272,7 +297,12 @@ export default async function AdminFeedbackPage({
|
|||||||
<Link href={buildFilterHref(typeFilter, 'ALL')}>All Statuses</Link>
|
<Link href={buildFilterHref(typeFilter, 'ALL')}>All Statuses</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{(['NEW', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'RESOLVED'] as const).map((status) => (
|
{(['NEW', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'RESOLVED'] as const).map((status) => (
|
||||||
<Button key={status} variant={statusFilter === status ? 'default' : 'outline'} size="sm" asChild>
|
<Button
|
||||||
|
key={status}
|
||||||
|
variant={statusFilter === status ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
<Link href={buildFilterHref(typeFilter, status)}>{status.replace('_', ' ')}</Link>
|
<Link href={buildFilterHref(typeFilter, status)}>{status.replace('_', ' ')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -289,48 +319,83 @@ export default async function AdminFeedbackPage({
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<Link href={buildSortHref('submittedAt')} className="inline-flex items-center gap-1 hover:underline">
|
<Link
|
||||||
|
href={buildSortHref('submittedAt')}
|
||||||
|
className="inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
Submitted
|
Submitted
|
||||||
<span className="text-xs">{getSortIndicator('submittedAt', sortBy, sortDirection)}</span>
|
<span className="text-xs">
|
||||||
|
{getSortIndicator('submittedAt', sortBy, sortDirection)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<Link href={buildSortHref('user')} className="inline-flex items-center gap-1 hover:underline">
|
<Link
|
||||||
|
href={buildSortHref('user')}
|
||||||
|
className="inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
User
|
User
|
||||||
<span className="text-xs">{getSortIndicator('user', sortBy, sortDirection)}</span>
|
<span className="text-xs">
|
||||||
|
{getSortIndicator('user', sortBy, sortDirection)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<Link href={buildSortHref('type')} className="inline-flex items-center gap-1 hover:underline">
|
<Link
|
||||||
|
href={buildSortHref('type')}
|
||||||
|
className="inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
Type
|
Type
|
||||||
<span className="text-xs">{getSortIndicator('type', sortBy, sortDirection)}</span>
|
<span className="text-xs">
|
||||||
|
{getSortIndicator('type', sortBy, sortDirection)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Title</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>Message</TableHead>
|
<TableHead>Message</TableHead>
|
||||||
<TableHead className="text-center">Screenshot</TableHead>
|
<TableHead className="text-center">Screenshot</TableHead>
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
<Link href={buildSortHref('rating')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
<Link
|
||||||
|
href={buildSortHref('rating')}
|
||||||
|
className="inline-flex items-center justify-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
Rating
|
Rating
|
||||||
<span className="text-xs">{getSortIndicator('rating', sortBy, sortDirection)}</span>
|
<span className="text-xs">
|
||||||
|
{getSortIndicator('rating', sortBy, sortDirection)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
<Link href={buildSortHref('status')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
<Link
|
||||||
|
href={buildSortHref('status')}
|
||||||
|
className="inline-flex items-center justify-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
Status
|
Status
|
||||||
<span className="text-xs">{getSortIndicator('status', sortBy, sortDirection)}</span>
|
<span className="text-xs">
|
||||||
|
{getSortIndicator('status', sortBy, sortDirection)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
<Link href={buildSortHref('allowShowcase')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
<Link
|
||||||
|
href={buildSortHref('allowShowcase')}
|
||||||
|
className="inline-flex items-center justify-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
Consent
|
Consent
|
||||||
<span className="text-xs">{getSortIndicator('allowShowcase', sortBy, sortDirection)}</span>
|
<span className="text-xs">
|
||||||
|
{getSortIndicator('allowShowcase', sortBy, sortDirection)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
<Link href={buildSortHref('showOnLanding')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
<Link
|
||||||
|
href={buildSortHref('showOnLanding')}
|
||||||
|
className="inline-flex items-center justify-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
Landing
|
Landing
|
||||||
<span className="text-xs">{getSortIndicator('showOnLanding', sortBy, sortDirection)}</span>
|
<span className="text-xs">
|
||||||
|
{getSortIndicator('showOnLanding', sortBy, sortDirection)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
@@ -357,7 +422,9 @@ export default async function AdminFeedbackPage({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Badge variant="outline">{entry.type === 'FEEDBACK' ? 'Feedback' : 'Review'}</Badge>
|
<Badge variant="outline">
|
||||||
|
{entry.type === 'FEEDBACK' ? 'Feedback' : 'Review'}
|
||||||
|
</Badge>
|
||||||
{entry.category && (
|
{entry.category && (
|
||||||
<Badge variant="secondary" className="w-fit">
|
<Badge variant="secondary" className="w-fit">
|
||||||
{entry.category}
|
{entry.category}
|
||||||
@@ -366,12 +433,16 @@ export default async function AdminFeedbackPage({
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{entry.title}</TableCell>
|
<TableCell className="font-medium">{entry.title}</TableCell>
|
||||||
<TableCell className="max-w-[320px] truncate text-sm text-muted-foreground">{entry.message}</TableCell>
|
<TableCell className="max-w-[320px] truncate text-sm text-muted-foreground">
|
||||||
|
{entry.message}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{(entry.screenshots.length > 0 || entry.screenshotUrl) ? (
|
{entry.screenshots.length > 0 || entry.screenshotUrl ? (
|
||||||
<Link href={`/admin/feedback/${entry.id}`} className="text-xs underline">
|
<Link href={`/admin/feedback/${entry.id}`} className="text-xs underline">
|
||||||
{(entry.screenshots.length || (entry.screenshotUrl ? 1 : 0))} image
|
{entry.screenshots.length || (entry.screenshotUrl ? 1 : 0)} image
|
||||||
{(entry.screenshots.length || (entry.screenshotUrl ? 1 : 0)) > 1 ? 's' : ''}
|
{(entry.screenshots.length || (entry.screenshotUrl ? 1 : 0)) > 1
|
||||||
|
? 's'
|
||||||
|
: ''}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
@@ -381,8 +452,12 @@ export default async function AdminFeedbackPage({
|
|||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge variant="outline">{entry.status}</Badge>
|
<Badge variant="outline">{entry.status}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">{entry.allowShowcase ? 'Yes' : 'No'}</TableCell>
|
<TableCell className="text-center">
|
||||||
<TableCell className="text-center">{entry.showOnLanding ? 'Yes' : 'No'}</TableCell>
|
{entry.allowShowcase ? 'Yes' : 'No'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{entry.showOnLanding ? 'Yes' : 'No'}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
@@ -411,7 +486,12 @@ export default async function AdminFeedbackPage({
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Page {page} of {totalPages}
|
Page {page} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" disabled={page >= totalPages} asChild={page < totalPages}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
asChild={page < totalPages}
|
||||||
|
>
|
||||||
{page < totalPages ? <Link href={buildPageHref(page + 1)}>Next</Link> : 'Next'}
|
{page < totalPages ? <Link href={buildPageHref(page + 1)}>Next</Link> : 'Next'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+68
-63
@@ -4,70 +4,75 @@ import { Header } from '@/components/layout';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
|
import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
const session = await auth();
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session?.user?.isAdmin) {
|
if (!session?.user?.isAdmin) {
|
||||||
redirect('/');
|
redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col">
|
<div className="relative flex min-h-screen flex-col">
|
||||||
<Header user={session.user} showAppNavigation />
|
<Header user={session.user} showAppNavigation />
|
||||||
<div className="w-full px-4 md:px-8 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
|
<div className="w-full px-4 md:px-8 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
|
||||||
{/* Mobile Nav */}
|
{/* Mobile Nav */}
|
||||||
<div className="md:hidden py-4 border-b mb-4">
|
<div className="md:hidden py-4 border-b mb-4">
|
||||||
<nav className="flex items-center gap-4 overflow-x-auto">
|
<nav className="flex items-center gap-4 overflow-x-auto">
|
||||||
<Link href="/admin" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50">
|
<Link
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
href="/admin"
|
||||||
Dashboard
|
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
|
||||||
</Link>
|
>
|
||||||
<Link href="/admin/users" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50">
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
<Users className="h-4 w-4" />
|
Dashboard
|
||||||
Users
|
</Link>
|
||||||
</Link>
|
<Link
|
||||||
<Link href="/admin/feedback" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50">
|
href="/admin/users"
|
||||||
<MessageSquareQuote className="h-4 w-4" />
|
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
|
||||||
Feedback
|
>
|
||||||
</Link>
|
<Users className="h-4 w-4" />
|
||||||
</nav>
|
Users
|
||||||
</div>
|
</Link>
|
||||||
{/* Desktop Nav */}
|
<Link
|
||||||
<aside className="fixed top-14 z-30 -ml-2 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 md:sticky md:block">
|
href="/admin/feedback"
|
||||||
<div className="h-full py-6 pr-6 lg:py-8">
|
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
|
||||||
<nav className="flex flex-col gap-2">
|
>
|
||||||
<Link
|
<MessageSquareQuote className="h-4 w-4" />
|
||||||
href="/admin"
|
Feedback
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
</Link>
|
||||||
>
|
</nav>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/admin/users"
|
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
Users
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/admin/feedback"
|
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<MessageSquareQuote className="h-4 w-4" />
|
|
||||||
Feedback
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<main className="flex w-full flex-col overflow-hidden py-0 md:py-6 lg:py-8">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{/* Desktop Nav */}
|
||||||
|
<aside className="fixed top-14 z-30 -ml-2 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 md:sticky md:block">
|
||||||
|
<div className="h-full py-6 pr-6 lg:py-8">
|
||||||
|
<nav className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/users"
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/feedback"
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<MessageSquareQuote className="h-4 w-4" />
|
||||||
|
Feedback
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex w-full flex-col overflow-hidden py-0 md:py-6 lg:py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+254
-230
@@ -3,257 +3,281 @@ import { db } from '@/lib/db';
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getCachedBunnyStorageStats, getCachedTotalStorage, getCachedStripeStats } from '@/lib/admin-stats';
|
import {
|
||||||
|
getCachedBunnyStorageStats,
|
||||||
|
getCachedTotalStorage,
|
||||||
|
getCachedStripeStats,
|
||||||
|
} from '@/lib/admin-stats';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button';
|
import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button';
|
||||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film, MessageSquareQuote, Star, CreditCard, TrendingUp, UserCheck, AlertCircle, UserX } from 'lucide-react';
|
import {
|
||||||
|
Users,
|
||||||
|
Folder,
|
||||||
|
Video,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
HardDrive,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Film,
|
||||||
|
MessageSquareQuote,
|
||||||
|
Star,
|
||||||
|
CreditCard,
|
||||||
|
TrendingUp,
|
||||||
|
UserCheck,
|
||||||
|
AlertCircle,
|
||||||
|
UserX,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Admin Dashboard | OpenFrame',
|
title: 'Admin Dashboard | OpenFrame',
|
||||||
description: 'Admin overview dashboard',
|
description: 'Admin overview dashboard',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatBytes(bytes: number, decimals = 2) {
|
function formatBytes(bytes: number, decimals = 2) {
|
||||||
if (bytes < 0) return 'Error Fetching';
|
if (bytes < 0) return 'Error Fetching';
|
||||||
if (!+bytes) return '0 Bytes';
|
if (!+bytes) return '0 Bytes';
|
||||||
const k = 1000;
|
const k = 1000;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMrr(cents: number, currency: string) {
|
function formatMrr(cents: number, currency: string) {
|
||||||
const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD';
|
const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD';
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: safeCurrency,
|
currency: safeCurrency,
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(cents / 100);
|
}).format(cents / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminDashboardPage() {
|
export default async function AdminDashboardPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.isAdmin) {
|
if (!session?.user?.isAdmin) {
|
||||||
redirect('/');
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userFeedbackDelegate = (
|
||||||
|
db as unknown as {
|
||||||
|
userFeedback?: { count: (args?: unknown) => Promise<number> };
|
||||||
}
|
}
|
||||||
|
).userFeedback;
|
||||||
|
|
||||||
const userFeedbackDelegate = (db as unknown as {
|
// 1. Database Stats
|
||||||
userFeedback?: { count: (args?: unknown) => Promise<number> };
|
const [
|
||||||
}).userFeedback;
|
totalUsers,
|
||||||
|
totalProjects,
|
||||||
|
totalVideos,
|
||||||
|
totalComments,
|
||||||
|
totalVoiceComments,
|
||||||
|
totalImageComments,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.user.count(),
|
||||||
|
db.project.count(),
|
||||||
|
db.video.count(),
|
||||||
|
db.comment.count(),
|
||||||
|
db.comment.count({
|
||||||
|
where: { voiceUrl: { not: null } },
|
||||||
|
}),
|
||||||
|
db.comment.count({
|
||||||
|
where: { imageUrl: { not: null } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
// 1. Database Stats
|
let totalFeedback = 0;
|
||||||
const [
|
let totalReviews = 0;
|
||||||
totalUsers,
|
if (userFeedbackDelegate) {
|
||||||
totalProjects,
|
try {
|
||||||
totalVideos,
|
[totalFeedback, totalReviews] = await Promise.all([
|
||||||
totalComments,
|
userFeedbackDelegate.count({
|
||||||
totalVoiceComments,
|
where: { type: 'FEEDBACK' },
|
||||||
totalImageComments,
|
|
||||||
] = await Promise.all([
|
|
||||||
db.user.count(),
|
|
||||||
db.project.count(),
|
|
||||||
db.video.count(),
|
|
||||||
db.comment.count(),
|
|
||||||
db.comment.count({
|
|
||||||
where: { voiceUrl: { not: null } },
|
|
||||||
}),
|
}),
|
||||||
db.comment.count({
|
userFeedbackDelegate.count({
|
||||||
where: { imageUrl: { not: null } },
|
where: { type: 'REVIEW' },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
} catch (error) {
|
||||||
let totalFeedback = 0;
|
console.error('Failed to fetch feedback stats:', error);
|
||||||
let totalReviews = 0;
|
|
||||||
if (userFeedbackDelegate) {
|
|
||||||
try {
|
|
||||||
[totalFeedback, totalReviews] = await Promise.all([
|
|
||||||
userFeedbackDelegate.count({
|
|
||||||
where: { type: 'FEEDBACK' },
|
|
||||||
}),
|
|
||||||
userFeedbackDelegate.count({
|
|
||||||
where: { type: 'REVIEW' },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch feedback stats:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Storage Stats (Cached)
|
// 2. Storage Stats (Cached)
|
||||||
const [totalStorageBytes, bunnyStorageStats, stripeStats] = await Promise.all([
|
const [totalStorageBytes, bunnyStorageStats, stripeStats] = await Promise.all([
|
||||||
getCachedTotalStorage(),
|
getCachedTotalStorage(),
|
||||||
getCachedBunnyStorageStats(),
|
getCachedBunnyStorageStats(),
|
||||||
getCachedStripeStats(),
|
getCachedStripeStats(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard Overview</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard Overview</h2>
|
||||||
<RefreshR2StatsButton />
|
<RefreshR2StatsButton />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{totalUsers}</div>
|
<div className="text-2xl font-bold">{totalUsers}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Workspaces & Projects</CardTitle>
|
<CardTitle className="text-sm font-medium">Workspaces & Projects</CardTitle>
|
||||||
<Folder className="h-4 w-4 text-muted-foreground" />
|
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{totalProjects}</div>
|
<div className="text-2xl font-bold">{totalProjects}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">Total active projects on the platform</p>
|
||||||
Total active projects on the platform
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">Active Videos</CardTitle>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Video className="h-4 w-4 text-muted-foreground" />
|
||||||
<CardTitle className="text-sm font-medium">Active Videos</CardTitle>
|
</CardHeader>
|
||||||
<Video className="h-4 w-4 text-muted-foreground" />
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="text-2xl font-bold">{totalVideos}</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-2xl font-bold">{totalVideos}</div>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">Total Comments</CardTitle>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||||
<CardTitle className="text-sm font-medium">Total Comments</CardTitle>
|
</CardHeader>
|
||||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="text-2xl font-bold">{totalComments}</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-2xl font-bold">{totalComments}</div>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">Voice Recordings</CardTitle>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
<CardTitle className="text-sm font-medium">Voice Recordings</CardTitle>
|
</CardHeader>
|
||||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="text-2xl font-bold">{totalVoiceComments}</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-2xl font-bold">{totalVoiceComments}</div>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">Image Attachments</CardTitle>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
<CardTitle className="text-sm font-medium">Image Attachments</CardTitle>
|
</CardHeader>
|
||||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="text-2xl font-bold">{totalImageComments}</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-2xl font-bold">{totalImageComments}</div>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">Feedback Submissions</CardTitle>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<MessageSquareQuote className="h-4 w-4 text-muted-foreground" />
|
||||||
<CardTitle className="text-sm font-medium">Feedback Submissions</CardTitle>
|
</CardHeader>
|
||||||
<MessageSquareQuote className="h-4 w-4 text-muted-foreground" />
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="text-2xl font-bold">{totalFeedback}</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-2xl font-bold">{totalFeedback}</div>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">Review Submissions</CardTitle>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Star className="h-4 w-4 text-muted-foreground" />
|
||||||
<CardTitle className="text-sm font-medium">Review Submissions</CardTitle>
|
</CardHeader>
|
||||||
<Star className="h-4 w-4 text-muted-foreground" />
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="text-2xl font-bold">{totalReviews}</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-2xl font-bold">{totalReviews}</div>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
<CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle>
|
</CardHeader>
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="text-2xl font-bold">{formatBytes(totalStorageBytes)}</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-2xl font-bold">{formatBytes(totalStorageBytes)}</div>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">Bunny Stream Storage</CardTitle>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Film className="h-4 w-4 text-muted-foreground" />
|
||||||
<CardTitle className="text-sm font-medium">Bunny Stream Storage</CardTitle>
|
</CardHeader>
|
||||||
<Film className="h-4 w-4 text-muted-foreground" />
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="text-2xl font-bold">
|
||||||
<CardContent>
|
{isBunnyUploadsFeatureEnabled()
|
||||||
<div className="text-2xl font-bold">
|
? formatBytes(bunnyStorageStats.totalBytes)
|
||||||
{isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'}
|
: 'Disabled'}
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isStripeBillingEnabled() && stripeStats && (
|
{isStripeBillingEnabled() && stripeStats && (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-xl font-semibold tracking-tight pt-2">Billing & Revenue</h3>
|
<h3 className="text-xl font-semibold tracking-tight pt-2">Billing & Revenue</h3>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Monthly Recurring Revenue</CardTitle>
|
<CardTitle className="text-sm font-medium">Monthly Recurring Revenue</CardTitle>
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{formatMrr(stripeStats.mrrCents, stripeStats.currency)}</div>
|
<div className="text-2xl font-bold">
|
||||||
<p className="text-xs text-muted-foreground">Based on active subscriptions</p>
|
{formatMrr(stripeStats.mrrCents, stripeStats.currency)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<p className="text-xs text-muted-foreground">Based on active subscriptions</p>
|
||||||
<Card>
|
</CardContent>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
</Card>
|
||||||
<CardTitle className="text-sm font-medium">Active Subscribers</CardTitle>
|
<Card>
|
||||||
<UserCheck className="h-4 w-4 text-muted-foreground" />
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium">Active Subscribers</CardTitle>
|
||||||
<CardContent>
|
<UserCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="text-2xl font-bold">{stripeStats.activeSubscribers}</div>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<div className="text-2xl font-bold">{stripeStats.activeSubscribers}</div>
|
||||||
<Card>
|
</CardContent>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
</Card>
|
||||||
<CardTitle className="text-sm font-medium">On Trial</CardTitle>
|
<Card>
|
||||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium">On Trial</CardTitle>
|
||||||
<CardContent>
|
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="text-2xl font-bold">{stripeStats.trialingUsers}</div>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<div className="text-2xl font-bold">{stripeStats.trialingUsers}</div>
|
||||||
<Card>
|
</CardContent>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
</Card>
|
||||||
<CardTitle className="text-sm font-medium">Free Users</CardTitle>
|
<Card>
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium">Free Users</CardTitle>
|
||||||
<CardContent>
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="text-2xl font-bold">{stripeStats.freeUsers}</div>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<div className="text-2xl font-bold">{stripeStats.freeUsers}</div>
|
||||||
<Card>
|
</CardContent>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
</Card>
|
||||||
<CardTitle className="text-sm font-medium">Past Due</CardTitle>
|
<Card>
|
||||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium">Past Due</CardTitle>
|
||||||
<CardContent>
|
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="text-2xl font-bold">{stripeStats.pastDueUsers}</div>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<div className="text-2xl font-bold">{stripeStats.pastDueUsers}</div>
|
||||||
<Card>
|
</CardContent>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
</Card>
|
||||||
<CardTitle className="text-sm font-medium">Canceled</CardTitle>
|
<Card>
|
||||||
<UserX className="h-4 w-4 text-muted-foreground" />
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium">Canceled</CardTitle>
|
||||||
<CardContent>
|
<UserX className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="text-2xl font-bold">{stripeStats.canceledUsers}</div>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<div className="text-2xl font-bold">{stripeStats.canceledUsers}</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</>
|
</Card>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+416
-378
@@ -5,449 +5,487 @@ import { auth } from '@/lib/auth';
|
|||||||
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
getCachedBunnyStorageStats,
|
getCachedBunnyStorageStats,
|
||||||
getCachedUserBunnyStorage,
|
getCachedUserBunnyStorage,
|
||||||
getCachedUserDownloadEgress,
|
getCachedUserDownloadEgress,
|
||||||
getCachedUserMediaStorage
|
getCachedUserMediaStorage,
|
||||||
} from '@/lib/admin-stats';
|
} from '@/lib/admin-stats';
|
||||||
import { Film, HardDrive } from 'lucide-react';
|
import { Film, HardDrive } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Manage Users | Admin',
|
title: 'Manage Users | Admin',
|
||||||
};
|
};
|
||||||
|
|
||||||
type SortBy =
|
type SortBy =
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'joinedDate'
|
| 'joinedDate'
|
||||||
| 'workspacesOwned'
|
| 'workspacesOwned'
|
||||||
| 'invitedMembers'
|
| 'invitedMembers'
|
||||||
| 'projectsOwned'
|
| 'projectsOwned'
|
||||||
| 'totalComments'
|
| 'totalComments'
|
||||||
| 'bunnyUpload'
|
| 'bunnyUpload'
|
||||||
| 'downloadEgress'
|
| 'downloadEgress'
|
||||||
| 'mediaStorage';
|
| 'mediaStorage';
|
||||||
|
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
const SORTABLE_COLUMNS: SortBy[] = [
|
const SORTABLE_COLUMNS: SortBy[] = [
|
||||||
'user',
|
'user',
|
||||||
'joinedDate',
|
'joinedDate',
|
||||||
'workspacesOwned',
|
'workspacesOwned',
|
||||||
'invitedMembers',
|
'invitedMembers',
|
||||||
'projectsOwned',
|
'projectsOwned',
|
||||||
'totalComments',
|
'totalComments',
|
||||||
'bunnyUpload',
|
'bunnyUpload',
|
||||||
'downloadEgress',
|
'downloadEgress',
|
||||||
'mediaStorage',
|
'mediaStorage',
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatBytes(bytes: number, decimals = 2) {
|
function formatBytes(bytes: number, decimals = 2) {
|
||||||
if (bytes < 0) return 'Error Fetching';
|
if (bytes < 0) return 'Error Fetching';
|
||||||
if (!+bytes) return '0 Bytes';
|
if (!+bytes) return '0 Bytes';
|
||||||
const k = 1000;
|
const k = 1000;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSortBy(value: string | undefined): value is SortBy {
|
function isSortBy(value: string | undefined): value is SortBy {
|
||||||
return !!value && SORTABLE_COLUMNS.includes(value as SortBy);
|
return !!value && SORTABLE_COLUMNS.includes(value as SortBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultSortDirection(sortBy: SortBy): SortDirection {
|
function getDefaultSortDirection(sortBy: SortBy): SortDirection {
|
||||||
return sortBy === 'user' ? 'asc' : 'desc';
|
return sortBy === 'user' ? 'asc' : 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSortIndicator(column: SortBy, activeSortBy: SortBy, activeSortDirection: SortDirection): string {
|
function getSortIndicator(
|
||||||
if (column !== activeSortBy) return '↕';
|
column: SortBy,
|
||||||
return activeSortDirection === 'asc' ? '↑' : '↓';
|
activeSortBy: SortBy,
|
||||||
|
activeSortDirection: SortDirection
|
||||||
|
): string {
|
||||||
|
if (column !== activeSortBy) return '↕';
|
||||||
|
return activeSortDirection === 'asc' ? '↑' : '↓';
|
||||||
}
|
}
|
||||||
|
|
||||||
function canSortInDb(sortBy: SortBy): boolean {
|
function canSortInDb(sortBy: SortBy): boolean {
|
||||||
return sortBy === 'user'
|
return (
|
||||||
|| sortBy === 'joinedDate'
|
sortBy === 'user' ||
|
||||||
|| sortBy === 'workspacesOwned'
|
sortBy === 'joinedDate' ||
|
||||||
|| sortBy === 'projectsOwned'
|
sortBy === 'workspacesOwned' ||
|
||||||
|| sortBy === 'totalComments';
|
sortBy === 'projectsOwned' ||
|
||||||
|
sortBy === 'totalComments'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUsersOrderBy(sortBy: SortBy, sortDirection: SortDirection): Prisma.UserOrderByWithRelationInput[] {
|
function getUsersOrderBy(
|
||||||
const createdAtTieBreaker: Prisma.UserOrderByWithRelationInput = { createdAt: 'desc' };
|
sortBy: SortBy,
|
||||||
|
sortDirection: SortDirection
|
||||||
|
): Prisma.UserOrderByWithRelationInput[] {
|
||||||
|
const createdAtTieBreaker: Prisma.UserOrderByWithRelationInput = { createdAt: 'desc' };
|
||||||
|
|
||||||
if (sortBy === 'user') {
|
if (sortBy === 'user') {
|
||||||
return [
|
return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker];
|
||||||
{ name: sortDirection },
|
}
|
||||||
{ email: sortDirection },
|
|
||||||
createdAtTieBreaker,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortBy === 'joinedDate') {
|
if (sortBy === 'joinedDate') {
|
||||||
return [{ createdAt: sortDirection }];
|
return [{ createdAt: sortDirection }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortBy === 'workspacesOwned') {
|
if (sortBy === 'workspacesOwned') {
|
||||||
return [
|
return [{ ownedWorkspaces: { _count: sortDirection } }, createdAtTieBreaker];
|
||||||
{ ownedWorkspaces: { _count: sortDirection } },
|
}
|
||||||
createdAtTieBreaker,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortBy === 'projectsOwned') {
|
if (sortBy === 'projectsOwned') {
|
||||||
return [
|
return [{ projects: { _count: sortDirection } }, createdAtTieBreaker];
|
||||||
{ projects: { _count: sortDirection } },
|
}
|
||||||
createdAtTieBreaker,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortBy === 'totalComments') {
|
if (sortBy === 'totalComments') {
|
||||||
return [
|
return [{ comments: { _count: sortDirection } }, createdAtTieBreaker];
|
||||||
{ comments: { _count: sortDirection } },
|
}
|
||||||
createdAtTieBreaker,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [createdAtTieBreaker];
|
return [createdAtTieBreaker];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminUsersPage({
|
export default async function AdminUsersPage({
|
||||||
searchParams
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string }>
|
searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.isAdmin) {
|
if (!session?.user?.isAdmin) {
|
||||||
redirect('/');
|
redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const requestedPage = Number(resolvedSearchParams?.page) || 1;
|
const requestedPage = Number(resolvedSearchParams?.page) || 1;
|
||||||
const sortBy: SortBy = isSortBy(resolvedSearchParams?.sortBy) ? resolvedSearchParams.sortBy : 'joinedDate';
|
const sortBy: SortBy = isSortBy(resolvedSearchParams?.sortBy)
|
||||||
const sortDirection: SortDirection =
|
? resolvedSearchParams.sortBy
|
||||||
resolvedSearchParams?.sortDirection === 'asc' || resolvedSearchParams?.sortDirection === 'desc'
|
: 'joinedDate';
|
||||||
? resolvedSearchParams.sortDirection
|
const sortDirection: SortDirection =
|
||||||
: getDefaultSortDirection(sortBy);
|
resolvedSearchParams?.sortDirection === 'asc' || resolvedSearchParams?.sortDirection === 'desc'
|
||||||
const pageSize = 20;
|
? resolvedSearchParams.sortDirection
|
||||||
const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] = await Promise.all([
|
: getDefaultSortDirection(sortBy);
|
||||||
db.user.count(),
|
const pageSize = 20;
|
||||||
getCachedUserMediaStorage(),
|
const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] =
|
||||||
getCachedUserBunnyStorage(),
|
await Promise.all([
|
||||||
getCachedUserDownloadEgress(),
|
db.user.count(),
|
||||||
getCachedBunnyStorageStats(),
|
getCachedUserMediaStorage(),
|
||||||
|
getCachedUserBunnyStorage(),
|
||||||
|
getCachedUserDownloadEgress(),
|
||||||
|
getCachedBunnyStorageStats(),
|
||||||
]);
|
]);
|
||||||
const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize));
|
const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize));
|
||||||
const page = Math.min(Math.max(1, requestedPage), totalPages);
|
const page = Math.min(Math.max(1, requestedPage), totalPages);
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const select = {
|
const select = {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
ownedWorkspaces: {
|
ownedWorkspaces: {
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
members: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
ownedWorkspaces: true,
|
members: true,
|
||||||
projects: true,
|
},
|
||||||
comments: true,
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} satisfies Prisma.UserSelect;
|
_count: {
|
||||||
|
select: {
|
||||||
|
ownedWorkspaces: true,
|
||||||
|
projects: true,
|
||||||
|
comments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.UserSelect;
|
||||||
|
|
||||||
let paginatedUsers: Array<{
|
let paginatedUsers: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
ownedWorkspaces: Array<{ _count: { members: number } }>;
|
ownedWorkspaces: Array<{ _count: { members: number } }>;
|
||||||
_count: { ownedWorkspaces: number; projects: number; comments: number };
|
_count: { ownedWorkspaces: number; projects: number; comments: number };
|
||||||
invitedMembersCount: number;
|
invitedMembersCount: number;
|
||||||
bunnyUploadBytes: number;
|
bunnyUploadBytes: number;
|
||||||
downloadEgressBytes: number;
|
downloadEgressBytes: number;
|
||||||
mediaStorageBytes: number;
|
mediaStorageBytes: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (canSortInDb(sortBy)) {
|
if (canSortInDb(sortBy)) {
|
||||||
const users = await db.user.findMany({
|
const users = await db.user.findMany({
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: getUsersOrderBy(sortBy, sortDirection),
|
orderBy: getUsersOrderBy(sortBy, sortDirection),
|
||||||
select,
|
select,
|
||||||
});
|
});
|
||||||
|
|
||||||
paginatedUsers = users.map((user) => ({
|
paginatedUsers = users.map((user) => ({
|
||||||
...user,
|
...user,
|
||||||
invitedMembersCount: user.ownedWorkspaces.reduce(
|
invitedMembersCount: user.ownedWorkspaces.reduce(
|
||||||
(total, workspace) => total + workspace._count.members,
|
(total, workspace) => total + workspace._count.members,
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
bunnyUploadBytes: userBunnyStorage[user.id] || 0,
|
bunnyUploadBytes: userBunnyStorage[user.id] || 0,
|
||||||
downloadEgressBytes: userDownloadEgress[user.id] || 0,
|
downloadEgressBytes: userDownloadEgress[user.id] || 0,
|
||||||
mediaStorageBytes: userStorage[user.id]?.total || 0,
|
mediaStorageBytes: userStorage[user.id]?.total || 0,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const users = await db.user.findMany({ select });
|
const users = await db.user.findMany({ select });
|
||||||
|
|
||||||
const usersWithMetrics = users.map((user) => ({
|
const usersWithMetrics = users.map((user) => ({
|
||||||
...user,
|
...user,
|
||||||
invitedMembersCount: user.ownedWorkspaces.reduce(
|
invitedMembersCount: user.ownedWorkspaces.reduce(
|
||||||
(total, workspace) => total + workspace._count.members,
|
(total, workspace) => total + workspace._count.members,
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
bunnyUploadBytes: userBunnyStorage[user.id] || 0,
|
bunnyUploadBytes: userBunnyStorage[user.id] || 0,
|
||||||
downloadEgressBytes: userDownloadEgress[user.id] || 0,
|
downloadEgressBytes: userDownloadEgress[user.id] || 0,
|
||||||
mediaStorageBytes: userStorage[user.id]?.total || 0,
|
mediaStorageBytes: userStorage[user.id]?.total || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const sortedUsers = usersWithMetrics.sort((a, b) => {
|
const sortedUsers = usersWithMetrics.sort((a, b) => {
|
||||||
let comparison = 0;
|
let comparison = 0;
|
||||||
|
|
||||||
if (sortBy === 'invitedMembers') {
|
if (sortBy === 'invitedMembers') {
|
||||||
comparison = a.invitedMembersCount - b.invitedMembersCount;
|
comparison = a.invitedMembersCount - b.invitedMembersCount;
|
||||||
} else if (sortBy === 'bunnyUpload') {
|
} else if (sortBy === 'bunnyUpload') {
|
||||||
comparison = a.bunnyUploadBytes - b.bunnyUploadBytes;
|
comparison = a.bunnyUploadBytes - b.bunnyUploadBytes;
|
||||||
} else if (sortBy === 'downloadEgress') {
|
} else if (sortBy === 'downloadEgress') {
|
||||||
comparison = a.downloadEgressBytes - b.downloadEgressBytes;
|
comparison = a.downloadEgressBytes - b.downloadEgressBytes;
|
||||||
} else if (sortBy === 'mediaStorage') {
|
} else if (sortBy === 'mediaStorage') {
|
||||||
comparison = a.mediaStorageBytes - b.mediaStorageBytes;
|
comparison = a.mediaStorageBytes - b.mediaStorageBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comparison === 0) {
|
if (comparison === 0) {
|
||||||
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortDirection === 'asc' ? comparison : -comparison;
|
return sortDirection === 'asc' ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
|
|
||||||
paginatedUsers = sortedUsers.slice(skip, skip + pageSize);
|
paginatedUsers = sortedUsers.slice(skip, skip + pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildUsersPageHref = (
|
const buildUsersPageHref = (
|
||||||
targetPage: number,
|
targetPage: number,
|
||||||
targetSortBy: SortBy = sortBy,
|
targetSortBy: SortBy = sortBy,
|
||||||
targetSortDirection: SortDirection = sortDirection
|
targetSortDirection: SortDirection = sortDirection
|
||||||
): string => {
|
): string => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: String(targetPage),
|
page: String(targetPage),
|
||||||
sortBy: targetSortBy,
|
sortBy: targetSortBy,
|
||||||
sortDirection: targetSortDirection,
|
sortDirection: targetSortDirection,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `/admin/users?${params.toString()}`;
|
return `/admin/users?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildSortHref = (column: SortBy): string => {
|
const buildSortHref = (column: SortBy): string => {
|
||||||
const nextDirection: SortDirection =
|
const nextDirection: SortDirection =
|
||||||
column === sortBy
|
column === sortBy
|
||||||
? sortDirection === 'asc'
|
? sortDirection === 'asc'
|
||||||
? 'desc'
|
? 'desc'
|
||||||
: 'asc'
|
: 'asc'
|
||||||
: getDefaultSortDirection(column);
|
: getDefaultSortDirection(column);
|
||||||
|
|
||||||
return buildUsersPageHref(1, column, nextDirection);
|
return buildUsersPageHref(1, column, nextDirection);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Users</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Users</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Bunny Stream Storage</CardTitle>
|
||||||
|
<Film className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{isBunnyUploadsFeatureEnabled()
|
||||||
|
? formatBytes(bunnyStorageStats.totalBytes)
|
||||||
|
: 'Disabled'}
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Bunny Stream Storage</CardTitle>
|
<CardTitle className="text-sm font-medium">Cloudflare R2 Media Storage</CardTitle>
|
||||||
<Film className="h-4 w-4 text-muted-foreground" />
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'}
|
{formatBytes(Object.values(userStorage).reduce((sum, item) => sum + item.total, 0))}
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Cloudflare R2 Media Storage</CardTitle>
|
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatBytes(Object.values(userStorage).reduce((sum, item) => sum + item.total, 0))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>All Users</CardTitle>
|
<CardTitle>All Users</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
A comprehensive list of all {totalUsers} users registered on the platform.
|
A comprehensive list of all {totalUsers} users registered on the platform.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<Link href={buildSortHref('user')} className="inline-flex items-center gap-1 hover:underline">
|
<Link
|
||||||
User
|
href={buildSortHref('user')}
|
||||||
<span className="text-xs">{getSortIndicator('user', sortBy, sortDirection)}</span>
|
className="inline-flex items-center gap-1 hover:underline"
|
||||||
</Link>
|
>
|
||||||
</TableHead>
|
User
|
||||||
<TableHead>
|
<span className="text-xs">
|
||||||
<Link href={buildSortHref('joinedDate')} className="inline-flex items-center gap-1 hover:underline">
|
{getSortIndicator('user', sortBy, sortDirection)}
|
||||||
Joined Date
|
</span>
|
||||||
<span className="text-xs">{getSortIndicator('joinedDate', sortBy, sortDirection)}</span>
|
</Link>
|
||||||
</Link>
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead>
|
||||||
<TableHead className="text-center">
|
<Link
|
||||||
<Link href={buildSortHref('workspacesOwned')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
href={buildSortHref('joinedDate')}
|
||||||
Workspaces Owned
|
className="inline-flex items-center gap-1 hover:underline"
|
||||||
<span className="text-xs">{getSortIndicator('workspacesOwned', sortBy, sortDirection)}</span>
|
>
|
||||||
</Link>
|
Joined Date
|
||||||
</TableHead>
|
<span className="text-xs">
|
||||||
<TableHead className="text-center">
|
{getSortIndicator('joinedDate', sortBy, sortDirection)}
|
||||||
<Link href={buildSortHref('invitedMembers')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
</span>
|
||||||
Invited Members
|
</Link>
|
||||||
<span className="text-xs">{getSortIndicator('invitedMembers', sortBy, sortDirection)}</span>
|
</TableHead>
|
||||||
</Link>
|
<TableHead className="text-center">
|
||||||
</TableHead>
|
<Link
|
||||||
<TableHead className="text-center">
|
href={buildSortHref('workspacesOwned')}
|
||||||
<Link href={buildSortHref('projectsOwned')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
className="inline-flex items-center justify-center gap-1 hover:underline"
|
||||||
Projects Owned
|
>
|
||||||
<span className="text-xs">{getSortIndicator('projectsOwned', sortBy, sortDirection)}</span>
|
Workspaces Owned
|
||||||
</Link>
|
<span className="text-xs">
|
||||||
</TableHead>
|
{getSortIndicator('workspacesOwned', sortBy, sortDirection)}
|
||||||
<TableHead className="text-center">
|
</span>
|
||||||
<Link href={buildSortHref('totalComments')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
</Link>
|
||||||
Total Comments
|
</TableHead>
|
||||||
<span className="text-xs">{getSortIndicator('totalComments', sortBy, sortDirection)}</span>
|
<TableHead className="text-center">
|
||||||
</Link>
|
<Link
|
||||||
</TableHead>
|
href={buildSortHref('invitedMembers')}
|
||||||
<TableHead className="text-right">
|
className="inline-flex items-center justify-center gap-1 hover:underline"
|
||||||
<Link href={buildSortHref('bunnyUpload')} className="inline-flex items-center justify-end gap-1 hover:underline">
|
>
|
||||||
Bunny Upload
|
Invited Members
|
||||||
<span className="text-xs">{getSortIndicator('bunnyUpload', sortBy, sortDirection)}</span>
|
<span className="text-xs">
|
||||||
</Link>
|
{getSortIndicator('invitedMembers', sortBy, sortDirection)}
|
||||||
</TableHead>
|
</span>
|
||||||
<TableHead className="text-right">
|
</Link>
|
||||||
<Link href={buildSortHref('downloadEgress')} className="inline-flex items-center justify-end gap-1 hover:underline">
|
</TableHead>
|
||||||
Download Egress (Est.)
|
<TableHead className="text-center">
|
||||||
<span className="text-xs">{getSortIndicator('downloadEgress', sortBy, sortDirection)}</span>
|
<Link
|
||||||
</Link>
|
href={buildSortHref('projectsOwned')}
|
||||||
</TableHead>
|
className="inline-flex items-center justify-center gap-1 hover:underline"
|
||||||
<TableHead className="text-right">
|
>
|
||||||
<Link href={buildSortHref('mediaStorage')} className="inline-flex items-center justify-end gap-1 hover:underline">
|
Projects Owned
|
||||||
Media Storage
|
<span className="text-xs">
|
||||||
<span className="text-xs">{getSortIndicator('mediaStorage', sortBy, sortDirection)}</span>
|
{getSortIndicator('projectsOwned', sortBy, sortDirection)}
|
||||||
</Link>
|
</span>
|
||||||
</TableHead>
|
</Link>
|
||||||
</TableRow>
|
</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="text-center">
|
||||||
<TableBody>
|
<Link
|
||||||
{paginatedUsers.length === 0 ? (
|
href={buildSortHref('totalComments')}
|
||||||
<TableRow>
|
className="inline-flex items-center justify-center gap-1 hover:underline"
|
||||||
<TableCell colSpan={9} className="h-24 text-center">
|
>
|
||||||
No users found.
|
Total Comments
|
||||||
</TableCell>
|
<span className="text-xs">
|
||||||
</TableRow>
|
{getSortIndicator('totalComments', sortBy, sortDirection)}
|
||||||
) : (
|
</span>
|
||||||
paginatedUsers.map((user) => (
|
</Link>
|
||||||
<TableRow key={user.id}>
|
</TableHead>
|
||||||
<TableCell>
|
<TableHead className="text-right">
|
||||||
<div className="flex flex-col">
|
<Link
|
||||||
<span className="font-medium">{user.name || 'Anonymous'}</span>
|
href={buildSortHref('bunnyUpload')}
|
||||||
<span className="text-xs text-muted-foreground">{user.email}</span>
|
className="inline-flex items-center justify-end gap-1 hover:underline"
|
||||||
</div>
|
>
|
||||||
</TableCell>
|
Bunny Upload
|
||||||
<TableCell>
|
<span className="text-xs">
|
||||||
{format(new Date(user.createdAt), 'MMM dd, yyyy')}
|
{getSortIndicator('bunnyUpload', sortBy, sortDirection)}
|
||||||
</TableCell>
|
</span>
|
||||||
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
</Link>
|
||||||
<TableCell className="text-center">{user.invitedMembersCount}</TableCell>
|
</TableHead>
|
||||||
<TableCell className="text-center">{user._count.projects}</TableCell>
|
<TableHead className="text-right">
|
||||||
<TableCell className="text-center">{user._count.comments}</TableCell>
|
<Link
|
||||||
<TableCell className="text-right text-sm font-medium">
|
href={buildSortHref('downloadEgress')}
|
||||||
{formatBytes(user.bunnyUploadBytes)}
|
className="inline-flex items-center justify-end gap-1 hover:underline"
|
||||||
</TableCell>
|
>
|
||||||
<TableCell className="text-right text-sm font-medium">
|
Download Egress (Est.)
|
||||||
{formatBytes(user.downloadEgressBytes)}
|
<span className="text-xs">
|
||||||
</TableCell>
|
{getSortIndicator('downloadEgress', sortBy, sortDirection)}
|
||||||
<TableCell className="text-right text-sm">
|
</span>
|
||||||
<div className="flex flex-col items-end">
|
</Link>
|
||||||
<span className="font-medium text-foreground">{formatBytes(user.mediaStorageBytes)}</span>
|
</TableHead>
|
||||||
{(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && (
|
<TableHead className="text-right">
|
||||||
<span className="text-xs text-muted-foreground mt-0.5 whitespace-nowrap space-x-1">
|
<Link
|
||||||
{userStorage[user.id]?.voice > 0 && <span>🎤 {formatBytes(userStorage[user.id]?.voice)}</span>}
|
href={buildSortHref('mediaStorage')}
|
||||||
{userStorage[user.id]?.voice > 0 && userStorage[user.id]?.image > 0 && <span>•</span>}
|
className="inline-flex items-center justify-end gap-1 hover:underline"
|
||||||
{userStorage[user.id]?.image > 0 && <span>🖼️ {formatBytes(userStorage[user.id]?.image)}</span>}
|
>
|
||||||
</span>
|
Media Storage
|
||||||
)}
|
<span className="text-xs">
|
||||||
</div>
|
{getSortIndicator('mediaStorage', sortBy, sortDirection)}
|
||||||
</TableCell>
|
</span>
|
||||||
</TableRow>
|
</Link>
|
||||||
))
|
</TableHead>
|
||||||
)}
|
</TableRow>
|
||||||
</TableBody>
|
</TableHeader>
|
||||||
</Table>
|
<TableBody>
|
||||||
</div>
|
{paginatedUsers.length === 0 ? (
|
||||||
{/* Pagination */}
|
<TableRow>
|
||||||
{totalPages > 1 && (
|
<TableCell colSpan={9} className="h-24 text-center">
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
No users found.
|
||||||
<Button
|
</TableCell>
|
||||||
variant="outline"
|
</TableRow>
|
||||||
size="sm"
|
) : (
|
||||||
disabled={page <= 1}
|
paginatedUsers.map((user) => (
|
||||||
asChild={page > 1}
|
<TableRow key={user.id}>
|
||||||
>
|
<TableCell>
|
||||||
{page > 1 ? (
|
<div className="flex flex-col">
|
||||||
<Link href={buildUsersPageHref(page - 1)}>Previous</Link>
|
<span className="font-medium">{user.name || 'Anonymous'}</span>
|
||||||
) : (
|
<span className="text-xs text-muted-foreground">{user.email}</span>
|
||||||
"Previous"
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
</Button>
|
<TableCell>{format(new Date(user.createdAt), 'MMM dd, yyyy')}</TableCell>
|
||||||
<span className="text-sm font-medium">
|
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
||||||
Page {page} of {totalPages}
|
<TableCell className="text-center">{user.invitedMembersCount}</TableCell>
|
||||||
|
<TableCell className="text-center">{user._count.projects}</TableCell>
|
||||||
|
<TableCell className="text-center">{user._count.comments}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-medium">
|
||||||
|
{formatBytes(user.bunnyUploadBytes)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-medium">
|
||||||
|
{formatBytes(user.downloadEgressBytes)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{formatBytes(user.mediaStorageBytes)}
|
||||||
|
</span>
|
||||||
|
{(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && (
|
||||||
|
<span className="text-xs text-muted-foreground mt-0.5 whitespace-nowrap space-x-1">
|
||||||
|
{userStorage[user.id]?.voice > 0 && (
|
||||||
|
<span>🎤 {formatBytes(userStorage[user.id]?.voice)}</span>
|
||||||
|
)}
|
||||||
|
{userStorage[user.id]?.voice > 0 &&
|
||||||
|
userStorage[user.id]?.image > 0 && <span>•</span>}
|
||||||
|
{userStorage[user.id]?.image > 0 && (
|
||||||
|
<span>🖼️ {formatBytes(userStorage[user.id]?.image)}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
asChild={page < totalPages}
|
|
||||||
>
|
|
||||||
{page < totalPages ? (
|
|
||||||
<Link href={buildUsersPageHref(page + 1)}>Next</Link>
|
|
||||||
) : (
|
|
||||||
"Next"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
</CardContent>
|
</TableRow>
|
||||||
</Card>
|
))
|
||||||
</div>
|
)}
|
||||||
);
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} asChild={page > 1}>
|
||||||
|
{page > 1 ? <Link href={buildUsersPageHref(page - 1)}>Previous</Link> : 'Previous'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
asChild={page < totalPages}
|
||||||
|
>
|
||||||
|
{page < totalPages ? <Link href={buildUsersPageHref(page + 1)}>Next</Link> : 'Next'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,42 +26,57 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { feedbackId } = await params;
|
const { feedbackId } = await params;
|
||||||
const userFeedbackDelegate = (db as unknown as {
|
const userFeedbackDelegate = (
|
||||||
userFeedback?: {
|
db as unknown as {
|
||||||
findUnique: (args: unknown) => Promise<{
|
userFeedback?: {
|
||||||
id: string;
|
findUnique: (args: unknown) => Promise<{
|
||||||
screenshotUrl: string | null;
|
id: string;
|
||||||
screenshots?: Array<{ url: string }>;
|
screenshotUrl: string | null;
|
||||||
} | null>;
|
screenshots?: Array<{ url: string }>;
|
||||||
delete: (args: { where: { id: string } }) => Promise<{ id: string }>;
|
} | null>;
|
||||||
findFirst: (args: { where: { screenshotUrl: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
delete: (args: { where: { id: string } }) => Promise<{ id: string }>;
|
||||||
};
|
findFirst: (args: {
|
||||||
userFeedbackScreenshot?: {
|
where: { screenshotUrl: string };
|
||||||
findFirst: (args: { where: { url: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
select: { id: true };
|
||||||
};
|
}) => Promise<{ id: string } | null>;
|
||||||
}).userFeedback;
|
};
|
||||||
const userFeedbackScreenshotDelegate = (db as unknown as {
|
userFeedbackScreenshot?: {
|
||||||
userFeedbackScreenshot?: {
|
findFirst: (args: {
|
||||||
findFirst: (args: { where: { url: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
where: { url: string };
|
||||||
};
|
select: { id: true };
|
||||||
}).userFeedbackScreenshot;
|
}) => Promise<{ id: string } | null>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).userFeedback;
|
||||||
|
const userFeedbackScreenshotDelegate = (
|
||||||
|
db as unknown as {
|
||||||
|
userFeedbackScreenshot?: {
|
||||||
|
findFirst: (args: {
|
||||||
|
where: { url: string };
|
||||||
|
select: { id: true };
|
||||||
|
}) => Promise<{ id: string } | null>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).userFeedbackScreenshot;
|
||||||
|
|
||||||
if (!userFeedbackDelegate) {
|
if (!userFeedbackDelegate) {
|
||||||
return apiErrors.internalError('Feedback model is not available yet');
|
return apiErrors.internalError('Feedback model is not available yet');
|
||||||
}
|
}
|
||||||
|
|
||||||
let feedbackRecord = await userFeedbackDelegate.findUnique({
|
let feedbackRecord = await userFeedbackDelegate
|
||||||
where: { id: feedbackId },
|
.findUnique({
|
||||||
include: {
|
where: { id: feedbackId },
|
||||||
screenshots: {
|
include: {
|
||||||
select: { url: true },
|
screenshots: {
|
||||||
|
select: { url: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
}).catch((error) => {
|
.catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : '';
|
const message = error instanceof Error ? error.message : '';
|
||||||
if (message.includes('Unknown field `screenshots`')) return null;
|
if (message.includes('Unknown field `screenshots`')) return null;
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!feedbackRecord) {
|
if (!feedbackRecord) {
|
||||||
feedbackRecord = await userFeedbackDelegate.findUnique({
|
feedbackRecord = await userFeedbackDelegate.findUnique({
|
||||||
@@ -88,31 +103,34 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
const filename = extractImageFilenameFromProxyUrl(url);
|
const filename = extractImageFilenameFromProxyUrl(url);
|
||||||
if (!filename) return;
|
if (!filename) return;
|
||||||
|
|
||||||
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] = await Promise.all([
|
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] =
|
||||||
db.comment.findFirst({
|
await Promise.all([
|
||||||
where: { imageUrl: url },
|
db.comment.findFirst({
|
||||||
select: { id: true },
|
where: { imageUrl: url },
|
||||||
}),
|
|
||||||
userFeedbackDelegate.findFirst({
|
|
||||||
where: { screenshotUrl: url },
|
|
||||||
select: { id: true },
|
|
||||||
}),
|
|
||||||
userFeedbackScreenshotDelegate
|
|
||||||
? userFeedbackScreenshotDelegate.findFirst({
|
|
||||||
where: { url },
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
}),
|
||||||
: Promise.resolve(null),
|
userFeedbackDelegate.findFirst({
|
||||||
]);
|
where: { screenshotUrl: url },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
userFeedbackScreenshotDelegate
|
||||||
|
? userFeedbackScreenshotDelegate.findFirst({
|
||||||
|
where: { url },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
if (commentReferenced || feedbackReferenced || feedbackAttachmentReferenced) return;
|
if (commentReferenced || feedbackReferenced || feedbackAttachmentReferenced) return;
|
||||||
|
|
||||||
await r2Client.send(
|
await r2Client
|
||||||
new DeleteObjectCommand({
|
.send(
|
||||||
Bucket: R2_BUCKET_NAME,
|
new DeleteObjectCommand({
|
||||||
Key: `images/${filename}`,
|
Bucket: R2_BUCKET_NAME,
|
||||||
})
|
Key: `images/${filename}`,
|
||||||
).catch(() => undefined);
|
})
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
include: {
|
include: {
|
||||||
video: {
|
video: {
|
||||||
include: {
|
include: {
|
||||||
project: { select: { id: true, ownerId: true, workspaceId: true, visibility: true } },
|
project: {
|
||||||
|
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -38,7 +40,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
if (!approvalRequest) return apiErrors.notFound('Approval request');
|
if (!approvalRequest) return apiErrors.notFound('Approval request');
|
||||||
|
|
||||||
const access = await checkProjectAccess(approvalRequest.version.video.project, session.user.id, { intent: 'manage' });
|
const access = await checkProjectAccess(
|
||||||
|
approvalRequest.version.video.project,
|
||||||
|
session.user.id,
|
||||||
|
{ intent: 'manage' }
|
||||||
|
);
|
||||||
const canCancel = approvalRequest.requestedById === session.user.id || access.canEdit;
|
const canCancel = approvalRequest.requestedById === session.user.id || access.canEdit;
|
||||||
if (!canCancel) return apiErrors.forbidden('Access denied');
|
if (!canCancel) return apiErrors.forbidden('Access denied');
|
||||||
|
|
||||||
@@ -46,33 +52,36 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.conflict('Only pending approval requests can be canceled');
|
return apiErrors.conflict('Only pending approval requests can be canceled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await db.$transaction(async (tx) => {
|
const updated = await db.$transaction(
|
||||||
const current = await tx.approvalRequest.findUnique({
|
async (tx) => {
|
||||||
where: { id: requestId },
|
const current = await tx.approvalRequest.findUnique({
|
||||||
select: { status: true },
|
where: { id: requestId },
|
||||||
});
|
select: { status: true },
|
||||||
if (!current) throw new Error('__NOT_FOUND__');
|
});
|
||||||
if (current.status !== 'PENDING') throw new Error('__NOT_PENDING__');
|
if (!current) throw new Error('__NOT_FOUND__');
|
||||||
|
if (current.status !== 'PENDING') throw new Error('__NOT_PENDING__');
|
||||||
|
|
||||||
return tx.approvalRequest.update({
|
return tx.approvalRequest.update({
|
||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
data: {
|
data: {
|
||||||
status: 'CANCELED',
|
status: 'CANCELED',
|
||||||
canceledAt: new Date(),
|
canceledAt: new Date(),
|
||||||
canceledById: session.user.id,
|
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 } } },
|
|
||||||
},
|
},
|
||||||
},
|
include: {
|
||||||
});
|
requestedBy: { select: { id: true, name: true, email: true, image: true } },
|
||||||
}, {
|
canceledBy: { select: { id: true, name: true, email: true, image: true } },
|
||||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
decisions: {
|
||||||
});
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: { approver: { select: { id: true, name: true, email: true, image: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const response = successResponse({ request: updated });
|
const response = successResponse({ request: updated });
|
||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
|||||||
@@ -41,7 +41,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
include: {
|
include: {
|
||||||
video: {
|
video: {
|
||||||
include: {
|
include: {
|
||||||
project: { select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true } },
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ownerId: true,
|
||||||
|
workspaceId: true,
|
||||||
|
visibility: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,102 +74,105 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.conflict('You have already responded to this request');
|
return apiErrors.conflict('You have already responded to this request');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await db.$transaction(async (tx) => {
|
const updated = await db.$transaction(
|
||||||
const currentRequest = await tx.approvalRequest.findUnique({
|
async (tx) => {
|
||||||
where: { id: requestId },
|
const currentRequest = await tx.approvalRequest.findUnique({
|
||||||
include: {
|
where: { id: requestId },
|
||||||
decisions: {
|
include: {
|
||||||
orderBy: { createdAt: 'asc' },
|
decisions: {
|
||||||
include: {
|
orderBy: { createdAt: 'asc' },
|
||||||
approver: { select: { id: true, name: true, email: true, image: true } },
|
include: {
|
||||||
|
approver: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
requestedBy: { select: { id: true, name: true, email: true, image: true } },
|
||||||
requestedBy: { select: { id: true, name: true, email: true, image: true } },
|
version: {
|
||||||
version: {
|
include: {
|
||||||
include: {
|
video: {
|
||||||
video: {
|
include: {
|
||||||
include: {
|
project: { select: { id: true, name: true } },
|
||||||
project: { select: { id: true, name: true } },
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
if (!currentRequest) {
|
||||||
if (!currentRequest) {
|
throw new Error('__NOT_FOUND__');
|
||||||
throw new Error('__NOT_FOUND__');
|
}
|
||||||
}
|
if (currentRequest.status !== 'PENDING') {
|
||||||
if (currentRequest.status !== 'PENDING') {
|
throw new Error('__NOT_PENDING__');
|
||||||
throw new Error('__NOT_PENDING__');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const decisionRow = await tx.approvalDecision.findUnique({
|
const decisionRow = await tx.approvalDecision.findUnique({
|
||||||
where: { requestId_approverId: { requestId, approverId: session.user.id } },
|
where: { requestId_approverId: { requestId, approverId: session.user.id } },
|
||||||
select: { status: true },
|
select: { status: true },
|
||||||
});
|
});
|
||||||
if (!decisionRow) throw new Error('__NOT_APPROVER__');
|
if (!decisionRow) throw new Error('__NOT_APPROVER__');
|
||||||
if (decisionRow.status !== 'PENDING') throw new Error('__ALREADY_RESPONDED__');
|
if (decisionRow.status !== 'PENDING') throw new Error('__ALREADY_RESPONDED__');
|
||||||
|
|
||||||
await tx.approvalDecision.update({
|
await tx.approvalDecision.update({
|
||||||
where: { requestId_approverId: { requestId, approverId: session.user.id } },
|
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: {
|
data: {
|
||||||
status: 'REJECTED',
|
status: decision,
|
||||||
resolvedAt: new Date(),
|
note: note || null,
|
||||||
|
respondedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
const pendingCount = await tx.approvalDecision.count({
|
if (decision === 'REJECTED') {
|
||||||
where: { requestId, status: 'PENDING' },
|
|
||||||
});
|
|
||||||
const rejectedCount = await tx.approvalDecision.count({
|
|
||||||
where: { requestId, status: 'REJECTED' },
|
|
||||||
});
|
|
||||||
if (pendingCount === 0 && rejectedCount === 0) {
|
|
||||||
await tx.approvalRequest.update({
|
await tx.approvalRequest.update({
|
||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
data: {
|
data: {
|
||||||
status: 'APPROVED',
|
status: 'REJECTED',
|
||||||
resolvedAt: new Date(),
|
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({
|
return tx.approvalRequest.findUnique({
|
||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
include: {
|
include: {
|
||||||
requestedBy: { select: { id: true, name: true, email: true, image: true } },
|
requestedBy: { select: { id: true, name: true, email: true, image: true } },
|
||||||
canceledBy: { select: { id: true, name: true, email: true, image: true } },
|
canceledBy: { select: { id: true, name: true, email: true, image: true } },
|
||||||
decisions: {
|
decisions: {
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
approver: { select: { id: true, name: true, email: true, image: true } },
|
approver: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
version: {
|
||||||
version: {
|
include: {
|
||||||
include: {
|
video: {
|
||||||
video: {
|
include: {
|
||||||
include: {
|
project: { select: { id: true, name: true } },
|
||||||
project: { select: { id: true, name: true } },
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
},
|
||||||
}, {
|
{
|
||||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!updated) return apiErrors.notFound('Approval request');
|
if (!updated) return apiErrors.notFound('Approval request');
|
||||||
|
|
||||||
@@ -212,9 +223,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message === '__NOT_PENDING__') return apiErrors.conflict('This approval request is no longer pending');
|
if (error.message === '__NOT_PENDING__')
|
||||||
if (error.message === '__ALREADY_RESPONDED__') return apiErrors.conflict('You have already responded to this request');
|
return apiErrors.conflict('This approval request is no longer pending');
|
||||||
if (error.message === '__NOT_APPROVER__') return apiErrors.forbidden('You are not an approver on this request');
|
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 (error.message === '__NOT_FOUND__') return apiErrors.notFound('Approval request');
|
||||||
}
|
}
|
||||||
if (isSerializableConflict(error)) {
|
if (isSerializableConflict(error)) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export const { GET } = handlers;
|
|||||||
|
|
||||||
// Wrap NextAuth POST with login rate limiting
|
// Wrap NextAuth POST with login rate limiting
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const limited = await rateLimit(request, 'login');
|
const limited = await rateLimit(request, 'login');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const response = await handlers.POST(request as any);
|
const response = await handlers.POST(request as any);
|
||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-138
@@ -2,149 +2,157 @@ import { NextRequest } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations';
|
import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations';
|
||||||
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
getClientIp,
|
||||||
|
rateLimitHeaders,
|
||||||
|
RATE_LIMIT_CONFIGS,
|
||||||
|
} from '@/lib/rate-limit';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { createVerificationToken, isEmailVerificationEnabled, sendVerificationEmail } from '@/lib/email-verification';
|
import {
|
||||||
|
createVerificationToken,
|
||||||
|
isEmailVerificationEnabled,
|
||||||
|
sendVerificationEmail,
|
||||||
|
} from '@/lib/email-verification';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Rate limiting by IP
|
// Rate limiting by IP
|
||||||
const clientIp = getClientIp(request);
|
const clientIp = getClientIp(request);
|
||||||
const rateLimitKey = `register:${clientIp}`;
|
const rateLimitKey = `register:${clientIp}`;
|
||||||
const rateLimit = await checkRateLimit(rateLimitKey, 'register');
|
const rateLimit = await checkRateLimit(rateLimitKey, 'register');
|
||||||
|
|
||||||
if (!rateLimit.allowed) {
|
if (!rateLimit.allowed) {
|
||||||
return apiErrors.rateLimited('Too many registration attempts. Please try again later.');
|
return apiErrors.rateLimited('Too many registration attempts. Please try again later.');
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, email, password, inviteCode, invitationToken } = body;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 100) {
|
|
||||||
return apiErrors.badRequest('Name must be between 2 and 100 characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
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(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 && isInviteCodeRequired()) {
|
|
||||||
// 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 || password.length > 128) {
|
|
||||||
return apiErrors.badRequest('Password must be between 8 and 128 characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email already exists
|
|
||||||
const existingUser = await db.user.findUnique({
|
|
||||||
where: { email: normalizedEmail },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
return apiErrors.conflict('An account with this email already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
|
||||||
|
|
||||||
// If SMTP is not configured, auto-verify the email so users aren't locked out
|
|
||||||
const emailVerificationRequired = isEmailVerificationEnabled();
|
|
||||||
|
|
||||||
// Create user
|
|
||||||
const user = await db.user.create({
|
|
||||||
data: {
|
|
||||||
name: name.trim(),
|
|
||||||
email: normalizedEmail,
|
|
||||||
password: hashedPassword,
|
|
||||||
emailVerified: emailVerificationRequired ? null : new Date(),
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send verification email if SMTP is configured
|
|
||||||
if (emailVerificationRequired) {
|
|
||||||
const verificationToken = await createVerificationToken(normalizedEmail);
|
|
||||||
await sendVerificationEmail(normalizedEmail, verificationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = emailVerificationRequired
|
|
||||||
? 'Account created. Please check your email to verify your address before signing in.'
|
|
||||||
: 'Account created successfully';
|
|
||||||
|
|
||||||
const response = successResponse(
|
|
||||||
{ message, user, emailVerificationRequired },
|
|
||||||
201
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add rate limit headers to successful response
|
|
||||||
const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests);
|
|
||||||
Object.entries(headers).forEach(([key, value]) => {
|
|
||||||
response.headers.set(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Registration error:', error);
|
|
||||||
return apiErrors.internalError('Failed to create account');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, email, password, inviteCode, invitationToken } = body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 100) {
|
||||||
|
return apiErrors.badRequest('Name must be between 2 and 100 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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 && isInviteCodeRequired()) {
|
||||||
|
// 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 || password.length > 128) {
|
||||||
|
return apiErrors.badRequest('Password must be between 8 and 128 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
const existingUser = await db.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return apiErrors.conflict('An account with this email already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// If SMTP is not configured, auto-verify the email so users aren't locked out
|
||||||
|
const emailVerificationRequired = isEmailVerificationEnabled();
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await db.user.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
email: normalizedEmail,
|
||||||
|
password: hashedPassword,
|
||||||
|
emailVerified: emailVerificationRequired ? null : new Date(),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send verification email if SMTP is configured
|
||||||
|
if (emailVerificationRequired) {
|
||||||
|
const verificationToken = await createVerificationToken(normalizedEmail);
|
||||||
|
await sendVerificationEmail(normalizedEmail, verificationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = emailVerificationRequired
|
||||||
|
? 'Account created. Please check your email to verify your address before signing in.'
|
||||||
|
: 'Account created successfully';
|
||||||
|
|
||||||
|
const response = successResponse({ message, user, emailVerificationRequired }, 201);
|
||||||
|
|
||||||
|
// Add rate limit headers to successful response
|
||||||
|
const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests);
|
||||||
|
Object.entries(headers).forEach(([key, value]) => {
|
||||||
|
response.headers.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Registration error:', error);
|
||||||
|
return apiErrors.internalError('Failed to create account');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,54 +2,63 @@ import { NextRequest } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
|
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { createVerificationToken, isEmailVerificationEnabled, sendVerificationEmail } from '@/lib/email-verification';
|
import {
|
||||||
|
createVerificationToken,
|
||||||
|
isEmailVerificationEnabled,
|
||||||
|
sendVerificationEmail,
|
||||||
|
} from '@/lib/email-verification';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!isEmailVerificationEnabled()) {
|
if (!isEmailVerificationEnabled()) {
|
||||||
return apiErrors.badRequest('Email verification is not enabled');
|
return apiErrors.badRequest('Email verification is not enabled');
|
||||||
}
|
|
||||||
|
|
||||||
// Rate-limit by IP to prevent abuse
|
|
||||||
const clientIp = getClientIp(request);
|
|
||||||
const rateLimitResult = await checkRateLimit(`resend-verification:${clientIp}`, 'resend-verification');
|
|
||||||
if (!rateLimitResult.allowed) {
|
|
||||||
return apiErrors.rateLimited('Too many requests. Please try again later.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { email } = body;
|
|
||||||
|
|
||||||
if (!email || typeof email !== 'string' || email.length > 254 || !email.includes('@')) {
|
|
||||||
return apiErrors.badRequest('Valid email is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(normalizedEmail)) {
|
|
||||||
return apiErrors.badRequest('Valid email is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up user — return a generic success regardless of whether the email
|
|
||||||
// exists to avoid user enumeration
|
|
||||||
const user = await db.user.findUnique({
|
|
||||||
where: { email: normalizedEmail },
|
|
||||||
select: { id: true, emailVerified: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user && !user.emailVerified) {
|
|
||||||
const token = await createVerificationToken(normalizedEmail);
|
|
||||||
await sendVerificationEmail(normalizedEmail, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
return withCacheControl(
|
|
||||||
successResponse({ message: 'If that email has an unverified account, a new verification link has been sent.' }),
|
|
||||||
'private, no-store'
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logError('Resend verification error:', err);
|
|
||||||
return apiErrors.internalError('Failed to resend verification email');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate-limit by IP to prevent abuse
|
||||||
|
const clientIp = getClientIp(request);
|
||||||
|
const rateLimitResult = await checkRateLimit(
|
||||||
|
`resend-verification:${clientIp}`,
|
||||||
|
'resend-verification'
|
||||||
|
);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return apiErrors.rateLimited('Too many requests. Please try again later.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { email } = body;
|
||||||
|
|
||||||
|
if (!email || typeof email !== 'string' || email.length > 254 || !email.includes('@')) {
|
||||||
|
return apiErrors.badRequest('Valid email is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(normalizedEmail)) {
|
||||||
|
return apiErrors.badRequest('Valid email is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up user — return a generic success regardless of whether the email
|
||||||
|
// exists to avoid user enumeration
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
select: { id: true, emailVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user && !user.emailVerified) {
|
||||||
|
const token = await createVerificationToken(normalizedEmail);
|
||||||
|
await sendVerificationEmail(normalizedEmail, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withCacheControl(
|
||||||
|
successResponse({
|
||||||
|
message: 'If that email has an unverified account, a new verification link has been sent.',
|
||||||
|
}),
|
||||||
|
'private, no-store'
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Resend verification error:', err);
|
||||||
|
return apiErrors.internalError('Failed to resend verification email');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,26 +7,26 @@ import { logError } from '@/lib/logger';
|
|||||||
const TOKEN_REGEX = /^[0-9a-f]{64}$/;
|
const TOKEN_REGEX = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Rate-limit by IP to prevent token enumeration attacks.
|
// Rate-limit by IP to prevent token enumeration attacks.
|
||||||
const limited = await rateLimit(request, 'verify-email');
|
const limited = await rateLimit(request, 'verify-email');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const token = request.nextUrl.searchParams.get('token');
|
const token = request.nextUrl.searchParams.get('token');
|
||||||
|
|
||||||
if (!token || !TOKEN_REGEX.test(token.trim())) {
|
if (!token || !TOKEN_REGEX.test(token.trim())) {
|
||||||
return NextResponse.redirect(new URL('/login?error=InvalidVerificationToken', request.url));
|
return NextResponse.redirect(new URL('/login?error=InvalidVerificationToken', request.url));
|
||||||
}
|
|
||||||
|
|
||||||
const email = await consumeVerificationToken(token.trim());
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
return NextResponse.redirect(new URL('/login?error=InvalidVerificationToken', request.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.redirect(new URL('/login?verified=true', request.url));
|
|
||||||
} catch (err) {
|
|
||||||
logError('Email verification error:', err);
|
|
||||||
return NextResponse.redirect(new URL('/login?error=VerificationFailed', request.url));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const email = await consumeVerificationToken(token.trim());
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=InvalidVerificationToken', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL('/login?verified=true', request.url));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Email verification error:', err);
|
||||||
|
return NextResponse.redirect(new URL('/login?error=VerificationFailed', request.url));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export async function GET() {
|
|||||||
cancelAt: billing.subscription.cancelAt?.toISOString() ?? null,
|
cancelAt: billing.subscription.cancelAt?.toISOString() ?? null,
|
||||||
trialEndsAt: billing.subscription.trialEndsAt?.toISOString() ?? null,
|
trialEndsAt: billing.subscription.trialEndsAt?.toISOString() ?? null,
|
||||||
billingAccessEndedAt: billing.subscription.billingAccessEndedAt?.toISOString() ?? null,
|
billingAccessEndedAt: billing.subscription.billingAccessEndedAt?.toISOString() ?? null,
|
||||||
storageCleanupEligibleAt: billing.subscription.storageCleanupEligibleAt?.toISOString() ?? null,
|
storageCleanupEligibleAt:
|
||||||
|
billing.subscription.storageCleanupEligibleAt?.toISOString() ?? null,
|
||||||
},
|
},
|
||||||
workspaceCreation: billing.workspaceCreation,
|
workspaceCreation: billing.workspaceCreation,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,349 +18,374 @@ type RouteParams = { params: Promise<{ commentId: string }> };
|
|||||||
|
|
||||||
// GET /api/comments/[commentId]
|
// GET /api/comments/[commentId]
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { commentId } = await params;
|
const { commentId } = await params;
|
||||||
|
|
||||||
const comment = await db.comment.findUnique({
|
const comment = await db.comment.findUnique({
|
||||||
where: { id: commentId },
|
where: { id: commentId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
content: true,
|
content: true,
|
||||||
timestamp: true,
|
timestamp: true,
|
||||||
timestampEnd: true,
|
timestampEnd: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
isResolved: true,
|
isResolved: true,
|
||||||
resolvedAt: true,
|
resolvedAt: true,
|
||||||
voiceUrl: true,
|
voiceUrl: true,
|
||||||
voiceDuration: true,
|
voiceDuration: true,
|
||||||
imageUrl: true,
|
imageUrl: true,
|
||||||
parentId: true,
|
parentId: true,
|
||||||
authorId: true,
|
authorId: true,
|
||||||
tagId: true,
|
tagId: true,
|
||||||
versionId: true,
|
versionId: true,
|
||||||
guestName: true,
|
guestName: true,
|
||||||
author: { select: { id: true, name: true, image: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
replies: {
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
content: true,
|
content: true,
|
||||||
timestamp: true,
|
timestamp: true,
|
||||||
timestampEnd: true,
|
timestampEnd: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
isResolved: true,
|
isResolved: true,
|
||||||
resolvedAt: true,
|
resolvedAt: true,
|
||||||
voiceUrl: true,
|
voiceUrl: true,
|
||||||
voiceDuration: true,
|
voiceDuration: true,
|
||||||
imageUrl: true,
|
imageUrl: true,
|
||||||
parentId: true,
|
parentId: true,
|
||||||
authorId: true,
|
authorId: true,
|
||||||
tagId: true,
|
tagId: true,
|
||||||
versionId: true,
|
versionId: true,
|
||||||
guestName: true,
|
guestName: true,
|
||||||
author: { select: { id: true, name: true, image: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
version: {
|
version: {
|
||||||
include: {
|
include: {
|
||||||
video: {
|
video: {
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
return apiErrors.notFound('Comment');
|
return apiErrors.notFound('Comment');
|
||||||
}
|
|
||||||
|
|
||||||
// Authorization check: verify user has access to the project
|
|
||||||
const project = comment.version.video.project;
|
|
||||||
const access = await checkProjectAccess(project, session?.user?.id);
|
|
||||||
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip internal project data from response
|
|
||||||
const commentData = { ...comment } as Omit<typeof comment, 'version'> & { version?: unknown };
|
|
||||||
delete commentData.version;
|
|
||||||
const response = successResponse(commentData);
|
|
||||||
return withCacheControl(response, 'private, no-cache');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching comment:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch comment');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authorization check: verify user has access to the project
|
||||||
|
const project = comment.version.video.project;
|
||||||
|
const access = await checkProjectAccess(project, session?.user?.id);
|
||||||
|
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip internal project data from response
|
||||||
|
const commentData = { ...comment } as Omit<typeof comment, 'version'> & { version?: unknown };
|
||||||
|
delete commentData.version;
|
||||||
|
const response = successResponse(commentData);
|
||||||
|
return withCacheControl(response, 'private, no-cache');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching comment:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch comment');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/comments/[commentId]
|
// PATCH /api/comments/[commentId]
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { commentId } = await params;
|
const { commentId } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { content, isResolved, tagId, annotationData } = body;
|
const { content, isResolved, tagId, annotationData } = body;
|
||||||
|
|
||||||
const comment = await db.comment.findUnique({
|
const comment = await db.comment.findUnique({
|
||||||
where: { id: commentId },
|
where: { id: commentId },
|
||||||
include: {
|
include: {
|
||||||
version: {
|
version: {
|
||||||
include: {
|
include: {
|
||||||
video: {
|
video: {
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
return apiErrors.notFound('Comment');
|
return apiErrors.notFound('Comment');
|
||||||
}
|
|
||||||
|
|
||||||
const project = comment.version.video.project;
|
|
||||||
const userId = session?.user?.id ?? null;
|
|
||||||
const access = await checkProjectAccess(project, userId ?? undefined, { intent: 'manage' });
|
|
||||||
const isOwner = userId === project.ownerId;
|
|
||||||
const isAuthor = !!userId && comment.authorId === userId;
|
|
||||||
const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null;
|
|
||||||
const isGuestAuthor = !userId
|
|
||||||
&& !comment.authorId
|
|
||||||
&& !!comment.guestIdentityId
|
|
||||||
&& guestIdentityId === comment.guestIdentityId;
|
|
||||||
const canEditOwnContent = isAuthor || isGuestAuthor;
|
|
||||||
const canResolveComment = access.canEdit;
|
|
||||||
|
|
||||||
if (!userId && !isGuestAuthor) {
|
|
||||||
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
|
||||||
const shareAccess = shareSession
|
|
||||||
? await validateShareLinkAccess({
|
|
||||||
token: shareSession.token,
|
|
||||||
projectId: project.id,
|
|
||||||
videoId: comment.version.video.id,
|
|
||||||
requiredPermission: 'COMMENT',
|
|
||||||
passwordVerified: shareSession.passwordVerified,
|
|
||||||
})
|
|
||||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
|
||||||
const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
|
|
||||||
if (!hasGuestAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only author can edit content or tag
|
|
||||||
if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !canEditOwnContent) {
|
|
||||||
return apiErrors.forbidden('Only the author can edit comment content');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Owner, author, members, or workspace members can resolve/unresolve
|
|
||||||
if (isResolved !== undefined && !canResolveComment) {
|
|
||||||
return apiErrors.forbidden('Only admins can resolve comments');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
|
||||||
if (content !== undefined && typeof content === 'string') updateData.content = content.trim();
|
|
||||||
if (tagId !== undefined) {
|
|
||||||
// Verify tag belongs to this project to prevent cross-project tag leakage (IDOR)
|
|
||||||
if (tagId !== null) {
|
|
||||||
const tag = await db.commentTag.findFirst({
|
|
||||||
where: { id: tagId, projectId: project.id },
|
|
||||||
});
|
|
||||||
if (!tag) {
|
|
||||||
return apiErrors.badRequest('Tag not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateData.tagId = tagId;
|
|
||||||
}
|
|
||||||
if (annotationData !== undefined) {
|
|
||||||
if (annotationData === null) {
|
|
||||||
updateData.annotationData = null;
|
|
||||||
} else {
|
|
||||||
if (!Array.isArray(annotationData)) {
|
|
||||||
return apiErrors.badRequest('annotationData must be an array of valid stroke objects');
|
|
||||||
}
|
|
||||||
const validStrokes = validateAnnotationStrokes(annotationData);
|
|
||||||
if (validStrokes === null) {
|
|
||||||
return apiErrors.badRequest('annotationData must be an array of valid stroke objects');
|
|
||||||
}
|
|
||||||
updateData.annotationData = JSON.stringify(validStrokes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isResolved !== undefined) {
|
|
||||||
updateData.isResolved = isResolved;
|
|
||||||
updateData.resolvedAt = isResolved ? new Date() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedComment = await db.comment.update({
|
|
||||||
where: { id: commentId },
|
|
||||||
data: updateData,
|
|
||||||
include: {
|
|
||||||
author: { select: { id: true, name: true, image: true } },
|
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
|
||||||
replies: {
|
|
||||||
include: {
|
|
||||||
author: { select: { id: true, name: true, image: true } },
|
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedCommentData = Object.fromEntries(
|
|
||||||
Object.entries(updatedComment).filter(([key]) => key !== 'guestIdentityId')
|
|
||||||
);
|
|
||||||
const response = successResponse({
|
|
||||||
...updatedCommentData,
|
|
||||||
canEdit: canEditOwnContent,
|
|
||||||
canDelete: canEditOwnContent || isOwner,
|
|
||||||
replies: updatedComment.replies.map((reply) => {
|
|
||||||
const canEditReply = !!userId
|
|
||||||
? reply.authorId === userId
|
|
||||||
: !!guestIdentityId
|
|
||||||
&& !reply.authorId
|
|
||||||
&& !!reply.guestIdentityId
|
|
||||||
&& reply.guestIdentityId === guestIdentityId;
|
|
||||||
const replyData = Object.fromEntries(
|
|
||||||
Object.entries(reply).filter(([key]) => key !== 'guestIdentityId')
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...replyData,
|
|
||||||
canEdit: canEditReply,
|
|
||||||
canDelete: canEditReply || isOwner,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating comment:', error);
|
|
||||||
return apiErrors.internalError('Failed to update comment');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = comment.version.video.project;
|
||||||
|
const userId = session?.user?.id ?? null;
|
||||||
|
const access = await checkProjectAccess(project, userId ?? undefined, { intent: 'manage' });
|
||||||
|
const isOwner = userId === project.ownerId;
|
||||||
|
const isAuthor = !!userId && comment.authorId === userId;
|
||||||
|
const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null;
|
||||||
|
const isGuestAuthor =
|
||||||
|
!userId &&
|
||||||
|
!comment.authorId &&
|
||||||
|
!!comment.guestIdentityId &&
|
||||||
|
guestIdentityId === comment.guestIdentityId;
|
||||||
|
const canEditOwnContent = isAuthor || isGuestAuthor;
|
||||||
|
const canResolveComment = access.canEdit;
|
||||||
|
|
||||||
|
if (!userId && !isGuestAuthor) {
|
||||||
|
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
||||||
|
const shareAccess = shareSession
|
||||||
|
? await validateShareLinkAccess({
|
||||||
|
token: shareSession.token,
|
||||||
|
projectId: project.id,
|
||||||
|
videoId: comment.version.video.id,
|
||||||
|
requiredPermission: 'COMMENT',
|
||||||
|
passwordVerified: shareSession.passwordVerified,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
hasAccess: false,
|
||||||
|
canComment: false,
|
||||||
|
canDownload: false,
|
||||||
|
allowGuests: false,
|
||||||
|
requiresPassword: false,
|
||||||
|
};
|
||||||
|
const hasGuestAccess =
|
||||||
|
project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
|
||||||
|
if (!hasGuestAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only author can edit content or tag
|
||||||
|
if (
|
||||||
|
(content !== undefined || tagId !== undefined || annotationData !== undefined) &&
|
||||||
|
!canEditOwnContent
|
||||||
|
) {
|
||||||
|
return apiErrors.forbidden('Only the author can edit comment content');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner, author, members, or workspace members can resolve/unresolve
|
||||||
|
if (isResolved !== undefined && !canResolveComment) {
|
||||||
|
return apiErrors.forbidden('Only admins can resolve comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (content !== undefined && typeof content === 'string') updateData.content = content.trim();
|
||||||
|
if (tagId !== undefined) {
|
||||||
|
// Verify tag belongs to this project to prevent cross-project tag leakage (IDOR)
|
||||||
|
if (tagId !== null) {
|
||||||
|
const tag = await db.commentTag.findFirst({
|
||||||
|
where: { id: tagId, projectId: project.id },
|
||||||
|
});
|
||||||
|
if (!tag) {
|
||||||
|
return apiErrors.badRequest('Tag not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateData.tagId = tagId;
|
||||||
|
}
|
||||||
|
if (annotationData !== undefined) {
|
||||||
|
if (annotationData === null) {
|
||||||
|
updateData.annotationData = null;
|
||||||
|
} else {
|
||||||
|
if (!Array.isArray(annotationData)) {
|
||||||
|
return apiErrors.badRequest('annotationData must be an array of valid stroke objects');
|
||||||
|
}
|
||||||
|
const validStrokes = validateAnnotationStrokes(annotationData);
|
||||||
|
if (validStrokes === null) {
|
||||||
|
return apiErrors.badRequest('annotationData must be an array of valid stroke objects');
|
||||||
|
}
|
||||||
|
updateData.annotationData = JSON.stringify(validStrokes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isResolved !== undefined) {
|
||||||
|
updateData.isResolved = isResolved;
|
||||||
|
updateData.resolvedAt = isResolved ? new Date() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedComment = await db.comment.update({
|
||||||
|
where: { id: commentId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
|
replies: {
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedCommentData = Object.fromEntries(
|
||||||
|
Object.entries(updatedComment).filter(([key]) => key !== 'guestIdentityId')
|
||||||
|
);
|
||||||
|
const response = successResponse({
|
||||||
|
...updatedCommentData,
|
||||||
|
canEdit: canEditOwnContent,
|
||||||
|
canDelete: canEditOwnContent || isOwner,
|
||||||
|
replies: updatedComment.replies.map((reply) => {
|
||||||
|
const canEditReply = !!userId
|
||||||
|
? reply.authorId === userId
|
||||||
|
: !!guestIdentityId &&
|
||||||
|
!reply.authorId &&
|
||||||
|
!!reply.guestIdentityId &&
|
||||||
|
reply.guestIdentityId === guestIdentityId;
|
||||||
|
const replyData = Object.fromEntries(
|
||||||
|
Object.entries(reply).filter(([key]) => key !== 'guestIdentityId')
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...replyData,
|
||||||
|
canEdit: canEditReply,
|
||||||
|
canDelete: canEditReply || isOwner,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating comment:', error);
|
||||||
|
return apiErrors.internalError('Failed to update comment');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/comments/[commentId]
|
// DELETE /api/comments/[commentId]
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { commentId } = await params;
|
const { commentId } = await params;
|
||||||
|
|
||||||
const comment = await db.comment.findUnique({
|
const comment = await db.comment.findUnique({
|
||||||
where: { id: commentId },
|
where: { id: commentId },
|
||||||
include: {
|
include: {
|
||||||
version: {
|
version: {
|
||||||
include: {
|
include: {
|
||||||
video: {
|
video: {
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
replies: { select: { voiceUrl: true, imageUrl: true } },
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
replies: { select: { voiceUrl: true, imageUrl: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
return apiErrors.notFound('Comment');
|
return apiErrors.notFound('Comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = comment.version.video.project;
|
||||||
|
const userId = session?.user?.id ?? null;
|
||||||
|
const isAuthor = !!userId && comment.authorId === userId;
|
||||||
|
|
||||||
|
// Project owners/admins and workspace admins can delete any comment
|
||||||
|
const access = userId ? await checkProjectAccess(project, userId, { intent: 'manage' }) : null;
|
||||||
|
const isPrivilegedUser = !!access?.canEdit;
|
||||||
|
|
||||||
|
let canDelete = isAuthor || isPrivilegedUser;
|
||||||
|
if (!canDelete && !userId) {
|
||||||
|
const guestIdentityId = getGuestIdentityFromRequest(request);
|
||||||
|
const isGuestAuthor =
|
||||||
|
!comment.authorId &&
|
||||||
|
!!comment.guestIdentityId &&
|
||||||
|
guestIdentityId === comment.guestIdentityId;
|
||||||
|
|
||||||
|
if (isGuestAuthor) {
|
||||||
|
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
||||||
|
const shareAccess = shareSession
|
||||||
|
? await validateShareLinkAccess({
|
||||||
|
token: shareSession.token,
|
||||||
|
projectId: project.id,
|
||||||
|
videoId: comment.version.video.id,
|
||||||
|
requiredPermission: 'COMMENT',
|
||||||
|
passwordVerified: shareSession.passwordVerified,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
hasAccess: false,
|
||||||
|
canComment: false,
|
||||||
|
canDownload: false,
|
||||||
|
allowGuests: false,
|
||||||
|
requiresPassword: false,
|
||||||
|
};
|
||||||
|
const hasGuestAccess =
|
||||||
|
project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
|
||||||
|
if (!hasGuestAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
canDelete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const project = comment.version.video.project;
|
if (!canDelete) {
|
||||||
const userId = session?.user?.id ?? null;
|
return apiErrors.forbidden('You do not have permission to delete this comment');
|
||||||
const isAuthor = !!userId && comment.authorId === userId;
|
}
|
||||||
|
|
||||||
// Project owners/admins and workspace admins can delete any comment
|
// Collect all media URLs to delete from R2 (comment + its replies)
|
||||||
const access = userId ? await checkProjectAccess(project, userId, { intent: 'manage' }) : null;
|
const mediaUrls: string[] = [];
|
||||||
const isPrivilegedUser = !!access?.canEdit;
|
if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl);
|
||||||
|
if (comment.imageUrl) mediaUrls.push(comment.imageUrl);
|
||||||
|
for (const reply of comment.replies) {
|
||||||
|
if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
|
||||||
|
if (reply.imageUrl) mediaUrls.push(reply.imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
let canDelete = isAuthor || isPrivilegedUser;
|
await db.comment.delete({ where: { id: commentId } });
|
||||||
if (!canDelete && !userId) {
|
|
||||||
const guestIdentityId = getGuestIdentityFromRequest(request);
|
|
||||||
const isGuestAuthor = !comment.authorId
|
|
||||||
&& !!comment.guestIdentityId
|
|
||||||
&& guestIdentityId === comment.guestIdentityId;
|
|
||||||
|
|
||||||
if (isGuestAuthor) {
|
// Clean up media files from R2 (best-effort, don't block on failure)
|
||||||
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
const AUDIO_PREFIX = '/api/upload/audio/';
|
||||||
const shareAccess = shareSession
|
const IMAGE_PREFIX = '/api/upload/image/';
|
||||||
? await validateShareLinkAccess({
|
const mediaKeys = [
|
||||||
token: shareSession.token,
|
...new Set(
|
||||||
projectId: project.id,
|
mediaUrls
|
||||||
videoId: comment.version.video.id,
|
.map((url) => {
|
||||||
requiredPermission: 'COMMENT',
|
|
||||||
passwordVerified: shareSession.passwordVerified,
|
|
||||||
})
|
|
||||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
|
||||||
const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
|
|
||||||
if (!hasGuestAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
canDelete = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canDelete) {
|
|
||||||
return apiErrors.forbidden('You do not have permission to delete this comment');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all media URLs to delete from R2 (comment + its replies)
|
|
||||||
const mediaUrls: string[] = [];
|
|
||||||
if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl);
|
|
||||||
if (comment.imageUrl) mediaUrls.push(comment.imageUrl);
|
|
||||||
for (const reply of comment.replies) {
|
|
||||||
if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
|
|
||||||
if (reply.imageUrl) mediaUrls.push(reply.imageUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.comment.delete({ where: { id: commentId } });
|
|
||||||
|
|
||||||
// Clean up media files from R2 (best-effort, don't block on failure)
|
|
||||||
const AUDIO_PREFIX = '/api/upload/audio/';
|
|
||||||
const IMAGE_PREFIX = '/api/upload/image/';
|
|
||||||
const mediaKeys = [...new Set(mediaUrls.map((url) => {
|
|
||||||
// Extract filename using string parsing (safe against ReDoS)
|
// Extract filename using string parsing (safe against ReDoS)
|
||||||
if (url.includes(AUDIO_PREFIX)) {
|
if (url.includes(AUDIO_PREFIX)) {
|
||||||
const filename = url.slice(url.indexOf(AUDIO_PREFIX) + AUDIO_PREFIX.length);
|
const filename = url.slice(url.indexOf(AUDIO_PREFIX) + AUDIO_PREFIX.length);
|
||||||
return filename ? `voice/${filename}` : null;
|
return filename ? `voice/${filename}` : null;
|
||||||
}
|
}
|
||||||
if (url.includes(IMAGE_PREFIX)) {
|
if (url.includes(IMAGE_PREFIX)) {
|
||||||
const filename = url.slice(url.indexOf(IMAGE_PREFIX) + IMAGE_PREFIX.length);
|
const filename = url.slice(url.indexOf(IMAGE_PREFIX) + IMAGE_PREFIX.length);
|
||||||
return filename ? `images/${filename}` : null;
|
return filename ? `images/${filename}` : null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).filter((key): key is string => Boolean(key)))];
|
})
|
||||||
|
.filter((key): key is string => Boolean(key))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
await runWithConcurrency(mediaKeys, CLEANUP_DELETE_CONCURRENCY, async (key) => {
|
await runWithConcurrency(mediaKeys, CLEANUP_DELETE_CONCURRENCY, async (key) => {
|
||||||
try {
|
try {
|
||||||
await r2Client.send(
|
await r2Client.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
Bucket: R2_BUCKET_NAME,
|
Bucket: R2_BUCKET_NAME,
|
||||||
Key: key,
|
Key: key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`Failed to delete media from R2 (key: ${key}):`, err);
|
logError(`Failed to delete media from R2 (key: ${key}):`, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = successResponse({ message: 'Comment deleted' });
|
const response = successResponse({ message: 'Comment deleted' });
|
||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Error deleting comment:', error);
|
logError('Error deleting comment:', error);
|
||||||
return apiErrors.internalError('Failed to delete comment');
|
return apiErrors.internalError('Failed to delete comment');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-22
@@ -35,9 +35,11 @@ export async function POST(request: NextRequest) {
|
|||||||
const legacyScreenshotUrl = body.screenshotUrl?.trim() ?? null;
|
const legacyScreenshotUrl = body.screenshotUrl?.trim() ?? null;
|
||||||
const screenshotUrls = Array.isArray(body.screenshotUrls)
|
const screenshotUrls = Array.isArray(body.screenshotUrls)
|
||||||
? body.screenshotUrls
|
? body.screenshotUrls
|
||||||
.map((url) => (typeof url === 'string' ? url.trim() : ''))
|
.map((url) => (typeof url === 'string' ? url.trim() : ''))
|
||||||
.filter((url) => !!url)
|
.filter((url) => !!url)
|
||||||
: (legacyScreenshotUrl ? [legacyScreenshotUrl] : []);
|
: legacyScreenshotUrl
|
||||||
|
? [legacyScreenshotUrl]
|
||||||
|
: [];
|
||||||
|
|
||||||
if (type !== FeedbackEntryType.FEEDBACK && type !== FeedbackEntryType.REVIEW) {
|
if (type !== FeedbackEntryType.FEEDBACK && type !== FeedbackEntryType.REVIEW) {
|
||||||
return apiErrors.badRequest('Invalid entry type');
|
return apiErrors.badRequest('Invalid entry type');
|
||||||
@@ -70,7 +72,11 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === FeedbackEntryType.REVIEW) {
|
if (type === FeedbackEntryType.REVIEW) {
|
||||||
if (!Number.isInteger(body.rating) || (body.rating as number) < 1 || (body.rating as number) > 5) {
|
if (
|
||||||
|
!Number.isInteger(body.rating) ||
|
||||||
|
(body.rating as number) < 1 ||
|
||||||
|
(body.rating as number) > 5
|
||||||
|
) {
|
||||||
return apiErrors.badRequest('Review rating must be between 1 and 5');
|
return apiErrors.badRequest('Review rating must be between 1 and 5');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,17 +89,19 @@ export async function POST(request: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
type,
|
type,
|
||||||
category: type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
|
category:
|
||||||
|
type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
|
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
|
||||||
rating: type === FeedbackEntryType.REVIEW ? body.rating : null,
|
rating: type === FeedbackEntryType.REVIEW ? body.rating : null,
|
||||||
allowShowcase: type === FeedbackEntryType.REVIEW ? !!body.allowShowcase : false,
|
allowShowcase: type === FeedbackEntryType.REVIEW ? !!body.allowShowcase : false,
|
||||||
screenshots: type === FeedbackEntryType.FEEDBACK
|
screenshots:
|
||||||
? {
|
type === FeedbackEntryType.FEEDBACK
|
||||||
create: screenshotUrls.map((url) => ({ url })),
|
? {
|
||||||
}
|
create: screenshotUrls.map((url) => ({ url })),
|
||||||
: undefined,
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -112,7 +120,8 @@ export async function POST(request: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
type,
|
type,
|
||||||
category: type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
|
category:
|
||||||
|
type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
|
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
|
||||||
@@ -128,19 +137,25 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (usedLegacyCreatePath && type === FeedbackEntryType.FEEDBACK && screenshotUrls.length > 1) {
|
if (usedLegacyCreatePath && type === FeedbackEntryType.FEEDBACK && screenshotUrls.length > 1) {
|
||||||
const screenshotDelegate = (db as unknown as {
|
const screenshotDelegate = (
|
||||||
userFeedbackScreenshot?: {
|
db as unknown as {
|
||||||
createMany: (args: { data: Array<{ feedbackId: string; url: string }> }) => Promise<unknown>;
|
userFeedbackScreenshot?: {
|
||||||
};
|
createMany: (args: {
|
||||||
}).userFeedbackScreenshot;
|
data: Array<{ feedbackId: string; url: string }>;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).userFeedbackScreenshot;
|
||||||
|
|
||||||
if (screenshotDelegate) {
|
if (screenshotDelegate) {
|
||||||
await screenshotDelegate.createMany({
|
await screenshotDelegate
|
||||||
data: screenshotUrls.map((url) => ({
|
.createMany({
|
||||||
feedbackId: entry.id,
|
data: screenshotUrls.map((url) => ({
|
||||||
url,
|
feedbackId: entry.id,
|
||||||
})),
|
url,
|
||||||
}).catch(() => undefined);
|
})),
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
|||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead
|
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
|
||||||
|
|
||||||
// POST /api/feedback/upload
|
// POST /api/feedback/upload
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -4,24 +4,24 @@ import { apiErrors, successResponse } from '@/lib/api-response';
|
|||||||
import { checkRateLimit, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
|
import { checkRateLimit, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = RATE_LIMIT_CONFIGS['onboarding-complete'];
|
const cfg = RATE_LIMIT_CONFIGS['onboarding-complete'];
|
||||||
const rl = await checkRateLimit(session.user.id, 'onboarding-complete', cfg);
|
const rl = await checkRateLimit(session.user.id, 'onboarding-complete', cfg);
|
||||||
if (!rl.allowed) {
|
if (!rl.allowed) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
|
||||||
JSON.stringify({ error: 'Too many requests. Please try again later.' }),
|
status: 429,
|
||||||
{ status: 429, headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) } }
|
headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) },
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.user.update({
|
|
||||||
where: { id: session.user.id },
|
|
||||||
data: { onboardingCompletedAt: new Date() },
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return successResponse({ completed: true });
|
await db.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { onboardingCompletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return successResponse({ completed: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,114 +10,114 @@ type RouteParams = { params: Promise<{ projectId: string; memberId: string }> };
|
|||||||
|
|
||||||
// PATCH /api/projects/[projectId]/members/[memberId] - Update member role
|
// PATCH /api/projects/[projectId]/members/[memberId] - Update member role
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'manage-member');
|
const limited = await rateLimit(request, 'manage-member');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, memberId } = await params;
|
const { projectId, memberId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
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 access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
|
||||||
const isOwner = project.ownerId === session.user.id;
|
|
||||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
|
||||||
|
|
||||||
if (!access.canEdit || (!isOwner && !isAdmin)) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { role } = body;
|
|
||||||
|
|
||||||
const validRoles = ['ADMIN', 'COMMENTATOR'];
|
|
||||||
if (!validRoles.includes(role)) {
|
|
||||||
return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const member = await db.projectMember.findFirst({
|
|
||||||
where: { id: memberId, projectId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!member) {
|
|
||||||
return apiErrors.notFound('Member');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedMember = await db.projectMember.update({
|
|
||||||
where: { id: member.id },
|
|
||||||
data: { role: role as ProjectMemberRole },
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, image: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(updatedMember);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating member role:', error);
|
|
||||||
return apiErrors.internalError('Failed to update member role');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: { members: { where: { userId: session.user.id } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||||
|
const isOwner = project.ownerId === session.user.id;
|
||||||
|
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||||
|
|
||||||
|
if (!access.canEdit || (!isOwner && !isAdmin)) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { role } = body;
|
||||||
|
|
||||||
|
const validRoles = ['ADMIN', 'COMMENTATOR'];
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await db.projectMember.findFirst({
|
||||||
|
where: { id: memberId, projectId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return apiErrors.notFound('Member');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMember = await db.projectMember.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { role: role as ProjectMemberRole },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, image: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(updatedMember);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating member role:', error);
|
||||||
|
return apiErrors.internalError('Failed to update member role');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/projects/[projectId]/members/[memberId] - Remove member
|
// DELETE /api/projects/[projectId]/members/[memberId] - Remove member
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'manage-member');
|
const limited = await rateLimit(request, 'manage-member');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, memberId } = await params;
|
const { projectId, memberId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
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 access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
|
||||||
const isOwner = project.ownerId === session.user.id;
|
|
||||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
|
||||||
|
|
||||||
const memberToRemove = await db.projectMember.findFirst({
|
|
||||||
where: { id: memberId, projectId },
|
|
||||||
select: { id: true, userId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!memberToRemove) {
|
|
||||||
return apiErrors.notFound('Member');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSelf = memberToRemove.userId === session.user.id;
|
|
||||||
|
|
||||||
if ((!access.canEdit || (!isOwner && !isAdmin)) && !isSelf) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.projectMember.delete({ where: { id: memberToRemove.id } });
|
|
||||||
|
|
||||||
const response = successResponse({ message: 'Member removed' });
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error removing member:', error);
|
|
||||||
return apiErrors.internalError('Failed to remove member');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: { members: { where: { userId: session.user.id } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||||
|
const isOwner = project.ownerId === session.user.id;
|
||||||
|
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||||
|
|
||||||
|
const memberToRemove = await db.projectMember.findFirst({
|
||||||
|
where: { id: memberId, projectId },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!memberToRemove) {
|
||||||
|
return apiErrors.notFound('Member');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf = memberToRemove.userId === session.user.id;
|
||||||
|
|
||||||
|
if ((!access.canEdit || (!isOwner && !isAdmin)) && !isSelf) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.projectMember.delete({ where: { id: memberToRemove.id } });
|
||||||
|
|
||||||
|
const response = successResponse({ message: 'Member removed' });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error removing member:', error);
|
||||||
|
return apiErrors.internalError('Failed to remove member');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { db } from '@/lib/db';
|
|||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { InvitationRole, ProjectMemberRole } from '@prisma/client';
|
import { InvitationRole, ProjectMemberRole } from '@prisma/client';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations';
|
import {
|
||||||
|
buildInvitationUrl,
|
||||||
|
createOrRefreshInvitation,
|
||||||
|
sendInvitationEmail,
|
||||||
|
} from '@/lib/invitations';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
@@ -11,169 +15,169 @@ type RouteParams = { params: Promise<{ projectId: string }> };
|
|||||||
|
|
||||||
// GET /api/projects/[projectId]/members - List members
|
// GET /api/projects/[projectId]/members - List members
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
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 access = await checkProjectAccess(project, session.user.id);
|
|
||||||
const isOwner = project.ownerId === session.user.id;
|
|
||||||
const isMember = project.members.length > 0;
|
|
||||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
|
||||||
|
|
||||||
if (!access.hasAccess || (!isOwner && !isMember)) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 response = successResponse({ members, owner, pendingInvitations });
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching project members:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch members');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: {
|
||||||
|
members: { where: { userId: session.user.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(project, session.user.id);
|
||||||
|
const isOwner = project.ownerId === session.user.id;
|
||||||
|
const isMember = project.members.length > 0;
|
||||||
|
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||||
|
|
||||||
|
if (!access.hasAccess || (!isOwner && !isMember)) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 response = successResponse({ members, owner, pendingInvitations });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching project members:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch members');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/projects/[projectId]/members - Invite a member
|
// POST /api/projects/[projectId]/members - Invite a member
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'invite-member');
|
const limited = await rateLimit(request, 'invite-member');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is owner or admin
|
|
||||||
const project = await db.project.findUnique({
|
|
||||||
where: { id: projectId },
|
|
||||||
include: { members: { where: { userId: session.user.id } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
return apiErrors.notFound('Project');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
|
||||||
const isOwner = project.ownerId === session.user.id;
|
|
||||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
|
||||||
|
|
||||||
if (!access.canEdit || (!isOwner && !isAdmin)) {
|
|
||||||
return apiErrors.forbidden('Only project owners and admins can invite members');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { email, role } = body;
|
|
||||||
|
|
||||||
if (!email || typeof email !== 'string') {
|
|
||||||
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';
|
|
||||||
|
|
||||||
// If this email belongs to an existing user, validate owner/member conflicts.
|
|
||||||
const userToInvite = await db.user.findUnique({
|
|
||||||
where: { email: normalizedEmail },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userToInvite?.id === project.ownerId) {
|
|
||||||
return apiErrors.badRequest('Cannot invite the project owner as a member');
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invitation = await createOrRefreshInvitation({
|
|
||||||
email: normalizedEmail,
|
|
||||||
scope: 'PROJECT',
|
|
||||||
role: memberRole as InvitationRole,
|
|
||||||
invitedById: session.user.id,
|
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const invitationUrl = buildInvitationUrl(invitation.token);
|
|
||||||
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) {
|
|
||||||
logError('Error inviting project member:', error);
|
|
||||||
return apiErrors.internalError('Failed to invite member');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is owner or admin
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: { members: { where: { userId: session.user.id } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||||
|
const isOwner = project.ownerId === session.user.id;
|
||||||
|
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||||
|
|
||||||
|
if (!access.canEdit || (!isOwner && !isAdmin)) {
|
||||||
|
return apiErrors.forbidden('Only project owners and admins can invite members');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { email, role } = body;
|
||||||
|
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
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';
|
||||||
|
|
||||||
|
// If this email belongs to an existing user, validate owner/member conflicts.
|
||||||
|
const userToInvite = await db.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userToInvite?.id === project.ownerId) {
|
||||||
|
return apiErrors.badRequest('Cannot invite the project owner as a member');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitation = await createOrRefreshInvitation({
|
||||||
|
email: normalizedEmail,
|
||||||
|
scope: 'PROJECT',
|
||||||
|
role: memberRole as InvitationRole,
|
||||||
|
invitedById: session.user.id,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invitationUrl = buildInvitationUrl(invitation.token);
|
||||||
|
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) {
|
||||||
|
logError('Error inviting project member:', error);
|
||||||
|
return apiErrors.internalError('Failed to invite member');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,230 +12,230 @@ type RouteParams = { params: Promise<{ projectId: string }> };
|
|||||||
|
|
||||||
// GET /api/projects/[projectId] - Get a single project
|
// GET /api/projects/[projectId] - Get a single project
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const MAX_LIMIT = 100;
|
const MAX_LIMIT = 100;
|
||||||
const MAX_OFFSET = 10000;
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
// Parse pagination params
|
// Parse pagination params
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const limitParam = searchParams.get('limit');
|
const limitParam = searchParams.get('limit');
|
||||||
const offsetParam = searchParams.get('offset');
|
const offsetParam = searchParams.get('offset');
|
||||||
|
|
||||||
const limitRaw = limitParam === null ? 20 : Number(limitParam);
|
const limitRaw = limitParam === null ? 20 : Number(limitParam);
|
||||||
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
||||||
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
||||||
}
|
|
||||||
|
|
||||||
const offset = offsetParam === null ? 0 : Number(offsetParam);
|
|
||||||
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
|
|
||||||
return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = limitRaw;
|
|
||||||
|
|
||||||
const project = await db.project.findUnique({
|
|
||||||
where: { id: projectId },
|
|
||||||
include: {
|
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
|
||||||
members: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, image: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
videos: {
|
|
||||||
orderBy: { position: 'asc' },
|
|
||||||
skip: offset,
|
|
||||||
take: limit,
|
|
||||||
include: {
|
|
||||||
versions: {
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: { versionNumber: 'desc' },
|
|
||||||
take: 1,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
thumbnailUrl: true,
|
|
||||||
duration: true,
|
|
||||||
versionNumber: true,
|
|
||||||
_count: { select: { comments: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { select: { versions: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { select: { videos: true, members: true, shareLinks: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
return apiErrors.notFound('Project');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(project, session?.user?.id);
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse(project);
|
|
||||||
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching project:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch project');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offset = offsetParam === null ? 0 : Number(offsetParam);
|
||||||
|
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
|
||||||
|
return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = limitRaw;
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, image: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
videos: {
|
||||||
|
orderBy: { position: 'asc' },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { versionNumber: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
duration: true,
|
||||||
|
versionNumber: true,
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { videos: true, members: true, shareLinks: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(project, session?.user?.id);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse(project);
|
||||||
|
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching project:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch project');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/projects/[projectId] - Update a project
|
// PATCH /api/projects/[projectId] - Update a project
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const projectAccessTarget = await db.project.findUnique({
|
|
||||||
where: { id: projectId },
|
|
||||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
|
||||||
});
|
|
||||||
const access = projectAccessTarget
|
|
||||||
? await checkProjectAccess(projectAccessTarget, session.user.id, { intent: 'manage' })
|
|
||||||
: null;
|
|
||||||
if (!access?.canEdit) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, description, visibility } = body;
|
|
||||||
|
|
||||||
if (name !== undefined) {
|
|
||||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
||||||
return apiErrors.badRequest('Name must be a non-empty string');
|
|
||||||
}
|
|
||||||
if (name.trim().length > 100) {
|
|
||||||
return apiErrors.badRequest('Name must be 100 characters or fewer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description !== undefined && description !== null) {
|
|
||||||
if (typeof description !== 'string') {
|
|
||||||
return apiErrors.badRequest('Description must be a string');
|
|
||||||
}
|
|
||||||
if (description.trim().length > 1000) {
|
|
||||||
return apiErrors.badRequest('Description must be 1000 characters or fewer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_VISIBILITY = ['PRIVATE', 'INVITE', 'PUBLIC'] as const;
|
|
||||||
if (visibility !== undefined && !VALID_VISIBILITY.includes(visibility)) {
|
|
||||||
return apiErrors.badRequest('Invalid visibility value');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
|
||||||
if (name !== undefined) updateData.name = name.trim();
|
|
||||||
if (description !== undefined) updateData.description = description?.trim() || null;
|
|
||||||
if (visibility !== undefined) updateData.visibility = visibility;
|
|
||||||
|
|
||||||
const project = await db.project.update({
|
|
||||||
where: { id: projectId },
|
|
||||||
data: updateData,
|
|
||||||
include: {
|
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
|
||||||
_count: { select: { videos: true, members: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(project);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating project:', error);
|
|
||||||
return apiErrors.internalError('Failed to update project');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectAccessTarget = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||||
|
});
|
||||||
|
const access = projectAccessTarget
|
||||||
|
? await checkProjectAccess(projectAccessTarget, session.user.id, { intent: 'manage' })
|
||||||
|
: null;
|
||||||
|
if (!access?.canEdit) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description, visibility } = body;
|
||||||
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||||
|
return apiErrors.badRequest('Name must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (name.trim().length > 100) {
|
||||||
|
return apiErrors.badRequest('Name must be 100 characters or fewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (description !== undefined && description !== null) {
|
||||||
|
if (typeof description !== 'string') {
|
||||||
|
return apiErrors.badRequest('Description must be a string');
|
||||||
|
}
|
||||||
|
if (description.trim().length > 1000) {
|
||||||
|
return apiErrors.badRequest('Description must be 1000 characters or fewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_VISIBILITY = ['PRIVATE', 'INVITE', 'PUBLIC'] as const;
|
||||||
|
if (visibility !== undefined && !VALID_VISIBILITY.includes(visibility)) {
|
||||||
|
return apiErrors.badRequest('Invalid visibility value');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (name !== undefined) updateData.name = name.trim();
|
||||||
|
if (description !== undefined) updateData.description = description?.trim() || null;
|
||||||
|
if (visibility !== undefined) updateData.visibility = visibility;
|
||||||
|
|
||||||
|
const project = await db.project.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { videos: true, members: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(project);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating project:', error);
|
||||||
|
return apiErrors.internalError('Failed to update project');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/projects/[projectId] - Delete a project
|
// DELETE /api/projects/[projectId] - Delete a project
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
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: 'delete' });
|
|
||||||
if (!access.canDelete) {
|
|
||||||
return apiErrors.forbidden('Only the project owner can delete it');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [projectVersionRefs, projectAssetRefs, mediaUrls] = await Promise.all([
|
|
||||||
db.videoVersion.findMany({
|
|
||||||
where: {
|
|
||||||
video: { projectId },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
providerId: true,
|
|
||||||
videoId: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
db.videoAsset.findMany({
|
|
||||||
where: {
|
|
||||||
video: { projectId },
|
|
||||||
provider: 'BUNNY',
|
|
||||||
providerVideoId: { not: null },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
providerVideoId: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
collectProjectMediaUrls(projectId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const bunnyRefs = [
|
|
||||||
...projectVersionRefs,
|
|
||||||
...projectAssetRefs.map((asset) => ({
|
|
||||||
providerId: 'bunny',
|
|
||||||
videoId: asset.providerVideoId as string,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
await db.project.delete({ where: { id: projectId } });
|
|
||||||
|
|
||||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
|
||||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
|
||||||
deleteMediaFilesBestEffort(mediaUrls),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const cleanupInput = {
|
|
||||||
bunny: bunnyCleanupResult,
|
|
||||||
r2: r2CleanupResult,
|
|
||||||
};
|
|
||||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
|
||||||
if (cleanupWarnings) {
|
|
||||||
logCleanupWarnings({ entityType: 'project', entityId: projectId }, cleanupInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse({
|
|
||||||
message: 'Project deleted',
|
|
||||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
|
||||||
});
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error deleting project:', error);
|
|
||||||
return apiErrors.internalError('Failed to delete project');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: 'delete' });
|
||||||
|
if (!access.canDelete) {
|
||||||
|
return apiErrors.forbidden('Only the project owner can delete it');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [projectVersionRefs, projectAssetRefs, mediaUrls] = await Promise.all([
|
||||||
|
db.videoVersion.findMany({
|
||||||
|
where: {
|
||||||
|
video: { projectId },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
providerId: true,
|
||||||
|
videoId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: {
|
||||||
|
video: { projectId },
|
||||||
|
provider: 'BUNNY',
|
||||||
|
providerVideoId: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
providerVideoId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
collectProjectMediaUrls(projectId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bunnyRefs = [
|
||||||
|
...projectVersionRefs,
|
||||||
|
...projectAssetRefs.map((asset) => ({
|
||||||
|
providerId: 'bunny',
|
||||||
|
videoId: asset.providerVideoId as string,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.project.delete({ where: { id: projectId } });
|
||||||
|
|
||||||
|
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||||
|
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||||
|
deleteMediaFilesBestEffort(mediaUrls),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cleanupInput = {
|
||||||
|
bunny: bunnyCleanupResult,
|
||||||
|
r2: r2CleanupResult,
|
||||||
|
};
|
||||||
|
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||||
|
if (cleanupWarnings) {
|
||||||
|
logCleanupWarnings({ entityType: 'project', entityId: projectId }, cleanupInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
message: 'Project deleted',
|
||||||
|
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error deleting project:', error);
|
||||||
|
return apiErrors.internalError('Failed to delete project');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,114 +9,114 @@ type RouteParams = { params: Promise<{ projectId: string; tagId: string }> };
|
|||||||
|
|
||||||
// PATCH /api/projects/[projectId]/tags/[tagId] - Update a tag
|
// PATCH /api/projects/[projectId]/tags/[tagId] - Update a tag
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, tagId } = await params;
|
const { projectId, tagId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify tag belongs to this project
|
|
||||||
const existingTag = await db.commentTag.findUnique({
|
|
||||||
where: { id: tagId },
|
|
||||||
});
|
|
||||||
if (!existingTag || existingTag.projectId !== projectId) {
|
|
||||||
return apiErrors.notFound('Tag');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, color, position } = body;
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
|
||||||
if (name !== undefined) {
|
|
||||||
if (!name.trim()) {
|
|
||||||
return apiErrors.badRequest('Name cannot be empty');
|
|
||||||
}
|
|
||||||
updateData.name = name.trim();
|
|
||||||
}
|
|
||||||
if (color !== undefined) {
|
|
||||||
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
|
||||||
return apiErrors.badRequest('Invalid color format');
|
|
||||||
}
|
|
||||||
updateData.color = color.toUpperCase();
|
|
||||||
}
|
|
||||||
if (position !== undefined) {
|
|
||||||
updateData.position = position;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tag = await db.commentTag.update({
|
|
||||||
where: { id: tagId },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(tag);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating tag:', error);
|
|
||||||
if ((error as { code?: string }).code === 'P2002') {
|
|
||||||
return apiErrors.conflict('Tag name already exists');
|
|
||||||
}
|
|
||||||
return apiErrors.internalError('Failed to update tag');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tag belongs to this project
|
||||||
|
const existingTag = await db.commentTag.findUnique({
|
||||||
|
where: { id: tagId },
|
||||||
|
});
|
||||||
|
if (!existingTag || existingTag.projectId !== projectId) {
|
||||||
|
return apiErrors.notFound('Tag');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, color, position } = body;
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (name !== undefined) {
|
||||||
|
if (!name.trim()) {
|
||||||
|
return apiErrors.badRequest('Name cannot be empty');
|
||||||
|
}
|
||||||
|
updateData.name = name.trim();
|
||||||
|
}
|
||||||
|
if (color !== undefined) {
|
||||||
|
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||||
|
return apiErrors.badRequest('Invalid color format');
|
||||||
|
}
|
||||||
|
updateData.color = color.toUpperCase();
|
||||||
|
}
|
||||||
|
if (position !== undefined) {
|
||||||
|
updateData.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = await db.commentTag.update({
|
||||||
|
where: { id: tagId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(tag);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating tag:', error);
|
||||||
|
if ((error as { code?: string }).code === 'P2002') {
|
||||||
|
return apiErrors.conflict('Tag name already exists');
|
||||||
|
}
|
||||||
|
return apiErrors.internalError('Failed to update tag');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/projects/[projectId]/tags/[tagId] - Delete a tag
|
// DELETE /api/projects/[projectId]/tags/[tagId] - Delete a tag
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, tagId } = await params;
|
const { projectId, tagId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify tag belongs to this project
|
|
||||||
const existingTag = await db.commentTag.findUnique({
|
|
||||||
where: { id: tagId },
|
|
||||||
});
|
|
||||||
if (!existingTag || existingTag.projectId !== projectId) {
|
|
||||||
return apiErrors.notFound('Tag');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.commentTag.delete({ where: { id: tagId } });
|
|
||||||
|
|
||||||
const response = successResponse({ message: 'Tag deleted' });
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error deleting tag:', error);
|
|
||||||
return apiErrors.internalError('Failed to delete tag');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tag belongs to this project
|
||||||
|
const existingTag = await db.commentTag.findUnique({
|
||||||
|
where: { id: tagId },
|
||||||
|
});
|
||||||
|
if (!existingTag || existingTag.projectId !== projectId) {
|
||||||
|
return apiErrors.notFound('Tag');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.commentTag.delete({ where: { id: tagId } });
|
||||||
|
|
||||||
|
const response = successResponse({ message: 'Tag deleted' });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error deleting tag:', error);
|
||||||
|
return apiErrors.internalError('Failed to delete tag');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,125 +11,131 @@ type RouteParams = { params: Promise<{ projectId: string }> };
|
|||||||
|
|
||||||
// GET /api/projects/[projectId]/tags - Get all tags for a project
|
// GET /api/projects/[projectId]/tags - Get all tags for a project
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const videoId = request.nextUrl.searchParams.get('videoId');
|
const videoId = request.nextUrl.searchParams.get('videoId');
|
||||||
|
|
||||||
const project = await db.project.findUnique({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||||
|
});
|
||||||
|
if (!project) return apiErrors.notFound('Project');
|
||||||
|
|
||||||
|
if (session?.user?.id) {
|
||||||
|
const access = await checkProjectAccess(project, session.user.id);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let hasGuestAccess = project.visibility === 'PUBLIC';
|
||||||
|
if (!hasGuestAccess && videoId) {
|
||||||
|
const video = await db.video.findFirst({
|
||||||
|
where: { id: videoId, projectId },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!project) return apiErrors.notFound('Project');
|
if (video) {
|
||||||
|
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||||
if (session?.user?.id) {
|
const shareAccess = shareSession
|
||||||
const access = await checkProjectAccess(project, session.user.id);
|
? await validateShareLinkAccess({
|
||||||
if (!access.hasAccess) {
|
token: shareSession.token,
|
||||||
return apiErrors.notFound('Project');
|
projectId,
|
||||||
}
|
videoId: video.id,
|
||||||
} else {
|
requiredPermission: 'COMMENT',
|
||||||
let hasGuestAccess = project.visibility === 'PUBLIC';
|
passwordVerified: shareSession.passwordVerified,
|
||||||
if (!hasGuestAccess && videoId) {
|
})
|
||||||
const video = await db.video.findFirst({
|
: {
|
||||||
where: { id: videoId, projectId },
|
hasAccess: false,
|
||||||
select: { id: true },
|
canComment: false,
|
||||||
});
|
canDownload: false,
|
||||||
if (video) {
|
allowGuests: false,
|
||||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
requiresPassword: false,
|
||||||
const shareAccess = shareSession
|
};
|
||||||
? await validateShareLinkAccess({
|
hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests;
|
||||||
token: shareSession.token,
|
|
||||||
projectId,
|
|
||||||
videoId: video.id,
|
|
||||||
requiredPermission: 'COMMENT',
|
|
||||||
passwordVerified: shareSession.passwordVerified,
|
|
||||||
})
|
|
||||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
|
||||||
hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasGuestAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tags = await db.commentTag.findMany({
|
if (!hasGuestAccess) {
|
||||||
where: { projectId },
|
return apiErrors.forbidden('Access denied');
|
||||||
orderBy: { position: 'asc' },
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(tags);
|
|
||||||
const cacheControl = session?.user?.id
|
|
||||||
? 'private, max-age=120, stale-while-revalidate=300'
|
|
||||||
: 'private, no-cache';
|
|
||||||
return withCacheControl(response, cacheControl);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching tags:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch tags');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tags = await db.commentTag.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { position: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(tags);
|
||||||
|
const cacheControl = session?.user?.id
|
||||||
|
? 'private, max-age=120, stale-while-revalidate=300'
|
||||||
|
: 'private, no-cache';
|
||||||
|
return withCacheControl(response, cacheControl);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching tags:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch tags');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/projects/[projectId]/tags - Create a new tag
|
// POST /api/projects/[projectId]/tags - Create a new tag
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
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 body = await request.json();
|
|
||||||
const { name, color } = body;
|
|
||||||
|
|
||||||
if (!name?.trim() || !color?.trim()) {
|
|
||||||
return apiErrors.badRequest('Name and color are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hex color validation
|
|
||||||
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
|
||||||
return apiErrors.badRequest('Invalid color format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get max position
|
|
||||||
const maxPos = await db.commentTag.aggregate({
|
|
||||||
where: { projectId },
|
|
||||||
_max: { position: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const tag = await db.commentTag.create({
|
|
||||||
data: {
|
|
||||||
name: name.trim(),
|
|
||||||
color: color.toUpperCase(),
|
|
||||||
position: (maxPos._max.position ?? -1) + 1,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(tag, 201);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error creating tag:', error);
|
|
||||||
if ((error as { code?: string }).code === 'P2002') {
|
|
||||||
return apiErrors.conflict('Tag name already exists');
|
|
||||||
}
|
|
||||||
return apiErrors.internalError('Failed to create tag');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 body = await request.json();
|
||||||
|
const { name, color } = body;
|
||||||
|
|
||||||
|
if (!name?.trim() || !color?.trim()) {
|
||||||
|
return apiErrors.badRequest('Name and color are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex color validation
|
||||||
|
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||||
|
return apiErrors.badRequest('Invalid color format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get max position
|
||||||
|
const maxPos = await db.commentTag.aggregate({
|
||||||
|
where: { projectId },
|
||||||
|
_max: { position: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag = await db.commentTag.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
color: color.toUpperCase(),
|
||||||
|
position: (maxPos._max.position ?? -1) + 1,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(tag, 201);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error creating tag:', error);
|
||||||
|
if ((error as { code?: string }).code === 'P2002') {
|
||||||
|
return apiErrors.conflict('Tag name already exists');
|
||||||
|
}
|
||||||
|
return apiErrors.internalError('Failed to create tag');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,272 +13,276 @@ type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
|||||||
|
|
||||||
// GET /api/projects/[projectId]/videos/[videoId]
|
// GET /api/projects/[projectId]/videos/[videoId]
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, videoId } = await params;
|
const { projectId, videoId } = await params;
|
||||||
|
|
||||||
// Parse query params for pagination and options
|
// Parse query params for pagination and options
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const includeComments = searchParams.get('includeComments') !== 'false';
|
const includeComments = searchParams.get('includeComments') !== 'false';
|
||||||
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
|
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
|
||||||
const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
|
const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
|
||||||
const includeReplies = searchParams.get('includeReplies') === 'true';
|
const includeReplies = searchParams.get('includeReplies') === 'true';
|
||||||
|
|
||||||
const video = await db.video.findFirst({
|
const video = await db.video.findFirst({
|
||||||
where: { id: videoId, projectId },
|
where: { id: videoId, projectId },
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
versions: {
|
versions: {
|
||||||
orderBy: { versionNumber: 'desc' },
|
orderBy: { versionNumber: 'desc' },
|
||||||
...(includeComments ? {
|
...(includeComments
|
||||||
include: {
|
? {
|
||||||
comments: {
|
include: {
|
||||||
orderBy: { timestamp: 'asc' },
|
comments: {
|
||||||
skip: commentOffset,
|
orderBy: { timestamp: 'asc' },
|
||||||
take: commentLimit,
|
skip: commentOffset,
|
||||||
select: {
|
take: commentLimit,
|
||||||
id: true,
|
select: {
|
||||||
content: true,
|
id: true,
|
||||||
timestamp: true,
|
content: true,
|
||||||
timestampEnd: true,
|
timestamp: true,
|
||||||
createdAt: true,
|
timestampEnd: true,
|
||||||
updatedAt: true,
|
createdAt: true,
|
||||||
isResolved: true,
|
updatedAt: true,
|
||||||
resolvedAt: true,
|
isResolved: true,
|
||||||
voiceUrl: true,
|
resolvedAt: true,
|
||||||
voiceDuration: true,
|
voiceUrl: true,
|
||||||
imageUrl: true,
|
voiceDuration: true,
|
||||||
annotationData: true,
|
imageUrl: true,
|
||||||
parentId: true,
|
annotationData: true,
|
||||||
authorId: true,
|
parentId: true,
|
||||||
tagId: true,
|
authorId: true,
|
||||||
versionId: true,
|
tagId: true,
|
||||||
guestName: true,
|
versionId: true,
|
||||||
// guestEmail excluded for privacy
|
guestName: true,
|
||||||
author: { select: { id: true, name: true, image: true } },
|
// guestEmail excluded for privacy
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
...(includeReplies ? {
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
...(includeReplies
|
||||||
orderBy: { createdAt: 'asc' },
|
? {
|
||||||
select: {
|
replies: {
|
||||||
id: true,
|
orderBy: { createdAt: 'asc' },
|
||||||
content: true,
|
select: {
|
||||||
timestamp: true,
|
id: true,
|
||||||
timestampEnd: true,
|
content: true,
|
||||||
createdAt: true,
|
timestamp: true,
|
||||||
updatedAt: true,
|
timestampEnd: true,
|
||||||
isResolved: true,
|
createdAt: true,
|
||||||
resolvedAt: true,
|
updatedAt: true,
|
||||||
voiceUrl: true,
|
isResolved: true,
|
||||||
voiceDuration: true,
|
resolvedAt: true,
|
||||||
imageUrl: true,
|
voiceUrl: true,
|
||||||
annotationData: true,
|
voiceDuration: true,
|
||||||
parentId: true,
|
imageUrl: true,
|
||||||
authorId: true,
|
annotationData: true,
|
||||||
tagId: true,
|
parentId: true,
|
||||||
versionId: true,
|
authorId: true,
|
||||||
guestName: true,
|
tagId: true,
|
||||||
// guestEmail excluded for privacy
|
versionId: true,
|
||||||
author: { select: { id: true, name: true, image: true } },
|
guestName: true,
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
// guestEmail excluded for privacy
|
||||||
},
|
author: { select: { id: true, name: true, image: true } },
|
||||||
},
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
} : {}),
|
},
|
||||||
},
|
|
||||||
where: { parentId: null },
|
|
||||||
},
|
},
|
||||||
_count: { select: { comments: true } },
|
}
|
||||||
},
|
: {}),
|
||||||
} : {
|
},
|
||||||
select: {
|
where: { parentId: null },
|
||||||
id: true,
|
},
|
||||||
thumbnailUrl: true,
|
_count: { select: { comments: true } },
|
||||||
duration: true,
|
|
||||||
versionNumber: true,
|
|
||||||
versionLabel: true,
|
|
||||||
providerId: true,
|
|
||||||
videoId: true,
|
|
||||||
originalUrl: true,
|
|
||||||
title: true,
|
|
||||||
isActive: true,
|
|
||||||
_count: { select: { comments: true } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
});
|
: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
duration: true,
|
||||||
|
versionNumber: true,
|
||||||
|
versionLabel: true,
|
||||||
|
providerId: true,
|
||||||
|
videoId: true,
|
||||||
|
originalUrl: true,
|
||||||
|
title: true,
|
||||||
|
isActive: true,
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return apiErrors.notFound('Video');
|
return apiErrors.notFound('Video');
|
||||||
}
|
|
||||||
|
|
||||||
// Check access including workspace membership
|
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
|
||||||
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse({
|
|
||||||
...video,
|
|
||||||
isAuthenticated: !!session?.user?.id,
|
|
||||||
currentUserId: session?.user?.id || null,
|
|
||||||
currentUserName: session?.user?.name || null,
|
|
||||||
canDownload: access.hasAccess,
|
|
||||||
canManageTags: access.canEdit,
|
|
||||||
canResolveComments: access.canEdit,
|
|
||||||
canRequestApproval: access.canEdit,
|
|
||||||
canShareVideo: access.canEdit,
|
|
||||||
canUploadAssets: access.hasAccess,
|
|
||||||
canDownloadAssets: !!session?.user?.id && access.hasAccess,
|
|
||||||
});
|
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-cache');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching video:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch video');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check access including workspace membership
|
||||||
|
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||||
|
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
...video,
|
||||||
|
isAuthenticated: !!session?.user?.id,
|
||||||
|
currentUserId: session?.user?.id || null,
|
||||||
|
currentUserName: session?.user?.name || null,
|
||||||
|
canDownload: access.hasAccess,
|
||||||
|
canManageTags: access.canEdit,
|
||||||
|
canResolveComments: access.canEdit,
|
||||||
|
canRequestApproval: access.canEdit,
|
||||||
|
canShareVideo: access.canEdit,
|
||||||
|
canUploadAssets: access.hasAccess,
|
||||||
|
canDownloadAssets: !!session?.user?.id && access.hasAccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, no-cache');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching video:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch video');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/projects/[projectId]/videos/[videoId]
|
// PATCH /api/projects/[projectId]/videos/[videoId]
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, videoId } = await params;
|
const { projectId, videoId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const video = await db.video.findFirst({
|
|
||||||
where: { id: videoId, projectId },
|
|
||||||
include: {
|
|
||||||
project: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!video) {
|
|
||||||
return apiErrors.notFound('Video');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
|
||||||
if (!access.canEdit) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { title, description, position } = body;
|
|
||||||
|
|
||||||
// Validate types before using string methods to prevent type confusion attacks
|
|
||||||
if (
|
|
||||||
position !== undefined &&
|
|
||||||
(typeof position !== 'number' || !Number.isInteger(position) || position < 0)
|
|
||||||
) {
|
|
||||||
return apiErrors.badRequest('position must be a non-negative integer');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
|
||||||
if (typeof title === 'string') updateData.title = title.trim();
|
|
||||||
if (typeof description === 'string') updateData.description = description.trim() || null;
|
|
||||||
if (position !== undefined) updateData.position = position;
|
|
||||||
|
|
||||||
const updatedVideo = await db.video.update({
|
|
||||||
where: { id: videoId },
|
|
||||||
data: updateData,
|
|
||||||
include: {
|
|
||||||
versions: { orderBy: { versionNumber: 'desc' } },
|
|
||||||
_count: { select: { versions: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(updatedVideo);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating video:', error);
|
|
||||||
return apiErrors.internalError('Failed to update video');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const video = await db.video.findFirst({
|
||||||
|
where: { id: videoId, projectId },
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.notFound('Video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
||||||
|
if (!access.canEdit) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, description, position } = body;
|
||||||
|
|
||||||
|
// Validate types before using string methods to prevent type confusion attacks
|
||||||
|
if (
|
||||||
|
position !== undefined &&
|
||||||
|
(typeof position !== 'number' || !Number.isInteger(position) || position < 0)
|
||||||
|
) {
|
||||||
|
return apiErrors.badRequest('position must be a non-negative integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (typeof title === 'string') updateData.title = title.trim();
|
||||||
|
if (typeof description === 'string') updateData.description = description.trim() || null;
|
||||||
|
if (position !== undefined) updateData.position = position;
|
||||||
|
|
||||||
|
const updatedVideo = await db.video.update({
|
||||||
|
where: { id: videoId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
versions: { orderBy: { versionNumber: 'desc' } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(updatedVideo);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating video:', error);
|
||||||
|
return apiErrors.internalError('Failed to update video');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/projects/[projectId]/videos/[videoId]
|
// DELETE /api/projects/[projectId]/videos/[videoId]
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, videoId } = await params;
|
const { projectId, videoId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const video = await db.video.findFirst({
|
|
||||||
where: { id: videoId, projectId },
|
|
||||||
include: {
|
|
||||||
versions: {
|
|
||||||
select: {
|
|
||||||
providerId: true,
|
|
||||||
videoId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
assets: {
|
|
||||||
select: {
|
|
||||||
provider: true,
|
|
||||||
providerVideoId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
project: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!video) {
|
|
||||||
return apiErrors.notFound('Video');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
|
||||||
if (!access.canEdit) {
|
|
||||||
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
|
||||||
}
|
|
||||||
|
|
||||||
const bunnyRefs = [
|
|
||||||
...video.versions,
|
|
||||||
...video.assets
|
|
||||||
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
|
|
||||||
.map((asset) => ({
|
|
||||||
providerId: 'bunny',
|
|
||||||
videoId: asset.providerVideoId as string,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const mediaUrls = await collectVideoMediaUrls(videoId);
|
|
||||||
|
|
||||||
await db.video.delete({ where: { id: videoId } });
|
|
||||||
|
|
||||||
revalidatePath(`/projects/${projectId}`);
|
|
||||||
|
|
||||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
|
||||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
|
||||||
deleteMediaFilesBestEffort(mediaUrls),
|
|
||||||
]);
|
|
||||||
const cleanupInput = {
|
|
||||||
bunny: bunnyCleanupResult,
|
|
||||||
r2: r2CleanupResult,
|
|
||||||
};
|
|
||||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
|
||||||
if (cleanupWarnings) {
|
|
||||||
logCleanupWarnings({ entityType: 'video', entityId: videoId }, cleanupInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse({
|
|
||||||
message: 'Video deleted',
|
|
||||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
|
||||||
});
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error deleting video:', error);
|
|
||||||
return apiErrors.internalError('Failed to delete video');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const video = await db.video.findFirst({
|
||||||
|
where: { id: videoId, projectId },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
select: {
|
||||||
|
providerId: true,
|
||||||
|
videoId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
select: {
|
||||||
|
provider: true,
|
||||||
|
providerVideoId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.notFound('Video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
||||||
|
if (!access.canEdit) {
|
||||||
|
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bunnyRefs = [
|
||||||
|
...video.versions,
|
||||||
|
...video.assets
|
||||||
|
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
|
||||||
|
.map((asset) => ({
|
||||||
|
providerId: 'bunny',
|
||||||
|
videoId: asset.providerVideoId as string,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const mediaUrls = await collectVideoMediaUrls(videoId);
|
||||||
|
|
||||||
|
await db.video.delete({ where: { id: videoId } });
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
|
||||||
|
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||||
|
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||||
|
deleteMediaFilesBestEffort(mediaUrls),
|
||||||
|
]);
|
||||||
|
const cleanupInput = {
|
||||||
|
bunny: bunnyCleanupResult,
|
||||||
|
r2: r2CleanupResult,
|
||||||
|
};
|
||||||
|
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||||
|
if (cleanupWarnings) {
|
||||||
|
logCleanupWarnings({ entityType: 'video', entityId: videoId }, cleanupInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
message: 'Video deleted',
|
||||||
|
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error deleting video:', error);
|
||||||
|
return apiErrors.internalError('Failed to delete video');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false;
|
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false;
|
||||||
const password = typeof body?.password === 'string' ? body.password.trim() : '';
|
const password = typeof body?.password === 'string' ? body.password.trim() : '';
|
||||||
if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
|
if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
|
||||||
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
|
return apiErrors.badRequest(
|
||||||
|
`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const passwordHash = password ? await bcrypt.hash(password, 12) : null;
|
const passwordHash = password ? await bcrypt.hash(password, 12) : null;
|
||||||
const token = randomBytes(24).toString('base64url');
|
const token = randomBytes(24).toString('base64url');
|
||||||
@@ -166,26 +168,50 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
link = await db.$transaction(async (tx) => {
|
link = await db.$transaction(
|
||||||
const existing = await tx.shareLink.findFirst({
|
async (tx) => {
|
||||||
where: {
|
const existing = await tx.shareLink.findFirst({
|
||||||
projectId,
|
where: {
|
||||||
videoId,
|
projectId,
|
||||||
permission: 'COMMENT',
|
videoId,
|
||||||
},
|
permission: 'COMMENT',
|
||||||
orderBy: { createdAt: 'desc' },
|
},
|
||||||
select: { id: true },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return tx.shareLink.update({
|
return tx.shareLink.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
allowGuests,
|
||||||
|
allowDownloads,
|
||||||
|
passwordHash,
|
||||||
|
expiresAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
token: true,
|
||||||
|
permission: true,
|
||||||
|
allowGuests: true,
|
||||||
|
allowDownloads: true,
|
||||||
|
expiresAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
passwordHash: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.shareLink.create({
|
||||||
data: {
|
data: {
|
||||||
token,
|
token,
|
||||||
|
projectId,
|
||||||
|
videoId,
|
||||||
|
permission: 'COMMENT',
|
||||||
allowGuests,
|
allowGuests,
|
||||||
allowDownloads,
|
allowDownloads,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
expiresAt: null,
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -198,33 +224,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
passwordHash: true,
|
passwordHash: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
|
||||||
return tx.shareLink.create({
|
);
|
||||||
data: {
|
|
||||||
token,
|
|
||||||
projectId,
|
|
||||||
videoId,
|
|
||||||
permission: 'COMMENT',
|
|
||||||
allowGuests,
|
|
||||||
allowDownloads,
|
|
||||||
passwordHash,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
token: true,
|
|
||||||
permission: true,
|
|
||||||
allowGuests: true,
|
|
||||||
allowDownloads: true,
|
|
||||||
expiresAt: true,
|
|
||||||
createdAt: true,
|
|
||||||
passwordHash: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034' && attempt < 2) {
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2034' &&
|
||||||
|
attempt < 2
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -261,11 +270,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined;
|
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined;
|
||||||
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined;
|
const allowDownloads =
|
||||||
|
typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined;
|
||||||
const rawPassword = typeof body?.password === 'string' ? body.password : undefined;
|
const rawPassword = typeof body?.password === 'string' ? body.password : undefined;
|
||||||
const clearPassword = body?.clearPassword === true;
|
const clearPassword = body?.clearPassword === true;
|
||||||
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
|
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
|
||||||
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
|
return apiErrors.badRequest(
|
||||||
|
`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db.shareLink.findFirst({
|
const existing = await db.shareLink.findFirst({
|
||||||
|
|||||||
@@ -9,151 +9,159 @@ import { logError } from '@/lib/logger';
|
|||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
|
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
|
||||||
|
|
||||||
async function getVersionWithAccess(projectId: string, videoId: string, versionId: string, userId: string) {
|
async function getVersionWithAccess(
|
||||||
const version = await db.videoVersion.findFirst({
|
projectId: string,
|
||||||
where: { id: versionId, videoParentId: videoId },
|
videoId: string,
|
||||||
|
versionId: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
const version = await db.videoVersion.findFirst({
|
||||||
|
where: { id: versionId, videoParentId: videoId },
|
||||||
|
include: {
|
||||||
|
video: {
|
||||||
include: {
|
include: {
|
||||||
video: {
|
project: true,
|
||||||
include: {
|
|
||||||
project: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!version || version.video.projectId !== projectId) {
|
if (!version || version.video.projectId !== projectId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = version.video.project;
|
const project = version.video.project;
|
||||||
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
|
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
|
||||||
|
|
||||||
return { version, canEdit: access.canEdit, isOwner: access.isOwner };
|
return { version, canEdit: access.canEdit, isOwner: access.isOwner };
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
|
// PATCH /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, videoId, versionId } = await params;
|
const { projectId, videoId, versionId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
|
|
||||||
if (!result) {
|
|
||||||
return apiErrors.notFound('Version');
|
|
||||||
}
|
|
||||||
if (!result.canEdit) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { duration, versionLabel, isActive } = body;
|
|
||||||
|
|
||||||
if (duration !== undefined && (typeof duration !== 'number' || !isFinite(duration) || duration < 0)) {
|
|
||||||
return apiErrors.badRequest('Invalid duration value');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
|
||||||
if (duration !== undefined) updateData.duration = duration;
|
|
||||||
if (versionLabel !== undefined) updateData.versionLabel = versionLabel?.trim() || null;
|
|
||||||
|
|
||||||
if (isActive === true) {
|
|
||||||
// Deactivate all other versions, then activate this one
|
|
||||||
await db.videoVersion.updateMany({
|
|
||||||
where: { videoParentId: videoId },
|
|
||||||
data: { isActive: false },
|
|
||||||
});
|
|
||||||
updateData.isActive = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await db.videoVersion.update({
|
|
||||||
where: { id: versionId },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(updated);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating version:', error);
|
|
||||||
return apiErrors.internalError('Failed to update version');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
|
||||||
|
if (!result) {
|
||||||
|
return apiErrors.notFound('Version');
|
||||||
|
}
|
||||||
|
if (!result.canEdit) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { duration, versionLabel, isActive } = body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
duration !== undefined &&
|
||||||
|
(typeof duration !== 'number' || !isFinite(duration) || duration < 0)
|
||||||
|
) {
|
||||||
|
return apiErrors.badRequest('Invalid duration value');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (duration !== undefined) updateData.duration = duration;
|
||||||
|
if (versionLabel !== undefined) updateData.versionLabel = versionLabel?.trim() || null;
|
||||||
|
|
||||||
|
if (isActive === true) {
|
||||||
|
// Deactivate all other versions, then activate this one
|
||||||
|
await db.videoVersion.updateMany({
|
||||||
|
where: { videoParentId: videoId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
updateData.isActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await db.videoVersion.update({
|
||||||
|
where: { id: versionId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(updated);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating version:', error);
|
||||||
|
return apiErrors.internalError('Failed to update version');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
|
// DELETE /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, videoId, versionId } = await params;
|
const { projectId, videoId, versionId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
|
|
||||||
if (!result) {
|
|
||||||
return apiErrors.notFound('Version');
|
|
||||||
}
|
|
||||||
if (!result.canEdit) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check there's more than one version — can't delete the last one
|
|
||||||
const versionCount = await db.videoVersion.count({
|
|
||||||
where: { videoParentId: videoId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (versionCount <= 1) {
|
|
||||||
return apiErrors.badRequest('Cannot delete the only version. Delete the video instead.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasActive = result.version.isActive;
|
|
||||||
const bunnyRef = {
|
|
||||||
providerId: result.version.providerId,
|
|
||||||
videoId: result.version.videoId,
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.$transaction(async (tx) => {
|
|
||||||
// Delete the version (cascades to comments).
|
|
||||||
await tx.videoVersion.delete({ where: { id: versionId } });
|
|
||||||
|
|
||||||
// If the deleted version was active, activate the latest remaining one.
|
|
||||||
if (wasActive) {
|
|
||||||
const latestVersion = await tx.videoVersion.findFirst({
|
|
||||||
where: { videoParentId: videoId },
|
|
||||||
orderBy: { versionNumber: 'desc' },
|
|
||||||
});
|
|
||||||
if (latestVersion) {
|
|
||||||
await tx.videoVersion.update({
|
|
||||||
where: { id: latestVersion.id },
|
|
||||||
data: { isActive: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
|
|
||||||
const cleanupInput = { bunny: bunnyCleanupResult };
|
|
||||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
|
||||||
if (cleanupWarnings) {
|
|
||||||
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse({
|
|
||||||
message: 'Version deleted',
|
|
||||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
|
||||||
});
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error deleting version:', error);
|
|
||||||
return apiErrors.internalError('Failed to delete version');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
|
||||||
|
if (!result) {
|
||||||
|
return apiErrors.notFound('Version');
|
||||||
|
}
|
||||||
|
if (!result.canEdit) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check there's more than one version — can't delete the last one
|
||||||
|
const versionCount = await db.videoVersion.count({
|
||||||
|
where: { videoParentId: videoId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (versionCount <= 1) {
|
||||||
|
return apiErrors.badRequest('Cannot delete the only version. Delete the video instead.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasActive = result.version.isActive;
|
||||||
|
const bunnyRef = {
|
||||||
|
providerId: result.version.providerId,
|
||||||
|
videoId: result.version.videoId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
// Delete the version (cascades to comments).
|
||||||
|
await tx.videoVersion.delete({ where: { id: versionId } });
|
||||||
|
|
||||||
|
// If the deleted version was active, activate the latest remaining one.
|
||||||
|
if (wasActive) {
|
||||||
|
const latestVersion = await tx.videoVersion.findFirst({
|
||||||
|
where: { videoParentId: videoId },
|
||||||
|
orderBy: { versionNumber: 'desc' },
|
||||||
|
});
|
||||||
|
if (latestVersion) {
|
||||||
|
await tx.videoVersion.update({
|
||||||
|
where: { id: latestVersion.id },
|
||||||
|
data: { isActive: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
|
||||||
|
const cleanupInput = { bunny: bunnyCleanupResult };
|
||||||
|
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||||
|
if (cleanupWarnings) {
|
||||||
|
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
message: 'Version deleted',
|
||||||
|
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error deleting version:', error);
|
||||||
|
return apiErrors.internalError('Failed to delete version');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,177 +12,181 @@ type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
|||||||
|
|
||||||
// GET /api/projects/[projectId]/videos/[videoId]/versions
|
// GET /api/projects/[projectId]/videos/[videoId]/versions
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, videoId } = await params;
|
const { projectId, videoId } = await params;
|
||||||
|
|
||||||
const video = await db.video.findFirst({
|
const video = await db.video.findFirst({
|
||||||
where: { id: videoId, projectId },
|
where: { id: videoId, projectId },
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return apiErrors.notFound('Video');
|
return apiErrors.notFound('Video');
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const versions = await db.videoVersion.findMany({
|
|
||||||
where: { videoParentId: videoId },
|
|
||||||
orderBy: { versionNumber: 'desc' },
|
|
||||||
include: {
|
|
||||||
_count: { select: { comments: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse({ versions });
|
|
||||||
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching versions:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch versions');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await db.videoVersion.findMany({
|
||||||
|
where: { videoParentId: videoId },
|
||||||
|
orderBy: { versionNumber: 'desc' },
|
||||||
|
include: {
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse({ versions });
|
||||||
|
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching versions:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch versions');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/projects/[projectId]/videos/[videoId]/versions - Add a new version
|
// POST /api/projects/[projectId]/videos/[videoId]/versions - Add a new version
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'create-version');
|
const limited = await rateLimit(request, 'create-version');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, videoId } = await params;
|
const { projectId, videoId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const video = await db.video.findFirst({
|
|
||||||
where: { id: videoId, projectId },
|
|
||||||
include: {
|
|
||||||
project: true,
|
|
||||||
versions: { orderBy: { versionNumber: 'desc' }, take: 1 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!video) {
|
|
||||||
return apiErrors.notFound('Video');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
|
||||||
if (!access.canEdit) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const {
|
|
||||||
videoUrl,
|
|
||||||
providerId,
|
|
||||||
providerVideoId,
|
|
||||||
versionLabel,
|
|
||||||
thumbnailUrl,
|
|
||||||
duration,
|
|
||||||
setActive,
|
|
||||||
uploadToken
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!videoUrl) {
|
|
||||||
return apiErrors.badRequest('Video URL is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (versionLabel !== undefined && versionLabel !== null) {
|
|
||||||
if (typeof versionLabel !== 'string') {
|
|
||||||
return apiErrors.badRequest('Version label must be a string');
|
|
||||||
}
|
|
||||||
if (versionLabel.trim().length > 100) {
|
|
||||||
return apiErrors.badRequest('Version label must be 100 characters or fewer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate URLs use safe schemes (http/https only)
|
|
||||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
|
||||||
if (videoUrlError) {
|
|
||||||
return apiErrors.badRequest(videoUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
|
||||||
if (thumbnailUrlError) {
|
|
||||||
return apiErrors.badRequest(thumbnailUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedProviderId = typeof providerId === 'string' && providerId.trim()
|
|
||||||
? providerId.trim().toLowerCase()
|
|
||||||
: 'youtube';
|
|
||||||
const normalizedProviderVideoId = typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
|
|
||||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
|
||||||
|
|
||||||
if (normalizedProviderId === 'bunny') {
|
|
||||||
if (!normalizedProviderVideoId || !normalizedUploadToken) {
|
|
||||||
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
|
||||||
userId: session.user.id,
|
|
||||||
projectId,
|
|
||||||
videoId: normalizedProviderVideoId,
|
|
||||||
});
|
|
||||||
if (!isValidUploadToken) {
|
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
|
|
||||||
|
|
||||||
// Use transaction to handle active flag
|
|
||||||
const version = await db.$transaction(async (tx: Parameters<Parameters<typeof db.$transaction>[0]>[0]) => {
|
|
||||||
// If setActive, deactivate all other versions
|
|
||||||
if (setActive) {
|
|
||||||
await tx.videoVersion.updateMany({
|
|
||||||
where: { videoParentId: videoId },
|
|
||||||
data: { isActive: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.videoVersion.create({
|
|
||||||
data: {
|
|
||||||
versionNumber: nextVersionNumber,
|
|
||||||
versionLabel: versionLabel?.trim() || null,
|
|
||||||
providerId: normalizedProviderId,
|
|
||||||
videoId: normalizedProviderVideoId,
|
|
||||||
originalUrl: videoUrl,
|
|
||||||
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
|
|
||||||
thumbnailUrl: thumbnailUrl || null,
|
|
||||||
duration: duration || null,
|
|
||||||
isActive: setActive ?? false,
|
|
||||||
videoParentId: videoId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
_count: { select: { comments: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Notify project owner (fire-and-forget, skip if they added it themselves)
|
|
||||||
if (video.project.ownerId !== session.user.id) {
|
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || '';
|
|
||||||
notifyProjectOwner(video.project.ownerId, {
|
|
||||||
type: 'new_version',
|
|
||||||
projectName: video.project.name,
|
|
||||||
videoTitle: video.title,
|
|
||||||
versionLabel: version.versionLabel || `Version ${version.versionNumber}`,
|
|
||||||
addedBy: session.user.name || 'A team member',
|
|
||||||
url: `${baseUrl}/watch/${video.id}`,
|
|
||||||
}).catch((err) => logError('Notification failed:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse(version, 201);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error creating version:', error);
|
|
||||||
return apiErrors.internalError('Failed to create version');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const video = await db.video.findFirst({
|
||||||
|
where: { id: videoId, projectId },
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
versions: { orderBy: { versionNumber: 'desc' }, take: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.notFound('Video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
||||||
|
if (!access.canEdit) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
videoUrl,
|
||||||
|
providerId,
|
||||||
|
providerVideoId,
|
||||||
|
versionLabel,
|
||||||
|
thumbnailUrl,
|
||||||
|
duration,
|
||||||
|
setActive,
|
||||||
|
uploadToken,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!videoUrl) {
|
||||||
|
return apiErrors.badRequest('Video URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionLabel !== undefined && versionLabel !== null) {
|
||||||
|
if (typeof versionLabel !== 'string') {
|
||||||
|
return apiErrors.badRequest('Version label must be a string');
|
||||||
|
}
|
||||||
|
if (versionLabel.trim().length > 100) {
|
||||||
|
return apiErrors.badRequest('Version label must be 100 characters or fewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URLs use safe schemes (http/https only)
|
||||||
|
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||||
|
if (videoUrlError) {
|
||||||
|
return apiErrors.badRequest(videoUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
||||||
|
if (thumbnailUrlError) {
|
||||||
|
return apiErrors.badRequest(thumbnailUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedProviderId =
|
||||||
|
typeof providerId === 'string' && providerId.trim()
|
||||||
|
? providerId.trim().toLowerCase()
|
||||||
|
: 'youtube';
|
||||||
|
const normalizedProviderVideoId =
|
||||||
|
typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
|
||||||
|
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||||
|
|
||||||
|
if (normalizedProviderId === 'bunny') {
|
||||||
|
if (!normalizedProviderVideoId || !normalizedUploadToken) {
|
||||||
|
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
||||||
|
userId: session.user.id,
|
||||||
|
projectId,
|
||||||
|
videoId: normalizedProviderVideoId,
|
||||||
|
});
|
||||||
|
if (!isValidUploadToken) {
|
||||||
|
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
|
||||||
|
|
||||||
|
// Use transaction to handle active flag
|
||||||
|
const version = await db.$transaction(
|
||||||
|
async (tx: Parameters<Parameters<typeof db.$transaction>[0]>[0]) => {
|
||||||
|
// If setActive, deactivate all other versions
|
||||||
|
if (setActive) {
|
||||||
|
await tx.videoVersion.updateMany({
|
||||||
|
where: { videoParentId: videoId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.videoVersion.create({
|
||||||
|
data: {
|
||||||
|
versionNumber: nextVersionNumber,
|
||||||
|
versionLabel: versionLabel?.trim() || null,
|
||||||
|
providerId: normalizedProviderId,
|
||||||
|
videoId: normalizedProviderVideoId,
|
||||||
|
originalUrl: videoUrl,
|
||||||
|
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
|
||||||
|
thumbnailUrl: thumbnailUrl || null,
|
||||||
|
duration: duration || null,
|
||||||
|
isActive: setActive ?? false,
|
||||||
|
videoParentId: videoId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify project owner (fire-and-forget, skip if they added it themselves)
|
||||||
|
if (video.project.ownerId !== session.user.id) {
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || '';
|
||||||
|
notifyProjectOwner(video.project.ownerId, {
|
||||||
|
type: 'new_version',
|
||||||
|
projectName: video.project.name,
|
||||||
|
videoTitle: video.title,
|
||||||
|
versionLabel: version.versionLabel || `Version ${version.versionNumber}`,
|
||||||
|
addedBy: session.user.name || 'A team member',
|
||||||
|
url: `${baseUrl}/watch/${video.id}`,
|
||||||
|
}).catch((err) => logError('Notification failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse(version, 201);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error creating version:', error);
|
||||||
|
return apiErrors.internalError('Failed to create version');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,152 +13,163 @@ import { enforceStorageQuota } from '@/lib/storage-quota';
|
|||||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||||
|
|
||||||
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
||||||
const project = await db.project.findUnique({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true, workspace: { select: { ownerId: true } } },
|
select: {
|
||||||
});
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ownerId: true,
|
||||||
|
workspaceId: true,
|
||||||
|
visibility: true,
|
||||||
|
workspace: { select: { ownerId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
|
|
||||||
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
|
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
|
||||||
const canEdit = access.canEdit;
|
const canEdit = access.canEdit;
|
||||||
|
|
||||||
if (!canEdit) return null;
|
if (!canEdit) return null;
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/projects/[projectId]/videos/bunny-init
|
// POST /api/projects/[projectId]/videos/bunny-init
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
|
||||||
if (!project) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
|
||||||
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return apiErrors.badRequest('Title is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isBunnyUploadsFeatureEnabled()) {
|
|
||||||
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
|
|
||||||
if (quotaError) return quotaError;
|
|
||||||
|
|
||||||
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
|
||||||
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
|
||||||
|
|
||||||
if (!apiKey || !libraryId) {
|
|
||||||
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Create video object in Bunny Stream
|
|
||||||
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'AccessKey': apiKey,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ title })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!bunnyRes.ok) {
|
|
||||||
logError('Failed to create Bunny Stream video', await bunnyRes.text());
|
|
||||||
return apiErrors.internalError('Failed to initialize video upload with provider');
|
|
||||||
}
|
|
||||||
|
|
||||||
const bunnyVideo = await bunnyRes.json();
|
|
||||||
const videoId = bunnyVideo.guid;
|
|
||||||
if (typeof videoId !== 'string' || videoId.length === 0) {
|
|
||||||
return apiErrors.internalError('Upload provider did not return a valid video identifier');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Generate TUS upload signature
|
|
||||||
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity
|
|
||||||
|
|
||||||
// SHA256(library_id + api_key + expiration_time + video_id)
|
|
||||||
const hash = crypto.createHash('sha256');
|
|
||||||
hash.update(libraryId + apiKey + expirationTime + videoId);
|
|
||||||
const signature = hash.digest('hex');
|
|
||||||
const uploadToken = createBunnyUploadToken({
|
|
||||||
userId: session.user.id,
|
|
||||||
projectId,
|
|
||||||
videoId,
|
|
||||||
}, 3600);
|
|
||||||
|
|
||||||
const response = successResponse({
|
|
||||||
videoId,
|
|
||||||
libraryId,
|
|
||||||
signature,
|
|
||||||
expirationTime,
|
|
||||||
uploadToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error initializing Bunny upload:', error);
|
|
||||||
return apiErrors.internalError('Failed to initialize upload');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return apiErrors.badRequest('Title is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBunnyUploadsFeatureEnabled()) {
|
||||||
|
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
|
||||||
|
if (quotaError) return quotaError;
|
||||||
|
|
||||||
|
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||||
|
const libraryId =
|
||||||
|
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||||
|
|
||||||
|
if (!apiKey || !libraryId) {
|
||||||
|
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create video object in Bunny Stream
|
||||||
|
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
AccessKey: apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bunnyRes.ok) {
|
||||||
|
logError('Failed to create Bunny Stream video', await bunnyRes.text());
|
||||||
|
return apiErrors.internalError('Failed to initialize video upload with provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bunnyVideo = await bunnyRes.json();
|
||||||
|
const videoId = bunnyVideo.guid;
|
||||||
|
if (typeof videoId !== 'string' || videoId.length === 0) {
|
||||||
|
return apiErrors.internalError('Upload provider did not return a valid video identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generate TUS upload signature
|
||||||
|
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity
|
||||||
|
|
||||||
|
// SHA256(library_id + api_key + expiration_time + video_id)
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
hash.update(libraryId + apiKey + expirationTime + videoId);
|
||||||
|
const signature = hash.digest('hex');
|
||||||
|
const uploadToken = createBunnyUploadToken(
|
||||||
|
{
|
||||||
|
userId: session.user.id,
|
||||||
|
projectId,
|
||||||
|
videoId,
|
||||||
|
},
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
videoId,
|
||||||
|
libraryId,
|
||||||
|
signature,
|
||||||
|
expirationTime,
|
||||||
|
uploadToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error initializing Bunny upload:', error);
|
||||||
|
return apiErrors.internalError('Failed to initialize upload');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/projects/[projectId]/videos/bunny-init
|
// DELETE /api/projects/[projectId]/videos/bunny-init
|
||||||
// Best-effort cleanup for interrupted uploads before a DB row is created.
|
// Best-effort cleanup for interrupted uploads before a DB row is created.
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
|
||||||
if (!project) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
|
||||||
const videoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
|
|
||||||
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
|
||||||
|
|
||||||
if (!videoId || !uploadToken) {
|
|
||||||
return apiErrors.badRequest('videoId and uploadToken are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
|
||||||
userId: session.user.id,
|
|
||||||
projectId,
|
|
||||||
videoId,
|
|
||||||
});
|
|
||||||
if (!isValidUploadToken) {
|
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
|
||||||
}
|
|
||||||
|
|
||||||
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
|
|
||||||
|
|
||||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error cleaning up pending Bunny upload:', error);
|
|
||||||
return apiErrors.internalError('Failed to cleanup pending upload');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const videoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
|
||||||
|
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||||
|
|
||||||
|
if (!videoId || !uploadToken) {
|
||||||
|
return apiErrors.badRequest('videoId and uploadToken are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
||||||
|
userId: session.user.id,
|
||||||
|
projectId,
|
||||||
|
videoId,
|
||||||
|
});
|
||||||
|
if (!isValidUploadToken) {
|
||||||
|
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
|
||||||
|
|
||||||
|
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error cleaning up pending Bunny upload:', error);
|
||||||
|
return apiErrors.internalError('Failed to cleanup pending upload');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,169 +12,179 @@ type RouteParams = { params: Promise<{ projectId: string }> };
|
|||||||
|
|
||||||
// GET /api/projects/[projectId]/videos - List all videos in a project
|
// GET /api/projects/[projectId]/videos - List all videos in a project
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
// Check project exists and user has access
|
// Check project exists and user has access
|
||||||
const project = await db.project.findUnique({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return apiErrors.notFound('Project');
|
return apiErrors.notFound('Project');
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(project, session?.user?.id);
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const videos = await db.video.findMany({
|
|
||||||
where: { projectId },
|
|
||||||
orderBy: { position: 'asc' },
|
|
||||||
include: {
|
|
||||||
versions: {
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: { versionNumber: 'desc' },
|
|
||||||
take: 1,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
thumbnailUrl: true,
|
|
||||||
duration: true,
|
|
||||||
versionNumber: true,
|
|
||||||
_count: { select: { comments: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { select: { versions: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse({ videos });
|
|
||||||
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching videos:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch videos');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(project, session?.user?.id);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = await db.video.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { position: 'asc' },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { versionNumber: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
duration: true,
|
||||||
|
versionNumber: true,
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse({ videos });
|
||||||
|
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching videos:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch videos');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/projects/[projectId]/videos - Add a new video to the project
|
// POST /api/projects/[projectId]/videos - Add a new video to the project
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'create-video');
|
const limited = await rateLimit(request, 'create-video');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
// Check project access (must be owner, project admin, or workspace admin)
|
|
||||||
const project = await db.project.findUnique({
|
|
||||||
where: { id: projectId },
|
|
||||||
select: { id: true, name: 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 body = await request.json();
|
|
||||||
const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration, uploadToken } = body;
|
|
||||||
|
|
||||||
if (!title || !videoUrl) {
|
|
||||||
return apiErrors.badRequest('Title and video URL are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate URLs use safe schemes (http/https only)
|
|
||||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
|
||||||
if (videoUrlError) {
|
|
||||||
return apiErrors.badRequest(videoUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
|
||||||
if (thumbnailUrlError) {
|
|
||||||
return apiErrors.badRequest(thumbnailUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedProviderId = typeof providerId === 'string' && providerId.trim()
|
|
||||||
? providerId.trim().toLowerCase()
|
|
||||||
: 'youtube';
|
|
||||||
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
|
|
||||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
|
||||||
|
|
||||||
if (normalizedProviderId === 'bunny') {
|
|
||||||
if (!normalizedVideoId || !normalizedUploadToken) {
|
|
||||||
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
|
||||||
userId: session.user.id,
|
|
||||||
projectId,
|
|
||||||
videoId: normalizedVideoId,
|
|
||||||
});
|
|
||||||
if (!isValidUploadToken) {
|
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the next position
|
|
||||||
const lastVideo = await db.video.findFirst({
|
|
||||||
where: { projectId },
|
|
||||||
orderBy: { position: 'desc' },
|
|
||||||
});
|
|
||||||
const nextPosition = (lastVideo?.position ?? -1) + 1;
|
|
||||||
|
|
||||||
// Create video with initial version
|
|
||||||
const video = await db.video.create({
|
|
||||||
data: {
|
|
||||||
title: title.trim(),
|
|
||||||
description: description?.trim() || null,
|
|
||||||
position: nextPosition,
|
|
||||||
projectId,
|
|
||||||
versions: {
|
|
||||||
create: {
|
|
||||||
versionNumber: 1,
|
|
||||||
providerId: normalizedProviderId,
|
|
||||||
videoId: normalizedVideoId,
|
|
||||||
originalUrl: videoUrl,
|
|
||||||
title: title.trim(),
|
|
||||||
thumbnailUrl: thumbnailUrl || null,
|
|
||||||
duration: duration || null,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
versions: true,
|
|
||||||
_count: { select: { versions: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Notify project owner (fire-and-forget, skip if they added it themselves)
|
|
||||||
if (project.ownerId !== session.user.id) {
|
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || '';
|
|
||||||
notifyProjectOwner(project.ownerId, {
|
|
||||||
type: 'new_video',
|
|
||||||
projectName: project.name,
|
|
||||||
videoTitle: title.trim(),
|
|
||||||
addedBy: session.user.name || 'A team member',
|
|
||||||
url: `${baseUrl}/watch/${video.id}`,
|
|
||||||
}).catch((err) => logError('Notification failed:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse(video, 201);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error creating video:', error);
|
|
||||||
return apiErrors.internalError('Failed to create video');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check project access (must be owner, project admin, or workspace admin)
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { id: true, name: 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 body = await request.json();
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
videoUrl,
|
||||||
|
providerId,
|
||||||
|
videoId,
|
||||||
|
thumbnailUrl,
|
||||||
|
duration,
|
||||||
|
uploadToken,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!title || !videoUrl) {
|
||||||
|
return apiErrors.badRequest('Title and video URL are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URLs use safe schemes (http/https only)
|
||||||
|
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||||
|
if (videoUrlError) {
|
||||||
|
return apiErrors.badRequest(videoUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
||||||
|
if (thumbnailUrlError) {
|
||||||
|
return apiErrors.badRequest(thumbnailUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedProviderId =
|
||||||
|
typeof providerId === 'string' && providerId.trim()
|
||||||
|
? providerId.trim().toLowerCase()
|
||||||
|
: 'youtube';
|
||||||
|
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
|
||||||
|
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||||
|
|
||||||
|
if (normalizedProviderId === 'bunny') {
|
||||||
|
if (!normalizedVideoId || !normalizedUploadToken) {
|
||||||
|
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
||||||
|
userId: session.user.id,
|
||||||
|
projectId,
|
||||||
|
videoId: normalizedVideoId,
|
||||||
|
});
|
||||||
|
if (!isValidUploadToken) {
|
||||||
|
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the next position
|
||||||
|
const lastVideo = await db.video.findFirst({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { position: 'desc' },
|
||||||
|
});
|
||||||
|
const nextPosition = (lastVideo?.position ?? -1) + 1;
|
||||||
|
|
||||||
|
// Create video with initial version
|
||||||
|
const video = await db.video.create({
|
||||||
|
data: {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
position: nextPosition,
|
||||||
|
projectId,
|
||||||
|
versions: {
|
||||||
|
create: {
|
||||||
|
versionNumber: 1,
|
||||||
|
providerId: normalizedProviderId,
|
||||||
|
videoId: normalizedVideoId,
|
||||||
|
originalUrl: videoUrl,
|
||||||
|
title: title.trim(),
|
||||||
|
thumbnailUrl: thumbnailUrl || null,
|
||||||
|
duration: duration || null,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
versions: true,
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify project owner (fire-and-forget, skip if they added it themselves)
|
||||||
|
if (project.ownerId !== session.user.id) {
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || '';
|
||||||
|
notifyProjectOwner(project.ownerId, {
|
||||||
|
type: 'new_video',
|
||||||
|
projectName: project.name,
|
||||||
|
videoTitle: title.trim(),
|
||||||
|
addedBy: session.user.name || 'A team member',
|
||||||
|
url: `${baseUrl}/watch/${video.id}`,
|
||||||
|
}).catch((err) => logError('Notification failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse(video, 201);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error creating video:', error);
|
||||||
|
return apiErrors.internalError('Failed to create video');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+181
-179
@@ -10,192 +10,194 @@ import { logError } from '@/lib/logger';
|
|||||||
|
|
||||||
// GET /api/projects - List all projects for the authenticated user
|
// GET /api/projects - List all projects for the authenticated user
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const MAX_LIMIT = 100;
|
const MAX_LIMIT = 100;
|
||||||
const MAX_PAGE = 1000;
|
const MAX_PAGE = 1000;
|
||||||
const MAX_OFFSET = 10000;
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const pageParam = searchParams.get('page');
|
|
||||||
const limitParam = searchParams.get('limit');
|
|
||||||
const workspaceId = searchParams.get('workspaceId');
|
|
||||||
|
|
||||||
const pageRaw = pageParam === null ? 1 : Number(pageParam);
|
|
||||||
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
|
|
||||||
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limitRaw = limitParam === null ? 10 : Number(limitParam);
|
|
||||||
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
|
||||||
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = pageRaw;
|
|
||||||
const limit = limitRaw;
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
|
|
||||||
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build base filter: user is owner OR a member
|
|
||||||
const baseFilter: Record<string, unknown> = {
|
|
||||||
OR: [
|
|
||||||
{ ownerId: session.user.id },
|
|
||||||
{ members: { some: { userId: session.user.id } } },
|
|
||||||
// Also include projects in workspaces where the user is a workspace member
|
|
||||||
...(workspaceId ? [] : [{
|
|
||||||
workspace: {
|
|
||||||
owner: buildBillingAccessWhereInput(),
|
|
||||||
members: { some: { userId: session.user.id } },
|
|
||||||
},
|
|
||||||
}]),
|
|
||||||
],
|
|
||||||
workspace: {
|
|
||||||
owner: buildBillingAccessWhereInput(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter by workspace if provided
|
|
||||||
if (workspaceId) {
|
|
||||||
baseFilter.workspaceId = workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get projects where user is owner OR a member
|
|
||||||
const [projects, total] = await Promise.all([
|
|
||||||
db.project.findMany({
|
|
||||||
where: baseFilter,
|
|
||||||
include: {
|
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
|
||||||
_count: { select: { videos: true, members: true } },
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
}),
|
|
||||||
db.project.count({
|
|
||||||
where: baseFilter,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const response = successResponse(
|
|
||||||
{ projects },
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching projects:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch projects');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const pageParam = searchParams.get('page');
|
||||||
|
const limitParam = searchParams.get('limit');
|
||||||
|
const workspaceId = searchParams.get('workspaceId');
|
||||||
|
|
||||||
|
const pageRaw = pageParam === null ? 1 : Number(pageParam);
|
||||||
|
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
|
||||||
|
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitRaw = limitParam === null ? 10 : Number(limitParam);
|
||||||
|
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
||||||
|
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pageRaw;
|
||||||
|
const limit = limitRaw;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
|
||||||
|
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build base filter: user is owner OR a member
|
||||||
|
const baseFilter: Record<string, unknown> = {
|
||||||
|
OR: [
|
||||||
|
{ ownerId: session.user.id },
|
||||||
|
{ members: { some: { userId: session.user.id } } },
|
||||||
|
// Also include projects in workspaces where the user is a workspace member
|
||||||
|
...(workspaceId
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
workspace: {
|
||||||
|
owner: buildBillingAccessWhereInput(),
|
||||||
|
members: { some: { userId: session.user.id } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
workspace: {
|
||||||
|
owner: buildBillingAccessWhereInput(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter by workspace if provided
|
||||||
|
if (workspaceId) {
|
||||||
|
baseFilter.workspaceId = workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get projects where user is owner OR a member
|
||||||
|
const [projects, total] = await Promise.all([
|
||||||
|
db.project.findMany({
|
||||||
|
where: baseFilter,
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { videos: true, members: true } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
db.project.count({
|
||||||
|
where: baseFilter,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = successResponse({ projects }, 200, {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching projects:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch projects');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/projects - Create a new project
|
// POST /api/projects - Create a new project
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'create-project');
|
const limited = await rateLimit(request, 'create-project');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, description, visibility, workspaceId } = body;
|
|
||||||
|
|
||||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
||||||
return apiErrors.badRequest('Project name is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaceId || typeof workspaceId !== 'string') {
|
|
||||||
return apiErrors.badRequest('A workspace is required. Every project must belong to a workspace.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URL-friendly slug
|
|
||||||
const baseSlug = name
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-');
|
|
||||||
|
|
||||||
// Find all existing slugs with the same prefix in a single query
|
|
||||||
const existingProjects = await db.project.findMany({
|
|
||||||
where: { slug: { startsWith: baseSlug } },
|
|
||||||
select: { slug: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate unique slug from the results
|
|
||||||
const usedSlugs = new Set(existingProjects.map(p => p.slug));
|
|
||||||
let slug = baseSlug;
|
|
||||||
let counter = 1;
|
|
||||||
while (usedSlugs.has(slug)) {
|
|
||||||
slug = `${baseSlug}-${counter}`;
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user has access to the workspace
|
|
||||||
const workspace = await db.workspace.findUnique({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
include: { members: { where: { userId: session.user.id } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return apiErrors.notFound('Workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkWorkspaceAccess(
|
|
||||||
{ id: workspace.id, ownerId: workspace.ownerId },
|
|
||||||
session.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!access.canEdit) {
|
|
||||||
return apiErrors.forbidden('Only workspace owners and admins can create projects');
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await db.$transaction(async (tx) => {
|
|
||||||
const createdProject = await tx.project.create({
|
|
||||||
data: {
|
|
||||||
name: name.trim(),
|
|
||||||
description: description?.trim() || null,
|
|
||||||
slug,
|
|
||||||
visibility: visibility || ProjectVisibility.PRIVATE,
|
|
||||||
ownerId: workspace.ownerId,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
|
||||||
_count: { select: { videos: true, members: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.commentTag.createMany({
|
|
||||||
data: DEFAULT_COMMENT_TAGS.map((tag) => ({
|
|
||||||
...tag,
|
|
||||||
projectId: createdProject.id,
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return createdProject;
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(project, 201);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error creating project:', error);
|
|
||||||
return apiErrors.internalError('Failed to create project');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description, visibility, workspaceId } = body;
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||||
|
return apiErrors.badRequest('Project name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId || typeof workspaceId !== 'string') {
|
||||||
|
return apiErrors.badRequest(
|
||||||
|
'A workspace is required. Every project must belong to a workspace.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate URL-friendly slug
|
||||||
|
const baseSlug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
|
||||||
|
// Find all existing slugs with the same prefix in a single query
|
||||||
|
const existingProjects = await db.project.findMany({
|
||||||
|
where: { slug: { startsWith: baseSlug } },
|
||||||
|
select: { slug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate unique slug from the results
|
||||||
|
const usedSlugs = new Set(existingProjects.map((p) => p.slug));
|
||||||
|
let slug = baseSlug;
|
||||||
|
let counter = 1;
|
||||||
|
while (usedSlugs.has(slug)) {
|
||||||
|
slug = `${baseSlug}-${counter}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user has access to the workspace
|
||||||
|
const workspace = await db.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
include: { members: { where: { userId: session.user.id } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return apiErrors.notFound('Workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(
|
||||||
|
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!access.canEdit) {
|
||||||
|
return apiErrors.forbidden('Only workspace owners and admins can create projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db.$transaction(async (tx) => {
|
||||||
|
const createdProject = await tx.project.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
slug,
|
||||||
|
visibility: visibility || ProjectVisibility.PRIVATE,
|
||||||
|
ownerId: workspace.ownerId,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { videos: true, members: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.commentTag.createMany({
|
||||||
|
data: DEFAULT_COMMENT_TAGS.map((tag) => ({
|
||||||
|
...tag,
|
||||||
|
projectId: createdProject.id,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdProject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(project, 201);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error creating project:', error);
|
||||||
|
return apiErrors.internalError('Failed to create project');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ export async function GET(request: NextRequest) {
|
|||||||
const cfg = RATE_LIMIT_CONFIGS['search'];
|
const cfg = RATE_LIMIT_CONFIGS['search'];
|
||||||
const rl = await checkRateLimit(userId, 'search', cfg);
|
const rl = await checkRateLimit(userId, 'search', cfg);
|
||||||
if (!rl.allowed) {
|
if (!rl.allowed) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
|
||||||
JSON.stringify({ error: 'Too many requests. Please try again later.' }),
|
status: 429,
|
||||||
{ status: 429, headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) } }
|
headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) },
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@@ -48,10 +48,7 @@ export async function GET(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const workspaceAccessFilter = {
|
const workspaceAccessFilter = {
|
||||||
OR: [
|
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
||||||
{ ownerId: userId },
|
|
||||||
{ members: { some: { userId } } },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [projects, workspaces, videos] = await Promise.all([
|
const [projects, workspaces, videos] = await Promise.all([
|
||||||
|
|||||||
@@ -9,206 +9,211 @@ import { logError } from '@/lib/logger';
|
|||||||
|
|
||||||
// GET /api/settings/notifications — Fetch current notification preferences
|
// GET /api/settings/notifications — Fetch current notification preferences
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await db.notificationSetting.findUnique({
|
|
||||||
where: { userId: session.user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return defaults if no settings exist yet
|
|
||||||
const response = successResponse(
|
|
||||||
settings ?? {
|
|
||||||
telegramChatId: null,
|
|
||||||
telegramEnabled: false,
|
|
||||||
emailEnabled: false,
|
|
||||||
onNewVideo: true,
|
|
||||||
onNewVersion: true,
|
|
||||||
onNewComment: true,
|
|
||||||
onNewReply: true,
|
|
||||||
onApprovalEvents: true,
|
|
||||||
timezone: 'UTC',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching notification settings:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch settings');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settings = await db.notificationSetting.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return defaults if no settings exist yet
|
||||||
|
const response = successResponse(
|
||||||
|
settings ?? {
|
||||||
|
telegramChatId: null,
|
||||||
|
telegramEnabled: false,
|
||||||
|
emailEnabled: false,
|
||||||
|
onNewVideo: true,
|
||||||
|
onNewVersion: true,
|
||||||
|
onNewComment: true,
|
||||||
|
onNewReply: true,
|
||||||
|
onApprovalEvents: true,
|
||||||
|
timezone: 'UTC',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching notification settings:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch settings');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/settings/notifications — Update notification preferences
|
// PUT /api/settings/notifications — Update notification preferences
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const {
|
|
||||||
telegramChatId,
|
|
||||||
telegramEnabled,
|
|
||||||
emailEnabled,
|
|
||||||
onNewVideo,
|
|
||||||
onNewVersion,
|
|
||||||
onNewComment,
|
|
||||||
onNewReply,
|
|
||||||
onApprovalEvents,
|
|
||||||
timezone,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
// Validate: if enabling Telegram, chatId is required and must be a valid Telegram ID
|
|
||||||
if (telegramChatId && !/^-?\d{1,20}$/.test(telegramChatId)) {
|
|
||||||
return apiErrors.badRequest('Invalid Chat ID format');
|
|
||||||
}
|
|
||||||
if (telegramEnabled && !telegramChatId) {
|
|
||||||
return apiErrors.badRequest('Chat ID is required to enable Telegram notifications');
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await db.notificationSetting.upsert({
|
|
||||||
where: { userId: session.user.id },
|
|
||||||
create: {
|
|
||||||
userId: session.user.id,
|
|
||||||
telegramChatId: telegramChatId || null,
|
|
||||||
telegramEnabled: !!telegramEnabled,
|
|
||||||
emailEnabled: !!emailEnabled,
|
|
||||||
onNewVideo: onNewVideo ?? true,
|
|
||||||
onNewVersion: onNewVersion ?? true,
|
|
||||||
onNewComment: onNewComment ?? true,
|
|
||||||
onNewReply: onNewReply ?? true,
|
|
||||||
onApprovalEvents: onApprovalEvents ?? true,
|
|
||||||
timezone: timezone || 'UTC',
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
telegramChatId: telegramChatId || null,
|
|
||||||
telegramEnabled: !!telegramEnabled,
|
|
||||||
emailEnabled: !!emailEnabled,
|
|
||||||
onNewVideo: onNewVideo ?? true,
|
|
||||||
onNewVersion: onNewVersion ?? true,
|
|
||||||
onNewComment: onNewComment ?? true,
|
|
||||||
onNewReply: onNewReply ?? true,
|
|
||||||
onApprovalEvents: onApprovalEvents ?? true,
|
|
||||||
timezone: timezone || 'UTC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(settings);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating notification settings:', error);
|
|
||||||
return apiErrors.internalError('Failed to update settings');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
telegramChatId,
|
||||||
|
telegramEnabled,
|
||||||
|
emailEnabled,
|
||||||
|
onNewVideo,
|
||||||
|
onNewVersion,
|
||||||
|
onNewComment,
|
||||||
|
onNewReply,
|
||||||
|
onApprovalEvents,
|
||||||
|
timezone,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Validate: if enabling Telegram, chatId is required and must be a valid Telegram ID
|
||||||
|
if (telegramChatId && !/^-?\d{1,20}$/.test(telegramChatId)) {
|
||||||
|
return apiErrors.badRequest('Invalid Chat ID format');
|
||||||
|
}
|
||||||
|
if (telegramEnabled && !telegramChatId) {
|
||||||
|
return apiErrors.badRequest('Chat ID is required to enable Telegram notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await db.notificationSetting.upsert({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
telegramChatId: telegramChatId || null,
|
||||||
|
telegramEnabled: !!telegramEnabled,
|
||||||
|
emailEnabled: !!emailEnabled,
|
||||||
|
onNewVideo: onNewVideo ?? true,
|
||||||
|
onNewVersion: onNewVersion ?? true,
|
||||||
|
onNewComment: onNewComment ?? true,
|
||||||
|
onNewReply: onNewReply ?? true,
|
||||||
|
onApprovalEvents: onApprovalEvents ?? true,
|
||||||
|
timezone: timezone || 'UTC',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
telegramChatId: telegramChatId || null,
|
||||||
|
telegramEnabled: !!telegramEnabled,
|
||||||
|
emailEnabled: !!emailEnabled,
|
||||||
|
onNewVideo: onNewVideo ?? true,
|
||||||
|
onNewVersion: onNewVersion ?? true,
|
||||||
|
onNewComment: onNewComment ?? true,
|
||||||
|
onNewReply: onNewReply ?? true,
|
||||||
|
onApprovalEvents: onApprovalEvents ?? true,
|
||||||
|
timezone: timezone || 'UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(settings);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating notification settings:', error);
|
||||||
|
return apiErrors.internalError('Failed to update settings');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/settings/notifications — Test a notification channel
|
// POST /api/settings/notifications — Test a notification channel
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { channel, telegramChatId } = body;
|
|
||||||
|
|
||||||
if (channel === 'telegram') {
|
|
||||||
const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
||||||
if (!telegramBotToken) {
|
|
||||||
return apiErrors.internalError('Telegram bot not configured (TELEGRAM_BOT_TOKEN missing)');
|
|
||||||
}
|
|
||||||
if (!telegramChatId) {
|
|
||||||
return apiErrors.badRequest('Chat ID is required');
|
|
||||||
}
|
|
||||||
if (!/^-?\d{1,20}$/.test(telegramChatId)) {
|
|
||||||
return apiErrors.badRequest('Invalid Chat ID format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsUrl = `${process.env.NEXTAUTH_URL || ''}/settings`;
|
|
||||||
const telegramPayload: Record<string, unknown> = {
|
|
||||||
chat_id: telegramChatId,
|
|
||||||
text: '✅ OpenFrame notifications connected successfully!\n\nYou will receive notifications here when activity happens on your projects.',
|
|
||||||
link_preview_options: { is_disabled: true },
|
|
||||||
};
|
|
||||||
// Telegram inline keyboard buttons require HTTPS URLs
|
|
||||||
if (settingsUrl.startsWith('https://')) {
|
|
||||||
telegramPayload.reply_markup = {
|
|
||||||
inline_keyboard: [[{ text: 'Open Settings', url: settingsUrl }]],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const res = await fetch(`https://api.telegram.org/bot${telegramBotToken}/sendMessage`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(telegramPayload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
logError('Telegram test failed:', (data as { description?: string }).description);
|
|
||||||
return apiErrors.badRequest('Telegram test failed: check that the Chat ID is correct and the bot has been started');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse({ message: 'Test message sent to Telegram' });
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel === 'email') {
|
|
||||||
const user = await db.user.findUnique({
|
|
||||||
where: { id: session.user.id },
|
|
||||||
select: { email: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user?.email) {
|
|
||||||
return apiErrors.badRequest('No email address on your account');
|
|
||||||
}
|
|
||||||
|
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
|
||||||
const smtpPort = Number(process.env.SMTP_PORT || '587');
|
|
||||||
const smtpUser = process.env.SMTP_USER;
|
|
||||||
const smtpPass = process.env.SMTP_PASSWORD;
|
|
||||||
|
|
||||||
if (!smtpHost || !smtpUser || !smtpPass) {
|
|
||||||
return apiErrors.internalError('Email service not configured (SMTP settings missing)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpPort === 465,
|
|
||||||
auth: { user: smtpUser, pass: smtpPass },
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromAddress = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: fromAddress,
|
|
||||||
to: user.email,
|
|
||||||
subject: '[OpenFrame] Test notification',
|
|
||||||
html: testEmailHtml(),
|
|
||||||
});
|
|
||||||
} catch (emailErr) {
|
|
||||||
logError('SMTP test email failed:', emailErr);
|
|
||||||
return apiErrors.internalError('Failed to send test email — check SMTP settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse({ message: `Test email sent to ${user.email}` });
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiErrors.badRequest('Unknown channel');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error testing notification:', error);
|
|
||||||
return apiErrors.internalError('Failed to test notification');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { channel, telegramChatId } = body;
|
||||||
|
|
||||||
|
if (channel === 'telegram') {
|
||||||
|
const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
if (!telegramBotToken) {
|
||||||
|
return apiErrors.internalError('Telegram bot not configured (TELEGRAM_BOT_TOKEN missing)');
|
||||||
|
}
|
||||||
|
if (!telegramChatId) {
|
||||||
|
return apiErrors.badRequest('Chat ID is required');
|
||||||
|
}
|
||||||
|
if (!/^-?\d{1,20}$/.test(telegramChatId)) {
|
||||||
|
return apiErrors.badRequest('Invalid Chat ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsUrl = `${process.env.NEXTAUTH_URL || ''}/settings`;
|
||||||
|
const telegramPayload: Record<string, unknown> = {
|
||||||
|
chat_id: telegramChatId,
|
||||||
|
text: '✅ OpenFrame notifications connected successfully!\n\nYou will receive notifications here when activity happens on your projects.',
|
||||||
|
link_preview_options: { is_disabled: true },
|
||||||
|
};
|
||||||
|
// Telegram inline keyboard buttons require HTTPS URLs
|
||||||
|
if (settingsUrl.startsWith('https://')) {
|
||||||
|
telegramPayload.reply_markup = {
|
||||||
|
inline_keyboard: [[{ text: 'Open Settings', url: settingsUrl }]],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const res = await fetch(`https://api.telegram.org/bot${telegramBotToken}/sendMessage`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(telegramPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
logError('Telegram test failed:', (data as { description?: string }).description);
|
||||||
|
return apiErrors.badRequest(
|
||||||
|
'Telegram test failed: check that the Chat ID is correct and the bot has been started'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse({ message: 'Test message sent to Telegram' });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === 'email') {
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { email: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.email) {
|
||||||
|
return apiErrors.badRequest('No email address on your account');
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
|
const smtpPort = Number(process.env.SMTP_PORT || '587');
|
||||||
|
const smtpUser = process.env.SMTP_USER;
|
||||||
|
const smtpPass = process.env.SMTP_PASSWORD;
|
||||||
|
|
||||||
|
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||||
|
return apiErrors.internalError('Email service not configured (SMTP settings missing)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: smtpHost,
|
||||||
|
port: smtpPort,
|
||||||
|
secure: smtpPort === 465,
|
||||||
|
auth: { user: smtpUser, pass: smtpPass },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromAddress =
|
||||||
|
process.env.SMTP_FROM ||
|
||||||
|
process.env.EMAIL_FROM ||
|
||||||
|
'OpenFrame <[email protected]>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: fromAddress,
|
||||||
|
to: user.email,
|
||||||
|
subject: '[OpenFrame] Test notification',
|
||||||
|
html: testEmailHtml(),
|
||||||
|
});
|
||||||
|
} catch (emailErr) {
|
||||||
|
logError('SMTP test email failed:', emailErr);
|
||||||
|
return apiErrors.internalError('Failed to send test email — check SMTP settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse({ message: `Test email sent to ${user.email}` });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiErrors.badRequest('Unknown channel');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error testing notification:', error);
|
||||||
|
return apiErrors.internalError('Failed to test notification');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
import {
|
import { markSubscriptionCanceledByCustomerId, syncStripeSubscriptionToUser } from '@/lib/billing';
|
||||||
markSubscriptionCanceledByCustomerId,
|
|
||||||
syncStripeSubscriptionToUser,
|
|
||||||
} from '@/lib/billing';
|
|
||||||
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
|
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
@@ -11,9 +8,7 @@ export const runtime = 'nodejs';
|
|||||||
|
|
||||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||||
const customerId =
|
const customerId =
|
||||||
typeof subscription.customer === 'string'
|
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||||
? subscription.customer
|
|
||||||
: subscription.customer.id;
|
|
||||||
|
|
||||||
const currentPeriodEnd =
|
const currentPeriodEnd =
|
||||||
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
|
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ export async function GET(
|
|||||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||||
const shareAccess = shareSession
|
const shareAccess = shareSession
|
||||||
? await validateShareLinkAccess({
|
? await validateShareLinkAccess({
|
||||||
token: shareSession.token,
|
token: shareSession.token,
|
||||||
projectId: video.projectId,
|
projectId: video.projectId,
|
||||||
videoId: video.id,
|
videoId: video.id,
|
||||||
requiredPermission: 'VIEW',
|
requiredPermission: 'VIEW',
|
||||||
passwordVerified: shareSession.passwordVerified,
|
passwordVerified: shareSession.passwordVerified,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!shareAccess?.hasAccess) {
|
if (!shareAccess?.hasAccess) {
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-qu
|
|||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead
|
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
|
||||||
|
|
||||||
// Canonical MIME types accepted
|
// Canonical MIME types accepted
|
||||||
const ALLOWED_TYPES = new Set(['audio/webm', 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/mpeg', 'audio/wav']);
|
const ALLOWED_TYPES = new Set([
|
||||||
|
'audio/webm',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/opus',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/wav',
|
||||||
|
]);
|
||||||
|
|
||||||
// Normalize known MIME aliases to canonical values
|
// Normalize known MIME aliases to canonical values
|
||||||
const MIME_ALIASES: Record<string, string> = {
|
const MIME_ALIASES: Record<string, string> = {
|
||||||
@@ -49,7 +56,11 @@ const SAFE_AUDIO_EXTENSIONS = new Set(['webm', 'ogg', 'opus', 'mp3', 'm4a', 'mp4
|
|||||||
|
|
||||||
// Reject content that looks like HTML/XML/script regardless of the declared MIME type.
|
// Reject content that looks like HTML/XML/script regardless of the declared MIME type.
|
||||||
function isHtmlContent(bytes: Buffer): boolean {
|
function isHtmlContent(bytes: Buffer): boolean {
|
||||||
const snippet = bytes.toString('latin1', 0, Math.min(bytes.length, 512)).trimStart().slice(0, 50).toLowerCase();
|
const snippet = bytes
|
||||||
|
.toString('latin1', 0, Math.min(bytes.length, 512))
|
||||||
|
.trimStart()
|
||||||
|
.slice(0, 50)
|
||||||
|
.toLowerCase();
|
||||||
return (
|
return (
|
||||||
snippet.startsWith('<!doctype') ||
|
snippet.startsWith('<!doctype') ||
|
||||||
snippet.startsWith('<html') ||
|
snippet.startsWith('<html') ||
|
||||||
@@ -133,15 +144,22 @@ export async function POST(request: NextRequest) {
|
|||||||
const shareSession = getShareSessionFromRequest(request, safeVideoId);
|
const shareSession = getShareSessionFromRequest(request, safeVideoId);
|
||||||
const shareAccess = shareSession
|
const shareAccess = shareSession
|
||||||
? await validateShareLinkAccess({
|
? await validateShareLinkAccess({
|
||||||
token: shareSession.token,
|
token: shareSession.token,
|
||||||
projectId: video.projectId,
|
projectId: video.projectId,
|
||||||
videoId: safeVideoId,
|
videoId: safeVideoId,
|
||||||
requiredPermission: 'COMMENT',
|
requiredPermission: 'COMMENT',
|
||||||
passwordVerified: shareSession.passwordVerified,
|
passwordVerified: shareSession.passwordVerified,
|
||||||
})
|
})
|
||||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
: {
|
||||||
|
hasAccess: false,
|
||||||
|
canComment: false,
|
||||||
|
canDownload: false,
|
||||||
|
allowGuests: false,
|
||||||
|
requiresPassword: false,
|
||||||
|
};
|
||||||
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
const canCommentWithShareLink =
|
||||||
|
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||||
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
||||||
return apiErrors.forbidden('Access denied');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
@@ -166,7 +184,12 @@ export async function POST(request: NextRequest) {
|
|||||||
return apiErrors.forbidden('Invalid upload token');
|
return apiErrors.forbidden('Invalid upload token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'audio', shareSession?.token ?? null);
|
const quotaError = await enforceGuestUploadQuota(
|
||||||
|
request,
|
||||||
|
safeVideoId,
|
||||||
|
'audio',
|
||||||
|
shareSession?.token ?? null
|
||||||
|
);
|
||||||
if (quotaError) return quotaError;
|
if (quotaError) return quotaError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,97 +11,97 @@ import { logError } from '@/lib/logger';
|
|||||||
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||||
|
|
||||||
const CONTENT_TYPE_MAP: Record<string, string> = {
|
const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||||
jpeg: 'image/jpeg',
|
jpeg: 'image/jpeg',
|
||||||
jpg: 'image/jpeg',
|
jpg: 'image/jpeg',
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
gif: 'image/gif',
|
gif: 'image/gif',
|
||||||
};
|
};
|
||||||
function getContentType(filename: string): string {
|
function getContentType(filename: string): string {
|
||||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
return CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
return CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ filename: string }> }
|
{ params }: { params: Promise<{ filename: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { filename } = await params;
|
const { filename } = await params;
|
||||||
|
|
||||||
// Validate filename to prevent path traversal
|
// Validate filename to prevent path traversal
|
||||||
if (!SAFE_FILENAME.test(filename)) {
|
if (!SAFE_FILENAME.test(filename)) {
|
||||||
return apiErrors.badRequest('Invalid filename');
|
return apiErrors.badRequest('Invalid filename');
|
||||||
}
|
|
||||||
|
|
||||||
// Parallelize the DB lookup and session check to narrow the timing delta
|
|
||||||
// between "asset not found" and "asset found, access denied" responses.
|
|
||||||
const imageUrl = `/api/upload/image/${filename}`;
|
|
||||||
const projectSelect = {
|
|
||||||
id: true,
|
|
||||||
ownerId: true,
|
|
||||||
workspaceId: true,
|
|
||||||
visibility: true,
|
|
||||||
} as const;
|
|
||||||
const videoSelect = {
|
|
||||||
id: true,
|
|
||||||
projectId: true,
|
|
||||||
project: { select: projectSelect },
|
|
||||||
} as const;
|
|
||||||
const [comment, videoAsset, session] = await Promise.all([
|
|
||||||
db.comment.findFirst({
|
|
||||||
where: { imageUrl },
|
|
||||||
select: {
|
|
||||||
version: {
|
|
||||||
select: { video: { select: videoSelect } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
db.videoAsset.findFirst({
|
|
||||||
where: { sourceUrl: imageUrl },
|
|
||||||
select: { video: { select: videoSelect } },
|
|
||||||
}),
|
|
||||||
auth(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const video = comment?.version?.video ?? videoAsset?.video ?? null;
|
|
||||||
if (!video) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
|
||||||
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
|
||||||
const shareAccess = shareSession
|
|
||||||
? await validateShareLinkAccess({
|
|
||||||
token: shareSession.token,
|
|
||||||
projectId: video.projectId,
|
|
||||||
videoId: video.id,
|
|
||||||
requiredPermission: 'VIEW',
|
|
||||||
passwordVerified: shareSession.passwordVerified,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!shareAccess?.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `images/${filename}`;
|
|
||||||
return proxyR2MediaObject({
|
|
||||||
request,
|
|
||||||
key,
|
|
||||||
fallbackContentType: getContentType(filename),
|
|
||||||
cacheControl: 'private, no-store',
|
|
||||||
extraHeaders: {
|
|
||||||
'X-Content-Type-Options': 'nosniff',
|
|
||||||
'Content-Security-Policy': "default-src 'none'; sandbox",
|
|
||||||
},
|
|
||||||
internalErrorMessage: 'Failed to retrieve image',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logError('Error serving image:', error);
|
|
||||||
return apiErrors.internalError('Failed to retrieve image');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parallelize the DB lookup and session check to narrow the timing delta
|
||||||
|
// between "asset not found" and "asset found, access denied" responses.
|
||||||
|
const imageUrl = `/api/upload/image/${filename}`;
|
||||||
|
const projectSelect = {
|
||||||
|
id: true,
|
||||||
|
ownerId: true,
|
||||||
|
workspaceId: true,
|
||||||
|
visibility: true,
|
||||||
|
} as const;
|
||||||
|
const videoSelect = {
|
||||||
|
id: true,
|
||||||
|
projectId: true,
|
||||||
|
project: { select: projectSelect },
|
||||||
|
} as const;
|
||||||
|
const [comment, videoAsset, session] = await Promise.all([
|
||||||
|
db.comment.findFirst({
|
||||||
|
where: { imageUrl },
|
||||||
|
select: {
|
||||||
|
version: {
|
||||||
|
select: { video: { select: videoSelect } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.videoAsset.findFirst({
|
||||||
|
where: { sourceUrl: imageUrl },
|
||||||
|
select: { video: { select: videoSelect } },
|
||||||
|
}),
|
||||||
|
auth(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const video = comment?.version?.video ?? videoAsset?.video ?? null;
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||||
|
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||||
|
const shareAccess = shareSession
|
||||||
|
? await validateShareLinkAccess({
|
||||||
|
token: shareSession.token,
|
||||||
|
projectId: video.projectId,
|
||||||
|
videoId: video.id,
|
||||||
|
requiredPermission: 'VIEW',
|
||||||
|
passwordVerified: shareSession.passwordVerified,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!shareAccess?.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `images/${filename}`;
|
||||||
|
return proxyR2MediaObject({
|
||||||
|
request,
|
||||||
|
key,
|
||||||
|
fallbackContentType: getContentType(filename),
|
||||||
|
cacheControl: 'private, no-store',
|
||||||
|
extraHeaders: {
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Content-Security-Policy': "default-src 'none'; sandbox",
|
||||||
|
},
|
||||||
|
internalErrorMessage: 'Failed to retrieve image',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error serving image:', error);
|
||||||
|
return apiErrors.internalError('Failed to retrieve image');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-155
@@ -9,169 +9,181 @@ import { validateShareLinkAccess } from '@/lib/share-links';
|
|||||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import {
|
import {
|
||||||
detectImageMime,
|
detectImageMime,
|
||||||
getImageExtension,
|
getImageExtension,
|
||||||
isAllowedImageType,
|
isAllowedImageType,
|
||||||
normalizeImageMime,
|
normalizeImageMime,
|
||||||
} from '@/lib/image-upload-validation';
|
} from '@/lib/image-upload-validation';
|
||||||
import {
|
import {
|
||||||
deriveGuestUploadContext,
|
deriveGuestUploadContext,
|
||||||
enforceGuestUploadQuota,
|
enforceGuestUploadQuota,
|
||||||
verifyGuestUploadToken,
|
verifyGuestUploadToken,
|
||||||
} from '@/lib/guest-upload-token';
|
} from '@/lib/guest-upload-token';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
|
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead
|
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Check Content-Length header BEFORE loading the file
|
// Check Content-Length header BEFORE loading the file
|
||||||
const contentLength = request.headers.get('content-length');
|
const contentLength = request.headers.get('content-length');
|
||||||
if (!contentLength) {
|
if (!contentLength) {
|
||||||
return apiErrors.badRequest('Missing Content-Length header');
|
return apiErrors.badRequest('Missing Content-Length header');
|
||||||
}
|
|
||||||
const bodySize = parseInt(contentLength, 10);
|
|
||||||
if (isNaN(bodySize) || bodySize <= 0) {
|
|
||||||
return apiErrors.badRequest('Invalid Content-Length header');
|
|
||||||
}
|
|
||||||
if (bodySize > MAX_MULTIPART_BODY_SIZE) {
|
|
||||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limit
|
|
||||||
const limited = await rateLimit(request, 'image-upload');
|
|
||||||
if (limited) return limited;
|
|
||||||
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
|
||||||
const files = formData.getAll('image');
|
|
||||||
if (files.length !== 1) {
|
|
||||||
return apiErrors.badRequest('No image file provided');
|
|
||||||
}
|
|
||||||
const file = files[0];
|
|
||||||
const videoId = formData.get('videoId');
|
|
||||||
const uploadToken = formData.get('uploadToken');
|
|
||||||
|
|
||||||
if (!(file instanceof File)) {
|
|
||||||
return apiErrors.badRequest('No image file provided');
|
|
||||||
}
|
|
||||||
if (typeof videoId !== 'string' || !videoId.trim()) {
|
|
||||||
return apiErrors.badRequest('videoId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeVideoId = videoId.trim();
|
|
||||||
const video = await db.video.findUnique({
|
|
||||||
where: { id: safeVideoId },
|
|
||||||
include: {
|
|
||||||
project: {
|
|
||||||
include: { workspace: { select: { ownerId: true } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!video) {
|
|
||||||
return apiErrors.notFound('Video');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
|
||||||
const shareSession = getShareSessionFromRequest(request, safeVideoId);
|
|
||||||
const shareAccess = shareSession
|
|
||||||
? await validateShareLinkAccess({
|
|
||||||
token: shareSession.token,
|
|
||||||
projectId: video.projectId,
|
|
||||||
videoId: safeVideoId,
|
|
||||||
requiredPermission: 'COMMENT',
|
|
||||||
passwordVerified: shareSession.passwordVerified,
|
|
||||||
})
|
|
||||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
|
||||||
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
|
||||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
|
||||||
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
if (typeof uploadToken !== 'string' || !uploadToken.trim()) {
|
|
||||||
return apiErrors.badRequest('uploadToken is required for guest uploads');
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
|
||||||
if (!expectedContext) {
|
|
||||||
return apiErrors.forbidden('Missing trusted client IP header');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidUploadToken = verifyGuestUploadToken(uploadToken.trim(), {
|
|
||||||
projectId: video.projectId,
|
|
||||||
videoId: safeVideoId,
|
|
||||||
intent: 'image',
|
|
||||||
context: expectedContext,
|
|
||||||
});
|
|
||||||
if (!isValidUploadToken) {
|
|
||||||
return apiErrors.forbidden('Invalid upload token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'image', shareSession?.token ?? null);
|
|
||||||
if (quotaError) return quotaError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Double-check file size (defense in depth - Content-Length can be spoofed)
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce per-user storage quota before uploading.
|
|
||||||
// All paths use the advisory-locked reservation so concurrent uploads always
|
|
||||||
// see each other's in-flight sizes, eliminating the TOCTOU race.
|
|
||||||
const workspaceOwnerId = video.project.workspace.ownerId;
|
|
||||||
const reserveResult = await reserveStorageQuota(workspaceOwnerId, BigInt(file.size));
|
|
||||||
if ('error' in reserveResult) return reserveResult.error;
|
|
||||||
const reservationId = reserveResult.reservationId;
|
|
||||||
|
|
||||||
// Check content type
|
|
||||||
const normalizedMime = normalizeImageMime(file.type);
|
|
||||||
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
|
|
||||||
await releaseStorageReservation(reservationId);
|
|
||||||
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to buffer
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
const detectedMime = detectImageMime(buffer);
|
|
||||||
if (!detectedMime) {
|
|
||||||
await releaseStorageReservation(reservationId);
|
|
||||||
return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const ext = getImageExtension(detectedMime);
|
|
||||||
const filename = `${randomUUID()}.${ext}`;
|
|
||||||
const key = `images/${filename}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Upload to R2
|
|
||||||
await r2Client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: R2_BUCKET_NAME,
|
|
||||||
Key: key,
|
|
||||||
Body: buffer,
|
|
||||||
ContentType: detectedMime,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (uploadError) {
|
|
||||||
await releaseStorageReservation(reservationId);
|
|
||||||
throw uploadError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the URL through our proxy endpoint
|
|
||||||
const imageUrl = `/api/upload/image/${filename}`;
|
|
||||||
|
|
||||||
const response = successResponse({ url: imageUrl, reservationId }, 201);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error uploading image:', error);
|
|
||||||
return apiErrors.internalError('Failed to upload image');
|
|
||||||
}
|
}
|
||||||
|
const bodySize = parseInt(contentLength, 10);
|
||||||
|
if (isNaN(bodySize) || bodySize <= 0) {
|
||||||
|
return apiErrors.badRequest('Invalid Content-Length header');
|
||||||
|
}
|
||||||
|
if (bodySize > MAX_MULTIPART_BODY_SIZE) {
|
||||||
|
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit
|
||||||
|
const limited = await rateLimit(request, 'image-upload');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const files = formData.getAll('image');
|
||||||
|
if (files.length !== 1) {
|
||||||
|
return apiErrors.badRequest('No image file provided');
|
||||||
|
}
|
||||||
|
const file = files[0];
|
||||||
|
const videoId = formData.get('videoId');
|
||||||
|
const uploadToken = formData.get('uploadToken');
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return apiErrors.badRequest('No image file provided');
|
||||||
|
}
|
||||||
|
if (typeof videoId !== 'string' || !videoId.trim()) {
|
||||||
|
return apiErrors.badRequest('videoId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeVideoId = videoId.trim();
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: { id: safeVideoId },
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
include: { workspace: { select: { ownerId: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.notFound('Video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||||
|
const shareSession = getShareSessionFromRequest(request, safeVideoId);
|
||||||
|
const shareAccess = shareSession
|
||||||
|
? await validateShareLinkAccess({
|
||||||
|
token: shareSession.token,
|
||||||
|
projectId: video.projectId,
|
||||||
|
videoId: safeVideoId,
|
||||||
|
requiredPermission: 'COMMENT',
|
||||||
|
passwordVerified: shareSession.passwordVerified,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
hasAccess: false,
|
||||||
|
canComment: false,
|
||||||
|
canDownload: false,
|
||||||
|
allowGuests: false,
|
||||||
|
requiresPassword: false,
|
||||||
|
};
|
||||||
|
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||||
|
const canCommentWithShareLink =
|
||||||
|
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||||
|
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
if (typeof uploadToken !== 'string' || !uploadToken.trim()) {
|
||||||
|
return apiErrors.badRequest('uploadToken is required for guest uploads');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||||
|
if (!expectedContext) {
|
||||||
|
return apiErrors.forbidden('Missing trusted client IP header');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidUploadToken = verifyGuestUploadToken(uploadToken.trim(), {
|
||||||
|
projectId: video.projectId,
|
||||||
|
videoId: safeVideoId,
|
||||||
|
intent: 'image',
|
||||||
|
context: expectedContext,
|
||||||
|
});
|
||||||
|
if (!isValidUploadToken) {
|
||||||
|
return apiErrors.forbidden('Invalid upload token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaError = await enforceGuestUploadQuota(
|
||||||
|
request,
|
||||||
|
safeVideoId,
|
||||||
|
'image',
|
||||||
|
shareSession?.token ?? null
|
||||||
|
);
|
||||||
|
if (quotaError) return quotaError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-check file size (defense in depth - Content-Length can be spoofed)
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce per-user storage quota before uploading.
|
||||||
|
// All paths use the advisory-locked reservation so concurrent uploads always
|
||||||
|
// see each other's in-flight sizes, eliminating the TOCTOU race.
|
||||||
|
const workspaceOwnerId = video.project.workspace.ownerId;
|
||||||
|
const reserveResult = await reserveStorageQuota(workspaceOwnerId, BigInt(file.size));
|
||||||
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
|
const reservationId = reserveResult.reservationId;
|
||||||
|
|
||||||
|
// Check content type
|
||||||
|
const normalizedMime = normalizeImageMime(file.type);
|
||||||
|
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
|
||||||
|
await releaseStorageReservation(reservationId);
|
||||||
|
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to buffer
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const detectedMime = detectImageMime(buffer);
|
||||||
|
if (!detectedMime) {
|
||||||
|
await releaseStorageReservation(reservationId);
|
||||||
|
return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const ext = getImageExtension(detectedMime);
|
||||||
|
const filename = `${randomUUID()}.${ext}`;
|
||||||
|
const key = `images/${filename}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upload to R2
|
||||||
|
await r2Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: R2_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: detectedMime,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (uploadError) {
|
||||||
|
await releaseStorageReservation(reservationId);
|
||||||
|
throw uploadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the URL through our proxy endpoint
|
||||||
|
const imageUrl = `/api/upload/image/${filename}`;
|
||||||
|
|
||||||
|
const response = successResponse({ url: imageUrl, reservationId }, 201);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error uploading image:', error);
|
||||||
|
return apiErrors.internalError('Failed to upload image');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,28 +75,40 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
include: {
|
include: {
|
||||||
video: {
|
video: {
|
||||||
include: {
|
include: {
|
||||||
project: { select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true } },
|
project: {
|
||||||
|
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!version) return apiErrors.notFound('Version');
|
if (!version) return apiErrors.notFound('Version');
|
||||||
|
|
||||||
const access = await checkProjectAccess(version.video.project, session.user.id, { intent: 'manage' });
|
const access = await checkProjectAccess(version.video.project, session.user.id, {
|
||||||
|
intent: 'manage',
|
||||||
|
});
|
||||||
if (!access.canEdit) return apiErrors.forbidden('Access denied');
|
if (!access.canEdit) return apiErrors.forbidden('Access denied');
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({})) as { approverIds?: unknown; message?: unknown };
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
approverIds?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
||||||
if (message.length > 2000) {
|
if (message.length > 2000) {
|
||||||
return apiErrors.badRequest('Message must be 2000 characters or fewer');
|
return apiErrors.badRequest('Message must be 2000 characters or fewer');
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawApproverIds = Array.isArray(body.approverIds) ? body.approverIds : [];
|
const rawApproverIds = Array.isArray(body.approverIds) ? body.approverIds : [];
|
||||||
const approverIds = Array.from(new Set(
|
const approverIds = Array.from(
|
||||||
rawApproverIds
|
new Set(
|
||||||
.filter((approverId): approverId is string => typeof approverId === 'string' && approverId.trim().length > 0)
|
rawApproverIds
|
||||||
.map((approverId) => approverId.trim())
|
.filter(
|
||||||
));
|
(approverId): approverId is string =>
|
||||||
|
typeof approverId === 'string' && approverId.trim().length > 0
|
||||||
|
)
|
||||||
|
.map((approverId) => approverId.trim())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (approverIds.length === 0) {
|
if (approverIds.length === 0) {
|
||||||
return apiErrors.badRequest('At least one approver is required');
|
return apiErrors.badRequest('At least one approver is required');
|
||||||
@@ -114,43 +126,46 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.badRequest('One or more approvers are not eligible for this project');
|
return apiErrors.badRequest('One or more approvers are not eligible for this project');
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await db.$transaction(async (tx) => {
|
const created = await db.$transaction(
|
||||||
const existingPending = await tx.approvalRequest.findFirst({
|
async (tx) => {
|
||||||
where: { versionId, status: 'PENDING' },
|
const existingPending = await tx.approvalRequest.findFirst({
|
||||||
select: { id: true },
|
where: { versionId, status: 'PENDING' },
|
||||||
});
|
select: { id: true },
|
||||||
if (existingPending) {
|
});
|
||||||
throw new Error('__PENDING_REQUEST_EXISTS__');
|
if (existingPending) {
|
||||||
}
|
throw new Error('__PENDING_REQUEST_EXISTS__');
|
||||||
|
}
|
||||||
|
|
||||||
return tx.approvalRequest.create({
|
return tx.approvalRequest.create({
|
||||||
data: {
|
data: {
|
||||||
versionId,
|
versionId,
|
||||||
requestedById: session.user.id,
|
requestedById: session.user.id,
|
||||||
message: message || null,
|
message: message || null,
|
||||||
decisions: {
|
decisions: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: approverIds.map((approverId) => ({
|
data: approverIds.map((approverId) => ({
|
||||||
approverId,
|
approverId,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
})),
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
include: {
|
||||||
include: {
|
requestedBy: { select: { id: true, name: true, email: true, image: true } },
|
||||||
requestedBy: { select: { id: true, name: true, email: true, image: true } },
|
canceledBy: { select: { id: true, name: true, email: true, image: true } },
|
||||||
canceledBy: { select: { id: true, name: true, email: true, image: true } },
|
decisions: {
|
||||||
decisions: {
|
orderBy: { createdAt: 'asc' },
|
||||||
orderBy: { createdAt: 'asc' },
|
include: {
|
||||||
include: {
|
approver: { select: { id: true, name: true, email: true, image: true } },
|
||||||
approver: { select: { id: true, name: true, email: true, image: true } },
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
},
|
||||||
}, {
|
{
|
||||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const requesterName = session.user.name || 'A team member';
|
const requesterName = session.user.name || 'A team member';
|
||||||
const versionLabel = version.versionLabel || `Version ${version.versionNumber}`;
|
const versionLabel = version.versionLabel || `Version ${version.versionNumber}`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,10 @@ function buildBunnySourceCacheKey(
|
|||||||
return `${videoId}:${requestedQuality ?? 'none'}:${sourcePreference}`;
|
return `${videoId}:${requestedQuality ?? 'none'}:${sourcePreference}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCachedBunnyDownloadSource(cacheKey: string, now: number): BunnyDownloadSource | null | undefined {
|
function getCachedBunnyDownloadSource(
|
||||||
|
cacheKey: string,
|
||||||
|
now: number
|
||||||
|
): BunnyDownloadSource | null | undefined {
|
||||||
const cached = bunnyDownloadSourceCache.get(cacheKey);
|
const cached = bunnyDownloadSourceCache.get(cacheKey);
|
||||||
if (!cached) return undefined;
|
if (!cached) return undefined;
|
||||||
|
|
||||||
@@ -60,7 +63,11 @@ function getCachedBunnyDownloadSource(cacheKey: string, now: number): BunnyDownl
|
|||||||
return cached.source;
|
return cached.source;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCachedBunnyDownloadSource(cacheKey: string, source: BunnyDownloadSource | null, now: number): void {
|
function setCachedBunnyDownloadSource(
|
||||||
|
cacheKey: string,
|
||||||
|
source: BunnyDownloadSource | null,
|
||||||
|
now: number
|
||||||
|
): void {
|
||||||
if (bunnyDownloadSourceCache.size >= BUNNY_SOURCE_CACHE_MAX_ENTRIES) {
|
if (bunnyDownloadSourceCache.size >= BUNNY_SOURCE_CACHE_MAX_ENTRIES) {
|
||||||
// Evict the oldest entry (Maps preserve insertion order)
|
// Evict the oldest entry (Maps preserve insertion order)
|
||||||
const firstKey = bunnyDownloadSourceCache.keys().next().value;
|
const firstKey = bunnyDownloadSourceCache.keys().next().value;
|
||||||
@@ -146,7 +153,10 @@ async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloa
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
|
async function resolveBunnyCompressedSource(
|
||||||
|
videoId: string,
|
||||||
|
requestedQuality: number | null
|
||||||
|
): Promise<BunnyDownloadSource> {
|
||||||
const hostname = resolveBunnyCdnHostname();
|
const hostname = resolveBunnyCdnHostname();
|
||||||
if (!hostname) {
|
if (!hostname) {
|
||||||
return {
|
return {
|
||||||
@@ -156,7 +166,11 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) {
|
if (
|
||||||
|
typeof requestedQuality === 'number' &&
|
||||||
|
Number.isFinite(requestedQuality) &&
|
||||||
|
requestedQuality > 0
|
||||||
|
) {
|
||||||
const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`;
|
const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`;
|
||||||
if (await isRemoteFileAvailable(requestedUrl)) {
|
if (await isRemoteFileAvailable(requestedUrl)) {
|
||||||
return {
|
return {
|
||||||
@@ -243,9 +257,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const rawQuality = searchParams.get('quality');
|
const rawQuality = searchParams.get('quality');
|
||||||
const sourceParam = searchParams.get('source');
|
const sourceParam = searchParams.get('source');
|
||||||
const sourcePreference: BunnyDownloadSourcePreference =
|
const sourcePreference: BunnyDownloadSourcePreference =
|
||||||
sourceParam === null ? 'auto' : sourceParam === 'original' || sourceParam === 'compressed'
|
sourceParam === null
|
||||||
? sourceParam
|
? 'auto'
|
||||||
: 'auto';
|
: sourceParam === 'original' || sourceParam === 'compressed'
|
||||||
|
? sourceParam
|
||||||
|
: 'auto';
|
||||||
|
|
||||||
const version = await db.videoVersion.findUnique({
|
const version = await db.videoVersion.findUnique({
|
||||||
where: { id: versionId },
|
where: { id: versionId },
|
||||||
@@ -275,13 +291,19 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const shareSession = getShareSessionFromRequest(request, version.video.id);
|
const shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||||
const shareAccess = shareSession
|
const shareAccess = shareSession
|
||||||
? await validateShareLinkAccess({
|
? await validateShareLinkAccess({
|
||||||
token: shareSession.token,
|
token: shareSession.token,
|
||||||
projectId: version.video.projectId,
|
projectId: version.video.projectId,
|
||||||
videoId: version.video.id,
|
videoId: version.video.id,
|
||||||
requiredPermission: 'VIEW',
|
requiredPermission: 'VIEW',
|
||||||
passwordVerified: shareSession.passwordVerified,
|
passwordVerified: shareSession.passwordVerified,
|
||||||
})
|
})
|
||||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
: {
|
||||||
|
hasAccess: false,
|
||||||
|
canComment: false,
|
||||||
|
canDownload: false,
|
||||||
|
allowGuests: false,
|
||||||
|
requiresPassword: false,
|
||||||
|
};
|
||||||
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||||
if (!access.hasAccess && !canDownloadViaShareLink) {
|
if (!access.hasAccess && !canDownloadViaShareLink) {
|
||||||
return apiErrors.forbidden('Access denied');
|
return apiErrors.forbidden('Access denied');
|
||||||
@@ -299,7 +321,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
rawQuality !== null &&
|
rawQuality !== null &&
|
||||||
(!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality))
|
(!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality))
|
||||||
) {
|
) {
|
||||||
return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240');
|
return apiErrors.badRequest(
|
||||||
|
'Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawQuality !== null && sourcePreference === 'original') {
|
if (rawQuality !== null && sourcePreference === 'original') {
|
||||||
@@ -349,7 +373,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
workspaceId: version.video.project.workspace.id,
|
workspaceId: version.video.project.workspace.id,
|
||||||
billedUserId: version.video.project.workspace.ownerId,
|
billedUserId: version.video.project.workspace.ownerId,
|
||||||
downloaderUserId: session?.user?.id ?? null,
|
downloaderUserId: session?.user?.id ?? null,
|
||||||
source: source.sourceType === 'original' ? DownloadEgressSource.ORIGINAL : DownloadEgressSource.COMPRESSED,
|
source:
|
||||||
|
source.sourceType === 'original'
|
||||||
|
? DownloadEgressSource.ORIGINAL
|
||||||
|
: DownloadEgressSource.COMPRESSED,
|
||||||
quality: source.quality,
|
quality: source.quality,
|
||||||
estimatedBytes,
|
estimatedBytes,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,10 +152,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.badRequest('Invalid source. Allowed values: original, compressed');
|
return apiErrors.badRequest('Invalid source. Allowed values: original, compressed');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
rawQuality !== null
|
rawQuality !== null &&
|
||||||
&& (!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality))
|
(!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality))
|
||||||
) {
|
) {
|
||||||
return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240');
|
return apiErrors.badRequest(
|
||||||
|
'Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (rawQuality !== null && sourcePreference === 'original') {
|
if (rawQuality !== null && sourcePreference === 'original') {
|
||||||
return apiErrors.badRequest('Quality cannot be used when source=original');
|
return apiErrors.badRequest('Quality cannot be used when source=original');
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import { db } from '@/lib/db';
|
|||||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||||
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||||
import {
|
import { canDeleteAssetForViewer, getVideoAssetAccessContext } from '@/lib/video-assets';
|
||||||
canDeleteAssetForViewer,
|
|
||||||
getVideoAssetAccessContext,
|
|
||||||
} from '@/lib/video-assets';
|
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
||||||
@@ -72,12 +69,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
|
let bunnyCleanupResult:
|
||||||
|
| Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>
|
||||||
|
| undefined;
|
||||||
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
||||||
bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([{
|
bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([
|
||||||
providerId: 'bunny',
|
{
|
||||||
videoId: asset.providerVideoId,
|
providerId: 'bunny',
|
||||||
}]);
|
videoId: asset.providerVideoId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanupInput = {
|
const cleanupInput = {
|
||||||
|
|||||||
@@ -43,12 +43,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
||||||
if (!context.viewerUserId) {
|
if (!context.viewerUserId) {
|
||||||
const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null);
|
const quotaError = await enforceGuestUploadQuota(
|
||||||
|
request,
|
||||||
|
context.video.id,
|
||||||
|
'bunny',
|
||||||
|
shareSession?.token ?? null
|
||||||
|
);
|
||||||
if (quotaError) return quotaError;
|
if (quotaError) return quotaError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||||
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
const libraryId =
|
||||||
|
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||||
if (!apiKey || !libraryId) {
|
if (!apiKey || !libraryId) {
|
||||||
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
||||||
}
|
}
|
||||||
@@ -81,23 +87,29 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
let uploadToken = '';
|
let uploadToken = '';
|
||||||
if (context.viewerUserId) {
|
if (context.viewerUserId) {
|
||||||
uploadToken = createBunnyUploadToken({
|
uploadToken = createBunnyUploadToken(
|
||||||
userId: context.viewerUserId,
|
{
|
||||||
projectId: context.video.projectId,
|
userId: context.viewerUserId,
|
||||||
videoId: bunnyVideoId,
|
projectId: context.video.projectId,
|
||||||
}, 3600);
|
videoId: bunnyVideoId,
|
||||||
|
},
|
||||||
|
3600
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||||
if (!expectedContext) {
|
if (!expectedContext) {
|
||||||
return apiErrors.forbidden('Missing trusted client IP header');
|
return apiErrors.forbidden('Missing trusted client IP header');
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadToken = createGuestUploadToken({
|
uploadToken = createGuestUploadToken(
|
||||||
projectId: context.video.projectId,
|
{
|
||||||
videoId: context.video.id,
|
projectId: context.video.projectId,
|
||||||
intent: 'bunny',
|
videoId: context.video.id,
|
||||||
context: expectedContext,
|
intent: 'bunny',
|
||||||
}, 3600);
|
context: expectedContext,
|
||||||
|
},
|
||||||
|
3600
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = successResponse({
|
const response = successResponse({
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ import {
|
|||||||
sanitizeAssetDisplayName,
|
sanitizeAssetDisplayName,
|
||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { enforceStorageQuota, reserveStorageQuota, releaseStorageReservation, PLAN_STORAGE_LIMIT_BYTES } from '@/lib/storage-quota';
|
import {
|
||||||
|
enforceStorageQuota,
|
||||||
|
reserveStorageQuota,
|
||||||
|
releaseStorageReservation,
|
||||||
|
PLAN_STORAGE_LIMIT_BYTES,
|
||||||
|
} from '@/lib/storage-quota';
|
||||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
@@ -69,10 +74,7 @@ type YouTubeTitleCacheRecord = {
|
|||||||
const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>();
|
const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>();
|
||||||
|
|
||||||
function isAllowedBunnyMediaUrl(url: string): boolean {
|
function isAllowedBunnyMediaUrl(url: string): boolean {
|
||||||
const allowedHosts = new Set<string>([
|
const allowedHosts = new Set<string>(['iframe.mediadelivery.net', 'video.bunnycdn.com']);
|
||||||
'iframe.mediadelivery.net',
|
|
||||||
'video.bunnycdn.com',
|
|
||||||
]);
|
|
||||||
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
|
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
|
||||||
if (bunnyCdnHostname) {
|
if (bunnyCdnHostname) {
|
||||||
allowedHosts.add(bunnyCdnHostname);
|
allowedHosts.add(bunnyCdnHostname);
|
||||||
@@ -87,7 +89,11 @@ function isAllowedBunnyMediaUrl(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shapeAssetForViewer(asset: AssetWithViewerFields, canExposeSource: boolean, canDelete: boolean) {
|
function shapeAssetForViewer(
|
||||||
|
asset: AssetWithViewerFields,
|
||||||
|
canExposeSource: boolean,
|
||||||
|
canDelete: boolean
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
videoId: asset.videoId,
|
videoId: asset.videoId,
|
||||||
@@ -161,10 +167,12 @@ async function isFreshImageAttachment(url: string): Promise<AttachmentCheck> {
|
|||||||
if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
|
if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const head = await r2Client.send(new HeadObjectCommand({
|
const head = await r2Client.send(
|
||||||
Bucket: R2_BUCKET_NAME,
|
new HeadObjectCommand({
|
||||||
Key: key,
|
Bucket: R2_BUCKET_NAME,
|
||||||
}));
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
|
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||||
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||||
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
|
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
|
||||||
@@ -178,10 +186,12 @@ async function isFreshAudioAttachment(url: string): Promise<AttachmentCheck> {
|
|||||||
if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
|
if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const head = await r2Client.send(new HeadObjectCommand({
|
const head = await r2Client.send(
|
||||||
Bucket: R2_BUCKET_NAME,
|
new HeadObjectCommand({
|
||||||
Key: key,
|
Bucket: R2_BUCKET_NAME,
|
||||||
}));
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
|
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||||
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||||
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
|
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
|
||||||
@@ -201,7 +211,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (!context) return apiErrors.notFound('Video');
|
if (!context) return apiErrors.notFound('Video');
|
||||||
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
|
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
|
||||||
|
|
||||||
const requestedLimit = parsePaginationParam(request.nextUrl.searchParams.get('limit'), ASSET_LIST_DEFAULT_LIMIT);
|
const requestedLimit = parsePaginationParam(
|
||||||
|
request.nextUrl.searchParams.get('limit'),
|
||||||
|
ASSET_LIST_DEFAULT_LIMIT
|
||||||
|
);
|
||||||
const requestedOffset = parsePaginationParam(request.nextUrl.searchParams.get('offset'), 0);
|
const requestedOffset = parsePaginationParam(request.nextUrl.searchParams.get('offset'), 0);
|
||||||
const limit = Math.min(ASSET_LIST_MAX_LIMIT, Math.max(1, requestedLimit));
|
const limit = Math.min(ASSET_LIST_MAX_LIMIT, Math.max(1, requestedLimit));
|
||||||
const offset = requestedOffset;
|
const offset = requestedOffset;
|
||||||
@@ -215,10 +228,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const etag = `"assets:${videoId}:${limit}:${offset}:${includeDeleteMetadata ? 1 : 0}:${context.canDownloadAssets ? 1 : 0}:${assetsRevision._count.id}:${assetsRevision._max.updatedAt?.getTime() ?? 0}"`;
|
const etag = `"assets:${videoId}:${limit}:${offset}:${includeDeleteMetadata ? 1 : 0}:${context.canDownloadAssets ? 1 : 0}:${assetsRevision._count.id}:${assetsRevision._max.updatedAt?.getTime() ?? 0}"`;
|
||||||
const ifNoneMatch = request.headers.get('if-none-match');
|
const ifNoneMatch = request.headers.get('if-none-match');
|
||||||
if (ifNoneMatch) {
|
if (ifNoneMatch) {
|
||||||
const matches = ifNoneMatch
|
const matches = ifNoneMatch.split(',').map(normalizeEtag).includes(normalizeEtag(etag));
|
||||||
.split(',')
|
|
||||||
.map(normalizeEtag)
|
|
||||||
.includes(normalizeEtag(etag));
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
const notModified = new NextResponse(null, { status: 304 });
|
const notModified = new NextResponse(null, { status: 304 });
|
||||||
notModified.headers.set('ETag', etag);
|
notModified.headers.set('ETag', etag);
|
||||||
@@ -254,12 +264,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const pagedAssets = hasMore ? assets.slice(0, limit) : assets;
|
const pagedAssets = hasMore ? assets.slice(0, limit) : assets;
|
||||||
|
|
||||||
const response = successResponse({
|
const response = successResponse({
|
||||||
assets: pagedAssets.map((asset) => shapeAssetForViewer(
|
assets: pagedAssets.map((asset) =>
|
||||||
asset,
|
shapeAssetForViewer(
|
||||||
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
|
asset,
|
||||||
context.canDownloadAssets || (asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess),
|
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
|
||||||
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
context.canDownloadAssets ||
|
||||||
)),
|
(asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess),
|
||||||
|
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
||||||
|
)
|
||||||
|
),
|
||||||
pagination: {
|
pagination: {
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
@@ -378,7 +391,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
providerVideoId = parsedSource.videoId;
|
providerVideoId = parsedSource.videoId;
|
||||||
const youtubeTitle = await fetchYouTubeTitle(providerVideoId);
|
const youtubeTitle = await fetchYouTubeTitle(providerVideoId);
|
||||||
displayName = sanitizeAssetDisplayName(requestedDisplayName, youtubeTitle || `YouTube ${providerVideoId}`);
|
displayName = sanitizeAssetDisplayName(
|
||||||
|
requestedDisplayName,
|
||||||
|
youtubeTitle || `YouTube ${providerVideoId}`
|
||||||
|
);
|
||||||
sourceUrl = parsedSource.originalUrl;
|
sourceUrl = parsedSource.originalUrl;
|
||||||
thumbnailUrl = getThumbnailUrl(parsedSource, 'large');
|
thumbnailUrl = getThumbnailUrl(parsedSource, 'large');
|
||||||
kind = 'VIDEO';
|
kind = 'VIDEO';
|
||||||
@@ -386,7 +402,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
if (provider === VideoAssetProvider.BUNNY) {
|
if (provider === VideoAssetProvider.BUNNY) {
|
||||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||||
providerVideoId = typeof body?.providerVideoId === 'string' ? body.providerVideoId.trim() : '';
|
providerVideoId =
|
||||||
|
typeof body?.providerVideoId === 'string' ? body.providerVideoId.trim() : '';
|
||||||
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||||
thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null;
|
thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null;
|
||||||
|
|
||||||
@@ -515,10 +532,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
sizeBytes: assetSizeBytes,
|
sizeBytes: assetSizeBytes,
|
||||||
uploadedByUserId: context.viewerUserId,
|
uploadedByUserId: context.viewerUserId,
|
||||||
uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null,
|
uploadedByGuestIdentityId: context.viewerUserId
|
||||||
|
? null
|
||||||
|
: (guestIdentity?.identityId ?? null),
|
||||||
uploadedByGuestName: context.viewerUserId
|
uploadedByGuestName: context.viewerUserId
|
||||||
? null
|
? null
|
||||||
: sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'),
|
: sanitizeAssetDisplayName(
|
||||||
|
typeof body?.guestName === 'string' ? body.guestName : null,
|
||||||
|
'Guest'
|
||||||
|
),
|
||||||
billedUserId,
|
billedUserId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -540,11 +562,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = successResponse(shapeAssetForViewer(
|
const response = successResponse(
|
||||||
created,
|
shapeAssetForViewer(created, context.canDownloadAssets, true),
|
||||||
context.canDownloadAssets,
|
201
|
||||||
true
|
);
|
||||||
), 201);
|
|
||||||
if (isGuest && guestIdentity?.shouldSetCookie) {
|
if (isGuest && guestIdentity?.shouldSetCookie) {
|
||||||
setGuestIdentityCookie(response, guestIdentity.identityId);
|
setGuestIdentityCookie(response, guestIdentity.identityId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,158 +9,169 @@ type RouteParams = { params: Promise<{ videoId: string }> };
|
|||||||
|
|
||||||
// GET /api/watch/[videoId]/progress - Get watch progress for the current user
|
// GET /api/watch/[videoId]/progress - Get watch progress for the current user
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized('Authentication required');
|
return apiErrors.unauthorized('Authentication required');
|
||||||
}
|
|
||||||
|
|
||||||
const { videoId } = await params;
|
|
||||||
|
|
||||||
// Get the video and its active version (project access data pre-fetched in same query)
|
|
||||||
const userId = session.user.id;
|
|
||||||
const video = await db.video.findUnique({
|
|
||||||
where: { id: videoId },
|
|
||||||
include: {
|
|
||||||
project: { include: projectAccessInclude(userId) },
|
|
||||||
versions: {
|
|
||||||
where: { isActive: true },
|
|
||||||
take: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!video) {
|
|
||||||
return apiErrors.notFound('Video');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = computeProjectAccess(video.project, userId);
|
|
||||||
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeVersion = video.versions[0];
|
|
||||||
if (!activeVersion) {
|
|
||||||
return apiErrors.notFound('Video version');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get watch progress for this user and version
|
|
||||||
const progress = await db.watchProgress.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_versionId: {
|
|
||||||
userId: session.user.id,
|
|
||||||
versionId: activeVersion.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return successResponse({
|
|
||||||
progress: progress ? progress.progress : 0,
|
|
||||||
duration: progress?.duration || activeVersion.duration || 0,
|
|
||||||
percentage: progress?.percentage || 0,
|
|
||||||
updatedAt: progress?.updatedAt || null,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching watch progress:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch watch progress');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { videoId } = await params;
|
||||||
|
|
||||||
|
// Get the video and its active version (project access data pre-fetched in same query)
|
||||||
|
const userId = session.user.id;
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: { id: videoId },
|
||||||
|
include: {
|
||||||
|
project: { include: projectAccessInclude(userId) },
|
||||||
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.notFound('Video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = computeProjectAccess(video.project, userId);
|
||||||
|
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVersion = video.versions[0];
|
||||||
|
if (!activeVersion) {
|
||||||
|
return apiErrors.notFound('Video version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get watch progress for this user and version
|
||||||
|
const progress = await db.watchProgress.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_versionId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
versionId: activeVersion.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return successResponse({
|
||||||
|
progress: progress ? progress.progress : 0,
|
||||||
|
duration: progress?.duration || activeVersion.duration || 0,
|
||||||
|
percentage: progress?.percentage || 0,
|
||||||
|
updatedAt: progress?.updatedAt || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching watch progress:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch watch progress');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/watch/[videoId]/progress - Save watch progress for the current user
|
// POST /api/watch/[videoId]/progress - Save watch progress for the current user
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
// Rate limit watch progress updates (30 per minute to allow pause + periodic + visibility changes)
|
// Rate limit watch progress updates (30 per minute to allow pause + periodic + visibility changes)
|
||||||
const limited = await rateLimit(request, 'watch-progress');
|
const limited = await rateLimit(request, 'watch-progress');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized('Authentication required');
|
return apiErrors.unauthorized('Authentication required');
|
||||||
}
|
|
||||||
|
|
||||||
const { videoId } = await params;
|
|
||||||
const body = await request.json();
|
|
||||||
const { progress, duration, versionId } = body;
|
|
||||||
|
|
||||||
const MAX_VIDEO_SECONDS = 86_400; // 24 hours — reasonable upper bound for any video
|
|
||||||
|
|
||||||
if (typeof progress !== 'number' || !isFinite(progress) || progress < 0 || progress > MAX_VIDEO_SECONDS) {
|
|
||||||
return apiErrors.badRequest('Invalid progress value');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration !== undefined && (typeof duration !== 'number' || !isFinite(duration) || duration < 0 || duration > MAX_VIDEO_SECONDS)) {
|
|
||||||
return apiErrors.badRequest('Invalid duration value');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (versionId !== undefined && typeof versionId !== 'string') {
|
|
||||||
return apiErrors.badRequest('Invalid versionId');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always load the requested video and validate access before writing progress.
|
|
||||||
// If versionId is provided, verify it belongs to this video; otherwise resolve active version.
|
|
||||||
// Project access data is pre-fetched in the same query — no extra round-trips.
|
|
||||||
const userId = session.user.id;
|
|
||||||
const video = await db.video.findUnique({
|
|
||||||
where: { id: videoId },
|
|
||||||
include: {
|
|
||||||
project: { include: projectAccessInclude(userId) },
|
|
||||||
versions: {
|
|
||||||
where: versionId ? { id: versionId } : { isActive: true },
|
|
||||||
take: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!video) {
|
|
||||||
return apiErrors.notFound('Video');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = computeProjectAccess(video.project, userId);
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetVersion = video.versions[0];
|
|
||||||
if (!targetVersion) {
|
|
||||||
return apiErrors.notFound('Video version');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate percentage
|
|
||||||
const safeDuration = duration || 0;
|
|
||||||
const percentage = safeDuration > 0 ? Math.min(100, (progress / safeDuration) * 100) : 0;
|
|
||||||
|
|
||||||
// Client already filters tiny deltas (<2s) before sending — safe to upsert directly.
|
|
||||||
const watchProgress = await db.watchProgress.upsert({
|
|
||||||
where: {
|
|
||||||
userId_versionId: {
|
|
||||||
userId: session.user.id,
|
|
||||||
versionId: targetVersion.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
progress,
|
|
||||||
duration: safeDuration,
|
|
||||||
percentage,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
userId: session.user.id,
|
|
||||||
versionId: targetVersion.id,
|
|
||||||
progress,
|
|
||||||
duration: safeDuration,
|
|
||||||
percentage,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return successResponse({
|
|
||||||
success: true,
|
|
||||||
progress: watchProgress.progress,
|
|
||||||
percentage: watchProgress.percentage,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error saving watch progress:', error);
|
|
||||||
return apiErrors.internalError('Failed to save watch progress');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { videoId } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const { progress, duration, versionId } = body;
|
||||||
|
|
||||||
|
const MAX_VIDEO_SECONDS = 86_400; // 24 hours — reasonable upper bound for any video
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof progress !== 'number' ||
|
||||||
|
!isFinite(progress) ||
|
||||||
|
progress < 0 ||
|
||||||
|
progress > MAX_VIDEO_SECONDS
|
||||||
|
) {
|
||||||
|
return apiErrors.badRequest('Invalid progress value');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
duration !== undefined &&
|
||||||
|
(typeof duration !== 'number' ||
|
||||||
|
!isFinite(duration) ||
|
||||||
|
duration < 0 ||
|
||||||
|
duration > MAX_VIDEO_SECONDS)
|
||||||
|
) {
|
||||||
|
return apiErrors.badRequest('Invalid duration value');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionId !== undefined && typeof versionId !== 'string') {
|
||||||
|
return apiErrors.badRequest('Invalid versionId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always load the requested video and validate access before writing progress.
|
||||||
|
// If versionId is provided, verify it belongs to this video; otherwise resolve active version.
|
||||||
|
// Project access data is pre-fetched in the same query — no extra round-trips.
|
||||||
|
const userId = session.user.id;
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: { id: videoId },
|
||||||
|
include: {
|
||||||
|
project: { include: projectAccessInclude(userId) },
|
||||||
|
versions: {
|
||||||
|
where: versionId ? { id: versionId } : { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.notFound('Video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = computeProjectAccess(video.project, userId);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetVersion = video.versions[0];
|
||||||
|
if (!targetVersion) {
|
||||||
|
return apiErrors.notFound('Video version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate percentage
|
||||||
|
const safeDuration = duration || 0;
|
||||||
|
const percentage = safeDuration > 0 ? Math.min(100, (progress / safeDuration) * 100) : 0;
|
||||||
|
|
||||||
|
// Client already filters tiny deltas (<2s) before sending — safe to upsert directly.
|
||||||
|
const watchProgress = await db.watchProgress.upsert({
|
||||||
|
where: {
|
||||||
|
userId_versionId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
versionId: targetVersion.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
progress,
|
||||||
|
duration: safeDuration,
|
||||||
|
percentage,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
versionId: targetVersion.id,
|
||||||
|
progress,
|
||||||
|
duration: safeDuration,
|
||||||
|
percentage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return successResponse({
|
||||||
|
success: true,
|
||||||
|
progress: watchProgress.progress,
|
||||||
|
percentage: watchProgress.percentage,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error saving watch progress:', error);
|
||||||
|
return apiErrors.internalError('Failed to save watch progress');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+200
-191
@@ -12,203 +12,212 @@ type RouteParams = { params: Promise<{ videoId: string }> };
|
|||||||
|
|
||||||
// GET /api/watch/[videoId] - Public watch endpoint (no projectId needed)
|
// GET /api/watch/[videoId] - Public watch endpoint (no projectId needed)
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
// Rate limit: 60 requests per minute per IP for public watch endpoint
|
// Rate limit: 60 requests per minute per IP for public watch endpoint
|
||||||
const limited = await rateLimit(request, 'watch', { windowMs: 60 * 1000, maxRequests: 60 });
|
const limited = await rateLimit(request, 'watch', { windowMs: 60 * 1000, maxRequests: 60 });
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { videoId } = await params;
|
const { videoId } = await params;
|
||||||
|
|
||||||
// Parse query params
|
// Parse query params
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const includeComments = searchParams.get('includeComments') === 'true';
|
const includeComments = searchParams.get('includeComments') === 'true';
|
||||||
|
|
||||||
const video = await db.video.findUnique({
|
const video = await db.video.findUnique({
|
||||||
where: { id: videoId },
|
where: { id: videoId },
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
versions: {
|
versions: {
|
||||||
orderBy: { versionNumber: 'desc' },
|
orderBy: { versionNumber: 'desc' },
|
||||||
...(includeComments ? {
|
...(includeComments
|
||||||
include: {
|
? {
|
||||||
comments: {
|
include: {
|
||||||
orderBy: { timestamp: 'asc' },
|
comments: {
|
||||||
where: { parentId: null },
|
orderBy: { timestamp: 'asc' },
|
||||||
select: {
|
where: { parentId: null },
|
||||||
id: true,
|
select: {
|
||||||
content: true,
|
id: true,
|
||||||
timestamp: true,
|
content: true,
|
||||||
timestampEnd: true,
|
timestamp: true,
|
||||||
createdAt: true,
|
timestampEnd: true,
|
||||||
updatedAt: true,
|
createdAt: true,
|
||||||
isResolved: true,
|
updatedAt: true,
|
||||||
resolvedAt: true,
|
isResolved: true,
|
||||||
voiceUrl: true,
|
resolvedAt: true,
|
||||||
voiceDuration: true,
|
voiceUrl: true,
|
||||||
imageUrl: true,
|
voiceDuration: true,
|
||||||
annotationData: true,
|
imageUrl: true,
|
||||||
parentId: true,
|
annotationData: true,
|
||||||
authorId: true,
|
parentId: true,
|
||||||
guestIdentityId: true,
|
authorId: true,
|
||||||
tagId: true,
|
guestIdentityId: true,
|
||||||
versionId: true,
|
tagId: true,
|
||||||
guestName: true,
|
versionId: true,
|
||||||
author: { select: { id: true, name: true, image: true } },
|
guestName: true,
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
replies: {
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
orderBy: { createdAt: 'asc' },
|
replies: {
|
||||||
select: {
|
orderBy: { createdAt: 'asc' },
|
||||||
id: true,
|
|
||||||
content: true,
|
|
||||||
timestamp: true,
|
|
||||||
timestampEnd: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
isResolved: true,
|
|
||||||
resolvedAt: true,
|
|
||||||
voiceUrl: true,
|
|
||||||
voiceDuration: true,
|
|
||||||
imageUrl: true,
|
|
||||||
annotationData: true,
|
|
||||||
parentId: true,
|
|
||||||
authorId: true,
|
|
||||||
guestIdentityId: true,
|
|
||||||
tagId: true,
|
|
||||||
versionId: true,
|
|
||||||
guestName: true,
|
|
||||||
author: { select: { id: true, name: true, image: true } },
|
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { select: { comments: true } },
|
|
||||||
},
|
|
||||||
} : {
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
thumbnailUrl: true,
|
content: true,
|
||||||
duration: true,
|
timestamp: true,
|
||||||
versionNumber: true,
|
timestampEnd: true,
|
||||||
versionLabel: true,
|
createdAt: true,
|
||||||
providerId: true,
|
updatedAt: true,
|
||||||
videoId: true,
|
isResolved: true,
|
||||||
originalUrl: true,
|
resolvedAt: true,
|
||||||
title: true,
|
voiceUrl: true,
|
||||||
isActive: true,
|
voiceDuration: true,
|
||||||
_count: { select: { comments: true } },
|
imageUrl: true,
|
||||||
|
annotationData: true,
|
||||||
|
parentId: true,
|
||||||
|
authorId: true,
|
||||||
|
guestIdentityId: true,
|
||||||
|
tagId: true,
|
||||||
|
versionId: true,
|
||||||
|
guestName: true,
|
||||||
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { comments: true } },
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
});
|
: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
duration: true,
|
||||||
|
versionNumber: true,
|
||||||
|
versionLabel: true,
|
||||||
|
providerId: true,
|
||||||
|
videoId: true,
|
||||||
|
originalUrl: true,
|
||||||
|
title: true,
|
||||||
|
isActive: true,
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return apiErrors.notFound('Video');
|
return apiErrors.notFound('Video');
|
||||||
}
|
|
||||||
|
|
||||||
// Check access including workspace membership
|
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
|
||||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
|
||||||
const shareAccess = shareSession
|
|
||||||
? await validateShareLinkAccess({
|
|
||||||
token: shareSession.token,
|
|
||||||
projectId: video.projectId,
|
|
||||||
videoId: video.id,
|
|
||||||
requiredPermission: 'VIEW',
|
|
||||||
passwordVerified: shareSession.passwordVerified,
|
|
||||||
})
|
|
||||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
|
||||||
|
|
||||||
if (!access.hasAccess && !shareAccess.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include auth context so the client knows if the viewer is a guest
|
|
||||||
const { project, ...videoData } = video;
|
|
||||||
const viewerUserId = session?.user?.id ?? null;
|
|
||||||
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
|
|
||||||
const isProjectOwner = viewerUserId === project.ownerId;
|
|
||||||
|
|
||||||
const versions = videoData.versions.map((version) => {
|
|
||||||
if (!('comments' in version)) {
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...version,
|
|
||||||
comments: version.comments.map((comment) => {
|
|
||||||
const canEditComment = viewerUserId
|
|
||||||
? comment.authorId === viewerUserId
|
|
||||||
: !!viewerGuestIdentityId
|
|
||||||
&& !!comment.guestIdentityId
|
|
||||||
&& comment.guestIdentityId === viewerGuestIdentityId;
|
|
||||||
const canDeleteComment = canEditComment || isProjectOwner;
|
|
||||||
const replies = comment.replies;
|
|
||||||
const commentData = Object.fromEntries(
|
|
||||||
Object.entries(comment).filter(
|
|
||||||
([key]) => key !== 'authorId' && key !== 'guestIdentityId' && key !== 'replies'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...commentData,
|
|
||||||
canEdit: canEditComment,
|
|
||||||
canDelete: canDeleteComment,
|
|
||||||
replies: replies.map((reply) => {
|
|
||||||
const canEditReply = viewerUserId
|
|
||||||
? reply.authorId === viewerUserId
|
|
||||||
: !!viewerGuestIdentityId
|
|
||||||
&& !!reply.guestIdentityId
|
|
||||||
&& reply.guestIdentityId === viewerGuestIdentityId;
|
|
||||||
const canDeleteReply = canEditReply || isProjectOwner;
|
|
||||||
const replyData = Object.fromEntries(
|
|
||||||
Object.entries(reply).filter(
|
|
||||||
([key]) => key !== 'authorId' && key !== 'guestIdentityId'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...replyData,
|
|
||||||
canEdit: canEditReply,
|
|
||||||
canDelete: canDeleteReply,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const canCommentWithMembership = access.hasAccess;
|
|
||||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
|
||||||
const canDownloadWithMembership = access.hasAccess;
|
|
||||||
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
|
||||||
const canUploadAssets = canCommentWithMembership || canCommentWithShareLink;
|
|
||||||
const canDownloadAssets = !!session?.user?.id && (access.hasAccess || shareAccess.hasAccess);
|
|
||||||
const response = successResponse({
|
|
||||||
...videoData,
|
|
||||||
versions,
|
|
||||||
projectId: video.projectId,
|
|
||||||
project: {
|
|
||||||
name: project.name,
|
|
||||||
ownerId: project.ownerId,
|
|
||||||
visibility: project.visibility,
|
|
||||||
},
|
|
||||||
isAuthenticated: !!session?.user?.id,
|
|
||||||
currentUserId: session?.user?.id || null,
|
|
||||||
currentUserName: session?.user?.name || null,
|
|
||||||
canComment: canCommentWithMembership || canCommentWithShareLink,
|
|
||||||
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
|
|
||||||
canManageTags: access.canEdit,
|
|
||||||
canResolveComments: access.canEdit,
|
|
||||||
canShareVideo: access.canEdit,
|
|
||||||
canUploadAssets,
|
|
||||||
canDownloadAssets,
|
|
||||||
});
|
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-cache');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching video:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch video');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check access including workspace membership
|
||||||
|
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||||
|
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||||
|
const shareAccess = shareSession
|
||||||
|
? await validateShareLinkAccess({
|
||||||
|
token: shareSession.token,
|
||||||
|
projectId: video.projectId,
|
||||||
|
videoId: video.id,
|
||||||
|
requiredPermission: 'VIEW',
|
||||||
|
passwordVerified: shareSession.passwordVerified,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
hasAccess: false,
|
||||||
|
canComment: false,
|
||||||
|
canDownload: false,
|
||||||
|
allowGuests: false,
|
||||||
|
requiresPassword: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!access.hasAccess && !shareAccess.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include auth context so the client knows if the viewer is a guest
|
||||||
|
const { project, ...videoData } = video;
|
||||||
|
const viewerUserId = session?.user?.id ?? null;
|
||||||
|
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
|
||||||
|
const isProjectOwner = viewerUserId === project.ownerId;
|
||||||
|
|
||||||
|
const versions = videoData.versions.map((version) => {
|
||||||
|
if (!('comments' in version)) {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...version,
|
||||||
|
comments: version.comments.map((comment) => {
|
||||||
|
const canEditComment = viewerUserId
|
||||||
|
? comment.authorId === viewerUserId
|
||||||
|
: !!viewerGuestIdentityId &&
|
||||||
|
!!comment.guestIdentityId &&
|
||||||
|
comment.guestIdentityId === viewerGuestIdentityId;
|
||||||
|
const canDeleteComment = canEditComment || isProjectOwner;
|
||||||
|
const replies = comment.replies;
|
||||||
|
const commentData = Object.fromEntries(
|
||||||
|
Object.entries(comment).filter(
|
||||||
|
([key]) => key !== 'authorId' && key !== 'guestIdentityId' && key !== 'replies'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commentData,
|
||||||
|
canEdit: canEditComment,
|
||||||
|
canDelete: canDeleteComment,
|
||||||
|
replies: replies.map((reply) => {
|
||||||
|
const canEditReply = viewerUserId
|
||||||
|
? reply.authorId === viewerUserId
|
||||||
|
: !!viewerGuestIdentityId &&
|
||||||
|
!!reply.guestIdentityId &&
|
||||||
|
reply.guestIdentityId === viewerGuestIdentityId;
|
||||||
|
const canDeleteReply = canEditReply || isProjectOwner;
|
||||||
|
const replyData = Object.fromEntries(
|
||||||
|
Object.entries(reply).filter(
|
||||||
|
([key]) => key !== 'authorId' && key !== 'guestIdentityId'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...replyData,
|
||||||
|
canEdit: canEditReply,
|
||||||
|
canDelete: canDeleteReply,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const canCommentWithMembership = access.hasAccess;
|
||||||
|
const canCommentWithShareLink =
|
||||||
|
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||||
|
const canDownloadWithMembership = access.hasAccess;
|
||||||
|
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||||
|
const canUploadAssets = canCommentWithMembership || canCommentWithShareLink;
|
||||||
|
const canDownloadAssets = !!session?.user?.id && (access.hasAccess || shareAccess.hasAccess);
|
||||||
|
const response = successResponse({
|
||||||
|
...videoData,
|
||||||
|
versions,
|
||||||
|
projectId: video.projectId,
|
||||||
|
project: {
|
||||||
|
name: project.name,
|
||||||
|
ownerId: project.ownerId,
|
||||||
|
visibility: project.visibility,
|
||||||
|
},
|
||||||
|
isAuthenticated: !!session?.user?.id,
|
||||||
|
currentUserId: session?.user?.id || null,
|
||||||
|
currentUserName: session?.user?.name || null,
|
||||||
|
canComment: canCommentWithMembership || canCommentWithShareLink,
|
||||||
|
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
|
||||||
|
canManageTags: access.canEdit,
|
||||||
|
canResolveComments: access.canEdit,
|
||||||
|
canShareVideo: access.canEdit,
|
||||||
|
canUploadAssets,
|
||||||
|
canDownloadAssets,
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, no-cache');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching video:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch video');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,13 +64,19 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||||
const shareAccess = shareSession
|
const shareAccess = shareSession
|
||||||
? await validateShareLinkAccess({
|
? await validateShareLinkAccess({
|
||||||
token: shareSession.token,
|
token: shareSession.token,
|
||||||
projectId: video.projectId,
|
projectId: video.projectId,
|
||||||
videoId: video.id,
|
videoId: video.id,
|
||||||
requiredPermission: 'COMMENT',
|
requiredPermission: 'COMMENT',
|
||||||
passwordVerified: shareSession.passwordVerified,
|
passwordVerified: shareSession.passwordVerified,
|
||||||
})
|
})
|
||||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
: {
|
||||||
|
hasAccess: false,
|
||||||
|
canComment: false,
|
||||||
|
canDownload: false,
|
||||||
|
allowGuests: false,
|
||||||
|
requiresPassword: false,
|
||||||
|
};
|
||||||
|
|
||||||
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||||
const canCommentWithShareLink = shareAccess.canComment && shareAccess.allowGuests;
|
const canCommentWithShareLink = shareAccess.canComment && shareAccess.allowGuests;
|
||||||
|
|||||||
@@ -10,143 +10,143 @@ type RouteParams = { params: Promise<{ workspaceId: string; memberId: string }>
|
|||||||
|
|
||||||
// PATCH /api/workspaces/[workspaceId]/members/[memberId] - Update member role
|
// PATCH /api/workspaces/[workspaceId]/members/[memberId] - Update member role
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'manage-member');
|
const limited = await rateLimit(request, 'manage-member');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId, memberId } = await params;
|
const { workspaceId, memberId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is owner or admin
|
|
||||||
const workspace = await db.workspace.findUnique({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
include: { members: { where: { userId: session.user.id } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return apiErrors.notFound('Workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkWorkspaceAccess(
|
|
||||||
{ id: workspace.id, ownerId: workspace.ownerId },
|
|
||||||
session.user.id
|
|
||||||
);
|
|
||||||
const isOwner = workspace.ownerId === session.user.id;
|
|
||||||
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
|
||||||
|
|
||||||
if (!access.canEdit || (!isOwner && !isAdmin)) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { role } = body;
|
|
||||||
|
|
||||||
const validRoles = ['ADMIN', 'COMMENTATOR'];
|
|
||||||
if (!validRoles.includes(role)) {
|
|
||||||
return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const member = await db.workspaceMember.findFirst({
|
|
||||||
where: { id: memberId, workspaceId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!member) {
|
|
||||||
return apiErrors.notFound('Member');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedMember = await db.workspaceMember.update({
|
|
||||||
where: { id: member.id },
|
|
||||||
data: { role: role as WorkspaceMemberRole },
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, image: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(updatedMember);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating member role:', error);
|
|
||||||
return apiErrors.internalError('Failed to update member role');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is owner or admin
|
||||||
|
const workspace = await db.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
include: { members: { where: { userId: session.user.id } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return apiErrors.notFound('Workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(
|
||||||
|
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
const isOwner = workspace.ownerId === session.user.id;
|
||||||
|
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
||||||
|
|
||||||
|
if (!access.canEdit || (!isOwner && !isAdmin)) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { role } = body;
|
||||||
|
|
||||||
|
const validRoles = ['ADMIN', 'COMMENTATOR'];
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await db.workspaceMember.findFirst({
|
||||||
|
where: { id: memberId, workspaceId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return apiErrors.notFound('Member');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMember = await db.workspaceMember.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { role: role as WorkspaceMemberRole },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, image: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(updatedMember);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating member role:', error);
|
||||||
|
return apiErrors.internalError('Failed to update member role');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/workspaces/[workspaceId]/members/[memberId] - Remove member
|
// DELETE /api/workspaces/[workspaceId]/members/[memberId] - Remove member
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'manage-member');
|
const limited = await rateLimit(request, 'manage-member');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId, memberId } = await params;
|
const { workspaceId, memberId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
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 access = await checkWorkspaceAccess(
|
|
||||||
{ id: workspace.id, ownerId: workspace.ownerId },
|
|
||||||
session.user.id
|
|
||||||
);
|
|
||||||
const isOwner = workspace.ownerId === session.user.id;
|
|
||||||
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
|
||||||
|
|
||||||
// Users can remove themselves, admins/owners can remove anyone
|
|
||||||
const memberToRemove = await db.workspaceMember.findFirst({
|
|
||||||
where: { id: memberId, workspaceId },
|
|
||||||
select: { id: true, userId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!memberToRemove) {
|
|
||||||
return apiErrors.notFound('Member');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSelf = memberToRemove.userId === session.user.id;
|
|
||||||
|
|
||||||
if ((!access.canEdit || (!isOwner && !isAdmin)) && !isSelf) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.$transaction(async (tx) => {
|
|
||||||
await tx.projectMember.deleteMany({
|
|
||||||
where: {
|
|
||||||
userId: memberToRemove.userId,
|
|
||||||
project: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.project.updateMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
ownerId: memberToRemove.userId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
ownerId: workspace.ownerId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.workspaceMember.delete({ where: { id: memberToRemove.id } });
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse({ message: 'Member removed' });
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error removing member:', error);
|
|
||||||
return apiErrors.internalError('Failed to remove member');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspace = await db.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
include: { members: { where: { userId: session.user.id } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return apiErrors.notFound('Workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(
|
||||||
|
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
const isOwner = workspace.ownerId === session.user.id;
|
||||||
|
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
||||||
|
|
||||||
|
// Users can remove themselves, admins/owners can remove anyone
|
||||||
|
const memberToRemove = await db.workspaceMember.findFirst({
|
||||||
|
where: { id: memberId, workspaceId },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!memberToRemove) {
|
||||||
|
return apiErrors.notFound('Member');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf = memberToRemove.userId === session.user.id;
|
||||||
|
|
||||||
|
if ((!access.canEdit || (!isOwner && !isAdmin)) && !isSelf) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.projectMember.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: memberToRemove.userId,
|
||||||
|
project: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.project.updateMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
ownerId: memberToRemove.userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
ownerId: workspace.ownerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.workspaceMember.delete({ where: { id: memberToRemove.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse({ message: 'Member removed' });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error removing member:', error);
|
||||||
|
return apiErrors.internalError('Failed to remove member');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { db } from '@/lib/db';
|
|||||||
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
||||||
import { InvitationRole, WorkspaceMemberRole } from '@prisma/client';
|
import { InvitationRole, WorkspaceMemberRole } from '@prisma/client';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations';
|
import {
|
||||||
|
buildInvitationUrl,
|
||||||
|
createOrRefreshInvitation,
|
||||||
|
sendInvitationEmail,
|
||||||
|
} from '@/lib/invitations';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
@@ -11,215 +15,211 @@ type RouteParams = { params: Promise<{ workspaceId: string }> };
|
|||||||
|
|
||||||
// GET /api/workspaces/[workspaceId]/members - List members
|
// GET /api/workspaces/[workspaceId]/members - List members
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId } = await params;
|
const { workspaceId } = await params;
|
||||||
const MAX_LIMIT = 100;
|
const MAX_LIMIT = 100;
|
||||||
const MAX_PAGE = 1000;
|
const MAX_PAGE = 1000;
|
||||||
const MAX_OFFSET = 10000;
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const pageParam = searchParams.get('page');
|
|
||||||
const limitParam = searchParams.get('limit');
|
|
||||||
|
|
||||||
const pageRaw = pageParam === null ? 1 : Number(pageParam);
|
|
||||||
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
|
|
||||||
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limitRaw = limitParam === null ? 20 : Number(limitParam);
|
|
||||||
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
|
||||||
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = pageRaw;
|
|
||||||
const limit = limitRaw;
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
|
|
||||||
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await db.workspace.findUnique({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
include: {
|
|
||||||
members: { where: { userId: session.user.id } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return apiErrors.notFound('Workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkWorkspaceAccess(
|
|
||||||
{ id: workspace.id, ownerId: workspace.ownerId },
|
|
||||||
session.user.id
|
|
||||||
);
|
|
||||||
const isOwner = workspace.ownerId === session.user.id;
|
|
||||||
const isMember = workspace.members.length > 0;
|
|
||||||
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
|
||||||
|
|
||||||
if (!access.hasAccess || (!isOwner && !isMember)) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const canViewPendingInvitations = isOwner || isAdmin;
|
|
||||||
const [members, total, pendingInvitations] = await Promise.all([
|
|
||||||
db.workspaceMember.findMany({
|
|
||||||
where: { workspaceId },
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, email: true, image: true } },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
}),
|
|
||||||
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
|
|
||||||
const owner = await db.user.findUnique({
|
|
||||||
where: { id: workspace.ownerId },
|
|
||||||
select: { id: true, name: true, email: true, image: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(
|
|
||||||
{ members, owner, pendingInvitations },
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching workspace members:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch members');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const pageParam = searchParams.get('page');
|
||||||
|
const limitParam = searchParams.get('limit');
|
||||||
|
|
||||||
|
const pageRaw = pageParam === null ? 1 : Number(pageParam);
|
||||||
|
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
|
||||||
|
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitRaw = limitParam === null ? 20 : Number(limitParam);
|
||||||
|
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
||||||
|
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pageRaw;
|
||||||
|
const limit = limitRaw;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
|
||||||
|
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await db.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
include: {
|
||||||
|
members: { where: { userId: session.user.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return apiErrors.notFound('Workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(
|
||||||
|
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
const isOwner = workspace.ownerId === session.user.id;
|
||||||
|
const isMember = workspace.members.length > 0;
|
||||||
|
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
||||||
|
|
||||||
|
if (!access.hasAccess || (!isOwner && !isMember)) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const canViewPendingInvitations = isOwner || isAdmin;
|
||||||
|
const [members, total, pendingInvitations] = await Promise.all([
|
||||||
|
db.workspaceMember.findMany({
|
||||||
|
where: { workspaceId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
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
|
||||||
|
const owner = await db.user.findUnique({
|
||||||
|
where: { id: workspace.ownerId },
|
||||||
|
select: { id: true, name: true, email: true, image: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse({ members, owner, pendingInvitations }, 200, {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching workspace members:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch members');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/workspaces/[workspaceId]/members - Invite a member
|
// POST /api/workspaces/[workspaceId]/members - Invite a member
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'invite-member');
|
const limited = await rateLimit(request, 'invite-member');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId } = await params;
|
const { workspaceId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is owner or admin
|
|
||||||
const workspace = await db.workspace.findUnique({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
include: { members: { where: { userId: session.user.id } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return apiErrors.notFound('Workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkWorkspaceAccess(
|
|
||||||
{ id: workspace.id, ownerId: workspace.ownerId },
|
|
||||||
session.user.id
|
|
||||||
);
|
|
||||||
const isOwner = workspace.ownerId === session.user.id;
|
|
||||||
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
|
||||||
|
|
||||||
if (!access.canEdit || (!isOwner && !isAdmin)) {
|
|
||||||
return apiErrors.forbidden('Only workspace owners and admins can invite members');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { email, role } = body;
|
|
||||||
|
|
||||||
if (!email || typeof email !== 'string') {
|
|
||||||
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';
|
|
||||||
|
|
||||||
// If this email belongs to an existing user, validate owner/member conflicts.
|
|
||||||
const userToInvite = await db.user.findUnique({
|
|
||||||
where: { email: normalizedEmail },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userToInvite?.id === workspace.ownerId) {
|
|
||||||
return apiErrors.badRequest('Cannot invite the workspace owner as a member');
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invitation = await createOrRefreshInvitation({
|
|
||||||
email: normalizedEmail,
|
|
||||||
scope: 'WORKSPACE',
|
|
||||||
role: memberRole as InvitationRole,
|
|
||||||
invitedById: session.user.id,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const invitationUrl = buildInvitationUrl(invitation.token);
|
|
||||||
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) {
|
|
||||||
logError('Error inviting workspace member:', error);
|
|
||||||
return apiErrors.internalError('Failed to invite member');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is owner or admin
|
||||||
|
const workspace = await db.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
include: { members: { where: { userId: session.user.id } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return apiErrors.notFound('Workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(
|
||||||
|
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
const isOwner = workspace.ownerId === session.user.id;
|
||||||
|
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
||||||
|
|
||||||
|
if (!access.canEdit || (!isOwner && !isAdmin)) {
|
||||||
|
return apiErrors.forbidden('Only workspace owners and admins can invite members');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { email, role } = body;
|
||||||
|
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
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';
|
||||||
|
|
||||||
|
// If this email belongs to an existing user, validate owner/member conflicts.
|
||||||
|
const userToInvite = await db.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userToInvite?.id === workspace.ownerId) {
|
||||||
|
return apiErrors.badRequest('Cannot invite the workspace owner as a member');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitation = await createOrRefreshInvitation({
|
||||||
|
email: normalizedEmail,
|
||||||
|
scope: 'WORKSPACE',
|
||||||
|
role: memberRole as InvitationRole,
|
||||||
|
invitedById: session.user.id,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invitationUrl = buildInvitationUrl(invitation.token);
|
||||||
|
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) {
|
||||||
|
logError('Error inviting workspace member:', error);
|
||||||
|
return apiErrors.internalError('Failed to invite member');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,228 +12,228 @@ type RouteParams = { params: Promise<{ workspaceId: string }> };
|
|||||||
|
|
||||||
// GET /api/workspaces/[workspaceId] - Get a single workspace
|
// GET /api/workspaces/[workspaceId] - Get a single workspace
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId } = await params;
|
const { workspaceId } = await params;
|
||||||
const MAX_LIMIT = 100;
|
const MAX_LIMIT = 100;
|
||||||
const MAX_OFFSET = 10000;
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const limitParam = searchParams.get('limit');
|
|
||||||
const offsetParam = searchParams.get('offset');
|
|
||||||
|
|
||||||
const limitRaw = limitParam === null ? 20 : Number(limitParam);
|
|
||||||
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
|
||||||
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = offsetParam === null ? 0 : Number(offsetParam);
|
|
||||||
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
|
|
||||||
return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = limitRaw;
|
|
||||||
|
|
||||||
const workspace = await db.workspace.findUnique({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
include: {
|
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
|
||||||
members: {
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, name: true, image: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
projects: {
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
skip: offset,
|
|
||||||
take: limit,
|
|
||||||
include: {
|
|
||||||
_count: { select: { videos: true, members: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { select: { projects: true, members: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return apiErrors.notFound('Workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkWorkspaceAccess(
|
|
||||||
{ id: workspace.id, ownerId: workspace.ownerId },
|
|
||||||
session.user.id
|
|
||||||
);
|
|
||||||
if (!access.hasAccess) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse(workspace);
|
|
||||||
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching workspace:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch workspace');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const limitParam = searchParams.get('limit');
|
||||||
|
const offsetParam = searchParams.get('offset');
|
||||||
|
|
||||||
|
const limitRaw = limitParam === null ? 20 : Number(limitParam);
|
||||||
|
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
||||||
|
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = offsetParam === null ? 0 : Number(offsetParam);
|
||||||
|
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
|
||||||
|
return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = limitRaw;
|
||||||
|
|
||||||
|
const workspace = await db.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, image: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
_count: { select: { videos: true, members: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { projects: true, members: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return apiErrors.notFound('Workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(
|
||||||
|
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse(workspace);
|
||||||
|
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching workspace:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch workspace');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/workspaces/[workspaceId] - Update a workspace
|
// PATCH /api/workspaces/[workspaceId] - Update a workspace
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId } = await params;
|
const { workspaceId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceAccessTarget = await db.workspace.findUnique({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
select: { id: true, ownerId: true },
|
|
||||||
});
|
|
||||||
if (!workspaceAccessTarget) {
|
|
||||||
return apiErrors.notFound('Workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkWorkspaceAccess(workspaceAccessTarget, session.user.id);
|
|
||||||
if (!access.canEdit) {
|
|
||||||
return apiErrors.forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, description } = body;
|
|
||||||
|
|
||||||
if (name !== undefined) {
|
|
||||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
||||||
return apiErrors.badRequest('Name must be a non-empty string');
|
|
||||||
}
|
|
||||||
if (name.trim().length > 100) {
|
|
||||||
return apiErrors.badRequest('Name must be 100 characters or fewer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description !== undefined && description !== null) {
|
|
||||||
if (typeof description !== 'string') {
|
|
||||||
return apiErrors.badRequest('Description must be a string');
|
|
||||||
}
|
|
||||||
if (description.trim().length > 1000) {
|
|
||||||
return apiErrors.badRequest('Description must be 1000 characters or fewer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
|
||||||
if (name !== undefined) updateData.name = name.trim();
|
|
||||||
if (description !== undefined) updateData.description = description?.trim() || null;
|
|
||||||
|
|
||||||
const workspace = await db.workspace.update({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
data: updateData,
|
|
||||||
include: {
|
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
|
||||||
_count: { select: { projects: true, members: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(workspace);
|
|
||||||
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error updating workspace:', error);
|
|
||||||
return apiErrors.internalError('Failed to update workspace');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspaceAccessTarget = await db.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
select: { id: true, ownerId: true },
|
||||||
|
});
|
||||||
|
if (!workspaceAccessTarget) {
|
||||||
|
return apiErrors.notFound('Workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(workspaceAccessTarget, session.user.id);
|
||||||
|
if (!access.canEdit) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description } = body;
|
||||||
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||||
|
return apiErrors.badRequest('Name must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (name.trim().length > 100) {
|
||||||
|
return apiErrors.badRequest('Name must be 100 characters or fewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (description !== undefined && description !== null) {
|
||||||
|
if (typeof description !== 'string') {
|
||||||
|
return apiErrors.badRequest('Description must be a string');
|
||||||
|
}
|
||||||
|
if (description.trim().length > 1000) {
|
||||||
|
return apiErrors.badRequest('Description must be 1000 characters or fewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (name !== undefined) updateData.name = name.trim();
|
||||||
|
if (description !== undefined) updateData.description = description?.trim() || null;
|
||||||
|
|
||||||
|
const workspace = await db.workspace.update({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { projects: true, members: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(workspace);
|
||||||
|
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating workspace:', error);
|
||||||
|
return apiErrors.internalError('Failed to update workspace');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/workspaces/[workspaceId] - Delete a workspace
|
// DELETE /api/workspaces/[workspaceId] - Delete a workspace
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'mutate');
|
const limited = await rateLimit(request, 'mutate');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId } = await params;
|
const { workspaceId } = await params;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await db.workspace.findUnique({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
select: { id: true, ownerId: true },
|
|
||||||
});
|
|
||||||
if (!workspace) {
|
|
||||||
return apiErrors.notFound('Workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await checkWorkspaceAccess(workspace, session.user.id);
|
|
||||||
if (!access.canDelete) {
|
|
||||||
return apiErrors.forbidden('Only the workspace owner can delete it');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [workspaceVersionRefs, workspaceAssetRefs, mediaUrls] = await Promise.all([
|
|
||||||
db.videoVersion.findMany({
|
|
||||||
where: {
|
|
||||||
video: {
|
|
||||||
project: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
providerId: true,
|
|
||||||
videoId: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
db.videoAsset.findMany({
|
|
||||||
where: {
|
|
||||||
provider: 'BUNNY',
|
|
||||||
providerVideoId: { not: null },
|
|
||||||
video: {
|
|
||||||
project: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
providerVideoId: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
collectWorkspaceMediaUrls(workspaceId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const bunnyRefs = [
|
|
||||||
...workspaceVersionRefs,
|
|
||||||
...workspaceAssetRefs.map((asset) => ({
|
|
||||||
providerId: 'bunny',
|
|
||||||
videoId: asset.providerVideoId as string,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
await db.workspace.delete({ where: { id: workspaceId } });
|
|
||||||
|
|
||||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
|
||||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
|
||||||
deleteMediaFilesBestEffort(mediaUrls),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const cleanupInput = {
|
|
||||||
bunny: bunnyCleanupResult,
|
|
||||||
r2: r2CleanupResult,
|
|
||||||
};
|
|
||||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
|
||||||
if (cleanupWarnings) {
|
|
||||||
logCleanupWarnings({ entityType: 'workspace', entityId: workspaceId }, cleanupInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = successResponse({
|
|
||||||
message: 'Workspace deleted',
|
|
||||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
|
||||||
});
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error deleting workspace:', error);
|
|
||||||
return apiErrors.internalError('Failed to delete workspace');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspace = await db.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
select: { id: true, ownerId: true },
|
||||||
|
});
|
||||||
|
if (!workspace) {
|
||||||
|
return apiErrors.notFound('Workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(workspace, session.user.id);
|
||||||
|
if (!access.canDelete) {
|
||||||
|
return apiErrors.forbidden('Only the workspace owner can delete it');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [workspaceVersionRefs, workspaceAssetRefs, mediaUrls] = await Promise.all([
|
||||||
|
db.videoVersion.findMany({
|
||||||
|
where: {
|
||||||
|
video: {
|
||||||
|
project: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
providerId: true,
|
||||||
|
videoId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: {
|
||||||
|
provider: 'BUNNY',
|
||||||
|
providerVideoId: { not: null },
|
||||||
|
video: {
|
||||||
|
project: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
providerVideoId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
collectWorkspaceMediaUrls(workspaceId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bunnyRefs = [
|
||||||
|
...workspaceVersionRefs,
|
||||||
|
...workspaceAssetRefs.map((asset) => ({
|
||||||
|
providerId: 'bunny',
|
||||||
|
videoId: asset.providerVideoId as string,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.workspace.delete({ where: { id: workspaceId } });
|
||||||
|
|
||||||
|
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||||
|
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||||
|
deleteMediaFilesBestEffort(mediaUrls),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cleanupInput = {
|
||||||
|
bunny: bunnyCleanupResult,
|
||||||
|
r2: r2CleanupResult,
|
||||||
|
};
|
||||||
|
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||||
|
if (cleanupWarnings) {
|
||||||
|
logCleanupWarnings({ entityType: 'workspace', entityId: workspaceId }, cleanupInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
message: 'Workspace deleted',
|
||||||
|
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error deleting workspace:', error);
|
||||||
|
return apiErrors.internalError('Failed to delete workspace');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-129
@@ -8,142 +8,138 @@ import { logError } from '@/lib/logger';
|
|||||||
|
|
||||||
// GET /api/workspaces - List all workspaces for the authenticated user
|
// GET /api/workspaces - List all workspaces for the authenticated user
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const MAX_LIMIT = 100;
|
const MAX_LIMIT = 100;
|
||||||
const MAX_PAGE = 1000;
|
const MAX_PAGE = 1000;
|
||||||
const MAX_OFFSET = 10000;
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const pageParam = searchParams.get('page');
|
|
||||||
const limitParam = searchParams.get('limit');
|
|
||||||
|
|
||||||
const pageRaw = pageParam === null ? 1 : Number(pageParam);
|
|
||||||
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
|
|
||||||
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limitRaw = limitParam === null ? 20 : Number(limitParam);
|
|
||||||
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
|
||||||
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = pageRaw;
|
|
||||||
const limit = limitRaw;
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
|
|
||||||
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = {
|
|
||||||
OR: [
|
|
||||||
{ ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
|
|
||||||
{ members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get workspaces where user is owner OR a member
|
|
||||||
const [workspaces, total] = await Promise.all([
|
|
||||||
db.workspace.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
|
||||||
_count: { select: { projects: true, members: true } },
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
}),
|
|
||||||
db.workspace.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const response = successResponse(
|
|
||||||
{ workspaces },
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching workspaces:', error);
|
|
||||||
return apiErrors.internalError('Failed to fetch workspaces');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const pageParam = searchParams.get('page');
|
||||||
|
const limitParam = searchParams.get('limit');
|
||||||
|
|
||||||
|
const pageRaw = pageParam === null ? 1 : Number(pageParam);
|
||||||
|
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
|
||||||
|
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitRaw = limitParam === null ? 20 : Number(limitParam);
|
||||||
|
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
|
||||||
|
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pageRaw;
|
||||||
|
const limit = limitRaw;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
|
||||||
|
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
OR: [
|
||||||
|
{ ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
|
||||||
|
{ members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get workspaces where user is owner OR a member
|
||||||
|
const [workspaces, total] = await Promise.all([
|
||||||
|
db.workspace.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { projects: true, members: true } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
db.workspace.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = successResponse({ workspaces }, 200, {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching workspaces:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch workspaces');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/workspaces - Create a new workspace
|
// POST /api/workspaces - Create a new workspace
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'create-workspace');
|
const limited = await rateLimit(request, 'create-workspace');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
const billing = await getWorkspaceCreationEligibility(session.user.id);
|
|
||||||
if (!billing.canCreateWorkspace) {
|
|
||||||
return apiErrors.forbidden(
|
|
||||||
billing.reason || 'Upgrade your account to create another workspace'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, description } = body;
|
|
||||||
|
|
||||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
||||||
return apiErrors.badRequest('Workspace name is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate slug
|
|
||||||
const baseSlug = name
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-');
|
|
||||||
|
|
||||||
// Find all existing slugs with the same prefix in a single query
|
|
||||||
const existingWorkspaces = await db.workspace.findMany({
|
|
||||||
where: { slug: { startsWith: baseSlug } },
|
|
||||||
select: { slug: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate unique slug from the results
|
|
||||||
const usedSlugs = new Set(existingWorkspaces.map(w => w.slug));
|
|
||||||
let slug = baseSlug;
|
|
||||||
let counter = 1;
|
|
||||||
while (usedSlugs.has(slug)) {
|
|
||||||
slug = `${baseSlug}-${counter}`;
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await db.workspace.create({
|
|
||||||
data: {
|
|
||||||
name: name.trim(),
|
|
||||||
description: description?.trim() || null,
|
|
||||||
slug,
|
|
||||||
ownerId: session.user.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
|
||||||
_count: { select: { projects: true, members: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = successResponse(workspace, 201);
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error creating workspace:', error);
|
|
||||||
return apiErrors.internalError('Failed to create workspace');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const billing = await getWorkspaceCreationEligibility(session.user.id);
|
||||||
|
if (!billing.canCreateWorkspace) {
|
||||||
|
return apiErrors.forbidden(
|
||||||
|
billing.reason || 'Upgrade your account to create another workspace'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description } = body;
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||||
|
return apiErrors.badRequest('Workspace name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug
|
||||||
|
const baseSlug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
|
||||||
|
// Find all existing slugs with the same prefix in a single query
|
||||||
|
const existingWorkspaces = await db.workspace.findMany({
|
||||||
|
where: { slug: { startsWith: baseSlug } },
|
||||||
|
select: { slug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate unique slug from the results
|
||||||
|
const usedSlugs = new Set(existingWorkspaces.map((w) => w.slug));
|
||||||
|
let slug = baseSlug;
|
||||||
|
let counter = 1;
|
||||||
|
while (usedSlugs.has(slug)) {
|
||||||
|
slug = `${baseSlug}-${counter}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await db.workspace.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
slug,
|
||||||
|
ownerId: session.user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { projects: true, members: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse(workspace, 201);
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error creating workspace:', error);
|
||||||
|
return apiErrors.internalError('Failed to create workspace');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-11
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
export default function RootError({
|
export default function RootError({
|
||||||
error,
|
error,
|
||||||
@@ -12,7 +12,7 @@ export default function RootError({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error("Root error:", error);
|
console.error('Root error:', error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,17 +23,13 @@ export default function RootError({
|
|||||||
<p className="text-muted-foreground max-w-md">
|
<p className="text-muted-foreground max-w-md">
|
||||||
An unexpected error occurred. We've been notified and are working to fix it.
|
An unexpected error occurred. We've been notified and are working to fix it.
|
||||||
</p>
|
</p>
|
||||||
{error.digest && (
|
{error.digest && <p className="text-muted-foreground text-xs">Error ID: {error.digest}</p>}
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Error ID: {error.digest}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={reset} variant="default">
|
<Button onClick={reset} variant="default">
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => window.location.href = "/"} variant="outline">
|
<Button onClick={() => (window.location.href = '/')} variant="outline">
|
||||||
Go home
|
Go home
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-11
@@ -1,6 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
@import "shadcn/tailwind.css";
|
@import 'shadcn/tailwind.css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
--input: oklch(0.923 0.003 48.717);
|
--input: oklch(0.923 0.003 48.717);
|
||||||
--ring: oklch(0.709 0.01 56.259);
|
--ring: oklch(0.709 0.01 56.259);
|
||||||
--chart-1: oklch(0.87 0.12 207);
|
--chart-1: oklch(0.87 0.12 207);
|
||||||
--chart-2: oklch(0.80 0.13 212);
|
--chart-2: oklch(0.8 0.13 212);
|
||||||
--chart-3: oklch(0.71 0.13 215);
|
--chart-3: oklch(0.71 0.13 215);
|
||||||
--chart-4: oklch(0.61 0.11 222);
|
--chart-4: oklch(0.61 0.11 222);
|
||||||
--chart-5: oklch(0.52 0.09 223);
|
--chart-5: oklch(0.52 0.09 223);
|
||||||
@@ -90,28 +90,28 @@
|
|||||||
--popover: oklch(0.216 0.006 56.043);
|
--popover: oklch(0.216 0.006 56.043);
|
||||||
--popover-foreground: oklch(0.985 0.001 106.423);
|
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||||
--primary: oklch(0.71 0.13 215);
|
--primary: oklch(0.71 0.13 215);
|
||||||
--primary-foreground: oklch(0.30 0.05 230);
|
--primary-foreground: oklch(0.3 0.05 230);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.268 0.007 34.298);
|
--muted: oklch(0.268 0.007 34.298);
|
||||||
--muted-foreground: oklch(0.709 0.01 56.259);
|
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||||
--accent: oklch(0.71 0.13 215);
|
--accent: oklch(0.71 0.13 215);
|
||||||
--accent-foreground: oklch(0.30 0.05 230);
|
--accent-foreground: oklch(0.3 0.05 230);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.553 0.013 58.071);
|
--ring: oklch(0.553 0.013 58.071);
|
||||||
--chart-1: oklch(0.87 0.12 207);
|
--chart-1: oklch(0.87 0.12 207);
|
||||||
--chart-2: oklch(0.80 0.13 212);
|
--chart-2: oklch(0.8 0.13 212);
|
||||||
--chart-3: oklch(0.71 0.13 215);
|
--chart-3: oklch(0.71 0.13 215);
|
||||||
--chart-4: oklch(0.61 0.11 222);
|
--chart-4: oklch(0.61 0.11 222);
|
||||||
--chart-5: oklch(0.52 0.09 223);
|
--chart-5: oklch(0.52 0.09 223);
|
||||||
--sidebar: oklch(0.216 0.006 56.043);
|
--sidebar: oklch(0.216 0.006 56.043);
|
||||||
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||||
--sidebar-primary: oklch(0.80 0.13 212);
|
--sidebar-primary: oklch(0.8 0.13 212);
|
||||||
--sidebar-primary-foreground: oklch(0.30 0.05 230);
|
--sidebar-primary-foreground: oklch(0.3 0.05 230);
|
||||||
--sidebar-accent: oklch(0.71 0.13 215);
|
--sidebar-accent: oklch(0.71 0.13 215);
|
||||||
--sidebar-accent-foreground: oklch(0.30 0.05 230);
|
--sidebar-accent-foreground: oklch(0.3 0.05 230);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.553 0.013 58.071);
|
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.noise-overlay::before {
|
.noise-overlay::before {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--foreground);
|
background: var(--foreground);
|
||||||
|
|||||||
+35
-31
@@ -1,9 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next';
|
||||||
import { Geist_Mono, JetBrains_Mono } from "next/font/google";
|
import { Geist_Mono, JetBrains_Mono } from 'next/font/google';
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from 'sonner';
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
import { seoConfig } from "@/lib/seo";
|
import { seoConfig } from '@/lib/seo';
|
||||||
import "./globals.css";
|
import './globals.css';
|
||||||
|
|
||||||
const jetbrainsMono = JetBrains_Mono({
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
@@ -31,20 +31,20 @@ export const metadata: Metadata = {
|
|||||||
authors: [{ name: seoConfig.name, url: seoConfig.url }],
|
authors: [{ name: seoConfig.name, url: seoConfig.url }],
|
||||||
creator: seoConfig.name,
|
creator: seoConfig.name,
|
||||||
publisher: seoConfig.name,
|
publisher: seoConfig.name,
|
||||||
category: "technology",
|
category: 'technology',
|
||||||
referrer: "no-referrer",
|
referrer: 'no-referrer',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: "/",
|
canonical: '/',
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: [{ url: seoConfig.logo, type: "image/svg+xml" }],
|
icon: [{ url: seoConfig.logo, type: 'image/svg+xml' }],
|
||||||
shortcut: [seoConfig.logo],
|
shortcut: [seoConfig.logo],
|
||||||
apple: [{ url: seoConfig.logo }],
|
apple: [{ url: seoConfig.logo }],
|
||||||
},
|
},
|
||||||
manifest: "/manifest.webmanifest",
|
manifest: '/manifest.webmanifest',
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: 'website',
|
||||||
locale: "en_US",
|
locale: 'en_US',
|
||||||
siteName: seoConfig.name,
|
siteName: seoConfig.name,
|
||||||
url: seoConfig.url,
|
url: seoConfig.url,
|
||||||
title: `${seoConfig.name} | ${seoConfig.title}`,
|
title: `${seoConfig.name} | ${seoConfig.title}`,
|
||||||
@@ -59,7 +59,7 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: 'summary_large_image',
|
||||||
title: `${seoConfig.name} | ${seoConfig.title}`,
|
title: `${seoConfig.name} | ${seoConfig.title}`,
|
||||||
description: seoConfig.description,
|
description: seoConfig.description,
|
||||||
images: [seoConfig.ogImage],
|
images: [seoConfig.ogImage],
|
||||||
@@ -70,9 +70,9 @@ export const metadata: Metadata = {
|
|||||||
googleBot: {
|
googleBot: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
"max-image-preview": "large",
|
'max-image-preview': 'large',
|
||||||
"max-snippet": -1,
|
'max-snippet': -1,
|
||||||
"max-video-preview": -1,
|
'max-video-preview': -1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
formatDetection: {
|
formatDetection: {
|
||||||
@@ -84,24 +84,24 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
const structuredData = [
|
const structuredData = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
'@context': 'https://schema.org',
|
||||||
"@type": "Organization",
|
'@type': 'Organization',
|
||||||
name: seoConfig.name,
|
name: seoConfig.name,
|
||||||
url: seoConfig.url,
|
url: seoConfig.url,
|
||||||
logo: `${seoConfig.url}${seoConfig.logoPath}`,
|
logo: `${seoConfig.url}${seoConfig.logoPath}`,
|
||||||
sameAs: [seoConfig.githubUrl],
|
sameAs: [seoConfig.githubUrl],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
'@context': 'https://schema.org',
|
||||||
"@type": "WebSite",
|
'@type': 'WebSite',
|
||||||
name: seoConfig.name,
|
name: seoConfig.name,
|
||||||
url: seoConfig.url,
|
url: seoConfig.url,
|
||||||
description: seoConfig.description,
|
description: seoConfig.description,
|
||||||
publisher: {
|
publisher: {
|
||||||
"@type": "Organization",
|
'@type': 'Organization',
|
||||||
name: seoConfig.name,
|
name: seoConfig.name,
|
||||||
logo: {
|
logo: {
|
||||||
"@type": "ImageObject",
|
'@type': 'ImageObject',
|
||||||
url: `${seoConfig.url}${seoConfig.logoPath}`,
|
url: `${seoConfig.url}${seoConfig.logoPath}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -114,21 +114,25 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${jetbrainsMono.variable} ${geistMono.variable}`} suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`${jetbrainsMono.variable} ${geistMono.variable}`}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<body className="antialiased min-h-screen bg-background font-sans">
|
<body className="antialiased min-h-screen bg-background font-sans">
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
/>
|
/>
|
||||||
<ThemeProvider
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||||
attribute="class"
|
|
||||||
defaultTheme="dark"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" className="fixed h-0 w-0">
|
<svg aria-hidden="true" className="fixed h-0 w-0">
|
||||||
<filter id="openframe-noise">
|
<filter id="openframe-noise">
|
||||||
<feTurbulence type="fractalNoise" baseFrequency="0.92" numOctaves="2" stitchTiles="stitch" />
|
<feTurbulence
|
||||||
|
type="fractalNoise"
|
||||||
|
baseFrequency="0.92"
|
||||||
|
numOctaves="2"
|
||||||
|
stitchTiles="stitch"
|
||||||
|
/>
|
||||||
</filter>
|
</filter>
|
||||||
</svg>
|
</svg>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export default function OnboardingLayout({ children }: { children: React.ReactNode }) {
|
export default function OnboardingLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+41
-41
@@ -5,49 +5,49 @@ import { redirect } from 'next/navigation';
|
|||||||
import { OnboardingWizard } from './onboarding-wizard';
|
import { OnboardingWizard } from './onboarding-wizard';
|
||||||
|
|
||||||
export default async function OnboardingPage() {
|
export default async function OnboardingPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [user, billing, creatableWorkspaces] = await Promise.all([
|
const [user, billing, creatableWorkspaces] = await Promise.all([
|
||||||
db.user.findUnique({
|
db.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { onboardingCompletedAt: true, name: true, email: true },
|
select: { onboardingCompletedAt: true, name: true, email: true },
|
||||||
}),
|
}),
|
||||||
getBillingOverview(session.user.id),
|
getBillingOverview(session.user.id),
|
||||||
db.workspace.findMany({
|
db.workspace.findMany({
|
||||||
where: {
|
where: {
|
||||||
owner: buildBillingAccessWhereInput(),
|
owner: buildBillingAccessWhereInput(),
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: session.user.id },
|
{ ownerId: session.user.id },
|
||||||
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
|
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
ownerId: true,
|
ownerId: true,
|
||||||
},
|
},
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (user?.onboardingCompletedAt) {
|
if (user?.onboardingCompletedAt) {
|
||||||
redirect('/dashboard');
|
redirect('/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userName = user?.name || user?.email?.split('@')[0] || 'there';
|
const userName = user?.name || user?.email?.split('@')[0] || 'there';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingWizard
|
<OnboardingWizard
|
||||||
userName={userName}
|
userName={userName}
|
||||||
canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace}
|
canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace}
|
||||||
availableWorkspaces={creatableWorkspaces.map((workspace) => ({
|
availableWorkspaces={creatableWorkspaces.map((workspace) => ({
|
||||||
id: workspace.id,
|
id: workspace.id,
|
||||||
name: workspace.name,
|
name: workspace.name,
|
||||||
isOwner: workspace.ownerId === session.user.id,
|
isOwner: workspace.ownerId === session.user.id,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-50
@@ -12,11 +12,17 @@ export default function PrivacyPolicyPage() {
|
|||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8">
|
<header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto flex max-w-[900px] items-center justify-between">
|
<div className="mx-auto flex max-w-[900px] items-center justify-between">
|
||||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
<Video className="h-4 w-4 text-primary" />
|
<Video className="h-4 w-4 text-primary" />
|
||||||
OpenFrame
|
OpenFrame
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
← Back to Home
|
← Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,49 +33,93 @@ export default function PrivacyPolicyPage() {
|
|||||||
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
|
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
|
||||||
|
|
||||||
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
|
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">1. Introduction</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">1. Introduction</h2>
|
||||||
<p>
|
<p>
|
||||||
<strong className="text-foreground">IPEK TECH LLC</strong> (“Company”, “we”, “us”, or “our”), a Wyoming limited liability company, operates the OpenFrame platform at open-frame.net (the “Service”). This Privacy Policy explains how we collect, use, share, and protect information about you when you use our Service.
|
<strong className="text-foreground">IPEK TECH LLC</strong> (“Company”,
|
||||||
|
“we”, “us”, or “our”), a Wyoming limited liability
|
||||||
|
company, operates the OpenFrame platform at open-frame.net (the
|
||||||
|
“Service”). This Privacy Policy explains how we collect, use, share, and
|
||||||
|
protect information about you when you use our Service.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
By using the Service, you agree to the collection and use of information in accordance with this Privacy Policy.
|
By using the Service, you agree to the collection and use of information in accordance
|
||||||
|
with this Privacy Policy.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">2. Information We Collect</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
2. Information We Collect
|
||||||
|
</h2>
|
||||||
|
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">2.1 Information You Provide</h3>
|
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">
|
||||||
|
2.1 Information You Provide
|
||||||
|
</h3>
|
||||||
<ul className="list-disc pl-5 space-y-2">
|
<ul className="list-disc pl-5 space-y-2">
|
||||||
<li><strong className="text-foreground">Account information:</strong> Name, email address, and password when you register.</li>
|
<li>
|
||||||
<li><strong className="text-foreground">Profile information:</strong> Avatar image and display name.</li>
|
<strong className="text-foreground">Account information:</strong> Name, email
|
||||||
<li><strong className="text-foreground">Billing information:</strong> Payment details processed securely through Stripe. We do not store full card numbers on our servers.</li>
|
address, and password when you register.
|
||||||
<li><strong className="text-foreground">User Content:</strong> Videos, comments, annotations, and other content you upload or create within the Service.</li>
|
</li>
|
||||||
<li><strong className="text-foreground">Communications:</strong> Messages you send us via email or feedback forms.</li>
|
<li>
|
||||||
|
<strong className="text-foreground">Profile information:</strong> Avatar image and
|
||||||
|
display name.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Billing information:</strong> Payment details
|
||||||
|
processed securely through Stripe. We do not store full card numbers on our servers.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">User Content:</strong> Videos, comments,
|
||||||
|
annotations, and other content you upload or create within the Service.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Communications:</strong> Messages you send us
|
||||||
|
via email or feedback forms.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">2.2 Information Collected Automatically</h3>
|
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">
|
||||||
|
2.2 Information Collected Automatically
|
||||||
|
</h3>
|
||||||
<ul className="list-disc pl-5 space-y-2">
|
<ul className="list-disc pl-5 space-y-2">
|
||||||
<li><strong className="text-foreground">Usage data:</strong> Pages viewed, features used, actions taken within the Service, and timestamps.</li>
|
<li>
|
||||||
<li><strong className="text-foreground">Device and browser data:</strong> IP address, browser type, operating system, and referring URLs.</li>
|
<strong className="text-foreground">Usage data:</strong> Pages viewed, features
|
||||||
<li><strong className="text-foreground">Cookies and similar technologies:</strong> Session cookies for authentication and preference storage. We do not use third-party advertising cookies.</li>
|
used, actions taken within the Service, and timestamps.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Device and browser data:</strong> IP address,
|
||||||
|
browser type, operating system, and referring URLs.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Cookies and similar technologies:</strong>{' '}
|
||||||
|
Session cookies for authentication and preference storage. We do not use third-party
|
||||||
|
advertising cookies.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">2.3 Information from Third Parties</h3>
|
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">
|
||||||
|
2.3 Information from Third Parties
|
||||||
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
If you sign in via a third-party OAuth provider (Google or GitHub), we receive basic profile information (name, email, avatar) as permitted by your settings with that provider.
|
If you sign in via a third-party OAuth provider (Google or GitHub), we receive basic
|
||||||
|
profile information (name, email, avatar) as permitted by your settings with that
|
||||||
|
provider.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">3. How We Use Your Information</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
3. How We Use Your Information
|
||||||
|
</h2>
|
||||||
<p>We use the information we collect to:</p>
|
<p>We use the information we collect to:</p>
|
||||||
<ul className="mt-3 list-disc pl-5 space-y-2">
|
<ul className="mt-3 list-disc pl-5 space-y-2">
|
||||||
<li>Provide, operate, and improve the Service.</li>
|
<li>Provide, operate, and improve the Service.</li>
|
||||||
<li>Process transactions and manage your subscription.</li>
|
<li>Process transactions and manage your subscription.</li>
|
||||||
<li>Send transactional emails (account confirmations, password resets, billing notifications).</li>
|
<li>
|
||||||
|
Send transactional emails (account confirmations, password resets, billing
|
||||||
|
notifications).
|
||||||
|
</li>
|
||||||
<li>Respond to your inquiries and support requests.</li>
|
<li>Respond to your inquiries and support requests.</li>
|
||||||
<li>Send product updates or announcements (you may opt out at any time).</li>
|
<li>Send product updates or announcements (you may opt out at any time).</li>
|
||||||
<li>Monitor and analyze usage patterns to improve the Service.</li>
|
<li>Monitor and analyze usage patterns to improve the Service.</li>
|
||||||
@@ -79,103 +129,196 @@ export default function PrivacyPolicyPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">4. How We Share Your Information</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
4. How We Share Your Information
|
||||||
|
</h2>
|
||||||
<p>We do not sell your personal information. We may share your information with:</p>
|
<p>We do not sell your personal information. We may share your information with:</p>
|
||||||
<ul className="mt-3 list-disc pl-5 space-y-2">
|
<ul className="mt-3 list-disc pl-5 space-y-2">
|
||||||
<li><strong className="text-foreground">Service providers:</strong> Third parties who assist us in operating the Service (e.g., cloud storage, video delivery, payment processing via Stripe). These providers are contractually bound to protect your data.</li>
|
<li>
|
||||||
<li><strong className="text-foreground">Other users:</strong> User Content you choose to share via share links is accessible to recipients of those links per the permissions you configure.</li>
|
<strong className="text-foreground">Service providers:</strong> Third parties who
|
||||||
<li><strong className="text-foreground">Legal requirements:</strong> We may disclose information if required by law, court order, or governmental authority, or to protect the rights and safety of IPEK TECH LLC or others.</li>
|
assist us in operating the Service (e.g., cloud storage, video delivery, payment
|
||||||
<li><strong className="text-foreground">Business transfers:</strong> In the event of a merger, acquisition, or sale of assets, your information may be transferred as part of the transaction.</li>
|
processing via Stripe). These providers are contractually bound to protect your
|
||||||
|
data.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Other users:</strong> User Content you choose to
|
||||||
|
share via share links is accessible to recipients of those links per the permissions
|
||||||
|
you configure.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Legal requirements:</strong> We may disclose
|
||||||
|
information if required by law, court order, or governmental authority, or to
|
||||||
|
protect the rights and safety of IPEK TECH LLC or others.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Business transfers:</strong> In the event of a
|
||||||
|
merger, acquisition, or sale of assets, your information may be transferred as part
|
||||||
|
of the transaction.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">5. Data Retention</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">5. Data Retention</h2>
|
||||||
<p>
|
<p>
|
||||||
We retain your personal information for as long as your account is active or as needed to provide the Service. If you delete your account, we will delete or anonymize your personal information within a reasonable period, except where we are required to retain it for legal, regulatory, or legitimate business purposes (such as billing disputes).
|
We retain your personal information for as long as your account is active or as needed
|
||||||
|
to provide the Service. If you delete your account, we will delete or anonymize your
|
||||||
|
personal information within a reasonable period, except where we are required to
|
||||||
|
retain it for legal, regulatory, or legitimate business purposes (such as billing
|
||||||
|
disputes).
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
User Content you delete from the Service will be removed from our active storage; however, backup copies may persist for a limited time before being purged.
|
User Content you delete from the Service will be removed from our active storage;
|
||||||
|
however, backup copies may persist for a limited time before being purged.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">6. Security</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">6. Security</h2>
|
||||||
<p>
|
<p>
|
||||||
We implement industry-standard security measures to protect your information, including encryption in transit (TLS) and access controls. However, no method of transmission over the internet or electronic storage is 100% secure. We cannot guarantee absolute security and encourage you to use strong, unique passwords and to keep your account credentials confidential.
|
We implement industry-standard security measures to protect your information,
|
||||||
|
including encryption in transit (TLS) and access controls. However, no method of
|
||||||
|
transmission over the internet or electronic storage is 100% secure. We cannot
|
||||||
|
guarantee absolute security and encourage you to use strong, unique passwords and to
|
||||||
|
keep your account credentials confidential.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">7. Your Rights and Choices</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
<p>Depending on your location, you may have rights regarding your personal information, including:</p>
|
7. Your Rights and Choices
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Depending on your location, you may have rights regarding your personal information,
|
||||||
|
including:
|
||||||
|
</p>
|
||||||
<ul className="mt-3 list-disc pl-5 space-y-2">
|
<ul className="mt-3 list-disc pl-5 space-y-2">
|
||||||
<li><strong className="text-foreground">Access and portability:</strong> Request a copy of the data we hold about you.</li>
|
<li>
|
||||||
<li><strong className="text-foreground">Correction:</strong> Request correction of inaccurate data.</li>
|
<strong className="text-foreground">Access and portability:</strong> Request a copy
|
||||||
<li><strong className="text-foreground">Deletion:</strong> Request deletion of your personal information (subject to legal retention requirements).</li>
|
of the data we hold about you.
|
||||||
<li><strong className="text-foreground">Opt-out of marketing:</strong> Unsubscribe from marketing emails at any time via the unsubscribe link in any email or by contacting us.</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Correction:</strong> Request correction of
|
||||||
|
inaccurate data.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Deletion:</strong> Request deletion of your
|
||||||
|
personal information (subject to legal retention requirements).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Opt-out of marketing:</strong> Unsubscribe from
|
||||||
|
marketing emails at any time via the unsubscribe link in any email or by contacting
|
||||||
|
us.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
To exercise these rights, contact us at <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a>. We will respond within a reasonable timeframe.
|
To exercise these rights, contact us at{' '}
|
||||||
|
<a href="mailto:[email protected]" className="text-primary hover:underline">
|
||||||
|
info@open-frame.net
|
||||||
|
</a>
|
||||||
|
. We will respond within a reasonable timeframe.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">8. Cookies</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">8. Cookies</h2>
|
||||||
<p>
|
<p>
|
||||||
We use cookies strictly necessary for the operation of the Service (authentication sessions, CSRF protection) and limited analytics cookies to understand how the Service is used. We do not use third-party advertising cookies or tracking pixels. You may disable cookies in your browser settings, but doing so may affect your ability to use the Service.
|
We use cookies strictly necessary for the operation of the Service (authentication
|
||||||
|
sessions, CSRF protection) and limited analytics cookies to understand how the Service
|
||||||
|
is used. We do not use third-party advertising cookies or tracking pixels. You may
|
||||||
|
disable cookies in your browser settings, but doing so may affect your ability to use
|
||||||
|
the Service.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">9. Children's Privacy</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
9. Children's Privacy
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
The Service is not directed to individuals under the age of 18. We do not knowingly collect personal information from minors. If you believe we have inadvertently collected information from a minor, please contact us immediately at <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a> and we will take steps to delete such information.
|
The Service is not directed to individuals under the age of 18. We do not knowingly
|
||||||
|
collect personal information from minors. If you believe we have inadvertently
|
||||||
|
collected information from a minor, please contact us immediately at{' '}
|
||||||
|
<a href="mailto:[email protected]" className="text-primary hover:underline">
|
||||||
|
info@open-frame.net
|
||||||
|
</a>{' '}
|
||||||
|
and we will take steps to delete such information.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">10. International Data Transfers</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
10. International Data Transfers
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Your information may be stored and processed in the United States or other countries where our service providers operate. By using the Service, you consent to the transfer of your information to these locations, which may have different data protection laws than your country of residence.
|
Your information may be stored and processed in the United States or other countries
|
||||||
|
where our service providers operate. By using the Service, you consent to the transfer
|
||||||
|
of your information to these locations, which may have different data protection laws
|
||||||
|
than your country of residence.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">11. Third-Party Services</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
11. Third-Party Services
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
The Service may integrate with or link to third-party services (e.g., GitHub, Google, Stripe, Bunny CDN). This Privacy Policy does not apply to those services, and we encourage you to review their respective privacy policies.
|
The Service may integrate with or link to third-party services (e.g., GitHub, Google,
|
||||||
|
Stripe, Bunny CDN). This Privacy Policy does not apply to those services, and we
|
||||||
|
encourage you to review their respective privacy policies.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">12. Changes to This Policy</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
12. Changes to This Policy
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
We may update this Privacy Policy from time to time. We will notify you of material changes by posting the updated policy on this page and updating the “Last updated” date. Your continued use of the Service after changes constitutes acceptance of the updated policy.
|
We may update this Privacy Policy from time to time. We will notify you of material
|
||||||
|
changes by posting the updated policy on this page and updating the “Last
|
||||||
|
updated” date. Your continued use of the Service after changes constitutes
|
||||||
|
acceptance of the updated policy.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">13. Contact Us</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">13. Contact Us</h2>
|
||||||
<p>
|
<p>
|
||||||
If you have any questions or concerns about this Privacy Policy or our data practices, please contact us:
|
If you have any questions or concerns about this Privacy Policy or our data practices,
|
||||||
|
please contact us:
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
||||||
<p className="font-medium text-foreground">IPEK TECH LLC</p>
|
<p className="font-medium text-foreground">IPEK TECH LLC</p>
|
||||||
<p>Wyoming, United States</p>
|
<p>Wyoming, United States</p>
|
||||||
<p>Email: <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a></p>
|
<p>
|
||||||
|
Email:{' '}
|
||||||
|
<a href="mailto:[email protected]" className="text-primary hover:underline">
|
||||||
|
info@open-frame.net
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8">
|
<footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto flex max-w-[900px] items-center justify-between">
|
<div className="mx-auto flex max-w-[900px] items-center justify-between">
|
||||||
<span className="font-mono text-xs text-muted-foreground">© 2026 IPEK TECH LLC. All rights reserved.</span>
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
© 2026 IPEK TECH LLC. All rights reserved.
|
||||||
|
</span>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Link href="/terms" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Terms of Service</Link>
|
<Link
|
||||||
<Link href="/refund" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Refund Policy</Link>
|
href="/terms"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/refund"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Refund Policy
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
+102
-32
@@ -12,11 +12,17 @@ export default function RefundPolicyPage() {
|
|||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8">
|
<header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto flex max-w-[900px] items-center justify-between">
|
<div className="mx-auto flex max-w-[900px] items-center justify-between">
|
||||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
<Video className="h-4 w-4 text-primary" />
|
<Video className="h-4 w-4 text-primary" />
|
||||||
OpenFrame
|
OpenFrame
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
← Back to Home
|
← Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,28 +33,38 @@ export default function RefundPolicyPage() {
|
|||||||
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
|
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
|
||||||
|
|
||||||
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
|
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">1. Overview</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">1. Overview</h2>
|
||||||
<p>
|
<p>
|
||||||
This Refund Policy applies to all paid subscriptions to the OpenFrame platform operated by <strong className="text-foreground">IPEK TECH LLC</strong>, a Wyoming limited liability company. By subscribing, you acknowledge and agree to this policy.
|
This Refund Policy applies to all paid subscriptions to the OpenFrame platform
|
||||||
|
operated by <strong className="text-foreground">IPEK TECH LLC</strong>, a Wyoming
|
||||||
|
limited liability company. By subscribing, you acknowledge and agree to this policy.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">2. Free Trial</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">2. Free Trial</h2>
|
||||||
<p>
|
<p>
|
||||||
All new accounts are eligible for a <strong className="text-foreground">7-day free trial</strong> with full access to paid features. We strongly encourage you to evaluate the Service fully during this period before subscribing.
|
All new accounts are eligible for a{' '}
|
||||||
|
<strong className="text-foreground">7-day free trial</strong> with full access to paid
|
||||||
|
features. We strongly encourage you to evaluate the Service fully during this period
|
||||||
|
before subscribing.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
You may cancel at any time during your free trial without being charged. If you do not cancel before the trial ends, your chosen plan will automatically activate and payment will be collected.
|
You may cancel at any time during your free trial without being charged. If you do not
|
||||||
|
cancel before the trial ends, your chosen plan will automatically activate and payment
|
||||||
|
will be collected.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">3. General No-Refund Policy</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
3. General No-Refund Policy
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Because we offer a full-featured free trial, <strong className="text-foreground">all subscription fees are non-refundable</strong> once charged. This includes:
|
Because we offer a full-featured free trial,{' '}
|
||||||
|
<strong className="text-foreground">all subscription fees are non-refundable</strong>{' '}
|
||||||
|
once charged. This includes:
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 list-disc pl-5 space-y-2">
|
<ul className="mt-3 list-disc pl-5 space-y-2">
|
||||||
<li>Monthly subscription charges</li>
|
<li>Monthly subscription charges</li>
|
||||||
@@ -57,77 +73,131 @@ export default function RefundPolicyPage() {
|
|||||||
<li>Any other paid feature or upgrade</li>
|
<li>Any other paid feature or upgrade</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
Canceling your subscription stops future billing but does not entitle you to a refund for the current billing period. You will continue to have access to the Service until the end of your current paid period.
|
Canceling your subscription stops future billing but does not entitle you to a refund
|
||||||
|
for the current billing period. You will continue to have access to the Service until
|
||||||
|
the end of your current paid period.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">4. Exceptions — Extreme Circumstances</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
4. Exceptions — Extreme Circumstances
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Refunds may be considered <strong className="text-foreground">only in exceptional circumstances</strong>, at the sole discretion of IPEK TECH LLC. Circumstances that <em>may</em> qualify include:
|
Refunds may be considered{' '}
|
||||||
|
<strong className="text-foreground">only in exceptional circumstances</strong>, at the
|
||||||
|
sole discretion of IPEK TECH LLC. Circumstances that <em>may</em> qualify include:
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 list-disc pl-5 space-y-2">
|
<ul className="mt-3 list-disc pl-5 space-y-2">
|
||||||
<li><strong className="text-foreground">Extended platform outage:</strong> A verified, prolonged service disruption (greater than 72 consecutive hours) caused by our infrastructure that rendered the Service completely unusable during a billing period.</li>
|
<li>
|
||||||
<li><strong className="text-foreground">Duplicate charge:</strong> A billing error that resulted in you being charged more than once for the same subscription period.</li>
|
<strong className="text-foreground">Extended platform outage:</strong> A verified,
|
||||||
<li><strong className="text-foreground">Unauthorized transaction:</strong> A charge made to your account that you did not authorize and that was reported to us promptly (within 14 days of the charge).</li>
|
prolonged service disruption (greater than 72 consecutive hours) caused by our
|
||||||
|
infrastructure that rendered the Service completely unusable during a billing
|
||||||
|
period.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Duplicate charge:</strong> A billing error that
|
||||||
|
resulted in you being charged more than once for the same subscription period.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Unauthorized transaction:</strong> A charge made
|
||||||
|
to your account that you did not authorize and that was reported to us promptly
|
||||||
|
(within 14 days of the charge).
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-3 border-l-2 border-border pl-4 text-muted-foreground">
|
<p className="mt-3 border-l-2 border-border pl-4 text-muted-foreground">
|
||||||
Dissatisfaction with the product, a change in business circumstances, forgetting to cancel before renewal, or failure to use the Service during a billing period are not considered exceptional circumstances and do not qualify for a refund.
|
Dissatisfaction with the product, a change in business circumstances, forgetting to
|
||||||
|
cancel before renewal, or failure to use the Service during a billing period are not
|
||||||
|
considered exceptional circumstances and do not qualify for a refund.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">5. How to Request a Refund</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
5. How to Request a Refund
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
If you believe your situation qualifies as an exceptional circumstance, contact us within <strong className="text-foreground">14 days</strong> of the charge in question:
|
If you believe your situation qualifies as an exceptional circumstance, contact us
|
||||||
|
within <strong className="text-foreground">14 days</strong> of the charge in question:
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
||||||
<p>Email: <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a></p>
|
<p>
|
||||||
<p>Subject line: <span className="font-mono text-xs">Refund Request — [your account email]</span></p>
|
Email:{' '}
|
||||||
|
<a href="mailto:[email protected]" className="text-primary hover:underline">
|
||||||
|
info@open-frame.net
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Subject line:{' '}
|
||||||
|
<span className="font-mono text-xs">Refund Request — [your account email]</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
Please include: your registered email address, the date of the charge, the amount charged, and a description of the circumstances. We will review your request and respond within 5 business days.
|
Please include: your registered email address, the date of the charge, the amount
|
||||||
|
charged, and a description of the circumstances. We will review your request and
|
||||||
|
respond within 5 business days.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
Approved refunds will be issued to the original payment method and may take 5–10 business days to appear depending on your bank or card issuer.
|
Approved refunds will be issued to the original payment method and may take 5–10
|
||||||
|
business days to appear depending on your bank or card issuer.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">6. Chargebacks</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">6. Chargebacks</h2>
|
||||||
<p>
|
<p>
|
||||||
Filing a chargeback with your bank or payment provider without first contacting us to resolve the issue may result in immediate suspension of your account. We reserve the right to dispute chargebacks that are inconsistent with this Refund Policy.
|
Filing a chargeback with your bank or payment provider without first contacting us to
|
||||||
|
resolve the issue may result in immediate suspension of your account. We reserve the
|
||||||
|
right to dispute chargebacks that are inconsistent with this Refund Policy.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">7. Changes to This Policy</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||||
|
7. Changes to This Policy
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
We reserve the right to modify this Refund Policy at any time. Material changes will be communicated via the Service or by email. Your continued use of the Service after changes constitutes your acceptance of the updated policy.
|
We reserve the right to modify this Refund Policy at any time. Material changes will
|
||||||
|
be communicated via the Service or by email. Your continued use of the Service after
|
||||||
|
changes constitutes your acceptance of the updated policy.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold text-foreground mb-3">8. Contact</h2>
|
<h2 className="text-base font-semibold text-foreground mb-3">8. Contact</h2>
|
||||||
<p>
|
<p>For billing questions or refund requests:</p>
|
||||||
For billing questions or refund requests:
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
||||||
<p className="font-medium text-foreground">IPEK TECH LLC</p>
|
<p className="font-medium text-foreground">IPEK TECH LLC</p>
|
||||||
<p>Wyoming, United States</p>
|
<p>Wyoming, United States</p>
|
||||||
<p>Email: <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a></p>
|
<p>
|
||||||
|
Email:{' '}
|
||||||
|
<a href="mailto:[email protected]" className="text-primary hover:underline">
|
||||||
|
info@open-frame.net
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8">
|
<footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto flex max-w-[900px] items-center justify-between">
|
<div className="mx-auto flex max-w-[900px] items-center justify-between">
|
||||||
<span className="font-mono text-xs text-muted-foreground">© 2026 IPEK TECH LLC. All rights reserved.</span>
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
© 2026 IPEK TECH LLC. All rights reserved.
|
||||||
|
</span>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Link href="/terms" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Terms of Service</Link>
|
<Link
|
||||||
<Link href="/privacy" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Privacy Policy</Link>
|
href="/terms"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ export default function SignOutPage() {
|
|||||||
<LogOut className="h-6 w-6 text-muted-foreground" />
|
<LogOut className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">Sign out</CardTitle>
|
<CardTitle className="text-2xl">Sign out</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Are you sure you want to sign out?</CardDescription>
|
||||||
Are you sure you want to sign out?
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<Button onClick={handleSignOut} disabled={loading} className="w-full">
|
<Button onClick={handleSignOut} disabled={loading} className="w-full">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user