From 53de25d5a72c8c0f17e866cc7b5420d119791a79 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Tue, 25 Nov 2025 10:39:36 -0800 Subject: [PATCH 01/12] refactor: create presigned url direct upload flow for preview --- app/api/preview/helpers.ts | 68 ++++++++ .../preview => api/preview/process}/route.ts | 140 ++++++--------- app/api/preview/upload-url/route.ts | 72 ++++++++ lib/uploadthing-presigned.ts | 165 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 + 6 files changed, 364 insertions(+), 85 deletions(-) create mode 100644 app/api/preview/helpers.ts rename app/{(interview)/preview => api/preview/process}/route.ts (68%) create mode 100644 app/api/preview/upload-url/route.ts create mode 100644 lib/uploadthing-presigned.ts diff --git a/app/api/preview/helpers.ts b/app/api/preview/helpers.ts new file mode 100644 index 000000000..086773969 --- /dev/null +++ b/app/api/preview/helpers.ts @@ -0,0 +1,68 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { verifyApiToken } from '~/actions/apiTokens'; +import { env } from '~/env'; +import { getAppSetting } from '~/queries/appSettings'; +import { getServerSession } from '~/utils/auth'; + +// CORS headers for external clients (like Architect) +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +}; + +// Handle preflight OPTIONS request +export function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }); +} + +// Helper to create JSON responses with CORS headers +export function jsonResponse( + data: Record, + status = 200, +) { + return NextResponse.json(data, { status, headers: corsHeaders }); +} + +// Check preview mode and authentication +// Returns null if authorized, or an error response if not +export async function checkPreviewAuth( + req: NextRequest, +): Promise { + // Check if preview mode is enabled + if (!env.PREVIEW_MODE) { + return jsonResponse({ error: 'Preview mode is not enabled' }, 403); + } + + // Check authentication if required + const requireAuth = await getAppSetting('previewModeRequireAuth'); + + if (requireAuth) { + // Try session-based auth first + const session = await getServerSession(); + + if (!session) { + // Try API token auth + const authHeader = req.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + return jsonResponse( + { error: 'Authentication required. Provide session or API token.' }, + 401, + ); + } + + const { valid } = await verifyApiToken(token); + + if (!valid) { + return jsonResponse({ error: 'Invalid API token' }, 401); + } + } + } + + return null; +} diff --git a/app/(interview)/preview/route.ts b/app/api/preview/process/route.ts similarity index 68% rename from app/(interview)/preview/route.ts rename to app/api/preview/process/route.ts index 9c4cfc417..61513fb01 100644 --- a/app/(interview)/preview/route.ts +++ b/app/api/preview/process/route.ts @@ -4,96 +4,69 @@ import { validateProtocol, } from '@codaco/protocol-validation'; import JSZip from 'jszip'; +import { type NextRequest } from 'next/server'; import { hash } from 'ohash'; -import { type NextRequest, NextResponse } from 'next/server'; +import { addEvent } from '~/actions/activityFeed'; import { env } from '~/env'; +import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config'; import { prunePreviewProtocols } from '~/lib/preview-protocol-pruning'; -import { getAppSetting } from '~/queries/appSettings'; -import { getServerSession } from '~/utils/auth'; +import { getFileUrlByKey } from '~/lib/uploadthing-presigned'; import { prisma } from '~/utils/db'; import { getProtocolAssets, getProtocolJson } from '~/utils/protocolImport'; -import { verifyApiToken } from '~/actions/apiTokens'; -import { addEvent } from '~/actions/activityFeed'; -import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config'; +import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; -// CORS headers for external clients (like Architect) -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', -}; - -// Handle preflight OPTIONS request -export function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: corsHeaders, - }); -} +export { OPTIONS }; export async function POST(req: NextRequest) { - // Check if preview mode is enabled - if (!env.PREVIEW_MODE) { - return NextResponse.json( - { error: 'Preview mode is not enabled' }, - { status: 403, headers: corsHeaders }, - ); - } + const authError = await checkPreviewAuth(req); + if (authError) return authError; - // Check authentication if required - const requireAuth = await getAppSetting('previewModeRequireAuth'); - - if (requireAuth) { - // Try session-based auth first - const session = await getServerSession(); - - if (!session) { - // Try API token auth - const authHeader = req.headers.get('authorization'); - const token = authHeader?.replace('Bearer ', ''); - - if (!token) { - return NextResponse.json( - { error: 'Authentication required. Provide session or API token.' }, - { status: 401, headers: corsHeaders }, - ); - } + try { + // Get file info from request body + const body = (await req.json()) as { + fileKey?: string; + fileName?: string; + }; - const { valid } = await verifyApiToken(token); + const { fileKey, fileName } = body; - if (!valid) { - return NextResponse.json( - { error: 'Invalid API token' }, - { status: 401, headers: corsHeaders }, - ); - } + if (!fileKey || typeof fileKey !== 'string') { + return jsonResponse({ error: 'fileKey is required' }, 400); } - } - try { - // Get the uploaded file - const formData = await req.formData(); - const file = formData.get('protocol') as File | null; - - if (!file) { - return NextResponse.json( - { error: 'No protocol file provided' }, - { status: 400, headers: corsHeaders }, + // Get the file URL from the key + const fileUrl = await getFileUrlByKey(fileKey); + + if (!fileUrl) { + return jsonResponse( + { + error: + 'Failed to get file URL. UploadThing token may not be configured.', + }, + 500, ); } - // Verify it's a .netcanvas file - if (!file.name.endsWith('.netcanvas')) { - return NextResponse.json( - { error: 'File must be a .netcanvas file' }, - { status: 400, headers: corsHeaders }, + // Download the file from UploadThing + const fileResponse = await fetch(fileUrl); + + if (!fileResponse.ok) { + return jsonResponse( + { + error: `Failed to download file from UploadThing: ${fileResponse.status} ${fileResponse.statusText}`, + }, + 500, ); } - const protocolName = file.name.replace('.netcanvas', ''); + const arrayBuffer = await fileResponse.arrayBuffer(); + + // Derive protocol name from fileName or fileKey + const protocolName = fileName + ? fileName.replace('.netcanvas', '') + : `protocol-${fileKey.slice(0, 8)}`; // Parse the zip file - const arrayBuffer = await file.arrayBuffer(); const zip = await JSZip.loadAsync(arrayBuffer); // Extract protocol.json @@ -102,11 +75,11 @@ export async function POST(req: NextRequest) { // Check schema version const protocolVersion = protocolJson.schemaVersion; if (!APP_SUPPORTED_SCHEMA_VERSIONS.includes(protocolVersion)) { - return NextResponse.json( + return jsonResponse( { error: `Unsupported protocol schema version: ${protocolVersion}. Supported versions: ${APP_SUPPORTED_SCHEMA_VERSIONS.join(', ')}`, }, - { status: 400, headers: corsHeaders }, + 400, ); } @@ -125,12 +98,12 @@ export async function POST(req: NextRequest) { ({ message, path }) => `${message} (${path.join(' > ')})`, ); - return NextResponse.json( + return jsonResponse( { error: 'Protocol validation failed', validationErrors: errors, }, - { status: 400, headers: corsHeaders }, + 400, ); } @@ -265,7 +238,7 @@ export async function POST(req: NextRequest) { void addEvent( 'Preview Protocol Uploaded', - `Preview protocol "${protocolName}" uploaded`, + `Preview protocol "${protocolName}" uploaded via presigned URL`, ); } @@ -273,24 +246,21 @@ export async function POST(req: NextRequest) { const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); url.pathname = `/preview/${protocolId}`; - return NextResponse.json( - { - success: true, - protocolId, - redirectUrl: url.toString(), - }, - { status: 200, headers: corsHeaders }, - ); + return jsonResponse({ + success: true, + protocolId, + redirectUrl: url.toString(), + }); } catch (error) { // eslint-disable-next-line no-console - console.error('Preview protocol upload error:', error); + console.error('Preview protocol process error:', error); - return NextResponse.json( + return jsonResponse( { error: 'Failed to process protocol', details: error instanceof Error ? error.message : 'Unknown error', }, - { status: 500, headers: corsHeaders }, + 500, ); } } diff --git a/app/api/preview/upload-url/route.ts b/app/api/preview/upload-url/route.ts new file mode 100644 index 000000000..b2570854e --- /dev/null +++ b/app/api/preview/upload-url/route.ts @@ -0,0 +1,72 @@ +import { type NextRequest } from 'next/server'; +import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; +import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; + +export { OPTIONS }; + +export async function POST(req: NextRequest) { + const authError = await checkPreviewAuth(req); + if (authError) return authError; + + try { + // Get file info from request body + const body = (await req.json()) as { + fileName?: string; + fileSize?: number; + fileType?: string; + }; + + const { fileName, fileSize, fileType } = body; + + if (!fileName || typeof fileName !== 'string') { + return jsonResponse({ error: 'fileName is required' }, 400); + } + + if (!fileSize || typeof fileSize !== 'number') { + return jsonResponse( + { error: 'fileSize is required and must be a number' }, + 400, + ); + } + + // Verify it's a .netcanvas file + if (!fileName.endsWith('.netcanvas')) { + return jsonResponse({ error: 'File must be a .netcanvas file' }, 400); + } + + // Generate presigned URL + const result = await generatePresignedUploadUrl({ + fileName, + fileSize, + fileType: fileType ?? 'application/octet-stream', + }); + + if (!result) { + return jsonResponse( + { + error: + 'Failed to generate presigned URL. UploadThing token may not be configured.', + }, + 500, + ); + } + + return jsonResponse({ + success: true, + uploadUrl: result.uploadUrl, + fileKey: result.fileKey, + expiresAt: result.expiresAt, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error generating presigned URL:', error); + + return jsonResponse( + { + error: 'Failed to generate presigned URL', + details: error instanceof Error ? error.message : 'Unknown error', + }, + 500, + ); + } +} diff --git a/lib/uploadthing-presigned.ts b/lib/uploadthing-presigned.ts new file mode 100644 index 000000000..801e1fa61 --- /dev/null +++ b/lib/uploadthing-presigned.ts @@ -0,0 +1,165 @@ +'use server'; + +import { createHmac } from 'crypto'; +import Sqids, { defaultOptions } from 'sqids'; +import { getAppSetting } from '~/queries/appSettings'; + +type ParsedToken = { + apiKey: string; + appId: string; + regions: string[]; + ingestHost: string; +}; + +/** + * Parse the UploadThing token to extract appId, apiKey, and region info + */ +export async function parseUploadThingToken(): Promise { + const token = await getAppSetting('uploadThingToken'); + + if (!token) { + return null; + } + + try { + const decoded = Buffer.from(token, 'base64').toString('utf-8'); + const parsed = JSON.parse(decoded) as ParsedToken; + + return { + apiKey: parsed.apiKey, + appId: parsed.appId, + regions: parsed.regions, + ingestHost: parsed.ingestHost ?? 'ingest.uploadthing.com', + }; + } catch { + return null; + } +} + +// Algorithms from: https://docs.uploadthing.com/uploading-files#generating-presigned-urls + +function hmacSha256(data: string, secret: string): string { + return createHmac('sha256', secret).update(data).digest('hex'); +} + +function djb2(str: string): number { + let hash = 5381; + let i = str.length; + while (i) { + hash = (hash * 33) ^ str.charCodeAt(--i); + } + return (hash & 0xbfffffff) | ((hash >>> 1) & 0x40000000); +} + +function shuffle(str: string, seed: string) { + const chars = str.split(''); + const seedNum = djb2(seed); + let temp: string; + let j: number; + for (let i = 0; i < chars.length; i++) { + j = ((seedNum % (i + 1)) + i) % chars.length; + temp = chars[i] ?? ''; + chars[i] = chars[j] ?? ''; + chars[j] = temp; + } + return chars.join(''); +} + +function encodeBase64(str: string): string { + return Buffer.from(str).toString('base64url'); +} + +function generateKey(appId: string, fileSeed: string) { + // Hash and Encode the parts and apiKey as sqids + const alphabet = shuffle(defaultOptions.alphabet, appId); + const encodedAppId = new Sqids({ alphabet, minLength: 12 }).encode([ + Math.abs(djb2(appId)), + ]); + // We use a base64 encoding here to ensure the file seed is url safe, but + // you can do this however you want + const encodedFileSeed = encodeBase64(fileSeed); + return `${encodedAppId}${encodedFileSeed}`; +} + +type GeneratePresignedUrlOptions = { + fileName: string; + fileSize: number; + fileType?: string; + /** TTL in milliseconds, defaults to 1 hour */ + ttl?: number; +}; + +type PresignedUrlResult = { + uploadUrl: string; + fileKey: string; + expiresAt: number; +}; + +/** + * Generate a presigned URL for direct upload to UploadThing + */ +export async function generatePresignedUploadUrl( + options: GeneratePresignedUrlOptions, +): Promise { + const { + fileName, + fileSize, + fileType = 'application/octet-stream', + ttl = 60 * 60 * 1000, + } = options; + + const tokenData = await parseUploadThingToken(); + + if (!tokenData) { + return null; + } + + const { apiKey, appId, regions, ingestHost } = tokenData; + + // Generate a unique file seed based on file properties and timestamp + const fileSeed = `${fileName}-${fileSize}-${fileType}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const fileKey = generateKey(appId, fileSeed); + + // Build the presigned URL + const region = regions[0] ?? 'sea1'; + const expiresAt = Date.now() + ttl; + + const searchParams = new URLSearchParams({ + 'expires': String(expiresAt), + 'x-ut-identifier': appId, + 'x-ut-file-name': fileName, + 'x-ut-file-size': String(fileSize), + 'x-ut-file-type': fileType, + 'x-ut-slug': 'assetRouter', // Use the existing file router slug + 'x-ut-content-disposition': 'inline', + 'x-ut-acl': 'public-read', + }); + + // Construct the base URL + const baseUrl = `https://${region}.${ingestHost}/${fileKey}`; + const urlWithParams = `${baseUrl}?${searchParams.toString()}`; + + // Sign the URL + const signature = `hmac-sha256=${hmacSha256(urlWithParams, apiKey)}`; + const finalUrl = `${urlWithParams}&signature=${encodeURIComponent(signature)}`; + + return { + uploadUrl: finalUrl, + fileKey, + expiresAt, + }; +} + +/** + * Get the URL for an uploaded file by its key + */ +export async function getFileUrlByKey(fileKey: string): Promise { + const tokenData = await parseUploadThingToken(); + + if (!tokenData) { + return null; + } + + // UploadThing serves files from ufs.sh + return `https://${tokenData.appId}.ufs.sh/f/${fileKey}`; +} diff --git a/package.json b/package.json index dab89bf94..d8b9fcd3b 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "sanitize-filename": "^1.6.3", "server-only": "^0.0.1", "sharp": "^0.34.5", + "sqids": "^0.3.0", "strip-markdown": "^6.0.0", "superjson": "^2.2.2", "tailwind-merge": "^3.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec98d95f7..3c13c34e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + sqids: + specifier: ^0.3.0 + version: 0.3.0 strip-markdown: specifier: ^6.0.0 version: 6.0.0 From b7c2e0c7a33948d8bf01db0556beaefe510f0cf6 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Tue, 25 Nov 2025 14:11:10 -0800 Subject: [PATCH 02/12] refactor: presigned urls for assets instead of full protocol --- app/api/preview/prepare/route.ts | 156 +++++++++++++++++++++++++ app/api/preview/process/route.ts | 169 +++++++++------------------- app/api/preview/upload-url/route.ts | 72 ------------ lib/uploadthing-presigned.ts | 5 + 4 files changed, 217 insertions(+), 185 deletions(-) create mode 100644 app/api/preview/prepare/route.ts delete mode 100644 app/api/preview/upload-url/route.ts diff --git a/app/api/preview/prepare/route.ts b/app/api/preview/prepare/route.ts new file mode 100644 index 000000000..a8ad4b593 --- /dev/null +++ b/app/api/preview/prepare/route.ts @@ -0,0 +1,156 @@ +import { type NextRequest } from 'next/server'; +import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; +import { prisma } from '~/utils/db'; +import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; + +export { OPTIONS }; + +type AssetInput = { + assetId: string; + name: string; + size: number; + type: string; +}; + +type PrepareRequestBody = { + assets: AssetInput[]; +}; + +export async function POST(req: NextRequest) { + const authError = await checkPreviewAuth(req); + if (authError) return authError; + + try { + const body = (await req.json()) as PrepareRequestBody; + + const { assets } = body; + + if (!assets || !Array.isArray(assets)) { + return jsonResponse({ error: 'assets array is required' }, 400); + } + + // Validate each asset has required fields + for (const asset of assets) { + if (!asset.assetId || !asset.name || !asset.size || !asset.type) { + return jsonResponse( + { + error: + 'Each asset must have assetId, name, size, and type properties', + }, + 400, + ); + } + } + + // Check which assets already exist in the database + const assetIds = assets.map((a) => a.assetId); + const existingAssets = await prisma.asset.findMany({ + where: { + assetId: { + in: assetIds, + }, + }, + select: { + assetId: true, + key: true, + url: true, + name: true, + type: true, + size: true, + }, + }); + + const existingAssetIds = new Set(existingAssets.map((a) => a.assetId)); + + // Generate presigned URLs for new assets only + const newAssets = assets.filter((a) => !existingAssetIds.has(a.assetId)); + + const uploads: { + assetId: string; + uploadUrl: string; + fileKey: string; + fileUrl: string; + expiresAt: number; + }[] = []; + + for (const asset of newAssets) { + const result = await generatePresignedUploadUrl({ + fileName: asset.name, + fileSize: asset.size, + fileType: getContentType(asset.type, asset.name), + }); + + if (!result) { + return jsonResponse( + { + error: + 'Failed to generate presigned URL. UploadThing token may not be configured.', + }, + 500, + ); + } + + uploads.push({ + assetId: asset.assetId, + uploadUrl: result.uploadUrl, + fileKey: result.fileKey, + fileUrl: result.fileUrl, + expiresAt: result.expiresAt, + }); + } + + return jsonResponse({ + success: true, + uploads, + existing: existingAssets.map((a) => ({ + assetId: a.assetId, + key: a.key, + url: a.url, + name: a.name, + type: a.type, + size: a.size, + })), + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error preparing preview assets:', error); + + return jsonResponse( + { + error: 'Failed to prepare preview assets', + details: error instanceof Error ? error.message : 'Unknown error', + }, + 500, + ); + } +} + +/** + * Map Network Canvas asset types to content types + */ +function getContentType(assetType: string, fileName: string): string { + // Get extension from filename + const ext = fileName.split('.').pop()?.toLowerCase(); + + // Map by NC asset type first + switch (assetType) { + case 'image': + if (ext === 'png') return 'image/png'; + if (ext === 'gif') return 'image/gif'; + if (ext === 'webp') return 'image/webp'; + if (ext === 'svg') return 'image/svg+xml'; + return 'image/jpeg'; + case 'video': + if (ext === 'webm') return 'video/webm'; + if (ext === 'mov') return 'video/quicktime'; + return 'video/mp4'; + case 'audio': + if (ext === 'wav') return 'audio/wav'; + if (ext === 'ogg') return 'audio/ogg'; + return 'audio/mpeg'; + case 'network': + return 'application/json'; + default: + return 'application/octet-stream'; + } +} diff --git a/app/api/preview/process/route.ts b/app/api/preview/process/route.ts index 61513fb01..7c98a687a 100644 --- a/app/api/preview/process/route.ts +++ b/app/api/preview/process/route.ts @@ -1,76 +1,67 @@ import { type CurrentProtocol, migrateProtocol, + type VersionedProtocol, validateProtocol, } from '@codaco/protocol-validation'; -import JSZip from 'jszip'; import { type NextRequest } from 'next/server'; import { hash } from 'ohash'; import { addEvent } from '~/actions/activityFeed'; import { env } from '~/env'; import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config'; import { prunePreviewProtocols } from '~/lib/preview-protocol-pruning'; -import { getFileUrlByKey } from '~/lib/uploadthing-presigned'; import { prisma } from '~/utils/db'; -import { getProtocolAssets, getProtocolJson } from '~/utils/protocolImport'; import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; export { OPTIONS }; +type AssetInput = { + assetId: string; + key: string; + name: string; + type: string; + url: string; + size: number; + value?: string; // For apikey assets +}; + +type ProcessRequestBody = { + protocol: VersionedProtocol; + protocolName?: string; + assets: AssetInput[]; +}; + export async function POST(req: NextRequest) { const authError = await checkPreviewAuth(req); if (authError) return authError; try { - // Get file info from request body - const body = (await req.json()) as { - fileKey?: string; - fileName?: string; - }; + const body = (await req.json()) as ProcessRequestBody; - const { fileKey, fileName } = body; + const { protocol: protocolJson, protocolName, assets } = body; - if (!fileKey || typeof fileKey !== 'string') { - return jsonResponse({ error: 'fileKey is required' }, 400); + if (!protocolJson || typeof protocolJson !== 'object') { + return jsonResponse({ error: 'protocol object is required' }, 400); } - // Get the file URL from the key - const fileUrl = await getFileUrlByKey(fileKey); - - if (!fileUrl) { - return jsonResponse( - { - error: - 'Failed to get file URL. UploadThing token may not be configured.', - }, - 500, - ); + if (!assets || !Array.isArray(assets)) { + return jsonResponse({ error: 'assets array is required' }, 400); } - // Download the file from UploadThing - const fileResponse = await fetch(fileUrl); - - if (!fileResponse.ok) { - return jsonResponse( - { - error: `Failed to download file from UploadThing: ${fileResponse.status} ${fileResponse.statusText}`, - }, - 500, - ); + // Validate asset structure + for (const asset of assets) { + if (!asset.assetId || !asset.key || !asset.name || !asset.type) { + return jsonResponse( + { + error: 'Each asset must have assetId, key, name, and type', + }, + 400, + ); + } } - const arrayBuffer = await fileResponse.arrayBuffer(); - - // Derive protocol name from fileName or fileKey - const protocolName = fileName - ? fileName.replace('.netcanvas', '') - : `protocol-${fileKey.slice(0, 8)}`; - - // Parse the zip file - const zip = await JSZip.loadAsync(arrayBuffer); - - // Extract protocol.json - const protocolJson = await getProtocolJson(zip); + // Derive protocol name + const name = protocolName ?? `preview-${Date.now()}`; // Check schema version const protocolVersion = protocolJson.schemaVersion; @@ -136,17 +127,8 @@ export async function POST(req: NextRequest) { }); protocolId = updated.id; } else { - // Extract assets - const { fileAssets, apikeyAssets } = await getProtocolAssets( - protocolToValidate, - zip, - ); - - // Combine all assets for checking - const allAssets = [...fileAssets, ...apikeyAssets]; - // Check which assets already exist in the database - const assetIds = allAssets.map((a) => a.assetId); + const assetIds = assets.map((a) => a.assetId); const existingDbAssets = await prisma.asset.findMany({ where: { assetId: { @@ -158,67 +140,26 @@ export async function POST(req: NextRequest) { }, }); - const existingAssetIds = existingDbAssets.map((a) => a.assetId); - - // Only upload file assets that don't exist (apikey assets don't need uploading) - const fileAssetsToUpload = fileAssets.filter( - (a) => !existingAssetIds.includes(a.assetId), - ); - - // Apikey assets to create (if they don't already exist) - const apikeyAssetsToCreate = apikeyAssets.filter( - (a) => !existingAssetIds.includes(a.assetId), - ); - - // Upload new file assets to UploadThing (if any) - let newAssetRecords: { - key: string; - assetId: string; - name: string; - type: string; - url: string; - size: number; - value?: string; - }[] = []; - - if (fileAssetsToUpload.length > 0) { - const files = fileAssetsToUpload.map((asset) => asset.file); - - // Use UTApi for server-side upload - const { getUTApi } = await import('~/lib/uploadthing-server-helpers'); - const utapi = await getUTApi(); - - const uploadedFiles = await Promise.all( - files.map(async (file) => { - const uploaded = await utapi.uploadFiles(file); - if (uploaded.error) { - throw new Error(`Failed to upload asset: ${file.name}`); - } - return uploaded.data; - }), - ); - - newAssetRecords = fileAssetsToUpload.map((asset, idx) => { - const uploaded = uploadedFiles[idx]!; - return { - key: uploaded.key, - assetId: asset.assetId, - name: asset.name, - type: asset.type, - url: uploaded.url, - size: uploaded.size, - }; - }); - } - - // Add apikey assets (they don't need uploading, just database records) - newAssetRecords.push(...apikeyAssetsToCreate); + const existingAssetIds = new Set(existingDbAssets.map((a) => a.assetId)); + + // Assets to create (ones that don't already exist) + const assetsToCreate = assets + .filter((a) => !existingAssetIds.has(a.assetId)) + .map((a) => ({ + assetId: a.assetId, + key: a.key, + name: a.name, + type: a.type, + url: a.url, + size: a.size, + value: a.value, + })); // Create the protocol in the database const protocol = await prisma.protocol.create({ data: { hash: protocolHash, - name: protocolName, + name, schemaVersion: protocolJson.schemaVersion, description: protocolJson.description, lastModified: protocolJson.lastModified @@ -228,8 +169,10 @@ export async function POST(req: NextRequest) { codebook: protocolJson.codebook as never, isPreview: true, assets: { - create: newAssetRecords, - connect: existingAssetIds.map((assetId) => ({ assetId })), + create: assetsToCreate, + connect: Array.from(existingAssetIds).map((assetId) => ({ + assetId, + })), }, }, }); @@ -238,7 +181,7 @@ export async function POST(req: NextRequest) { void addEvent( 'Preview Protocol Uploaded', - `Preview protocol "${protocolName}" uploaded via presigned URL`, + `Preview protocol "${name}" uploaded via direct upload`, ); } diff --git a/app/api/preview/upload-url/route.ts b/app/api/preview/upload-url/route.ts deleted file mode 100644 index b2570854e..000000000 --- a/app/api/preview/upload-url/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type NextRequest } from 'next/server'; -import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; -import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; - -export { OPTIONS }; - -export async function POST(req: NextRequest) { - const authError = await checkPreviewAuth(req); - if (authError) return authError; - - try { - // Get file info from request body - const body = (await req.json()) as { - fileName?: string; - fileSize?: number; - fileType?: string; - }; - - const { fileName, fileSize, fileType } = body; - - if (!fileName || typeof fileName !== 'string') { - return jsonResponse({ error: 'fileName is required' }, 400); - } - - if (!fileSize || typeof fileSize !== 'number') { - return jsonResponse( - { error: 'fileSize is required and must be a number' }, - 400, - ); - } - - // Verify it's a .netcanvas file - if (!fileName.endsWith('.netcanvas')) { - return jsonResponse({ error: 'File must be a .netcanvas file' }, 400); - } - - // Generate presigned URL - const result = await generatePresignedUploadUrl({ - fileName, - fileSize, - fileType: fileType ?? 'application/octet-stream', - }); - - if (!result) { - return jsonResponse( - { - error: - 'Failed to generate presigned URL. UploadThing token may not be configured.', - }, - 500, - ); - } - - return jsonResponse({ - success: true, - uploadUrl: result.uploadUrl, - fileKey: result.fileKey, - expiresAt: result.expiresAt, - }); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error generating presigned URL:', error); - - return jsonResponse( - { - error: 'Failed to generate presigned URL', - details: error instanceof Error ? error.message : 'Unknown error', - }, - 500, - ); - } -} diff --git a/lib/uploadthing-presigned.ts b/lib/uploadthing-presigned.ts index 801e1fa61..9ba74d0cb 100644 --- a/lib/uploadthing-presigned.ts +++ b/lib/uploadthing-presigned.ts @@ -92,6 +92,7 @@ type GeneratePresignedUrlOptions = { type PresignedUrlResult = { uploadUrl: string; fileKey: string; + fileUrl: string; expiresAt: number; }; @@ -143,9 +144,13 @@ export async function generatePresignedUploadUrl( const signature = `hmac-sha256=${hmacSha256(urlWithParams, apiKey)}`; const finalUrl = `${urlWithParams}&signature=${encodeURIComponent(signature)}`; + // The URL where the file will be accessible after upload + const fileUrl = `https://${appId}.ufs.sh/f/${fileKey}`; + return { uploadUrl: finalUrl, fileKey, + fileUrl, expiresAt, }; } From 8d491042d9964ab3cf82d7a968f49e38a2b5c113 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Tue, 25 Nov 2025 14:15:25 -0800 Subject: [PATCH 03/12] knip --- app/api/preview/helpers.ts | 7 ++----- lib/uploadthing-presigned.ts | 16 +--------------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/app/api/preview/helpers.ts b/app/api/preview/helpers.ts index 086773969..e5251263e 100644 --- a/app/api/preview/helpers.ts +++ b/app/api/preview/helpers.ts @@ -5,7 +5,7 @@ import { getAppSetting } from '~/queries/appSettings'; import { getServerSession } from '~/utils/auth'; // CORS headers for external clients (like Architect) -export const corsHeaders = { +const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', @@ -20,10 +20,7 @@ export function OPTIONS() { } // Helper to create JSON responses with CORS headers -export function jsonResponse( - data: Record, - status = 200, -) { +export function jsonResponse(data: Record, status = 200) { return NextResponse.json(data, { status, headers: corsHeaders }); } diff --git a/lib/uploadthing-presigned.ts b/lib/uploadthing-presigned.ts index 9ba74d0cb..16e388bc8 100644 --- a/lib/uploadthing-presigned.ts +++ b/lib/uploadthing-presigned.ts @@ -14,7 +14,7 @@ type ParsedToken = { /** * Parse the UploadThing token to extract appId, apiKey, and region info */ -export async function parseUploadThingToken(): Promise { +async function parseUploadThingToken(): Promise { const token = await getAppSetting('uploadThingToken'); if (!token) { @@ -154,17 +154,3 @@ export async function generatePresignedUploadUrl( expiresAt, }; } - -/** - * Get the URL for an uploaded file by its key - */ -export async function getFileUrlByKey(fileKey: string): Promise { - const tokenData = await parseUploadThingToken(); - - if (!tokenData) { - return null; - } - - // UploadThing serves files from ufs.sh - return `https://${tokenData.appId}.ufs.sh/f/${fileKey}`; -} From 946f49d6e57d17c702de22b2c96a071275dc92e1 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Wed, 26 Nov 2025 10:11:09 -0800 Subject: [PATCH 04/12] refactor: single preview route --- app/api/preview/prepare/route.ts | 156 ------------------------ app/api/preview/{process => }/route.ts | 161 ++++++++++++++++++++----- 2 files changed, 129 insertions(+), 188 deletions(-) delete mode 100644 app/api/preview/prepare/route.ts rename app/api/preview/{process => }/route.ts (54%) diff --git a/app/api/preview/prepare/route.ts b/app/api/preview/prepare/route.ts deleted file mode 100644 index a8ad4b593..000000000 --- a/app/api/preview/prepare/route.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { type NextRequest } from 'next/server'; -import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; -import { prisma } from '~/utils/db'; -import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; - -export { OPTIONS }; - -type AssetInput = { - assetId: string; - name: string; - size: number; - type: string; -}; - -type PrepareRequestBody = { - assets: AssetInput[]; -}; - -export async function POST(req: NextRequest) { - const authError = await checkPreviewAuth(req); - if (authError) return authError; - - try { - const body = (await req.json()) as PrepareRequestBody; - - const { assets } = body; - - if (!assets || !Array.isArray(assets)) { - return jsonResponse({ error: 'assets array is required' }, 400); - } - - // Validate each asset has required fields - for (const asset of assets) { - if (!asset.assetId || !asset.name || !asset.size || !asset.type) { - return jsonResponse( - { - error: - 'Each asset must have assetId, name, size, and type properties', - }, - 400, - ); - } - } - - // Check which assets already exist in the database - const assetIds = assets.map((a) => a.assetId); - const existingAssets = await prisma.asset.findMany({ - where: { - assetId: { - in: assetIds, - }, - }, - select: { - assetId: true, - key: true, - url: true, - name: true, - type: true, - size: true, - }, - }); - - const existingAssetIds = new Set(existingAssets.map((a) => a.assetId)); - - // Generate presigned URLs for new assets only - const newAssets = assets.filter((a) => !existingAssetIds.has(a.assetId)); - - const uploads: { - assetId: string; - uploadUrl: string; - fileKey: string; - fileUrl: string; - expiresAt: number; - }[] = []; - - for (const asset of newAssets) { - const result = await generatePresignedUploadUrl({ - fileName: asset.name, - fileSize: asset.size, - fileType: getContentType(asset.type, asset.name), - }); - - if (!result) { - return jsonResponse( - { - error: - 'Failed to generate presigned URL. UploadThing token may not be configured.', - }, - 500, - ); - } - - uploads.push({ - assetId: asset.assetId, - uploadUrl: result.uploadUrl, - fileKey: result.fileKey, - fileUrl: result.fileUrl, - expiresAt: result.expiresAt, - }); - } - - return jsonResponse({ - success: true, - uploads, - existing: existingAssets.map((a) => ({ - assetId: a.assetId, - key: a.key, - url: a.url, - name: a.name, - type: a.type, - size: a.size, - })), - }); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error preparing preview assets:', error); - - return jsonResponse( - { - error: 'Failed to prepare preview assets', - details: error instanceof Error ? error.message : 'Unknown error', - }, - 500, - ); - } -} - -/** - * Map Network Canvas asset types to content types - */ -function getContentType(assetType: string, fileName: string): string { - // Get extension from filename - const ext = fileName.split('.').pop()?.toLowerCase(); - - // Map by NC asset type first - switch (assetType) { - case 'image': - if (ext === 'png') return 'image/png'; - if (ext === 'gif') return 'image/gif'; - if (ext === 'webp') return 'image/webp'; - if (ext === 'svg') return 'image/svg+xml'; - return 'image/jpeg'; - case 'video': - if (ext === 'webm') return 'video/webm'; - if (ext === 'mov') return 'video/quicktime'; - return 'video/mp4'; - case 'audio': - if (ext === 'wav') return 'audio/wav'; - if (ext === 'ogg') return 'audio/ogg'; - return 'audio/mpeg'; - case 'network': - return 'application/json'; - default: - return 'application/octet-stream'; - } -} diff --git a/app/api/preview/process/route.ts b/app/api/preview/route.ts similarity index 54% rename from app/api/preview/process/route.ts rename to app/api/preview/route.ts index 7c98a687a..d851d4171 100644 --- a/app/api/preview/process/route.ts +++ b/app/api/preview/route.ts @@ -1,8 +1,8 @@ import { type CurrentProtocol, migrateProtocol, - type VersionedProtocol, validateProtocol, + type VersionedProtocol, } from '@codaco/protocol-validation'; import { type NextRequest } from 'next/server'; import { hash } from 'ohash'; @@ -10,22 +10,21 @@ import { addEvent } from '~/actions/activityFeed'; import { env } from '~/env'; import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config'; import { prunePreviewProtocols } from '~/lib/preview-protocol-pruning'; +import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; import { prisma } from '~/utils/db'; -import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; +import { checkPreviewAuth, jsonResponse, OPTIONS } from './helpers'; export { OPTIONS }; type AssetInput = { assetId: string; - key: string; name: string; - type: string; - url: string; size: number; + type: string; value?: string; // For apikey assets }; -type ProcessRequestBody = { +type PreviewRequestBody = { protocol: VersionedProtocol; protocolName?: string; assets: AssetInput[]; @@ -36,7 +35,7 @@ export async function POST(req: NextRequest) { if (authError) return authError; try { - const body = (await req.json()) as ProcessRequestBody; + const body = (await req.json()) as PreviewRequestBody; const { protocol: protocolJson, protocolName, assets } = body; @@ -50,11 +49,16 @@ export async function POST(req: NextRequest) { // Validate asset structure for (const asset of assets) { - if (!asset.assetId || !asset.key || !asset.name || !asset.type) { + if (!asset.assetId || !asset.name || !asset.type) { return jsonResponse( - { - error: 'Each asset must have assetId, key, name, and type', - }, + { error: 'Each asset must have assetId, name, and type' }, + 400, + ); + } + // Size is required for non-apikey assets + if (asset.type !== 'apikey' && typeof asset.size !== 'number') { + return jsonResponse( + { error: 'Each non-apikey asset must have a size' }, 400, ); } @@ -116,6 +120,13 @@ export async function POST(req: NextRequest) { }); let protocolId: string; + const uploads: { + assetId: string; + uploadUrl: string; + fileKey: string; + fileUrl: string; + expiresAt: number; + }[] = []; if (existingPreview) { // Update timestamp to prevent premature pruning @@ -126,6 +137,7 @@ export async function POST(req: NextRequest) { }, }); protocolId = updated.id; + // No uploads needed - assets already exist } else { // Check which assets already exist in the database const assetIds = assets.map((a) => a.assetId); @@ -137,25 +149,83 @@ export async function POST(req: NextRequest) { }, select: { assetId: true, + key: true, + url: true, }, }); - const existingAssetIds = new Set(existingDbAssets.map((a) => a.assetId)); - - // Assets to create (ones that don't already exist) - const assetsToCreate = assets - .filter((a) => !existingAssetIds.has(a.assetId)) - .map((a) => ({ - assetId: a.assetId, - key: a.key, - name: a.name, - type: a.type, - url: a.url, - size: a.size, - value: a.value, - })); - - // Create the protocol in the database + const existingAssetMap = new Map( + existingDbAssets.map((a) => [a.assetId, a]), + ); + + // Separate assets into existing, new file assets, and apikey assets + const assetsToCreate: { + assetId: string; + key: string; + name: string; + type: string; + url: string; + size: number; + value?: string; + }[] = []; + + const existingAssetIds: string[] = []; + + for (const asset of assets) { + const existing = existingAssetMap.get(asset.assetId); + + if (existing) { + // Asset already exists - just connect it + existingAssetIds.push(asset.assetId); + } else if (asset.type === 'apikey') { + // Apikey assets don't need uploading + assetsToCreate.push({ + assetId: asset.assetId, + key: asset.assetId, + name: asset.name, + type: asset.type, + url: '', + size: 0, + value: asset.value, + }); + } else { + // New file asset - generate presigned URL + const presigned = await generatePresignedUploadUrl({ + fileName: asset.name, + fileSize: asset.size, + fileType: getContentType(asset.type, asset.name), + }); + + if (!presigned) { + return jsonResponse( + { + error: + 'Failed to generate presigned URL. UploadThing token may not be configured.', + }, + 500, + ); + } + + uploads.push({ + assetId: asset.assetId, + uploadUrl: presigned.uploadUrl, + fileKey: presigned.fileKey, + fileUrl: presigned.fileUrl, + expiresAt: presigned.expiresAt, + }); + + assetsToCreate.push({ + assetId: asset.assetId, + key: presigned.fileKey, + name: asset.name, + type: asset.type, + url: presigned.fileUrl, + size: asset.size, + }); + } + } + + // Create the protocol and assets in the database const protocol = await prisma.protocol.create({ data: { hash: protocolHash, @@ -170,9 +240,7 @@ export async function POST(req: NextRequest) { isPreview: true, assets: { create: assetsToCreate, - connect: Array.from(existingAssetIds).map((assetId) => ({ - assetId, - })), + connect: existingAssetIds.map((assetId) => ({ assetId })), }, }, }); @@ -185,7 +253,7 @@ export async function POST(req: NextRequest) { ); } - // Return the protocol ID and redirect URL + // Return the protocol ID, redirect URL, and any uploads needed const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); url.pathname = `/preview/${protocolId}`; @@ -193,10 +261,11 @@ export async function POST(req: NextRequest) { success: true, protocolId, redirectUrl: url.toString(), + uploads, }); } catch (error) { // eslint-disable-next-line no-console - console.error('Preview protocol process error:', error); + console.error('Preview protocol error:', error); return jsonResponse( { @@ -207,3 +276,31 @@ export async function POST(req: NextRequest) { ); } } + +/** + * Map Network Canvas asset types to content types + */ +function getContentType(assetType: string, fileName: string): string { + const ext = fileName.split('.').pop()?.toLowerCase(); + + switch (assetType) { + case 'image': + if (ext === 'png') return 'image/png'; + if (ext === 'gif') return 'image/gif'; + if (ext === 'webp') return 'image/webp'; + if (ext === 'svg') return 'image/svg+xml'; + return 'image/jpeg'; + case 'video': + if (ext === 'webm') return 'video/webm'; + if (ext === 'mov') return 'video/quicktime'; + return 'video/mp4'; + case 'audio': + if (ext === 'wav') return 'audio/wav'; + if (ext === 'ogg') return 'audio/ogg'; + return 'audio/mpeg'; + case 'network': + return 'application/json'; + default: + return 'application/octet-stream'; + } +} From 235f36face2f2e4ee3a05bdfac5ba840ed890621 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Tue, 2 Dec 2025 10:48:56 -0800 Subject: [PATCH 05/12] remove fileType and other props from search params --- app/api/preview/route.ts | 29 ----------------------------- lib/uploadthing-presigned.ts | 13 ++----------- 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts index d851d4171..d1f76f71b 100644 --- a/app/api/preview/route.ts +++ b/app/api/preview/route.ts @@ -193,7 +193,6 @@ export async function POST(req: NextRequest) { const presigned = await generatePresignedUploadUrl({ fileName: asset.name, fileSize: asset.size, - fileType: getContentType(asset.type, asset.name), }); if (!presigned) { @@ -276,31 +275,3 @@ export async function POST(req: NextRequest) { ); } } - -/** - * Map Network Canvas asset types to content types - */ -function getContentType(assetType: string, fileName: string): string { - const ext = fileName.split('.').pop()?.toLowerCase(); - - switch (assetType) { - case 'image': - if (ext === 'png') return 'image/png'; - if (ext === 'gif') return 'image/gif'; - if (ext === 'webp') return 'image/webp'; - if (ext === 'svg') return 'image/svg+xml'; - return 'image/jpeg'; - case 'video': - if (ext === 'webm') return 'video/webm'; - if (ext === 'mov') return 'video/quicktime'; - return 'video/mp4'; - case 'audio': - if (ext === 'wav') return 'audio/wav'; - if (ext === 'ogg') return 'audio/ogg'; - return 'audio/mpeg'; - case 'network': - return 'application/json'; - default: - return 'application/octet-stream'; - } -} diff --git a/lib/uploadthing-presigned.ts b/lib/uploadthing-presigned.ts index 16e388bc8..2867060ab 100644 --- a/lib/uploadthing-presigned.ts +++ b/lib/uploadthing-presigned.ts @@ -84,7 +84,6 @@ function generateKey(appId: string, fileSeed: string) { type GeneratePresignedUrlOptions = { fileName: string; fileSize: number; - fileType?: string; /** TTL in milliseconds, defaults to 1 hour */ ttl?: number; }; @@ -102,12 +101,7 @@ type PresignedUrlResult = { export async function generatePresignedUploadUrl( options: GeneratePresignedUrlOptions, ): Promise { - const { - fileName, - fileSize, - fileType = 'application/octet-stream', - ttl = 60 * 60 * 1000, - } = options; + const { fileName, fileSize, ttl = 60 * 60 * 1000 } = options; const tokenData = await parseUploadThingToken(); @@ -118,7 +112,7 @@ export async function generatePresignedUploadUrl( const { apiKey, appId, regions, ingestHost } = tokenData; // Generate a unique file seed based on file properties and timestamp - const fileSeed = `${fileName}-${fileSize}-${fileType}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const fileSeed = `${fileName}-${fileSize}-${Date.now()}-${Math.random().toString(36).slice(2)}`; const fileKey = generateKey(appId, fileSeed); // Build the presigned URL @@ -130,10 +124,7 @@ export async function generatePresignedUploadUrl( 'x-ut-identifier': appId, 'x-ut-file-name': fileName, 'x-ut-file-size': String(fileSize), - 'x-ut-file-type': fileType, 'x-ut-slug': 'assetRouter', // Use the existing file router slug - 'x-ut-content-disposition': 'inline', - 'x-ut-acl': 'public-read', }); // Construct the base URL From 2817c0704b16763ae4fbe26c8b1ad2868e56de2e Mon Sep 17 00:00:00 2001 From: buckhalt Date: Wed, 3 Dec 2025 11:56:43 -0800 Subject: [PATCH 06/12] refactor for 2 endpoints to prevent issues with failed uploads --- app/api/preview/{ => confirm}/route.ts | 74 ++++----------- app/api/preview/prepare/route.ts | 125 +++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 56 deletions(-) rename app/api/preview/{ => confirm}/route.ts (75%) create mode 100644 app/api/preview/prepare/route.ts diff --git a/app/api/preview/route.ts b/app/api/preview/confirm/route.ts similarity index 75% rename from app/api/preview/route.ts rename to app/api/preview/confirm/route.ts index d1f76f71b..30e8b9482 100644 --- a/app/api/preview/route.ts +++ b/app/api/preview/confirm/route.ts @@ -10,9 +10,8 @@ import { addEvent } from '~/actions/activityFeed'; import { env } from '~/env'; import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config'; import { prunePreviewProtocols } from '~/lib/preview-protocol-pruning'; -import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; import { prisma } from '~/utils/db'; -import { checkPreviewAuth, jsonResponse, OPTIONS } from './helpers'; +import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; export { OPTIONS }; @@ -21,10 +20,12 @@ type AssetInput = { name: string; size: number; type: string; + fileKey: string; + fileUrl: string; value?: string; // For apikey assets }; -type PreviewRequestBody = { +type ConfirmRequestBody = { protocol: VersionedProtocol; protocolName?: string; assets: AssetInput[]; @@ -35,8 +36,7 @@ export async function POST(req: NextRequest) { if (authError) return authError; try { - const body = (await req.json()) as PreviewRequestBody; - + const body = (await req.json()) as ConfirmRequestBody; const { protocol: protocolJson, protocolName, assets } = body; if (!protocolJson || typeof protocolJson !== 'object') { @@ -55,10 +55,10 @@ export async function POST(req: NextRequest) { 400, ); } - // Size is required for non-apikey assets - if (asset.type !== 'apikey' && typeof asset.size !== 'number') { + // fileKey and fileUrl required for non-apikey assets + if (asset.type !== 'apikey' && (!asset.fileKey || !asset.fileUrl)) { return jsonResponse( - { error: 'Each non-apikey asset must have a size' }, + { error: 'Each non-apikey asset must have fileKey and fileUrl' }, 400, ); } @@ -120,13 +120,6 @@ export async function POST(req: NextRequest) { }); let protocolId: string; - const uploads: { - assetId: string; - uploadUrl: string; - fileKey: string; - fileUrl: string; - expiresAt: number; - }[] = []; if (existingPreview) { // Update timestamp to prevent premature pruning @@ -137,7 +130,6 @@ export async function POST(req: NextRequest) { }, }); protocolId = updated.id; - // No uploads needed - assets already exist } else { // Check which assets already exist in the database const assetIds = assets.map((a) => a.assetId); @@ -149,16 +141,12 @@ export async function POST(req: NextRequest) { }, select: { assetId: true, - key: true, - url: true, }, }); - const existingAssetMap = new Map( - existingDbAssets.map((a) => [a.assetId, a]), - ); + const existingAssetSet = new Set(existingDbAssets.map((a) => a.assetId)); - // Separate assets into existing, new file assets, and apikey assets + // Separate assets into existing and new const assetsToCreate: { assetId: string; key: string; @@ -172,13 +160,11 @@ export async function POST(req: NextRequest) { const existingAssetIds: string[] = []; for (const asset of assets) { - const existing = existingAssetMap.get(asset.assetId); - - if (existing) { + if (existingAssetSet.has(asset.assetId)) { // Asset already exists - just connect it existingAssetIds.push(asset.assetId); } else if (asset.type === 'apikey') { - // Apikey assets don't need uploading + // Apikey assets don't have uploaded files assetsToCreate.push({ assetId: asset.assetId, key: asset.assetId, @@ -189,36 +175,13 @@ export async function POST(req: NextRequest) { value: asset.value, }); } else { - // New file asset - generate presigned URL - const presigned = await generatePresignedUploadUrl({ - fileName: asset.name, - fileSize: asset.size, - }); - - if (!presigned) { - return jsonResponse( - { - error: - 'Failed to generate presigned URL. UploadThing token may not be configured.', - }, - 500, - ); - } - - uploads.push({ - assetId: asset.assetId, - uploadUrl: presigned.uploadUrl, - fileKey: presigned.fileKey, - fileUrl: presigned.fileUrl, - expiresAt: presigned.expiresAt, - }); - + // New file asset - use the fileKey/fileUrl from the upload assetsToCreate.push({ assetId: asset.assetId, - key: presigned.fileKey, + key: asset.fileKey, name: asset.name, type: asset.type, - url: presigned.fileUrl, + url: asset.fileUrl, size: asset.size, }); } @@ -252,7 +215,7 @@ export async function POST(req: NextRequest) { ); } - // Return the protocol ID, redirect URL, and any uploads needed + // Return the protocol ID and redirect URL const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); url.pathname = `/preview/${protocolId}`; @@ -260,15 +223,14 @@ export async function POST(req: NextRequest) { success: true, protocolId, redirectUrl: url.toString(), - uploads, }); } catch (error) { // eslint-disable-next-line no-console - console.error('Preview protocol error:', error); + console.error('Preview confirm error:', error); return jsonResponse( { - error: 'Failed to process protocol', + error: 'Failed to confirm protocol', details: error instanceof Error ? error.message : 'Unknown error', }, 500, diff --git a/app/api/preview/prepare/route.ts b/app/api/preview/prepare/route.ts new file mode 100644 index 000000000..4dff5c2ba --- /dev/null +++ b/app/api/preview/prepare/route.ts @@ -0,0 +1,125 @@ +import { type NextRequest } from 'next/server'; +import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; +import { prisma } from '~/utils/db'; +import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; + +export { OPTIONS }; + +type AssetInput = { + assetId: string; + name: string; + size: number; +}; + +type PrepareRequestBody = { + assets: AssetInput[]; +}; + +export async function POST(req: NextRequest) { + const authError = await checkPreviewAuth(req); + if (authError) return authError; + + try { + const body = (await req.json()) as PrepareRequestBody; + const { assets } = body; + + if (!assets || !Array.isArray(assets)) { + return jsonResponse({ error: 'assets array is required' }, 400); + } + + // Validate asset structure + for (const asset of assets) { + if (!asset.assetId || !asset.name || typeof asset.size !== 'number') { + return jsonResponse( + { error: 'Each asset must have assetId, name, and size' }, + 400, + ); + } + } + + // Check which assets already exist in the database + const assetIds = assets.map((a) => a.assetId); + const existingDbAssets = await prisma.asset.findMany({ + where: { + assetId: { + in: assetIds, + }, + }, + select: { + assetId: true, + key: true, + url: true, + }, + }); + + const existingAssetMap = new Map( + existingDbAssets.map((a) => [a.assetId, { key: a.key, url: a.url }]), + ); + + // Build response: existing assets and new uploads + const existing: { + assetId: string; + fileKey: string; + fileUrl: string; + }[] = []; + + const uploads: { + assetId: string; + uploadUrl: string; + fileKey: string; + fileUrl: string; + expiresAt: number; + }[] = []; + + for (const asset of assets) { + const existingAsset = existingAssetMap.get(asset.assetId); + + if (existingAsset) { + // Asset already exists - no upload needed + existing.push({ + assetId: asset.assetId, + fileKey: existingAsset.key, + fileUrl: existingAsset.url, + }); + } else { + // New asset - generate presigned URL + const presigned = await generatePresignedUploadUrl({ + fileName: asset.name, + fileSize: asset.size, + }); + + if (!presigned) { + return jsonResponse( + { + error: + 'Failed to generate presigned URL. UploadThing token may not be configured.', + }, + 500, + ); + } + + uploads.push({ + assetId: asset.assetId, + uploadUrl: presigned.uploadUrl, + fileKey: presigned.fileKey, + fileUrl: presigned.fileUrl, + expiresAt: presigned.expiresAt, + }); + } + } + + return jsonResponse({ + success: true, + existing, + uploads, + }); + } catch (error) { + return jsonResponse( + { + error: 'Failed to prepare upload', + details: error instanceof Error ? error.message : 'Unknown error', + }, + 500, + ); + } +} From fb5878170742ad79f2828a8b9f0b2016d9b07c38 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Wed, 3 Dec 2025 14:48:01 -0800 Subject: [PATCH 07/12] delete orphaned participants in preview protocol prune includes updated test --- .../preview-protocol-pruning.test.ts | 40 +++++++++++++++++++ lib/preview-protocol-pruning.ts | 38 ++++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/preview-protocol-pruning.test.ts b/lib/__tests__/preview-protocol-pruning.test.ts index cab460878..6549738b5 100644 --- a/lib/__tests__/preview-protocol-pruning.test.ts +++ b/lib/__tests__/preview-protocol-pruning.test.ts @@ -10,6 +10,10 @@ const mockPrisma = { findMany: vi.fn(), deleteMany: vi.fn(), }, + participant: { + findMany: vi.fn(), + deleteMany: vi.fn(), + }, }; // Mock uploadthing API @@ -48,6 +52,7 @@ describe('prunePreviewProtocols', () => { mockPrisma.protocol.findMany.mockResolvedValue([oldProtocol]); mockPrisma.asset.findMany.mockResolvedValue([]); + mockPrisma.participant.findMany.mockResolvedValue([]); mockPrisma.protocol.deleteMany.mockResolvedValue({ count: 1 }); const result = await prunePreviewProtocols(); @@ -95,6 +100,7 @@ describe('prunePreviewProtocols', () => { mockDeleteFiles.mockResolvedValue({ success: true }); mockPrisma.protocol.findMany.mockResolvedValue([oldProtocol]); mockPrisma.asset.findMany.mockResolvedValue(assets); + mockPrisma.participant.findMany.mockResolvedValue([]); mockPrisma.asset.deleteMany.mockResolvedValue({ count: 2 }); mockPrisma.protocol.deleteMany.mockResolvedValue({ count: 1 }); @@ -104,6 +110,40 @@ describe('prunePreviewProtocols', () => { expect(mockDeleteFiles).toHaveBeenCalledWith(['ut-key-1', 'ut-key-2']); }); + it('should delete orphaned participants', async () => { + const { prunePreviewProtocols } = await import( + '../preview-protocol-pruning' + ); + + const oldProtocol = { + id: 'old-protocol', + hash: 'hash-123', + name: 'Old Protocol', + }; + + const participants = [ + { id: 'participant-1' }, + { id: 'participant-2' }, + ]; + + mockPrisma.protocol.findMany.mockResolvedValue([oldProtocol]); + mockPrisma.asset.findMany.mockResolvedValue([]); + mockPrisma.participant.findMany.mockResolvedValue(participants); + mockPrisma.protocol.deleteMany.mockResolvedValue({ count: 1 }); + mockPrisma.participant.deleteMany.mockResolvedValue({ count: 2 }); + + const result = await prunePreviewProtocols(); + + expect(result.deletedCount).toBe(1); + expect(mockPrisma.participant.deleteMany).toHaveBeenCalledWith({ + where: { + id: { + in: ['participant-1', 'participant-2'], + }, + }, + }); + }); + it('should handle errors gracefully', async () => { const { prunePreviewProtocols } = await import( '../preview-protocol-pruning' diff --git a/lib/preview-protocol-pruning.ts b/lib/preview-protocol-pruning.ts index 3b9350080..872eff3fe 100644 --- a/lib/preview-protocol-pruning.ts +++ b/lib/preview-protocol-pruning.ts @@ -5,7 +5,8 @@ const MAX_PREVIEW_PROTOCOL_AGE_HOURS = 24; /** * Prune preview protocols based on age limit. - * Deletes protocols older than 24 hours. + * Deletes protocols older than 24 hours, along with their + * orphaned assets and participants. */ export async function prunePreviewProtocols(): Promise<{ deletedCount: number; @@ -36,13 +37,15 @@ export async function prunePreviewProtocols(): Promise<{ return { deletedCount: 0 }; } + const protocolIds = oldProtocols.map((p) => p.id); + // Select assets that are ONLY associated with the protocols to be deleted const assetKeysToDelete = await prisma.asset.findMany({ where: { protocols: { every: { id: { - in: oldProtocols.map((p) => p.id), + in: protocolIds, }, }, }, @@ -50,6 +53,22 @@ export async function prunePreviewProtocols(): Promise<{ select: { key: true }, }); + // Find participants whose interviews are ALL with the protocols to be deleted + // These will become orphaned after cascade delete + const participantsToDelete = await prisma.participant.findMany({ + where: { + interviews: { + some: {}, // Has at least one interview + every: { + protocolId: { + in: protocolIds, + }, + }, + }, + }, + select: { id: true }, + }); + // Delete assets from UploadThing (best effort) if (assetKeysToDelete.length > 0) { try { @@ -72,15 +91,26 @@ export async function prunePreviewProtocols(): Promise<{ }); } - // Delete the protocols + // Delete the protocols (interviews cascade delete automatically) const result = await prisma.protocol.deleteMany({ where: { id: { - in: oldProtocols.map((p) => p.id), + in: protocolIds, }, }, }); + // Delete orphaned participants (their interviews were cascade deleted above) + if (participantsToDelete.length > 0) { + await prisma.participant.deleteMany({ + where: { + id: { + in: participantsToDelete.map((p) => p.id), + }, + }, + }); + } + return { deletedCount: result.count }; } catch (error) { // eslint-disable-next-line no-console From 5762f1fea7836fc977e167677acab7f814d6b0ba Mon Sep 17 00:00:00 2001 From: buckhalt Date: Thu, 4 Dec 2025 13:15:39 -0800 Subject: [PATCH 08/12] refactor preview for single endpoint with multiple message types types file with message exchange types copied to architect web --- app/api/preview/confirm/route.ts | 239 ------------------------ app/api/preview/helpers.ts | 24 ++- app/api/preview/prepare/route.ts | 125 ------------- app/api/preview/route.ts | 310 +++++++++++++++++++++++++++++++ app/api/preview/types.ts | 79 ++++++++ lib/uploadthing-presigned.ts | 2 + 6 files changed, 408 insertions(+), 371 deletions(-) delete mode 100644 app/api/preview/confirm/route.ts delete mode 100644 app/api/preview/prepare/route.ts create mode 100644 app/api/preview/route.ts create mode 100644 app/api/preview/types.ts diff --git a/app/api/preview/confirm/route.ts b/app/api/preview/confirm/route.ts deleted file mode 100644 index 30e8b9482..000000000 --- a/app/api/preview/confirm/route.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { - type CurrentProtocol, - migrateProtocol, - validateProtocol, - type VersionedProtocol, -} from '@codaco/protocol-validation'; -import { type NextRequest } from 'next/server'; -import { hash } from 'ohash'; -import { addEvent } from '~/actions/activityFeed'; -import { env } from '~/env'; -import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config'; -import { prunePreviewProtocols } from '~/lib/preview-protocol-pruning'; -import { prisma } from '~/utils/db'; -import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; - -export { OPTIONS }; - -type AssetInput = { - assetId: string; - name: string; - size: number; - type: string; - fileKey: string; - fileUrl: string; - value?: string; // For apikey assets -}; - -type ConfirmRequestBody = { - protocol: VersionedProtocol; - protocolName?: string; - assets: AssetInput[]; -}; - -export async function POST(req: NextRequest) { - const authError = await checkPreviewAuth(req); - if (authError) return authError; - - try { - const body = (await req.json()) as ConfirmRequestBody; - const { protocol: protocolJson, protocolName, assets } = body; - - if (!protocolJson || typeof protocolJson !== 'object') { - return jsonResponse({ error: 'protocol object is required' }, 400); - } - - if (!assets || !Array.isArray(assets)) { - return jsonResponse({ error: 'assets array is required' }, 400); - } - - // Validate asset structure - for (const asset of assets) { - if (!asset.assetId || !asset.name || !asset.type) { - return jsonResponse( - { error: 'Each asset must have assetId, name, and type' }, - 400, - ); - } - // fileKey and fileUrl required for non-apikey assets - if (asset.type !== 'apikey' && (!asset.fileKey || !asset.fileUrl)) { - return jsonResponse( - { error: 'Each non-apikey asset must have fileKey and fileUrl' }, - 400, - ); - } - } - - // Derive protocol name - const name = protocolName ?? `preview-${Date.now()}`; - - // Check schema version - const protocolVersion = protocolJson.schemaVersion; - if (!APP_SUPPORTED_SCHEMA_VERSIONS.includes(protocolVersion)) { - return jsonResponse( - { - error: `Unsupported protocol schema version: ${protocolVersion}. Supported versions: ${APP_SUPPORTED_SCHEMA_VERSIONS.join(', ')}`, - }, - 400, - ); - } - - // Migrate if needed - const protocolToValidate = ( - protocolJson.schemaVersion < 8 - ? migrateProtocol(protocolJson, 8) - : protocolJson - ) as CurrentProtocol; - - // Validate protocol - const validationResult = await validateProtocol(protocolToValidate); - - if (!validationResult.success) { - const errors = validationResult.error.issues.map( - ({ message, path }) => `${message} (${path.join(' > ')})`, - ); - - return jsonResponse( - { - error: 'Protocol validation failed', - validationErrors: errors, - }, - 400, - ); - } - - // Calculate protocol hash - const protocolHash = hash(protocolJson); - - // Run pruning process before creating new protocol - await prunePreviewProtocols(); - - // Check if this exact protocol already exists as a preview - const existingPreview = await prisma.protocol.findFirst({ - where: { - hash: protocolHash, - isPreview: true, - }, - orderBy: { - importedAt: 'desc', - }, - }); - - let protocolId: string; - - if (existingPreview) { - // Update timestamp to prevent premature pruning - const updated = await prisma.protocol.update({ - where: { id: existingPreview.id }, - data: { - importedAt: new Date(), - }, - }); - protocolId = updated.id; - } else { - // Check which assets already exist in the database - const assetIds = assets.map((a) => a.assetId); - const existingDbAssets = await prisma.asset.findMany({ - where: { - assetId: { - in: assetIds, - }, - }, - select: { - assetId: true, - }, - }); - - const existingAssetSet = new Set(existingDbAssets.map((a) => a.assetId)); - - // Separate assets into existing and new - const assetsToCreate: { - assetId: string; - key: string; - name: string; - type: string; - url: string; - size: number; - value?: string; - }[] = []; - - const existingAssetIds: string[] = []; - - for (const asset of assets) { - if (existingAssetSet.has(asset.assetId)) { - // Asset already exists - just connect it - existingAssetIds.push(asset.assetId); - } else if (asset.type === 'apikey') { - // Apikey assets don't have uploaded files - assetsToCreate.push({ - assetId: asset.assetId, - key: asset.assetId, - name: asset.name, - type: asset.type, - url: '', - size: 0, - value: asset.value, - }); - } else { - // New file asset - use the fileKey/fileUrl from the upload - assetsToCreate.push({ - assetId: asset.assetId, - key: asset.fileKey, - name: asset.name, - type: asset.type, - url: asset.fileUrl, - size: asset.size, - }); - } - } - - // Create the protocol and assets in the database - const protocol = await prisma.protocol.create({ - data: { - hash: protocolHash, - name, - schemaVersion: protocolJson.schemaVersion, - description: protocolJson.description, - lastModified: protocolJson.lastModified - ? new Date(protocolJson.lastModified) - : new Date(), - stages: protocolJson.stages as never, - codebook: protocolJson.codebook as never, - isPreview: true, - assets: { - create: assetsToCreate, - connect: existingAssetIds.map((assetId) => ({ assetId })), - }, - }, - }); - - protocolId = protocol.id; - - void addEvent( - 'Preview Protocol Uploaded', - `Preview protocol "${name}" uploaded via direct upload`, - ); - } - - // Return the protocol ID and redirect URL - const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); - url.pathname = `/preview/${protocolId}`; - - return jsonResponse({ - success: true, - protocolId, - redirectUrl: url.toString(), - }); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Preview confirm error:', error); - - return jsonResponse( - { - error: 'Failed to confirm protocol', - details: error instanceof Error ? error.message : 'Unknown error', - }, - 500, - ); - } -} diff --git a/app/api/preview/helpers.ts b/app/api/preview/helpers.ts index e5251263e..fa453c99c 100644 --- a/app/api/preview/helpers.ts +++ b/app/api/preview/helpers.ts @@ -3,6 +3,7 @@ import { verifyApiToken } from '~/actions/apiTokens'; import { env } from '~/env'; import { getAppSetting } from '~/queries/appSettings'; import { getServerSession } from '~/utils/auth'; +import type { ErrorResponse, PreviewResponse } from './types'; // CORS headers for external clients (like Architect) const corsHeaders = { @@ -20,7 +21,7 @@ export function OPTIONS() { } // Helper to create JSON responses with CORS headers -export function jsonResponse(data: Record, status = 200) { +export function jsonResponse(data: PreviewResponse, status = 200) { return NextResponse.json(data, { status, headers: corsHeaders }); } @@ -31,7 +32,11 @@ export async function checkPreviewAuth( ): Promise { // Check if preview mode is enabled if (!env.PREVIEW_MODE) { - return jsonResponse({ error: 'Preview mode is not enabled' }, 403); + const response: ErrorResponse = { + status: 'error', + message: 'Preview mode is not enabled', + }; + return jsonResponse(response, 403); } // Check authentication if required @@ -47,16 +52,21 @@ export async function checkPreviewAuth( const token = authHeader?.replace('Bearer ', ''); if (!token) { - return jsonResponse( - { error: 'Authentication required. Provide session or API token.' }, - 401, - ); + const response: ErrorResponse = { + status: 'error', + message: 'Authentication required. Provide session or API token.', + }; + return jsonResponse(response, 401); } const { valid } = await verifyApiToken(token); if (!valid) { - return jsonResponse({ error: 'Invalid API token' }, 401); + const response: ErrorResponse = { + status: 'error', + message: 'Invalid API token', + }; + return jsonResponse(response, 401); } } } diff --git a/app/api/preview/prepare/route.ts b/app/api/preview/prepare/route.ts deleted file mode 100644 index 4dff5c2ba..000000000 --- a/app/api/preview/prepare/route.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { type NextRequest } from 'next/server'; -import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; -import { prisma } from '~/utils/db'; -import { checkPreviewAuth, jsonResponse, OPTIONS } from '../helpers'; - -export { OPTIONS }; - -type AssetInput = { - assetId: string; - name: string; - size: number; -}; - -type PrepareRequestBody = { - assets: AssetInput[]; -}; - -export async function POST(req: NextRequest) { - const authError = await checkPreviewAuth(req); - if (authError) return authError; - - try { - const body = (await req.json()) as PrepareRequestBody; - const { assets } = body; - - if (!assets || !Array.isArray(assets)) { - return jsonResponse({ error: 'assets array is required' }, 400); - } - - // Validate asset structure - for (const asset of assets) { - if (!asset.assetId || !asset.name || typeof asset.size !== 'number') { - return jsonResponse( - { error: 'Each asset must have assetId, name, and size' }, - 400, - ); - } - } - - // Check which assets already exist in the database - const assetIds = assets.map((a) => a.assetId); - const existingDbAssets = await prisma.asset.findMany({ - where: { - assetId: { - in: assetIds, - }, - }, - select: { - assetId: true, - key: true, - url: true, - }, - }); - - const existingAssetMap = new Map( - existingDbAssets.map((a) => [a.assetId, { key: a.key, url: a.url }]), - ); - - // Build response: existing assets and new uploads - const existing: { - assetId: string; - fileKey: string; - fileUrl: string; - }[] = []; - - const uploads: { - assetId: string; - uploadUrl: string; - fileKey: string; - fileUrl: string; - expiresAt: number; - }[] = []; - - for (const asset of assets) { - const existingAsset = existingAssetMap.get(asset.assetId); - - if (existingAsset) { - // Asset already exists - no upload needed - existing.push({ - assetId: asset.assetId, - fileKey: existingAsset.key, - fileUrl: existingAsset.url, - }); - } else { - // New asset - generate presigned URL - const presigned = await generatePresignedUploadUrl({ - fileName: asset.name, - fileSize: asset.size, - }); - - if (!presigned) { - return jsonResponse( - { - error: - 'Failed to generate presigned URL. UploadThing token may not be configured.', - }, - 500, - ); - } - - uploads.push({ - assetId: asset.assetId, - uploadUrl: presigned.uploadUrl, - fileKey: presigned.fileKey, - fileUrl: presigned.fileUrl, - expiresAt: presigned.expiresAt, - }); - } - } - - return jsonResponse({ - success: true, - existing, - uploads, - }); - } catch (error) { - return jsonResponse( - { - error: 'Failed to prepare upload', - details: error instanceof Error ? error.message : 'Unknown error', - }, - 500, - ); - } -} diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts new file mode 100644 index 000000000..04484acbf --- /dev/null +++ b/app/api/preview/route.ts @@ -0,0 +1,310 @@ +import { + type CurrentProtocol, + migrateProtocol, + validateProtocol, +} from '@codaco/protocol-validation'; +import { type NextRequest } from 'next/server'; +import { hash } from 'ohash'; +import { addEvent } from '~/actions/activityFeed'; +import { env } from '~/env'; +import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config'; +import { prunePreviewProtocols } from '~/lib/preview-protocol-pruning'; +import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; +import { prisma } from '~/utils/db'; +import { checkPreviewAuth, jsonResponse, OPTIONS } from './helpers'; +import type { + AbortResponse, + CompleteResponse, + InitializeResponse, + PreviewRequest, + ReadyResponse, + RejectedResponse, +} from './types'; + +export { OPTIONS }; + +const REJECTED_RESPONSE: RejectedResponse = { + status: 'rejected', + message: 'Invalid protocol', +}; + +export async function POST(req: NextRequest) { + const authError = await checkPreviewAuth(req); + if (authError) return authError; + + try { + const body = (await req.json()) as PreviewRequest; + const { type } = body; + + switch (type) { + case 'initialize-preview': { + const { protocol: protocolJson, assetMeta, architectVersion } = body; + + // Check Architect version compatibility + const [major] = architectVersion.split('.').map(Number); + if (major && major < 7) { + const response: InitializeResponse = { + status: 'error', + message: + 'Architect versions below 7.x are not supported for preview mode', + }; + return jsonResponse(response, 400); + } + + // Validate protocol object exists + if (!protocolJson || typeof protocolJson !== 'object') { + return jsonResponse(REJECTED_RESPONSE, 400); + } + + // Check schema version + const protocolVersion = protocolJson.schemaVersion; + if (!APP_SUPPORTED_SCHEMA_VERSIONS.includes(protocolVersion)) { + return jsonResponse(REJECTED_RESPONSE, 400); + } + + // Migrate if needed + const protocolToValidate = ( + protocolJson.schemaVersion < 8 + ? migrateProtocol(protocolJson, 8) + : protocolJson + ) as CurrentProtocol; + + // Validate protocol + const validationResult = await validateProtocol(protocolToValidate); + + if (!validationResult.success) { + return jsonResponse(REJECTED_RESPONSE, 400); + } + + // Calculate protocol hash + const protocolHash = hash(protocolJson); + + // Run pruning process before creating new protocol + await prunePreviewProtocols(); + + // Check if this exact protocol already exists as a preview + const existingPreview = await prisma.protocol.findFirst({ + where: { + hash: protocolHash, + isPreview: true, + }, + orderBy: { + importedAt: 'desc', + }, + }); + + // If protocol exists, return ready immediately + if (existingPreview) { + // Update timestamp to prevent premature pruning + await prisma.protocol.update({ + where: { id: existingPreview.id }, + data: { importedAt: new Date() }, + }); + + const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + url.pathname = `/preview/${existingPreview.id}`; + + const response: ReadyResponse = { + status: 'ready', + previewUrl: url.toString(), + }; + return jsonResponse(response); + } + + // Check which assets already exist in the database + const assetIds = assetMeta.map((a) => a.assetId); + const existingDbAssets = await prisma.asset.findMany({ + where: { + assetId: { in: assetIds }, + }, + select: { + assetId: true, + key: true, + url: true, + }, + }); + + const existingAssetMap = new Map( + existingDbAssets.map((a) => [a.assetId, { key: a.key, url: a.url }]), + ); + + // Get asset manifest from protocol to look up asset types + const assetManifest = protocolToValidate.assetManifest ?? {}; + + // Generate presigned URLs for assets that don't exist and prepare asset records + const presignedUrls: string[] = []; + const assetsToCreate: { + assetId: string; + key: string; + name: string; + type: string; + url: string; + size: number; + }[] = []; + const existingAssetIds: string[] = []; + + for (const asset of assetMeta) { + const existingAsset = existingAssetMap.get(asset.assetId); + + if (existingAsset) { + // Asset already exists - will connect it + existingAssetIds.push(asset.assetId); + } else { + // Look up asset type from protocol's assetManifest + const manifestEntry = assetManifest[asset.assetId]; + const assetType = manifestEntry?.type ?? 'file'; + + // New asset - generate presigned URL and prepare asset record + const presigned = await generatePresignedUploadUrl({ + fileName: asset.name, + fileSize: asset.size, + }); + + if (!presigned) { + const response: InitializeResponse = { + status: 'error', + message: 'Failed to generate presigned URL', + }; + return jsonResponse(response, 500); + } + + presignedUrls.push(presigned.uploadUrl); + assetsToCreate.push({ + assetId: asset.assetId, + key: presigned.fileKey, + name: asset.name, + type: assetType, + url: presigned.fileUrl, + size: asset.size, + }); + } + } + + // Create the protocol with assets immediately + const protocol = await prisma.protocol.create({ + data: { + hash: protocolHash, + name: `preview-${Date.now()}`, + schemaVersion: protocolJson.schemaVersion, + description: protocolJson.description, + lastModified: protocolJson.lastModified + ? new Date(protocolJson.lastModified) + : new Date(), + stages: protocolJson.stages as never, + codebook: protocolJson.codebook as never, + isPreview: true, + assets: { + create: assetsToCreate, + connect: existingAssetIds.map((assetId) => ({ assetId })), + }, + }, + }); + + void addEvent( + 'Preview Protocol Uploaded', + `Preview protocol "${protocol.name}" initialized`, + ); + + // If no new assets to upload, return ready immediately + if (presignedUrls.length === 0) { + const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + url.pathname = `/preview/${protocol.id}`; + + const response: InitializeResponse = { + status: 'ready', + previewUrl: url.toString(), + }; + return jsonResponse(response); + } + + const response: InitializeResponse = { + status: 'job-created', + protocolId: protocol.id, + presignedUrls, + }; + return jsonResponse(response); + } + + case 'complete-preview': { + const { protocolId } = body; + + // Find the protocol + const protocol = await prisma.protocol.findUnique({ + where: { id: protocolId }, + }); + + if (!protocol) { + const response: CompleteResponse = { + status: 'error', + message: 'Preview job not found', + }; + return jsonResponse(response, 404); + } + + // Update timestamp to mark completion + await prisma.protocol.update({ + where: { id: protocol.id }, + data: { importedAt: new Date() }, + }); + + void addEvent( + 'Preview Protocol Uploaded', + `Preview protocol "${protocol.name}" completed`, + ); + + const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + url.pathname = `/preview/${protocol.id}`; + + const response: CompleteResponse = { + status: 'ready', + previewUrl: url.toString(), + }; + return jsonResponse(response); + } + + case 'abort-preview': { + const { protocolId } = body; + + // Find and delete the protocol + const protocol = await prisma.protocol.findUnique({ + where: { id: protocolId }, + }); + + if (!protocol) { + const response: AbortResponse = { + status: 'error', + message: 'Preview job not found', + }; + return jsonResponse(response, 404); + } + + // Delete the protocol (cascades to related entities) + await prisma.protocol.delete({ + where: { id: protocolId }, + }); + + void addEvent( + 'Protocol Uninstalled', + `Preview protocol "${protocol.name}" was aborted and removed`, + ); + + const response: AbortResponse = { + status: 'removed', + protocolId: protocolId, + }; + return jsonResponse(response); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Preview request error:', error); + + return jsonResponse( + { + status: 'error', + message: 'Failed to process preview request', + }, + 500, + ); + } +} diff --git a/app/api/preview/types.ts b/app/api/preview/types.ts new file mode 100644 index 000000000..b4cafaf2f --- /dev/null +++ b/app/api/preview/types.ts @@ -0,0 +1,79 @@ +/* + Types that cover the preview message exchange + Lives here and in Architect (/architect-vite/src/utils/preview/types.ts). + This must be kept in sync and updated in both places. + TODO: Move to shared pacakge when in the monorepo +*/ + +import { type VersionedProtocol } from '@codaco/protocol-validation'; + +// REQUEST TYPES +type AssetMetadata = { + assetId: string; + name: string; + size: number; +}; + +type InitializePreviewRequest = { + type: 'initialize-preview'; + protocol: VersionedProtocol; + assetMeta: AssetMetadata[]; + architectVersion: string; +}; + +type CompletePreviewRequest = { + type: 'complete-preview'; + protocolId: string; +}; + +type AbortPreviewRequest = { + type: 'abort-preview'; + protocolId: string; +}; + +export type PreviewRequest = + | InitializePreviewRequest + | CompletePreviewRequest + | AbortPreviewRequest; + +// RESPONSE TYPES + +type JobCreatedResponse = { + status: 'job-created'; + protocolId: string; // protocol id in db + presignedUrls: string[]; // I think we only need the URLs.. double check +}; + +// No assets to upload +export type ReadyResponse = { + status: 'ready'; + previewUrl: string; +}; + +export type RejectedResponse = { + status: 'rejected'; + message: 'Invalid protocol'; +}; + +export type ErrorResponse = { + status: 'error'; + message: string; +}; + +type RemovedResponse = { + status: 'removed'; + protocolId: string; +}; + +export type InitializeResponse = + | JobCreatedResponse + | RejectedResponse + | ErrorResponse + | ReadyResponse; +export type CompleteResponse = ReadyResponse | ErrorResponse; +export type AbortResponse = RemovedResponse | ErrorResponse; + +export type PreviewResponse = + | InitializeResponse + | CompleteResponse + | AbortResponse; diff --git a/lib/uploadthing-presigned.ts b/lib/uploadthing-presigned.ts index 2867060ab..c9d64a503 100644 --- a/lib/uploadthing-presigned.ts +++ b/lib/uploadthing-presigned.ts @@ -125,6 +125,8 @@ export async function generatePresignedUploadUrl( 'x-ut-file-name': fileName, 'x-ut-file-size': String(fileSize), 'x-ut-slug': 'assetRouter', // Use the existing file router slug + 'x-ut-content-disposition': 'inline', + 'x-ut-acl': 'public-read', }); // Construct the base URL From 9dc05d3de8ea9d5b533933378dce2f269330442b Mon Sep 17 00:00:00 2001 From: buckhalt Date: Thu, 4 Dec 2025 14:30:35 -0800 Subject: [PATCH 09/12] add isPending flag to preview protocols --- app/(interview)/preview/[protocolId]/route.ts | 8 +++++- app/api/preview/route.ts | 6 +++-- lib/preview-protocol-pruning.ts | 26 ++++++++++++------- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + 5 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20251204222357_add_protocol_is_pending/migration.sql diff --git a/app/(interview)/preview/[protocolId]/route.ts b/app/(interview)/preview/[protocolId]/route.ts index 70cc9504a..dff3d5393 100644 --- a/app/(interview)/preview/[protocolId]/route.ts +++ b/app/(interview)/preview/[protocolId]/route.ts @@ -30,7 +30,7 @@ const handler = async ( // Verify that this is actually a preview protocol const protocol = await prisma.protocol.findUnique({ where: { id: protocolId }, - select: { isPreview: true, name: true }, + select: { isPreview: true, isPending: true, name: true }, }); if (!protocol) { @@ -44,6 +44,12 @@ const handler = async ( return NextResponse.redirect(url); } + if (protocol.isPending) { + // Protocol assets are still being uploaded + url.pathname = '/onboard/error'; + return NextResponse.redirect(url); + } + // Create a new interview for preview // We use a fixed participant identifier for preview sessions const participantIdentifier = `preview-${Date.now()}`; diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts index 04484acbf..1d1712119 100644 --- a/app/api/preview/route.ts +++ b/app/api/preview/route.ts @@ -181,6 +181,7 @@ export async function POST(req: NextRequest) { } // Create the protocol with assets immediately + // Mark as pending if there are assets to upload const protocol = await prisma.protocol.create({ data: { hash: protocolHash, @@ -193,6 +194,7 @@ export async function POST(req: NextRequest) { stages: protocolJson.stages as never, codebook: protocolJson.codebook as never, isPreview: true, + isPending: presignedUrls.length > 0, assets: { create: assetsToCreate, connect: existingAssetIds.map((assetId) => ({ assetId })), @@ -241,10 +243,10 @@ export async function POST(req: NextRequest) { return jsonResponse(response, 404); } - // Update timestamp to mark completion + // Update timestamp and clear pending flag to mark completion await prisma.protocol.update({ where: { id: protocol.id }, - data: { importedAt: new Date() }, + data: { importedAt: new Date(), isPending: false }, }); void addEvent( diff --git a/lib/preview-protocol-pruning.ts b/lib/preview-protocol-pruning.ts index 872eff3fe..589d0b7cf 100644 --- a/lib/preview-protocol-pruning.ts +++ b/lib/preview-protocol-pruning.ts @@ -1,12 +1,16 @@ import { getUTApi } from '~/lib/uploadthing-server-helpers'; import { prisma } from '~/utils/db'; -const MAX_PREVIEW_PROTOCOL_AGE_HOURS = 24; +// Completed previews: 24 hours +const MAX_COMPLETED_PREVIEW_AGE_MS = 24 * 60 * 60 * 1000; +// Pending (abandoned) uploads: 15 minutes +const MAX_PENDING_PREVIEW_AGE_MS = 15 * 60 * 1000; /** * Prune preview protocols based on age limit. - * Deletes protocols older than 24 hours, along with their - * orphaned assets and participants. + * - Pending protocols (abandoned uploads) are deleted after 15 minutes + * - Completed protocols are deleted after 24 hours + * Also cleans up orphaned assets and participants. */ export async function prunePreviewProtocols(): Promise<{ deletedCount: number; @@ -14,17 +18,19 @@ export async function prunePreviewProtocols(): Promise<{ }> { try { const now = new Date(); - const cutoffDate = new Date( - now.getTime() - MAX_PREVIEW_PROTOCOL_AGE_HOURS * 60 * 60 * 1000, - ); + const completedCutoff = new Date(now.getTime() - MAX_COMPLETED_PREVIEW_AGE_MS); + const pendingCutoff = new Date(now.getTime() - MAX_PENDING_PREVIEW_AGE_MS); - // Find all preview protocols older than the cutoff date + // Find preview protocols to prune: + // - Pending protocols older than 15 minutes (abandoned uploads) + // - Completed protocols older than 24 hours const oldProtocols = await prisma.protocol.findMany({ where: { isPreview: true, - importedAt: { - lt: cutoffDate, - }, + OR: [ + { isPending: true, importedAt: { lt: pendingCutoff } }, + { isPending: false, importedAt: { lt: completedCutoff } }, + ], }, select: { id: true, diff --git a/prisma/migrations/20251204222357_add_protocol_is_pending/migration.sql b/prisma/migrations/20251204222357_add_protocol_is_pending/migration.sql new file mode 100644 index 000000000..96f4db178 --- /dev/null +++ b/prisma/migrations/20251204222357_add_protocol_is_pending/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Protocol" ADD COLUMN "isPending" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 735e24ef6..736b157db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,7 @@ model Protocol { interviews Interview[] experiments Json? isPreview Boolean @default(false) + isPending Boolean @default(false) @@index([isPreview, importedAt]) } From e63398e736818e2b0bed87f51516009572a05bb8 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Thu, 4 Dec 2025 14:40:31 -0800 Subject: [PATCH 10/12] fix test, cleanup supported architect version --- app/api/preview/route.ts | 10 ++++++---- app/api/preview/types.ts | 4 ++-- fresco.config.ts | 1 + .../preview-protocol-pruning.test.ts | 20 +++++++++++++++---- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts index 1d1712119..f639cfe60 100644 --- a/app/api/preview/route.ts +++ b/app/api/preview/route.ts @@ -7,7 +7,10 @@ import { type NextRequest } from 'next/server'; import { hash } from 'ohash'; import { addEvent } from '~/actions/activityFeed'; import { env } from '~/env'; -import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config'; +import { + APP_SUPPORTED_SCHEMA_VERSIONS, + MIN_ARCHITECT_VERSION_FOR_PREVIEW, +} from '~/fresco.config'; import { prunePreviewProtocols } from '~/lib/preview-protocol-pruning'; import { generatePresignedUploadUrl } from '~/lib/uploadthing-presigned'; import { prisma } from '~/utils/db'; @@ -42,11 +45,10 @@ export async function POST(req: NextRequest) { // Check Architect version compatibility const [major] = architectVersion.split('.').map(Number); - if (major && major < 7) { + if (major && major < MIN_ARCHITECT_VERSION_FOR_PREVIEW) { const response: InitializeResponse = { status: 'error', - message: - 'Architect versions below 7.x are not supported for preview mode', + message: `Architect versions below ${MIN_ARCHITECT_VERSION_FOR_PREVIEW}.x are not supported for preview mode`, }; return jsonResponse(response, 400); } diff --git a/app/api/preview/types.ts b/app/api/preview/types.ts index b4cafaf2f..970a1e827 100644 --- a/app/api/preview/types.ts +++ b/app/api/preview/types.ts @@ -40,8 +40,8 @@ export type PreviewRequest = type JobCreatedResponse = { status: 'job-created'; - protocolId: string; // protocol id in db - presignedUrls: string[]; // I think we only need the URLs.. double check + protocolId: string; + presignedUrls: string[]; }; // No assets to upload diff --git a/fresco.config.ts b/fresco.config.ts index 6deac3a3f..f9aa48b51 100644 --- a/fresco.config.ts +++ b/fresco.config.ts @@ -1,5 +1,6 @@ export const PROTOCOL_EXTENSION = '.netcanvas'; export const APP_SUPPORTED_SCHEMA_VERSIONS = [7, 8]; +export const MIN_ARCHITECT_VERSION_FOR_PREVIEW = 7; // If unconfigured, the app will shut down after 2 hours (7200000 ms) export const UNCONFIGURED_TIMEOUT = 7200000; diff --git a/lib/__tests__/preview-protocol-pruning.test.ts b/lib/__tests__/preview-protocol-pruning.test.ts index 6549738b5..c1391f453 100644 --- a/lib/__tests__/preview-protocol-pruning.test.ts +++ b/lib/__tests__/preview-protocol-pruning.test.ts @@ -159,7 +159,7 @@ describe('prunePreviewProtocols', () => { expect(result.error).toBe('Database error'); }); - it('should only query for preview protocols', async () => { + it('should only query for preview protocols with pending/completed cutoffs', async () => { const { prunePreviewProtocols } = await import( '../preview-protocol-pruning' ); @@ -174,13 +174,25 @@ describe('prunePreviewProtocols', () => { const mockCalls = mockPrisma.protocol.findMany.mock.calls; expect(mockCalls.length).toBeGreaterThan(0); - // Check that the query includes isPreview: true + // Check that the query includes isPreview: true and OR conditions for pending/completed const firstCall = mockCalls[0]; expect(firstCall).toBeDefined(); if (firstCall) { - const args = firstCall[0] as { where?: { isPreview?: boolean; importedAt?: { lt?: Date } } }; + type QueryArgs = { + where?: { + isPreview?: boolean; + OR?: Array<{ isPending?: boolean; importedAt?: { lt?: Date } }>; + }; + }; + const args = firstCall[0] as QueryArgs; expect(args.where?.isPreview).toBe(true); - expect(args.where?.importedAt?.lt).toBeInstanceOf(Date); + expect(args.where?.OR).toHaveLength(2); + // Pending protocols cutoff (15 min) + expect(args.where?.OR?.[0]?.isPending).toBe(true); + expect(args.where?.OR?.[0]?.importedAt?.lt).toBeInstanceOf(Date); + // Completed protocols cutoff (24 hours) + expect(args.where?.OR?.[1]?.isPending).toBe(false); + expect(args.where?.OR?.[1]?.importedAt?.lt).toBeInstanceOf(Date); } }); }); From d2e9a49b384fe0ad5c6e409fb8b8c81d5a22f901 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Thu, 4 Dec 2025 15:03:34 -0800 Subject: [PATCH 11/12] cleanup test, lint --- .../preview-protocol-pruning.test.ts | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/lib/__tests__/preview-protocol-pruning.test.ts b/lib/__tests__/preview-protocol-pruning.test.ts index c1391f453..aa81a24cd 100644 --- a/lib/__tests__/preview-protocol-pruning.test.ts +++ b/lib/__tests__/preview-protocol-pruning.test.ts @@ -27,7 +27,8 @@ vi.mock('~/utils/db', () => ({ // Mock uploadthing-server-helpers vi.mock('~/lib/uploadthing-server-helpers', () => ({ - getUTApi: () => mockGetUTApi() as Promise<{ deleteFiles: typeof mockDeleteFiles }>, + getUTApi: () => + mockGetUTApi() as Promise<{ deleteFiles: typeof mockDeleteFiles }>, })); describe('prunePreviewProtocols', () => { @@ -92,10 +93,7 @@ describe('prunePreviewProtocols', () => { name: 'Old Protocol', }; - const assets = [ - { key: 'ut-key-1' }, - { key: 'ut-key-2' }, - ]; + const assets = [{ key: 'ut-key-1' }, { key: 'ut-key-2' }]; mockDeleteFiles.mockResolvedValue({ success: true }); mockPrisma.protocol.findMany.mockResolvedValue([oldProtocol]); @@ -121,10 +119,7 @@ describe('prunePreviewProtocols', () => { name: 'Old Protocol', }; - const participants = [ - { id: 'participant-1' }, - { id: 'participant-2' }, - ]; + const participants = [{ id: 'participant-1' }, { id: 'participant-2' }]; mockPrisma.protocol.findMany.mockResolvedValue([oldProtocol]); mockPrisma.asset.findMany.mockResolvedValue([]); @@ -149,9 +144,7 @@ describe('prunePreviewProtocols', () => { '../preview-protocol-pruning' ); - mockPrisma.protocol.findMany.mockRejectedValue( - new Error('Database error'), - ); + mockPrisma.protocol.findMany.mockRejectedValue(new Error('Database error')); const result = await prunePreviewProtocols(); @@ -174,25 +167,18 @@ describe('prunePreviewProtocols', () => { const mockCalls = mockPrisma.protocol.findMany.mock.calls; expect(mockCalls.length).toBeGreaterThan(0); - // Check that the query includes isPreview: true and OR conditions for pending/completed + // Check that the query includes isPreview: true const firstCall = mockCalls[0]; expect(firstCall).toBeDefined(); if (firstCall) { type QueryArgs = { where?: { isPreview?: boolean; - OR?: Array<{ isPending?: boolean; importedAt?: { lt?: Date } }>; + OR?: { isPending?: boolean; importedAt?: { lt?: Date } }[]; }; }; const args = firstCall[0] as QueryArgs; expect(args.where?.isPreview).toBe(true); - expect(args.where?.OR).toHaveLength(2); - // Pending protocols cutoff (15 min) - expect(args.where?.OR?.[0]?.isPending).toBe(true); - expect(args.where?.OR?.[0]?.importedAt?.lt).toBeInstanceOf(Date); - // Completed protocols cutoff (24 hours) - expect(args.where?.OR?.[1]?.isPending).toBe(false); - expect(args.where?.OR?.[1]?.importedAt?.lt).toBeInstanceOf(Date); } }); }); From 2f3a9da9cf8e06b60d0251695a9d14dc373954d0 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Fri, 5 Dec 2025 12:59:14 -0800 Subject: [PATCH 12/12] preview mode dashboard changes redirect to block access besides settings page, banner to indicate preview mode, disable navbar buttons for preview blocked dashboard pages --- app/dashboard/_components/NavigationBar.tsx | 20 +++++- app/dashboard/interviews/page.tsx | 2 + app/dashboard/layout.tsx | 10 ++- app/dashboard/page.tsx | 2 + app/dashboard/participants/page.tsx | 2 + app/dashboard/protocols/page.tsx | 2 + app/dashboard/settings/page.tsx | 68 ++++++++++--------- ...{PreviewModeBanner.tsx => AlertBanner.tsx} | 7 +- lib/interviewer/containers/ProtocolScreen.tsx | 9 ++- utils/previewMode.ts | 13 ++++ 10 files changed, 94 insertions(+), 41 deletions(-) rename lib/interviewer/components/{PreviewModeBanner.tsx => AlertBanner.tsx} (89%) create mode 100644 utils/previewMode.ts diff --git a/app/dashboard/_components/NavigationBar.tsx b/app/dashboard/_components/NavigationBar.tsx index 449a2e93a..7d0a21ac0 100644 --- a/app/dashboard/_components/NavigationBar.tsx +++ b/app/dashboard/_components/NavigationBar.tsx @@ -15,11 +15,23 @@ const NavButton = ({ label, href, isActive = false, + disabled = false, }: { label: string; href: UrlObject | Route; isActive?: boolean; + disabled?: boolean; }) => { + if (disabled) { + return ( + + + {label} + + + ); + } + return ( )} ); }; -export function NavigationBar() { +export function NavigationBar({ previewMode }: { previewMode: boolean }) { const pathname = usePathname(); return ( @@ -58,21 +70,25 @@ export function NavigationBar() { href="/dashboard" isActive={pathname === '/dashboard'} label="Dashboard" + disabled={previewMode} /> diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 5d348c739..8b94e05c7 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -1,3 +1,5 @@ +import { env } from '~/env'; +import AlertBanner from '~/lib/interviewer/components/AlertBanner'; import { getAppSetting, requireAppNotExpired } from '~/queries/appSettings'; import { requirePageAuth } from '~/utils/auth'; import { NavigationBar } from './_components/NavigationBar'; @@ -18,7 +20,13 @@ const Layout = async ({ children }: { children: React.ReactNode }) => { return ( <> - + + {env.PREVIEW_MODE && ( + + Preview Mode - Protocol, participant, and interview + management are disabled. + + )} {!uploadThingToken && } {children} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index ce5f243f1..d91a92eb3 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -6,6 +6,7 @@ import PageHeader from '~/components/typography/PageHeader'; import Paragraph from '~/components/typography/Paragraph'; import { requireAppNotExpired } from '~/queries/appSettings'; import { requirePageAuth } from '~/utils/auth'; +import { requireNonPreviewMode } from '~/utils/previewMode'; import ActivityFeed from './_components/ActivityFeed/ActivityFeed'; import { searchParamsCache } from './_components/ActivityFeed/SearchParams'; import SummaryStatistics from './_components/SummaryStatistics/SummaryStatistics'; @@ -19,6 +20,7 @@ export default async function Home({ }) { await requireAppNotExpired(); await requirePageAuth(); + requireNonPreviewMode(); searchParamsCache.parse(searchParams); diff --git a/app/dashboard/participants/page.tsx b/app/dashboard/participants/page.tsx index 77c178852..57b7e4dbb 100644 --- a/app/dashboard/participants/page.tsx +++ b/app/dashboard/participants/page.tsx @@ -4,11 +4,13 @@ import Section from '~/components/layout/Section'; import PageHeader from '~/components/typography/PageHeader'; import { requireAppNotExpired } from '~/queries/appSettings'; import { requirePageAuth } from '~/utils/auth'; +import { requireNonPreviewMode } from '~/utils/previewMode'; import ImportExportSection from './_components/ExportParticipants/ImportExportSection'; export default async function ParticipantPage() { await requireAppNotExpired(); await requirePageAuth(); + requireNonPreviewMode(); return ( <> diff --git a/app/dashboard/protocols/page.tsx b/app/dashboard/protocols/page.tsx index 2e3f6c322..9f4bc160f 100644 --- a/app/dashboard/protocols/page.tsx +++ b/app/dashboard/protocols/page.tsx @@ -4,12 +4,14 @@ import Section from '~/components/layout/Section'; import PageHeader from '~/components/typography/PageHeader'; import { requireAppNotExpired } from '~/queries/appSettings'; import { requirePageAuth } from '~/utils/auth'; +import { requireNonPreviewMode } from '~/utils/previewMode'; import ProtocolsTable from '../_components/ProtocolsTable/ProtocolsTable'; import UpdateUploadThingTokenAlert from '../_components/UpdateUploadThingTokenAlert'; export default async function ProtocolsPage() { await requireAppNotExpired(); await requirePageAuth(); + requireNonPreviewMode(); return ( <> diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx index 4babd6026..9f44669c9 100644 --- a/app/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -15,12 +15,12 @@ import VersionSection, { VersionSectionSkeleton, } from '~/components/VersionSection'; import { env } from '~/env'; +import { getApiTokens } from '~/queries/apiTokens'; import { getAppSetting, getInstallationId, requireAppNotExpired, } from '~/queries/appSettings'; -import { getApiTokens } from '~/queries/apiTokens'; import { requirePageAuth } from '~/utils/auth'; import AnalyticsButton from '../_components/AnalyticsButton'; import RecruitmentTestSectionServer from '../_components/RecruitmentTestSectionServer'; @@ -72,20 +72,22 @@ export default async function Settings() { - - - - } - > - - If anonymous recruitment is enabled, you may generate an anonymous - participation URL. This URL can be shared with participants to allow - them to self-enroll in your study. - - + {!env.PREVIEW_MODE && ( + + + + } + > + + If anonymous recruitment is enabled, you may generate an anonymous + participation URL. This URL can be shared with participants to + allow them to self-enroll in your study. + + + )} - - - - } - > - - If this option is enabled, each participant will only be able to - submit a single completed interview for each - protocol (although they may have multiple incomplete interviews). - Once an interview has been completed, attempting to start a new - interview or to resume any other in-progress interview, will be - prevented. - - + {!env.PREVIEW_MODE && ( + + + + } + > + + If this option is enabled, each participant will only be able to + submit a single completed interview for each + protocol (although they may have multiple incomplete interviews). + Once an interview has been completed, attempting to start a new + interview or to resume any other in-progress interview, will be + prevented. + + + )}
- - Preview Mode - Data you enter here will be publicly visible - + {children}
); diff --git a/lib/interviewer/containers/ProtocolScreen.tsx b/lib/interviewer/containers/ProtocolScreen.tsx index c51000b25..9d64a150c 100644 --- a/lib/interviewer/containers/ProtocolScreen.tsx +++ b/lib/interviewer/containers/ProtocolScreen.tsx @@ -9,8 +9,8 @@ import { parseAsInteger, useQueryState } from 'nuqs'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import usePrevious from '~/hooks/usePrevious'; +import AlertBanner from '../components/AlertBanner'; import Navigation from '../components/Navigation'; -import PreviewModeBanner from '../components/PreviewModeBanner'; import { updatePrompt, updateStage } from '../ducks/modules/session'; import useReadyForNextStage from '../hooks/useReadyForNextStage'; import { @@ -268,7 +268,12 @@ export default function ProtocolScreen({ isPreview }: ProtocolScreenProps) { return ( <> - {isPreview && } + {isPreview && ( + + Preview Mode - Data you enter here will be publicly + visible. + + )}