mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(comments): carry a batch of screenshots on one comment
A comment held one image, and the paste handler took the first item off the clipboard and dropped the rest. Reviewing a cut usually means several screenshots about the same moment, which meant one comment per screenshot or one screenshot and a paragraph describing the others. Editing a comment could not attach anything at all: the edit box had no paste handler, no file picker and no way to remove what was already there. A comment now carries up to five images, in the composer, in a reply and in the editor. One paste stages every image on the clipboard, the file picker takes a multiple selection, and a drop lands on whichever editor is open. Over the cap the extras are refused out loud rather than dropped quietly. A single image still fills the width; several tile into a grid, and either opens full screen on click. The images move into their own table. `comments.imageUrl` stays and follows the first of them, so a reader that has not been updated keeps working, and the migration copies the existing attachments across so the new table is complete from the first read. Every path that resolves a URL back to a comment now asks the new table: R2 cleanup, the orphan sweep, the storage accounting and the reference checks that decide whether an object can be deleted. Left on the old column they would have treated images two through five as unreferenced and swept them. Detaching an image while editing only breaks the link. The file stays in R2 and in the assets pane, which is where it is deleted from and where its bytes are already billed.
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@ export default async function AdminDashboardPage() {
|
||||
where: { voiceUrl: { not: null } },
|
||||
}),
|
||||
db.comment.count({
|
||||
where: { imageUrl: { not: null } },
|
||||
where: { images: { some: {} } },
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -105,8 +105,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] =
|
||||
await Promise.all([
|
||||
db.comment.findFirst({
|
||||
where: { imageUrl: url },
|
||||
db.commentImage.findFirst({
|
||||
where: { url },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackDelegate.findFirst({
|
||||
|
||||
@@ -10,6 +10,14 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
import { runWithConcurrency } from '@/lib/async-pool';
|
||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||
import { parseCommentImageUrls } from '@/lib/comment-images';
|
||||
import { isFreshAttachment } from '@/lib/upload-freshness';
|
||||
import { extractImageFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets';
|
||||
import {
|
||||
reserveStorageQuota,
|
||||
releaseStorageReservation,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const CLEANUP_DELETE_CONCURRENCY = 5;
|
||||
@@ -36,6 +44,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
@@ -57,6 +66,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
@@ -103,6 +113,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
// PATCH /api/comments/[commentId]
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
// Carried out of the try so the catch below can scope the release to the
|
||||
// account the hold was opened against.
|
||||
let attachmentReservationId: string | null = null;
|
||||
let attachmentBilledUserId: string | null = null;
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
@@ -115,11 +129,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: {
|
||||
images: { select: { url: true }, orderBy: { position: 'asc' } },
|
||||
version: {
|
||||
include: {
|
||||
video: {
|
||||
include: {
|
||||
project: true,
|
||||
project: { include: { workspace: { select: { ownerId: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -169,14 +184,69 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only author can edit content or tag
|
||||
// `imageUrls` (or the legacy `imageUrl`) is the full list the comment should
|
||||
// end up with, so anything the caller left out is detached.
|
||||
const wantsImageUpdate = body.imageUrls !== undefined || body.imageUrl !== undefined;
|
||||
|
||||
// Only author can edit content, tag or attachments
|
||||
if (
|
||||
(content !== undefined || tagId !== undefined || annotationData !== undefined) &&
|
||||
(content !== undefined ||
|
||||
tagId !== undefined ||
|
||||
annotationData !== undefined ||
|
||||
wantsImageUpdate) &&
|
||||
!canEditOwnContent
|
||||
) {
|
||||
return apiErrors.forbidden('Only the author can edit comment content');
|
||||
}
|
||||
|
||||
let desiredImageUrls: string[] = [];
|
||||
let removedImageUrls: string[] = [];
|
||||
let addedImages: { url: string; sizeBytes: bigint }[] = [];
|
||||
|
||||
if (wantsImageUpdate) {
|
||||
const parsedImageUrls = parseCommentImageUrls(body);
|
||||
if ('error' in parsedImageUrls) {
|
||||
return apiErrors.badRequest(parsedImageUrls.error);
|
||||
}
|
||||
desiredImageUrls = parsedImageUrls.urls;
|
||||
|
||||
const existingUrls = comment.images.map((image) => image.url);
|
||||
const addedUrls = desiredImageUrls.filter((url) => !existingUrls.includes(url));
|
||||
removedImageUrls = existingUrls.filter((url) => !desiredImageUrls.includes(url));
|
||||
|
||||
if (addedUrls.length > 0) {
|
||||
// A file that already hangs off another comment would trip the unique
|
||||
// index mid-transaction, so refuse it here and answer with a 400.
|
||||
const alreadyClaimed = await db.commentImage.findFirst({
|
||||
where: { url: { in: addedUrls } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyClaimed) {
|
||||
return apiErrors.badRequest('Image is already attached to another comment');
|
||||
}
|
||||
|
||||
const checks = await Promise.all(
|
||||
addedUrls.map(async (url) => ({ url, ...(await isFreshAttachment(url, 'image')) }))
|
||||
);
|
||||
if (checks.some((check) => !check.isFresh)) {
|
||||
return apiErrors.badRequest('Image upload expired. Please upload again.');
|
||||
}
|
||||
addedImages = checks;
|
||||
}
|
||||
|
||||
const addedBytes = addedImages.reduce((total, image) => total + image.sizeBytes, BigInt(0));
|
||||
if (addedBytes > BigInt(0)) {
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
project.workspace.ownerId,
|
||||
addedBytes,
|
||||
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
attachmentReservationId = reserveResult.reservationId;
|
||||
attachmentBilledUserId = project.workspace.ownerId;
|
||||
}
|
||||
}
|
||||
|
||||
// Owner, author, members, or workspace members can resolve/unresolve
|
||||
if (isResolved !== undefined && !canResolveComment) {
|
||||
return apiErrors.forbidden('Only admins can resolve comments');
|
||||
@@ -214,20 +284,79 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
updateData.isResolved = isResolved;
|
||||
updateData.resolvedAt = isResolved ? new Date() : null;
|
||||
}
|
||||
if (wantsImageUpdate) {
|
||||
// The legacy column keeps pointing at the first image.
|
||||
updateData.imageUrl = desiredImageUrls[0] ?? 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 updatedComment = await db.$transaction(async (tx) => {
|
||||
// Consume the hold inside the transaction so quota is never double-counted.
|
||||
if (attachmentReservationId) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: attachmentReservationId,
|
||||
billedUserId: project.workspace.ownerId,
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.ATTACHMENT,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (wantsImageUpdate) {
|
||||
if (removedImageUrls.length > 0) {
|
||||
// Only the link is dropped. The file stays in R2 and in the assets pane,
|
||||
// which is where a detached upload is deleted from and where its storage
|
||||
// is already accounted for.
|
||||
await tx.commentImage.deleteMany({
|
||||
where: { commentId, url: { in: removedImageUrls } },
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, url] of desiredImageUrls.entries()) {
|
||||
const added = addedImages.find((image) => image.url === url);
|
||||
if (!added) {
|
||||
await tx.commentImage.update({ where: { url }, data: { position: index } });
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.commentImage.create({ data: { commentId, url, position: index } });
|
||||
|
||||
const fileName = extractImageFileNameFromProxyUrl(url);
|
||||
await tx.videoAsset.create({
|
||||
data: {
|
||||
videoId: comment.version.video.id,
|
||||
kind: 'IMAGE',
|
||||
provider: 'R2_IMAGE',
|
||||
displayName: sanitizeAssetDisplayName(null, fileName || 'Comment Image'),
|
||||
sourceUrl: url,
|
||||
thumbnailUrl: url,
|
||||
sizeBytes: added.sizeBytes,
|
||||
uploadedByUserId: userId,
|
||||
uploadedByGuestIdentityId: userId ? null : guestIdentityId,
|
||||
uploadedByGuestName: userId
|
||||
? null
|
||||
: sanitizeAssetDisplayName(comment.guestName, 'Guest').slice(0, 80),
|
||||
billedUserId: project.workspace.ownerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.comment.update({
|
||||
where: { id: commentId },
|
||||
data: updateData,
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
replies: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const updatedCommentData = Object.fromEntries(
|
||||
@@ -256,6 +385,11 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
await releaseStorageReservation(
|
||||
attachmentReservationId,
|
||||
attachmentBilledUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||
);
|
||||
logError('Error updating comment:', error);
|
||||
return apiErrors.internalError('Failed to update comment');
|
||||
}
|
||||
@@ -282,7 +416,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: { select: { voiceUrl: true, imageUrl: true } },
|
||||
images: { select: { url: true } },
|
||||
replies: {
|
||||
select: { voiceUrl: true, images: { select: { url: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -339,10 +476,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
// 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 image of comment.images) mediaUrls.push(image.url);
|
||||
for (const reply of comment.replies) {
|
||||
if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
|
||||
if (reply.imageUrl) mediaUrls.push(reply.imageUrl);
|
||||
for (const image of reply.images) mediaUrls.push(image.url);
|
||||
}
|
||||
|
||||
await db.comment.delete({ where: { id: commentId } });
|
||||
|
||||
@@ -50,6 +50,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -75,6 +76,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: {
|
||||
select: { id: true, url: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
|
||||
@@ -6,8 +6,6 @@ import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import {
|
||||
ensureGuestIdentityFromRequest,
|
||||
getGuestIdentityFromRequest,
|
||||
@@ -20,6 +18,8 @@ import {
|
||||
sanitizeAssetDisplayName,
|
||||
} from '@/lib/video-assets';
|
||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||
import { parseCommentImageUrls } from '@/lib/comment-images';
|
||||
import { isFreshAttachment } from '@/lib/upload-freshness';
|
||||
import { logError } from '@/lib/logger';
|
||||
import {
|
||||
reserveStorageQuota,
|
||||
@@ -29,35 +29,8 @@ import {
|
||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||
|
||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||
const SAFE_IMAGE_PATH =
|
||||
/^\/api\/upload\/image\/[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_AUDIO_PATH =
|
||||
/^\/api\/upload\/audio\/[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 UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
|
||||
|
||||
async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<AttachmentCheck> {
|
||||
const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/';
|
||||
if (!url.startsWith(prefix)) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
|
||||
const filename = url.slice(prefix.length);
|
||||
const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`;
|
||||
|
||||
try {
|
||||
const head = await r2Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
|
||||
} catch {
|
||||
return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEtag(value: string): string {
|
||||
return value.trim().replace(/^W\//, '');
|
||||
@@ -153,6 +126,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -175,6 +149,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -275,10 +250,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
guestName,
|
||||
guestEmail,
|
||||
tagId,
|
||||
imageUrl,
|
||||
annotationData,
|
||||
} = body;
|
||||
|
||||
// A comment carries a list of images now; `imageUrl` is still accepted as a
|
||||
// one-element list so an older client keeps working.
|
||||
const imageUrlsResult = parseCommentImageUrls(body);
|
||||
if ('error' in imageUrlsResult) {
|
||||
return apiErrors.badRequest(imageUrlsResult.error);
|
||||
}
|
||||
const attachedImageUrls = imageUrlsResult.urls;
|
||||
const primaryImageUrl = attachedImageUrls[0] ?? null;
|
||||
|
||||
// Validate required fields
|
||||
if (timestamp === undefined || timestamp === null) {
|
||||
return apiErrors.badRequest('Timestamp is required');
|
||||
@@ -324,7 +307,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!content && !voiceUrl && !imageUrl && !annotationData) {
|
||||
if (!content && !voiceUrl && attachedImageUrls.length === 0 && !annotationData) {
|
||||
return apiErrors.badRequest(
|
||||
'Either content, a voice recording, an image attachment, or an annotation is required'
|
||||
);
|
||||
@@ -402,17 +385,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
voiceSizeBytes = voiceCheck.sizeBytes;
|
||||
}
|
||||
|
||||
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
|
||||
return apiErrors.badRequest('Image URL must reference an uploaded image file');
|
||||
}
|
||||
let imageSizeBytes = BigInt(0);
|
||||
if (imageUrl) {
|
||||
const imageCheck = await isFreshAttachment(imageUrl, 'image');
|
||||
if (!imageCheck.isFresh) {
|
||||
return apiErrors.badRequest('Image upload expired. Please upload again.');
|
||||
}
|
||||
imageSizeBytes = imageCheck.sizeBytes;
|
||||
// The uploads happened in parallel, so check them the same way rather than
|
||||
// paying one R2 round trip per screenshot.
|
||||
const imageChecks = await Promise.all(
|
||||
attachedImageUrls.map(async (url) => ({ url, ...(await isFreshAttachment(url, 'image')) }))
|
||||
);
|
||||
if (imageChecks.some((check) => !check.isFresh)) {
|
||||
return apiErrors.badRequest('Image upload expired. Please upload again.');
|
||||
}
|
||||
const imageSizeBytes = imageChecks.reduce((total, check) => total + check.sizeBytes, BigInt(0));
|
||||
|
||||
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
|
||||
|
||||
@@ -451,7 +432,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
parentId: parentId || null,
|
||||
voiceUrl: voiceUrl || null,
|
||||
voiceDuration: voiceDuration || null,
|
||||
imageUrl: imageUrl || null,
|
||||
imageUrl: primaryImageUrl,
|
||||
images: {
|
||||
create: attachedImageUrls.map((url, index) => ({ url, position: index })),
|
||||
},
|
||||
annotationData: serializedAnnotationData,
|
||||
authorId: session?.user?.id || null,
|
||||
guestName: isGuest ? guestName : null,
|
||||
@@ -463,18 +447,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
replies: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If an image was attached to the comment, also add it to the assets pane
|
||||
if (imageUrl) {
|
||||
const fileName = extractImageFileNameFromProxyUrl(imageUrl);
|
||||
// Every attached image also shows up in the assets pane
|
||||
for (const check of imageChecks) {
|
||||
const fileName = extractImageFileNameFromProxyUrl(check.url);
|
||||
const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image');
|
||||
const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80);
|
||||
|
||||
@@ -484,9 +470,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
kind: 'IMAGE',
|
||||
provider: 'R2_IMAGE',
|
||||
displayName,
|
||||
sourceUrl: imageUrl,
|
||||
thumbnailUrl: imageUrl,
|
||||
sizeBytes: imageSizeBytes,
|
||||
sourceUrl: check.url,
|
||||
thumbnailUrl: check.url,
|
||||
sizeBytes: check.sizeBytes,
|
||||
uploadedByUserId: session?.user?.id || null,
|
||||
uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
|
||||
uploadedByGuestName: isGuest ? safeGuestName : null,
|
||||
@@ -543,7 +529,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
projectName: project.name,
|
||||
videoTitle,
|
||||
replyAuthor: commentAuthorName,
|
||||
replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||
replyText: content?.trim() || (primaryImageUrl ? '(image attachment)' : '(voice note)'),
|
||||
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
|
||||
timestamp: ts,
|
||||
url: `${baseUrl}/watch/${version.video.id}`,
|
||||
@@ -554,7 +540,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
projectName: project.name,
|
||||
videoTitle,
|
||||
commentAuthor: commentAuthorName,
|
||||
commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||
commentText: content?.trim() || (primaryImageUrl ? '(image attachment)' : '(voice note)'),
|
||||
timestamp: ts,
|
||||
url: `${baseUrl}/watch/${version.video.id}`,
|
||||
}).catch((err) => logError('Notification failed:', err));
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
|
||||
const [assetReferenceCount, commentReferenceCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
|
||||
tx.comment.count({ where: { imageUrl: asset.sourceUrl } }),
|
||||
tx.commentImage.count({ where: { url: asset.sourceUrl } }),
|
||||
]);
|
||||
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
if (asset.thumbnailUrl) {
|
||||
const [assetThumbnailCount, commentImageCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { thumbnailUrl: asset.thumbnailUrl } }),
|
||||
tx.comment.count({ where: { imageUrl: asset.thumbnailUrl } }),
|
||||
tx.commentImage.count({ where: { url: asset.thumbnailUrl } }),
|
||||
]);
|
||||
shouldDeleteVideoThumbnail = assetThumbnailCount === 0 && commentImageCount === 0;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -72,6 +73,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
|
||||
Reference in New Issue
Block a user