mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
fix: address security vulnerabilities and add image attachments
- Fix type confusion vulnerability in comment content updates - Validate pagination offsets to prevent negative values - Validate timestamp is a valid number before parsing - Exclude guestEmail from comment API responses for privacy - Fix TypeScript error in audio upload route - Add image attachment support for comments with upload API - Update admin dashboard to track image attachments - Rename cleanup functions to handle both voice and image media
This commit is contained in:
+14
-1
@@ -4,7 +4,7 @@ import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCachedTotalStorage } from '@/lib/admin-stats';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive } from 'lucide-react';
|
||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Dashboard | OpenFrame',
|
||||
@@ -34,6 +34,7 @@ export default async function AdminDashboardPage() {
|
||||
totalVideos,
|
||||
totalComments,
|
||||
totalVoiceComments,
|
||||
totalImageComments,
|
||||
] = await Promise.all([
|
||||
db.user.count(),
|
||||
db.project.count(),
|
||||
@@ -42,6 +43,9 @@ export default async function AdminDashboardPage() {
|
||||
db.comment.count({
|
||||
where: { voiceUrl: { not: null } },
|
||||
}),
|
||||
db.comment.count({
|
||||
where: { imageUrl: { not: null } },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 2. Storage Stats (Cached)
|
||||
@@ -101,6 +105,15 @@ export default async function AdminDashboardPage() {
|
||||
<div className="text-2xl font-bold">{totalVoiceComments}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Image Attachments</CardTitle>
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalImageComments}</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 Storage</CardTitle>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Metadata } from 'next';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCachedUserVoiceStorage } from '@/lib/admin-stats';
|
||||
import { getCachedUserMediaStorage } from '@/lib/admin-stats';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -69,8 +70,8 @@ export default async function AdminUsersPage({
|
||||
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
|
||||
// Determine voice storage per user (Cached)
|
||||
const userStorage = await getCachedUserVoiceStorage();
|
||||
// Determine media storage per user (Cached)
|
||||
const userStorage = await getCachedUserMediaStorage();
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
@@ -95,7 +96,7 @@ export default async function AdminUsersPage({
|
||||
<TableHead className="text-center">Workspaces Owned</TableHead>
|
||||
<TableHead className="text-center">Projects Owned</TableHead>
|
||||
<TableHead className="text-center">Total Comments</TableHead>
|
||||
<TableHead className="text-right">Voice Storage</TableHead>
|
||||
<TableHead className="text-right">Media Storage</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -120,8 +121,17 @@ export default async function AdminUsersPage({
|
||||
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
||||
<TableCell className="text-center">{user._count.projects}</TableCell>
|
||||
<TableCell className="text-center">{user._count.comments}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground text-sm">
|
||||
{formatBytes(userStorage[user.id] || 0)}
|
||||
<TableCell className="text-right text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium text-foreground">{formatBytes(userStorage[user.id]?.total || 0)}</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>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function GET() {
|
||||
totalVideos,
|
||||
totalComments,
|
||||
totalVoiceComments,
|
||||
totalImageComments,
|
||||
] = await Promise.all([
|
||||
db.user.count(),
|
||||
db.project.count(),
|
||||
@@ -28,6 +29,13 @@ export async function GET() {
|
||||
not: null,
|
||||
}
|
||||
}
|
||||
}),
|
||||
db.comment.count({
|
||||
where: {
|
||||
imageUrl: {
|
||||
not: null,
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -40,6 +48,7 @@ export async function GET() {
|
||||
totalVideos,
|
||||
totalComments,
|
||||
totalVoiceComments,
|
||||
totalImageComments,
|
||||
totalStorageBytes,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,12 +16,46 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
timestamp: true,
|
||||
timestampEnd: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isResolved: true,
|
||||
resolvedAt: true,
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
timestamp: true,
|
||||
timestampEnd: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isResolved: true,
|
||||
resolvedAt: true,
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
version: {
|
||||
@@ -155,7 +189,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (content !== undefined) updateData.content = content.trim();
|
||||
if (content !== undefined && typeof content === 'string') updateData.content = content.trim();
|
||||
if (tagId !== undefined) updateData.tagId = tagId;
|
||||
if (isResolved !== undefined) {
|
||||
updateData.isResolved = isResolved;
|
||||
@@ -201,7 +235,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: {
|
||||
replies: { select: { voiceUrl: true } },
|
||||
replies: { select: { voiceUrl: true, imageUrl: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -215,27 +249,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('You can only delete your own comments');
|
||||
}
|
||||
|
||||
// Collect all voice URLs to delete from R2 (comment + its replies)
|
||||
const voiceUrls: string[] = [];
|
||||
if (comment.voiceUrl) voiceUrls.push(comment.voiceUrl);
|
||||
// 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) voiceUrls.push(reply.voiceUrl);
|
||||
if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
|
||||
if (reply.imageUrl) mediaUrls.push(reply.imageUrl);
|
||||
}
|
||||
|
||||
await db.comment.delete({ where: { id: commentId } });
|
||||
|
||||
// Clean up audio files from R2 (best-effort, don't block on failure)
|
||||
// Clean up media files from R2 (best-effort, don't block on failure)
|
||||
const AUDIO_PREFIX = '/api/upload/audio/';
|
||||
for (const url of voiceUrls) {
|
||||
const IMAGE_PREFIX = '/api/upload/image/';
|
||||
for (const url of mediaUrls) {
|
||||
try {
|
||||
// Extract filename using string parsing (safe against ReDoS)
|
||||
const idx = url.indexOf(AUDIO_PREFIX);
|
||||
const filename = idx !== -1 ? url.slice(idx + AUDIO_PREFIX.length) : null;
|
||||
if (filename) {
|
||||
let key: string | null = null;
|
||||
if (url.includes(AUDIO_PREFIX)) {
|
||||
const filename = url.slice(url.indexOf(AUDIO_PREFIX) + AUDIO_PREFIX.length);
|
||||
if (filename) key = `voice/${filename}`;
|
||||
} else if (url.includes(IMAGE_PREFIX)) {
|
||||
const filename = url.slice(url.indexOf(IMAGE_PREFIX) + IMAGE_PREFIX.length);
|
||||
if (filename) key = `images/${filename}`;
|
||||
}
|
||||
|
||||
if (key) {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: `voice/${filename}`,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { ProjectMemberRole, ProjectVisibility } from '@prisma/client';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupProjectVoiceFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
@@ -52,11 +52,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
|
||||
// Parse pagination params
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
@@ -197,7 +197,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupProjectVoiceFiles(projectId);
|
||||
await cleanupProjectMediaFiles(projectId);
|
||||
|
||||
await db.project.delete({ where: { id: projectId } });
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
@@ -18,7 +18,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
// Parse query params for pagination and options
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
|
||||
const commentOffset = parseInt(searchParams.get('commentOffset') || '0');
|
||||
const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
|
||||
const includeReplies = searchParams.get('includeReplies') === 'true';
|
||||
|
||||
const video = await db.video.findFirst({
|
||||
@@ -32,20 +32,54 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
orderBy: { timestamp: 'asc' },
|
||||
skip: commentOffset,
|
||||
take: commentLimit,
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
timestamp: true,
|
||||
timestampEnd: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isResolved: true,
|
||||
resolvedAt: true,
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
// guestEmail excluded for privacy
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
...(includeReplies ? {
|
||||
replies: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
timestamp: true,
|
||||
timestampEnd: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isResolved: true,
|
||||
resolvedAt: true,
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
// guestEmail excluded for privacy
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
where: { parentId: null }, // Only top-level comments
|
||||
where: { parentId: null },
|
||||
},
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
@@ -194,7 +228,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupVideoVoiceFiles(videoId);
|
||||
await cleanupVideoMediaFiles(videoId);
|
||||
|
||||
await db.video.delete({ where: { id: videoId } });
|
||||
|
||||
|
||||
@@ -62,8 +62,8 @@ export async function GET(
|
||||
|
||||
// Convert stream to Uint8Array
|
||||
const chunks: Uint8Array[] = [];
|
||||
// @ts-expect-error - body is an iterable
|
||||
for await (const chunk of body) {
|
||||
const asyncIterable = body as AsyncIterable<Uint8Array>;
|
||||
for await (const chunk of asyncIterable) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
|
||||
// Only allow UUID filenames with safe extensions
|
||||
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> = {
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
|
||||
function getContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
return CONTENT_TYPE_MAP[ext] || 'image/jpeg';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
// Validate filename to prevent path traversal
|
||||
if (!SAFE_FILENAME.test(filename)) {
|
||||
return apiErrors.badRequest('Invalid filename');
|
||||
}
|
||||
|
||||
const key = `images/${filename}`;
|
||||
|
||||
// Get file metadata to determine content type
|
||||
const headResponse = await r2Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
|
||||
const contentType = headResponse.ContentType || getContentType(filename);
|
||||
|
||||
const objectResponse = await r2Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
|
||||
const body = objectResponse.Body;
|
||||
if (!body) {
|
||||
return apiErrors.internalError('Empty file');
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
// @ts-expect-error - body is an iterable
|
||||
for await (const chunk of body) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
uint8Array.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return new NextResponse(uint8Array, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorName = error instanceof Error ? error.name : '';
|
||||
if (errorName === 'NoSuchKey') {
|
||||
return apiErrors.notFound('File');
|
||||
}
|
||||
console.error('Error serving image:', error);
|
||||
return apiErrors.internalError('Failed to retrieve image');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/svg+xml'
|
||||
];
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Check Content-Length header BEFORE loading the file
|
||||
const contentLength = request.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
const fileSize = parseInt(contentLength, 10);
|
||||
if (isNaN(fileSize) || fileSize > MAX_FILE_SIZE) {
|
||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
const limited = await rateLimit(request, 'image-upload');
|
||||
if (limited) return limited;
|
||||
|
||||
// Require authentication
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
|
||||
// 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.');
|
||||
}
|
||||
|
||||
// Check content type
|
||||
const contentType = file.type;
|
||||
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||
return apiErrors.badRequest(`Unsupported image format: ${contentType}`);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = contentType.split('/')[1] === 'svg+xml' ? 'svg' : contentType.split('/')[1] || 'jpeg';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const key = `images/${filename}`;
|
||||
|
||||
// Convert to buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Upload to R2
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
|
||||
// Return the URL through our proxy endpoint
|
||||
const imageUrl = `/api/upload/image/${filename}`;
|
||||
|
||||
const response = successResponse({ url: imageUrl }, 201);
|
||||
return withCacheControl(response, 'public, max-age=31536000, immutable');
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
return apiErrors.internalError('Failed to upload image');
|
||||
}
|
||||
}
|
||||
@@ -71,12 +71,44 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
...(includeResolved ? {} : { isResolved: false }),
|
||||
},
|
||||
orderBy: { timestamp: 'asc' },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
timestamp: true,
|
||||
timestampEnd: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isResolved: true,
|
||||
resolvedAt: true,
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
timestamp: true,
|
||||
timestampEnd: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isResolved: true,
|
||||
resolvedAt: true,
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
@@ -152,15 +184,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId } = body;
|
||||
const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId, imageUrl } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (timestamp === undefined || timestamp === null) {
|
||||
return apiErrors.badRequest('Timestamp is required');
|
||||
}
|
||||
|
||||
if (!content && !voiceUrl) {
|
||||
return apiErrors.badRequest('Either content or voice recording is required');
|
||||
const parsedTimestamp = parseFloat(timestamp);
|
||||
if (isNaN(parsedTimestamp)) {
|
||||
return apiErrors.badRequest('Timestamp must be a valid number');
|
||||
}
|
||||
|
||||
if (!content && !voiceUrl && !imageUrl) {
|
||||
return apiErrors.badRequest('Either content, a voice recording, or an image attachment is required');
|
||||
}
|
||||
|
||||
// If replying, verify parent exists in same version
|
||||
@@ -187,14 +224,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrl && !imageUrl.startsWith('/api/')) {
|
||||
const imageUrlError = validateOptionalUrl(imageUrl, 'Image URL');
|
||||
if (imageUrlError) {
|
||||
return apiErrors.badRequest(imageUrlError);
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await db.comment.create({
|
||||
data: {
|
||||
content: content?.trim() || null,
|
||||
timestamp: parseFloat(timestamp),
|
||||
timestamp: parsedTimestamp,
|
||||
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
|
||||
parentId: parentId || null,
|
||||
voiceUrl: voiceUrl || null,
|
||||
voiceDuration: voiceDuration || null,
|
||||
imageUrl: imageUrl || null,
|
||||
authorId: session?.user?.id || null,
|
||||
guestName: isGuest ? guestName : null,
|
||||
guestEmail: isGuest ? guestEmail : null,
|
||||
@@ -234,7 +279,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
projectName: project.name,
|
||||
videoTitle,
|
||||
replyAuthor: commentAuthorName,
|
||||
replyText: content?.trim() || '(voice note)',
|
||||
replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
|
||||
timestamp: ts,
|
||||
url: `${baseUrl}/watch/${version.video.id}`,
|
||||
@@ -245,7 +290,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
projectName: project.name,
|
||||
videoTitle,
|
||||
commentAuthor: commentAuthorName,
|
||||
commentText: content?.trim() || '(voice note)',
|
||||
commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||
timestamp: ts,
|
||||
url: `${baseUrl}/watch/${version.video.id}`,
|
||||
}).catch((err) => console.error('Notification failed:', err));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupWorkspaceVoiceFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupWorkspaceMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ workspaceId: string }> };
|
||||
@@ -145,7 +145,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupWorkspaceVoiceFiles(workspaceId);
|
||||
await cleanupWorkspaceMediaFiles(workspaceId);
|
||||
|
||||
await db.workspace.delete({ where: { id: workspaceId } });
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
User,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -102,6 +104,7 @@ interface Comment {
|
||||
timestamp: number;
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
isResolved: boolean;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
@@ -112,6 +115,7 @@ interface Comment {
|
||||
content: string | null;
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
guestName: string | null;
|
||||
@@ -185,6 +189,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
||||
const [imageBlob, setImageBlob] = useState<File | null>(null);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const [voiceProgress, setVoiceProgress] = useState(0);
|
||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
|
||||
@@ -220,6 +227,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
|
||||
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
|
||||
const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null);
|
||||
const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
|
||||
const replyImageInputRef = useRef<HTMLInputElement>(null);
|
||||
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const replyAudioChunksRef = useRef<Blob[]>([]);
|
||||
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -229,6 +239,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
||||
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
|
||||
const isMutatingRef = useRef(false);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
const [guestName, setGuestName] = useState('');
|
||||
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
|
||||
@@ -893,17 +904,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}
|
||||
}, [isDragging, currentTime, handleSeekToTimestamp]);
|
||||
|
||||
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
|
||||
if (!voiceData && !commentText.trim()) return;
|
||||
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
|
||||
if (!voiceData && !imageBlob && !commentText.trim()) return;
|
||||
if (!activeVersion) return;
|
||||
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const optimisticComment: Comment = {
|
||||
id: tempId,
|
||||
content: voiceData ? commentText.trim() || null : commentText,
|
||||
content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
|
||||
timestamp: selectedTimestamp ?? currentTime,
|
||||
voiceUrl: voiceData?.url ?? null,
|
||||
voiceDuration: voiceData?.duration ?? null,
|
||||
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
|
||||
isResolved: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||
@@ -928,18 +940,37 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setSelectedTimestamp(null);
|
||||
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
|
||||
setAudioBlob(null);
|
||||
setImageBlob(null);
|
||||
|
||||
setIsSubmittingComment(true);
|
||||
isMutatingRef.current = true;
|
||||
|
||||
try {
|
||||
let imageData: { url: string } | undefined;
|
||||
|
||||
if (imageBlob) {
|
||||
setIsUploadingImage(true);
|
||||
const imageFormData = new FormData();
|
||||
imageFormData.append('image', imageBlob);
|
||||
|
||||
const imageRes = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: imageFormData,
|
||||
});
|
||||
|
||||
if (!imageRes.ok) throw new Error('Failed to upload image');
|
||||
const imageDataResponse = await imageRes.json();
|
||||
imageData = { url: imageDataResponse.data.url };
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: voiceData ? commentText.trim() || null : commentText,
|
||||
content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
|
||||
timestamp: selectedTimestamp ?? currentTime,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(imageData && { imageUrl: imageData.url }),
|
||||
...(isGuest && guestName && { guestName }),
|
||||
...(selectedTagId && { tagId: selectedTagId }),
|
||||
}),
|
||||
@@ -988,9 +1019,55 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
toast.error('Failed to add comment');
|
||||
} finally {
|
||||
setIsSubmittingComment(false);
|
||||
setIsUploadingImage(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]);
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob]);
|
||||
|
||||
const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('Image must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('Image must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
@@ -1174,6 +1251,46 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
};
|
||||
}, []);
|
||||
|
||||
const submitCommentWithMedia = useCallback(async () => {
|
||||
if (!activeVersion) return;
|
||||
|
||||
// If we only have audio, handle it via submitVoiceComment for backwards compatibility conceptually
|
||||
if (audioBlob && !imageBlob && !commentText.trim()) {
|
||||
submitVoiceComment();
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioBlob) setIsUploadingAudio(true);
|
||||
if (imageBlob) setIsUploadingImage(true);
|
||||
|
||||
try {
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
let imageData: { url: string } | undefined;
|
||||
|
||||
if (audioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
|
||||
if (!uploadRes.ok) throw new Error('Failed to upload audio');
|
||||
const uploadData = await uploadRes.json();
|
||||
voiceData = { url: uploadData.data.url, duration: recordingTime };
|
||||
}
|
||||
|
||||
await handleAddComment(voiceData, imageData); // Image is uploaded inside handleAddComment for both text/image cases
|
||||
|
||||
setAudioBlob(null);
|
||||
setRecordingTime(0);
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to submit comment with media:', err);
|
||||
toast.error('Failed to upload media');
|
||||
} finally {
|
||||
setIsUploadingAudio(false);
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
}, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment]);
|
||||
|
||||
const handleResolveComment = useCallback(
|
||||
async (commentId: string, currentlyResolved: boolean) => {
|
||||
isMutatingRef.current = true;
|
||||
@@ -1245,17 +1362,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
[activeVersionId]
|
||||
);
|
||||
|
||||
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
|
||||
if (!voiceData && !replyText.trim()) return;
|
||||
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
|
||||
if (!voiceData && !replyImageBlob && !replyText.trim()) return;
|
||||
if (!activeVersion) return;
|
||||
|
||||
const tempId = `temp-reply-${Date.now()}`;
|
||||
const parentComment = comments.find((c) => c.id === parentId);
|
||||
const optimisticReply = {
|
||||
id: tempId,
|
||||
content: voiceData ? replyText.trim() || null : replyText,
|
||||
content: (voiceData || replyImageBlob) ? replyText.trim() || null : replyText,
|
||||
voiceUrl: voiceData?.url ?? null,
|
||||
voiceDuration: voiceData?.duration ?? null,
|
||||
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||
guestName: isGuest ? guestName : null,
|
||||
@@ -1285,19 +1403,38 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setReplyingTo(null);
|
||||
setReplyAudioBlob(null);
|
||||
setReplyRecordingTime(0);
|
||||
setReplyImageBlob(null);
|
||||
|
||||
setIsSubmittingReply(true);
|
||||
isMutatingRef.current = true;
|
||||
|
||||
try {
|
||||
let submittedImageData: { url: string } | undefined = imageData;
|
||||
|
||||
if (replyImageBlob && !imageData) {
|
||||
setIsUploadingReplyImage(true);
|
||||
const imageFormData = new FormData();
|
||||
imageFormData.append('image', replyImageBlob);
|
||||
|
||||
const imageRes = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: imageFormData,
|
||||
});
|
||||
|
||||
if (!imageRes.ok) throw new Error('Failed to upload image reply');
|
||||
const imageDataResponse = await imageRes.json();
|
||||
submittedImageData = { url: imageDataResponse.data.url };
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: voiceData ? replyText.trim() || null : replyText,
|
||||
content: (voiceData || submittedImageData) ? replyText.trim() || null : replyText,
|
||||
timestamp: parentComment?.timestamp ?? currentTime,
|
||||
parentId,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(submittedImageData && { imageUrl: submittedImageData.url }),
|
||||
...(isGuest && guestName && { guestName }),
|
||||
}),
|
||||
});
|
||||
@@ -1366,9 +1503,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
toast.error('Failed to add reply');
|
||||
} finally {
|
||||
setIsSubmittingReply(false);
|
||||
setIsUploadingReplyImage(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
|
||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, replyImageBlob]);
|
||||
|
||||
const startReplyRecording = useCallback(async () => {
|
||||
try {
|
||||
@@ -1437,6 +1575,44 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}
|
||||
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
|
||||
|
||||
const submitReplyWithMedia = useCallback(async (parentId: string) => {
|
||||
if (!activeVersion) return;
|
||||
|
||||
if (replyAudioBlob && !replyImageBlob && !replyText.trim()) {
|
||||
submitVoiceReply(parentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (replyAudioBlob) setIsUploadingReplyAudio(true);
|
||||
if (replyImageBlob) setIsUploadingReplyImage(true);
|
||||
|
||||
try {
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
|
||||
if (replyAudioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
|
||||
if (!uploadRes.ok) throw new Error('Failed to upload audio reply');
|
||||
const uploadData = await uploadRes.json();
|
||||
voiceData = { url: uploadData.data.url, duration: replyRecordingTime };
|
||||
}
|
||||
|
||||
await handleReplyComment(parentId, voiceData);
|
||||
|
||||
setReplyAudioBlob(null);
|
||||
setReplyRecordingTime(0);
|
||||
setReplyImageBlob(null);
|
||||
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to submit reply with media:', err);
|
||||
toast.error('Failed to upload media');
|
||||
} finally {
|
||||
setIsUploadingReplyAudio(false);
|
||||
setIsUploadingReplyImage(false);
|
||||
}
|
||||
}, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]);
|
||||
|
||||
const handleEditComment = useCallback(async (commentId: string) => {
|
||||
if (!editText.trim()) return;
|
||||
setIsSubmittingEdit(true);
|
||||
@@ -1502,7 +1678,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
.filter((c) => c.id !== commentId)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
replies: c.replies.filter((r) => r.id !== commentId),
|
||||
replies: c.replies?.filter((r) => r.id !== commentId) || [],
|
||||
})),
|
||||
}
|
||||
: v
|
||||
@@ -2381,7 +2557,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>
|
||||
<div className="mb-2">
|
||||
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
|
||||
{comment.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setPreviewImage(comment.imageUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={comment.imageUrl} alt="Attachment" className="max-h-60 w-auto object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comment.voiceUrl && (
|
||||
@@ -2524,7 +2711,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>
|
||||
<div className="mb-1">
|
||||
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
|
||||
{reply.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setPreviewImage(reply.imageUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={reply.imageUrl} alt="Attachment" className="max-h-40 w-auto object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{reply.voiceUrl && (
|
||||
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
|
||||
@@ -2620,6 +2818,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{replyImageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
|
||||
setReplyImageBlob(null);
|
||||
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||
}}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
@@ -2627,20 +2841,34 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
rows={1}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => submitVoiceReply(comment.id)}
|
||||
disabled={isUploadingReplyAudio}
|
||||
onClick={() => submitReplyWithMedia(comment.id)}
|
||||
disabled={isUploadingReplyAudio || isUploadingReplyImage}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isUploadingReplyAudio ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Voice Reply'}
|
||||
{isUploadingReplyAudio || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Reply'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{replyImageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
|
||||
setReplyImageBlob(null);
|
||||
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||
}}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Textarea
|
||||
value={replyText}
|
||||
@@ -2658,6 +2886,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setReplyText('');
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, true)}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -2668,15 +2897,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
>
|
||||
<Mic className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => replyImageInputRef.current?.click()}
|
||||
title="Attach Image"
|
||||
className="h-8 w-8 shrink-0 self-end"
|
||||
>
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
ref={replyImageInputRef}
|
||||
onChange={(e) => handleImageSelect(e, true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleReplyComment(comment.id)}
|
||||
disabled={!replyText.trim() || isSubmittingReply}
|
||||
disabled={(!replyText.trim() && !replyImageBlob) || isSubmittingReply || isUploadingReplyImage}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
|
||||
{isSubmittingReply || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -2789,6 +3034,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{imageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button size="icon" variant="destructive" onClick={() => {
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
placeholder="Add a note to your voice comment (optional)..."
|
||||
value={commentText}
|
||||
@@ -2798,14 +3059,14 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submitVoiceComment}
|
||||
disabled={isUploadingAudio}
|
||||
onClick={submitCommentWithMedia}
|
||||
disabled={isUploadingAudio || isUploadingImage}
|
||||
className="w-full"
|
||||
>
|
||||
{isUploadingAudio ? (
|
||||
{isUploadingAudio || isUploadingImage ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Uploading...
|
||||
Uploading Media...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -2817,6 +3078,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{imageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button size="icon" variant="destructive" onClick={() => {
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="Add a comment..."
|
||||
@@ -2829,14 +3104,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
handleAddComment();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => handleAddComment()}
|
||||
disabled={!commentText.trim() || isSubmittingComment}
|
||||
disabled={(!commentText.trim() && !imageBlob) || isSubmittingComment || isUploadingImage}
|
||||
>
|
||||
{isSubmittingComment ? (
|
||||
{isSubmittingComment || isUploadingImage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
@@ -2850,6 +3126,21 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
title="Attach Image"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
ref={imageInputRef}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
{availableTags.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -2897,6 +3188,66 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!previewImage} onOpenChange={(open) => !open && setPreviewImage(null)}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex flex-col items-center justify-center rounded-none"
|
||||
>
|
||||
<DialogTitle className="sr-only">Image Preview</DialogTitle>
|
||||
<div className="absolute top-4 right-4 flex gap-3 z-50">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const response = await fetch(previewImage!);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = previewImage!.split('/').pop() || 'attachment.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error);
|
||||
toast.error('Failed to download image');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewImage(null);
|
||||
}}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
{previewImage && (
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+29
-13
@@ -34,10 +34,10 @@ export const getCachedTotalStorage = unstable_cache(
|
||||
{ revalidate: 600 }
|
||||
);
|
||||
|
||||
export const getCachedUserVoiceStorage = unstable_cache(
|
||||
export const getCachedUserMediaStorage = unstable_cache(
|
||||
async () => {
|
||||
// Return a plain object so it maps cleanly out of unstable_cache across requests
|
||||
const userStorage: Record<string, number> = {};
|
||||
const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
|
||||
try {
|
||||
const fileSizes = new Map<string, number>();
|
||||
let isTruncated = true;
|
||||
@@ -57,25 +57,41 @@ export const getCachedUserVoiceStorage = unstable_cache(
|
||||
continuationToken = data.NextContinuationToken;
|
||||
}
|
||||
|
||||
const voiceComments = await db.comment.findMany({
|
||||
where: { voiceUrl: { not: null }, authorId: { not: null } },
|
||||
select: { authorId: true, voiceUrl: true }
|
||||
const mediaComments = await db.comment.findMany({
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], authorId: { not: null } },
|
||||
select: { authorId: true, voiceUrl: true, imageUrl: true }
|
||||
});
|
||||
|
||||
for (const comment of voiceComments) {
|
||||
if (!comment.authorId || !comment.voiceUrl) continue;
|
||||
const keyParts = comment.voiceUrl.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const r2Key = `voice/${filename}`;
|
||||
const size = fileSizes.get(r2Key) || 0;
|
||||
for (const comment of mediaComments) {
|
||||
if (!comment.authorId) continue;
|
||||
|
||||
userStorage[comment.authorId] = (userStorage[comment.authorId] || 0) + size;
|
||||
if (!userStorage[comment.authorId]) {
|
||||
userStorage[comment.authorId] = { total: 0, voice: 0, image: 0 };
|
||||
}
|
||||
|
||||
if (comment.voiceUrl) {
|
||||
const keyParts = comment.voiceUrl.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const r2Key = `voice/${filename}`;
|
||||
const size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[comment.authorId].voice += size;
|
||||
userStorage[comment.authorId].total += size;
|
||||
}
|
||||
|
||||
if (comment.imageUrl) {
|
||||
const keyParts = comment.imageUrl.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const r2Key = `images/${filename}`;
|
||||
const size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[comment.authorId].image += size;
|
||||
userStorage[comment.authorId].total += size;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse user storage:', err);
|
||||
}
|
||||
return userStorage;
|
||||
},
|
||||
['admin-user-voice-storage'],
|
||||
['admin-user-media-storage'],
|
||||
{ revalidate: 600 }
|
||||
);
|
||||
|
||||
+59
-38
@@ -2,103 +2,124 @@ import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
/** The path prefix for images served by the upload API. */
|
||||
const IMAGE_PATH_PREFIX = '/api/upload/image/';
|
||||
/** The path prefix for audio URLs served by the upload API. */
|
||||
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
|
||||
|
||||
/**
|
||||
* Extract the R2 object key from a voice URL like /api/upload/audio/filename.webm.
|
||||
* Extract the R2 object key from a media URL.
|
||||
* Uses string parsing instead of regex to avoid ReDoS risk on untrusted input.
|
||||
*/
|
||||
function voiceUrlToKey(url: string): string | null {
|
||||
const idx = url.indexOf(AUDIO_PATH_PREFIX);
|
||||
if (idx === -1) return null;
|
||||
const filename = url.slice(idx + AUDIO_PATH_PREFIX.length);
|
||||
return filename ? `voice/${filename}` : null;
|
||||
function mediaUrlToKey(url: string): string | null {
|
||||
if (url.includes(AUDIO_PATH_PREFIX)) {
|
||||
const filename = url.slice(url.indexOf(AUDIO_PATH_PREFIX) + AUDIO_PATH_PREFIX.length);
|
||||
return filename ? `voice/${filename}` : null;
|
||||
} else if (url.includes(IMAGE_PATH_PREFIX)) {
|
||||
const filename = url.slice(url.indexOf(IMAGE_PATH_PREFIX) + IMAGE_PATH_PREFIX.length);
|
||||
return filename ? `images/${filename}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a list of voice files from R2 (best-effort, logs failures).
|
||||
* Delete a list of media files from R2 (best-effort, logs failures).
|
||||
*/
|
||||
async function deleteVoiceFiles(voiceUrls: string[]) {
|
||||
for (const url of voiceUrls) {
|
||||
async function deleteMediaFiles(mediaUrls: string[]) {
|
||||
for (const url of mediaUrls) {
|
||||
try {
|
||||
const key = voiceUrlToKey(url);
|
||||
const key = mediaUrlToKey(url);
|
||||
if (key) {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete audio from R2:', err);
|
||||
console.error('Failed to delete media from R2:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all voice URLs from comments under a given video (all versions).
|
||||
* Collect all media URLs from comments under a given video (all versions).
|
||||
*/
|
||||
export async function collectVideoVoiceUrls(videoId: string): Promise<string[]> {
|
||||
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
|
||||
const comments = await db.comment.findMany({
|
||||
where: {
|
||||
voiceUrl: { not: null },
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
version: { videoParentId: videoId },
|
||||
},
|
||||
select: { voiceUrl: true },
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
});
|
||||
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[];
|
||||
const urls: string[] = [];
|
||||
comments.forEach(c => {
|
||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||
if (c.imageUrl) urls.push(c.imageUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all voice URLs from comments under all videos in a project.
|
||||
* Collect all media URLs from comments under all videos in a project.
|
||||
*/
|
||||
export async function collectProjectVoiceUrls(projectId: string): Promise<string[]> {
|
||||
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
|
||||
const comments = await db.comment.findMany({
|
||||
where: {
|
||||
voiceUrl: { not: null },
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
version: { video: { projectId } },
|
||||
},
|
||||
select: { voiceUrl: true },
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
});
|
||||
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[];
|
||||
const urls: string[] = [];
|
||||
comments.forEach(c => {
|
||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||
if (c.imageUrl) urls.push(c.imageUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all voice URLs from comments under all projects in a workspace.
|
||||
* Collect all media URLs from comments under all projects in a workspace.
|
||||
*/
|
||||
export async function collectWorkspaceVoiceUrls(workspaceId: string): Promise<string[]> {
|
||||
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
|
||||
const comments = await db.comment.findMany({
|
||||
where: {
|
||||
voiceUrl: { not: null },
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
version: { video: { project: { workspaceId } } },
|
||||
},
|
||||
select: { voiceUrl: true },
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
});
|
||||
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[];
|
||||
const urls: string[] = [];
|
||||
comments.forEach(c => {
|
||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||
if (c.imageUrl) urls.push(c.imageUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all voice files for a video from R2.
|
||||
* Delete all media files for a video from R2.
|
||||
* Call BEFORE deleting the video from the database (cascade would remove comment rows).
|
||||
*/
|
||||
export async function cleanupVideoVoiceFiles(videoId: string) {
|
||||
const urls = await collectVideoVoiceUrls(videoId);
|
||||
await deleteVoiceFiles(urls);
|
||||
export async function cleanupVideoMediaFiles(videoId: string) {
|
||||
const urls = await collectVideoMediaUrls(videoId);
|
||||
await deleteMediaFiles(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all voice files for a project from R2.
|
||||
* Delete all media files for a project from R2.
|
||||
* Call BEFORE deleting the project from the database.
|
||||
*/
|
||||
export async function cleanupProjectVoiceFiles(projectId: string) {
|
||||
const urls = await collectProjectVoiceUrls(projectId);
|
||||
await deleteVoiceFiles(urls);
|
||||
export async function cleanupProjectMediaFiles(projectId: string) {
|
||||
const urls = await collectProjectMediaUrls(projectId);
|
||||
await deleteMediaFiles(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all voice files for a workspace from R2.
|
||||
* Delete all media files for a workspace from R2.
|
||||
* Call BEFORE deleting the workspace from the database.
|
||||
*/
|
||||
export async function cleanupWorkspaceVoiceFiles(workspaceId: string) {
|
||||
const urls = await collectWorkspaceVoiceUrls(workspaceId);
|
||||
await deleteVoiceFiles(urls);
|
||||
export async function cleanupWorkspaceMediaFiles(workspaceId: string) {
|
||||
const urls = await collectWorkspaceMediaUrls(workspaceId);
|
||||
await deleteMediaFiles(urls);
|
||||
}
|
||||
|
||||
@@ -263,6 +263,9 @@ model Comment {
|
||||
voiceUrl String? // URL to voice recording file
|
||||
voiceDuration Float? // Duration of voice recording in seconds
|
||||
|
||||
// Image attachment (optional)
|
||||
imageUrl String? // URL to uploaded image file
|
||||
|
||||
// Threading
|
||||
parentId String?
|
||||
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
|
||||
Reference in New Issue
Block a user