Files
OpenFrame/app/api/versions/[versionId]/comments/route.ts
T
yusufipk 7ca5abd041 feat(analytics): record where paying customers actually came from
Adds first-party acquisition attribution and a sixteen-event funnel, written to
this deployment's own database and read back on /admin/growth. Nothing is sent
anywhere else, and the whole subsystem is off unless OPENFRAME_ENABLE_ANALYTICS
is set, so a self-hosted instance carries the tables empty and pays nothing.

The proxy gives a visitor an anonymous id and stores what brought them in two
first-party cookies; signup copies that onto the account and claims the events
the visitor produced before they had one, which is what joins the two halves of
the funnel. Recording happens where each step actually happens rather than in
the browser: an ad blocker cannot undercount landing views, and blocking rates
differ by channel, so an undercounted denominator would have made GitHub traffic
look like it converts better than it does.

Every event carries a dedupe key on a UNIQUE column, so "recorded exactly once"
is a property of the schema rather than of fifteen call sites. Subscription
events are derived by comparing the row being overwritten with the row being
written inside the existing Stripe sync, which makes them order-independent and
replay-safe.

The scoreboard reports step-to-step conversion with the denominator beside it,
and splits by source over a rolling 28-day window rather than a week: at this
volume a weekly per-source cell holds single digits, and a percentage computed
from three visits reads exactly as confidently as one computed from three
hundred.

"How did you hear about us?" is asked on the first onboarding screen, not on the
registration form. The number being measured is the signup conversion rate, and
a question added to that form would move it.
2026-08-01 20:00:27 +03:00

594 lines
21 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth, computeProjectAccess, projectAccessInclude } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
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,
setGuestIdentityCookie,
} from '@/lib/guest-identity';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import {
extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl,
sanitizeAssetDisplayName,
} from '@/lib/video-assets';
import { validateAnnotationStrokes } from '@/lib/validation';
import { logError } from '@/lib/logger';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
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\//, '');
}
// GET /api/versions/[versionId]/comments
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
const { versionId } = await params;
const userId = session?.user?.id;
// Get version with project access data pre-fetched in the same query
const version = await db.videoVersion.findUnique({
where: { id: versionId },
include: {
video: {
include: {
project: { include: projectAccessInclude(userId) },
},
},
},
});
if (!version) {
return apiErrors.notFound('Version');
}
const project = version.video.project;
const access = computeProjectAccess(project, userId);
const shareSession = getShareSessionFromRequest(request, version.video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: project.id,
videoId: version.video.id,
requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, requiresPassword: false };
if (!access.hasAccess && !shareAccess.hasAccess) {
return apiErrors.forbidden('Access denied');
}
const { searchParams } = new URL(request.url);
const includeResolved = searchParams.get('includeResolved') !== 'false';
const limit = Math.min(Math.max(1, parseInt(searchParams.get('limit') ?? '200', 10)), 500);
const offset = Math.min(Math.max(0, parseInt(searchParams.get('offset') ?? '0', 10)), 50000);
const commentsFilter = {
versionId,
parentId: null as null,
...(includeResolved ? {} : { isResolved: false }),
};
const [commentsRevision, total] = await Promise.all([
db.comment.aggregate({
where: { versionId, ...(includeResolved ? {} : { isResolved: false }) },
_count: { id: true },
_max: { updatedAt: true },
}),
db.comment.count({ where: commentsFilter }),
]);
const etag = `"comments:${versionId}:${includeResolved ? 1 : 0}:${commentsRevision._count.id}:${commentsRevision._max.updatedAt?.getTime() ?? 0}"`;
const ifNoneMatch = request.headers.get('if-none-match');
if (ifNoneMatch) {
const matches = ifNoneMatch.split(',').map(normalizeEtag).includes(normalizeEtag(etag));
if (matches) {
const notModified = new NextResponse(null, { status: 304 });
notModified.headers.set('ETag', etag);
return withCacheControl(notModified, 'private, no-cache');
}
}
const comments = await db.comment.findMany({
where: commentsFilter,
orderBy: { timestamp: 'asc' },
skip: offset,
take: limit,
select: {
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,
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' },
select: {
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,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
},
});
const response = successResponse({
comments,
total,
hasMore: offset + comments.length < total,
offset,
limit,
});
response.headers.set('ETag', etag);
return withCacheControl(response, 'private, no-cache');
} catch (error) {
logError('Error fetching comments:', error);
return apiErrors.internalError('Failed to fetch comments');
}
}
// POST /api/versions/[versionId]/comments
export async function POST(request: NextRequest, { params }: RouteParams) {
let attachmentReservationId: string | null = null;
try {
const limited = await rateLimit(request, 'comment');
if (limited) return limited;
const session = await auth();
const { versionId } = await params;
const userId = session?.user?.id;
const version = await db.videoVersion.findUnique({
where: { id: versionId },
include: {
video: {
include: {
project: {
include: {
...projectAccessInclude(userId),
// workspace.select is already included by projectAccessInclude;
// ownerId is present on workspace via projectAccessInclude
},
},
},
},
},
});
if (!version) {
return apiErrors.notFound('Version');
}
const project = version.video.project;
const access = computeProjectAccess(project, userId);
const shareSession = getShareSessionFromRequest(request, version.video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: project.id,
videoId: version.video.id,
requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified,
})
: {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
// Check if user can comment
const canComment = access.hasAccess || shareAccess.canComment;
if (!canComment) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const {
content,
timestamp,
timestampEnd,
parentId,
voiceUrl,
voiceDuration,
guestName,
guestEmail,
tagId,
imageUrl,
annotationData,
} = body;
// Validate required fields
if (timestamp === undefined || timestamp === null) {
return apiErrors.badRequest('Timestamp is required');
}
const maxTimestamp =
typeof version.duration === 'number' && Number.isFinite(version.duration)
? version.duration
: null;
const parseCommentTimestamp = (value: unknown, fieldName: string) => {
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return {
error: apiErrors.badRequest(`${fieldName} must be a finite non-negative number`),
};
}
if (maxTimestamp !== null && parsed > maxTimestamp) {
return {
error: apiErrors.badRequest(`${fieldName} must be less than or equal to video duration`),
};
}
return { value: parsed };
};
const parsedTimestampResult = parseCommentTimestamp(timestamp, 'Timestamp');
if ('error' in parsedTimestampResult) {
return parsedTimestampResult.error;
}
const parsedTimestamp = parsedTimestampResult.value;
let parsedTimestampEnd: number | null = null;
if (timestampEnd !== undefined && timestampEnd !== null) {
const parsedTimestampEndResult = parseCommentTimestamp(timestampEnd, 'Timestamp end');
if ('error' in parsedTimestampEndResult) {
return parsedTimestampEndResult.error;
}
parsedTimestampEnd = parsedTimestampEndResult.value;
if (parsedTimestampEnd < parsedTimestamp) {
return apiErrors.badRequest('Timestamp end must be greater than or equal to timestamp');
}
}
if (!content && !voiceUrl && !imageUrl && !annotationData) {
return apiErrors.badRequest(
'Either content, a voice recording, an image attachment, or an annotation is required'
);
}
// Length limits to prevent DB bloat and DoS on export/notification paths
if (content !== undefined && content !== null && String(content).length > 10_000) {
return apiErrors.badRequest('Comment content must be 10,000 characters or fewer');
}
if (guestName !== undefined && guestName !== null && String(guestName).length > 100) {
return apiErrors.badRequest('Guest name must be 100 characters or fewer');
}
let normalizedGuestEmail: string | null = null;
if (guestEmail !== undefined && guestEmail !== null) {
normalizedGuestEmail = normalizeEmail(String(guestEmail));
if (!isValidEmailAddress(normalizedGuestEmail)) {
return apiErrors.badRequest('Guest email must be a valid email address');
}
}
// Validate annotation data structure to prevent prototype pollution and stored XSS.
// Reject anything that is not a well-formed array of AnnotationStroke objects.
// The HTTP body is already JSON-parsed by Next.js; double-encoded strings are rejected.
let serializedAnnotationData: string | null = null;
if (annotationData !== undefined && annotationData !== null) {
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');
}
// Re-serialize to canonical JSON — strips any extra properties from the input.
serializedAnnotationData = JSON.stringify(validStrokes);
}
// If replying, verify parent exists in same version
if (parentId) {
const parent = await db.comment.findFirst({
where: { id: parentId, versionId },
});
if (!parent) {
return apiErrors.badRequest('Parent comment not found');
}
}
// Guest comment validation
const isGuest = !session?.user?.id;
if (isGuest && shareAccess.hasAccess && !shareAccess.allowGuests) {
return apiErrors.forbidden('This share link requires sign in to comment');
}
if (isGuest && !guestName) {
return apiErrors.badRequest('Guest name is required for guest comments');
}
// Verify tag belongs to this project to prevent cross-project tag leakage (IDOR)
if (tagId) {
const tag = await db.commentTag.findFirst({
where: { id: tagId, projectId: project.id },
});
if (!tag) {
return apiErrors.badRequest('Tag not found');
}
}
if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) {
return apiErrors.badRequest('Voice URL must reference an uploaded audio file');
}
let voiceSizeBytes = BigInt(0);
if (voiceUrl) {
const voiceCheck = await isFreshAttachment(voiceUrl, 'audio');
if (!voiceCheck.isFresh) {
return apiErrors.badRequest('Voice upload expired. Please upload again.');
}
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;
}
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
// Enforce per-workspace storage quota for any R2 attachments on this comment.
// Uses the advisory-locked reservation path so concurrent comment submissions
// see each other's in-flight sizes, eliminating the TOCTOU race.
const totalAttachmentBytes = voiceSizeBytes + imageSizeBytes;
if (totalAttachmentBytes > BigInt(0)) {
const reserveResult = await reserveStorageQuota(
project.workspace.ownerId,
totalAttachmentBytes
);
if ('error' in reserveResult) return reserveResult.error;
attachmentReservationId = reserveResult.reservationId;
}
// Use a transaction to create both the comment and any asset rows atomically.
// Consume the reservation inside the transaction so quota is never double-counted.
const result = await db.$transaction(async (tx) => {
if (attachmentReservationId) {
await tx.uploadReservation.deleteMany({
where: { id: attachmentReservationId, billedUserId: project.workspace.ownerId },
});
}
const comment = await tx.comment.create({
data: {
content: content?.trim() || null,
timestamp: parsedTimestamp,
timestampEnd: parsedTimestampEnd,
parentId: parentId || null,
voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null,
imageUrl: imageUrl || null,
annotationData: serializedAnnotationData,
authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null,
guestEmail: isGuest ? normalizedGuestEmail : null,
guestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
tagId: tagId || null,
versionId,
},
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 } },
},
},
},
});
// If an image was attached to the comment, also add it to the assets pane
if (imageUrl) {
const fileName = extractImageFileNameFromProxyUrl(imageUrl);
const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image');
const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80);
await tx.videoAsset.create({
data: {
videoId: version.video.id,
kind: 'IMAGE',
provider: 'R2_IMAGE',
displayName,
sourceUrl: imageUrl,
thumbnailUrl: imageUrl,
sizeBytes: imageSizeBytes,
uploadedByUserId: session?.user?.id || null,
uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
uploadedByGuestName: isGuest ? safeGuestName : null,
billedUserId: project.workspace.ownerId,
},
});
}
// If a voice recording was attached, also track it in the assets pane
if (voiceUrl) {
const fileName = extractAudioFileNameFromProxyUrl(voiceUrl);
const displayName = sanitizeAssetDisplayName(null, fileName || 'Voice Comment');
const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80);
await tx.videoAsset.create({
data: {
videoId: version.video.id,
kind: 'AUDIO',
provider: 'R2_AUDIO',
displayName,
sourceUrl: voiceUrl,
sizeBytes: voiceSizeBytes,
uploadedByUserId: session?.user?.id || null,
uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
uploadedByGuestName: isGuest ? safeGuestName : null,
billedUserId: project.workspace.ownerId,
},
});
}
return comment;
});
const comment = result;
// Notify project owner (fire-and-forget, skip self-notifications)
const commentAuthorName = session?.user?.name || guestName || 'Someone';
const isOwnProject = session?.user?.id === project.ownerId;
if (!isOwnProject) {
const baseUrl = process.env.NEXTAUTH_URL || '';
const videoTitle = version.video.title || 'Untitled Video';
const mins = Math.floor(parseFloat(timestamp) / 60);
const secs = Math.floor(parseFloat(timestamp) % 60);
const ts = `${mins}:${secs.toString().padStart(2, '0')}`;
if (parentId) {
// It's a reply — look up parent author
const parentComment = await db.comment.findUnique({
where: { id: parentId },
include: { author: { select: { name: true } } },
});
notifyProjectOwner(project.ownerId, {
type: 'new_reply',
projectName: project.name,
videoTitle,
replyAuthor: commentAuthorName,
replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`,
}).catch((err) => logError('Notification failed:', err));
} else {
notifyProjectOwner(project.ownerId, {
type: 'new_comment',
projectName: project.name,
videoTitle,
commentAuthor: commentAuthorName,
commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`,
}).catch((err) => logError('Notification failed:', err));
}
}
// Feedback arriving from outside the team is the moment this product
// becomes worth paying for, so it is the activation step of the funnel.
// Keyed on the account, not the comment: what matters is the first time an
// account ever received one.
if (isGuest) {
await recordEvent({
name: 'FIRST_GUEST_COMMENT',
dedupeKey: eventKey('FIRST_GUEST_COMMENT', project.workspace.ownerId),
userId: project.workspace.ownerId,
});
}
const viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId
? null
: (guestIdentity?.identityId ?? getGuestIdentityFromRequest(request));
const canEditComment = viewerUserId
? comment.authorId === viewerUserId
: !!viewerGuestIdentityId &&
!!comment.guestIdentityId &&
comment.guestIdentityId === viewerGuestIdentityId;
const commentData = Object.fromEntries(
Object.entries(comment).filter(([key]) => key !== 'guestIdentityId')
);
const response = successResponse(
{
...commentData,
canEdit: canEditComment,
canDelete: canEditComment || viewerUserId === project.ownerId,
},
201
);
if (isGuest && guestIdentity?.shouldSetCookie) {
setGuestIdentityCookie(response, guestIdentity.identityId);
}
return withCacheControl(response, 'private, no-store');
} catch (error) {
await releaseStorageReservation(attachmentReservationId);
logError('Error creating comment:', error);
return apiErrors.internalError('Failed to create comment');
}
}