From dea65ff3882f77afc14eeecd7ed51ebe221326f6 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 9 Feb 2026 20:43:11 +0200 Subject: [PATCH 01/66] Trigger Build From 97d2df7fae5cd67daac142089a9aee342538c0a2 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Mon, 9 Feb 2026 11:41:23 -0800 Subject: [PATCH 02/66] Trigger Build From df160c7e32ab9c4e1c85aaeaae4c0e547bec9be7 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Feb 2026 15:03:45 +0200 Subject: [PATCH 03/66] fix: include VERCEL_DEPLOYMENT_ID in cache keys when options not provided When createCachedFunction was called without options, the keyParts array defaulted to the tags array only. This missed the VERCEL_DEPLOYMENT_ID prefix that was included when options were provided, causing cache key mismatches between deployments. Also adds comprehensive test coverage for the cache module. --- lib/__tests__/cache.test.ts | 121 ++++++++++++++++++++++++++++++++++++ lib/cache.ts | 2 +- 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/cache.test.ts diff --git a/lib/__tests__/cache.test.ts b/lib/__tests__/cache.test.ts new file mode 100644 index 000000000..6ea884d38 --- /dev/null +++ b/lib/__tests__/cache.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock unstable_cache to capture the keyParts it receives +const mockUnstableCache = vi.fn( + ( + fn: () => Promise, + keyParts: string[] | undefined, + _options?: object, + ) => { + const wrapper = () => fn(); + return Object.assign(wrapper, { _keyParts: keyParts }); + }, +); + +vi.mock('next/cache', () => ({ + unstable_cache: mockUnstableCache, + revalidateTag: vi.fn(), +})); + +vi.mock('~/env', () => ({ + env: { + DISABLE_NEXT_CACHE: false, + }, +})); + +describe('createCachedFunction', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('should include VERCEL_DEPLOYMENT_ID in keyParts when options.keyParts is provided', async () => { + vi.stubEnv('VERCEL_DEPLOYMENT_ID', 'dpl_test123'); + + vi.resetModules(); + const { createCachedFunction } = await import('../cache'); + + const testFn = () => Promise.resolve('result'); + createCachedFunction(testFn, ['appSettings'], { keyParts: ['custom-key'] }); + + expect(mockUnstableCache).toHaveBeenCalledWith( + testFn, + expect.arrayContaining(['custom-key', 'dpl_test123']), + expect.any(Object), + ); + }); + + it('should include VERCEL_DEPLOYMENT_ID in keyParts even when options is not provided', async () => { + vi.stubEnv('VERCEL_DEPLOYMENT_ID', 'dpl_test456'); + + vi.resetModules(); + const { createCachedFunction } = await import('../cache'); + + const testFn = () => Promise.resolve('result'); + createCachedFunction(testFn, ['appSettings']); + + const call = mockUnstableCache.mock.calls[0] as [ + () => Promise, + string[] | undefined, + ]; + const keyParts = call[1]; + + expect(keyParts).not.toBeUndefined(); + expect(keyParts).toContain('dpl_test456'); + }); + + it('should include VERCEL_DEPLOYMENT_ID in keyParts when options is provided without keyParts', async () => { + vi.stubEnv('VERCEL_DEPLOYMENT_ID', 'dpl_test789'); + + vi.resetModules(); + const { createCachedFunction } = await import('../cache'); + + const testFn = () => Promise.resolve('result'); + createCachedFunction(testFn, ['appSettings'], { revalidate: 60 }); + + const call = mockUnstableCache.mock.calls[0] as [ + () => Promise, + string[] | undefined, + object, + ]; + const keyParts = call[1]; + + expect(keyParts).not.toBeUndefined(); + expect(keyParts).toContain('dpl_test789'); + }); + + it('should pass empty array keyParts when VERCEL_DEPLOYMENT_ID is not set and no options provided', async () => { + vi.stubEnv('VERCEL_DEPLOYMENT_ID', ''); + + vi.resetModules(); + const { createCachedFunction } = await import('../cache'); + + const testFn = () => Promise.resolve('result'); + createCachedFunction(testFn, ['appSettings']); + + const call = mockUnstableCache.mock.calls[0] as [ + () => Promise, + string[] | undefined, + ]; + const keyParts = call[1]; + + expect(keyParts).toEqual([]); + }); + + it('should bypass cache entirely when DISABLE_NEXT_CACHE is true', async () => { + vi.resetModules(); + vi.doMock('~/env', () => ({ + env: { + DISABLE_NEXT_CACHE: true, + }, + })); + + const { createCachedFunction } = await import('../cache'); + + const testFn = () => Promise.resolve('result'); + const result = createCachedFunction(testFn, ['appSettings']); + + expect(result).toBe(testFn); + expect(mockUnstableCache).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/cache.ts b/lib/cache.ts index 4dee447ce..57cfc6512 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -46,7 +46,7 @@ export function createCachedFunction( // eslint-disable-next-line no-process-env const VERCEL_DEPLOYMENT_ID = process.env.VERCEL_DEPLOYMENT_ID; - const keyParts = options?.keyParts?.concat( + const keyParts = (options?.keyParts ?? []).concat( VERCEL_DEPLOYMENT_ID ? [VERCEL_DEPLOYMENT_ID] : [], ); From ee23e6b76bba722e851c17441249bad4eb3fc4cb Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Feb 2026 15:03:51 +0200 Subject: [PATCH 04/66] refactor: use shared setup-pnpm action for CI workflows Replace duplicated pnpm/Node.js setup logic across CI workflows with the shared complexdatacollective/github-actions/setup-pnpm composite action. Removes the local .github/actions/pnpm-cache action. --- .github/actions/pnpm-cache/action.yml | 72 ------------ .github/workflows/build.yml | 152 +++----------------------- .github/workflows/chromatic.yml | 14 +-- .github/workflows/e2e.yml | 33 ++---- 4 files changed, 32 insertions(+), 239 deletions(-) delete mode 100644 .github/actions/pnpm-cache/action.yml diff --git a/.github/actions/pnpm-cache/action.yml b/.github/actions/pnpm-cache/action.yml deleted file mode 100644 index 0af26d52f..000000000 --- a/.github/actions/pnpm-cache/action.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: 'pnpm Cache' -description: 'Restore or save pnpm store cache' - -inputs: - mode: - description: 'Whether to restore or save the cache' - required: true - path: - description: 'Custom cache path (defaults to pnpm store path)' - required: false - key-prefix: - description: 'Cache key prefix (defaults to {runner.os}-pnpm-store)' - required: false - cache-hit: - description: 'Whether cache was hit (required for save mode)' - required: false - cache-key: - description: 'Cache key to use for saving (required for save mode)' - required: false - -outputs: - cache-hit: - description: 'Whether cache was restored' - value: ${{ steps.cache-restore.outputs.cache-hit }} - cache-key: - description: 'Cache key for use in save step' - value: ${{ steps.cache-restore.outputs.cache-primary-key }} - -runs: - using: 'composite' - steps: - # Restore mode steps - - name: Get pnpm store directory - if: inputs.mode == 'restore' && inputs.path == '' - id: pnpm-store - shell: bash - run: echo "path=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Compute cache key prefix - if: inputs.mode == 'restore' - id: key-prefix - shell: bash - run: | - if [ -n "${{ inputs.key-prefix }}" ]; then - echo "value=${{ inputs.key-prefix }}" >> $GITHUB_OUTPUT - else - echo "value=${{ runner.os }}-pnpm-store" >> $GITHUB_OUTPUT - fi - - - name: Restore pnpm cache - if: inputs.mode == 'restore' - id: cache-restore - uses: actions/cache/restore@v4 - with: - path: ${{ inputs.path || steps.pnpm-store.outputs.path }} - key: ${{ steps.key-prefix.outputs.value }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ steps.key-prefix.outputs.value }}- - - # Save mode steps - - name: Get pnpm store directory for save - if: inputs.mode == 'save' && inputs.cache-hit != 'true' && inputs.path == '' - id: pnpm-store-save - shell: bash - run: echo "path=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Save pnpm cache - if: inputs.mode == 'save' && inputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: ${{ inputs.path || steps.pnpm-store-save.outputs.path }} - key: ${{ inputs.cache-key }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7fc5adc77..feec74f3b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,140 +11,24 @@ on: - '*' jobs: - lint: + check: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Lint + command: pnpm lint + - name: Type check + command: pnpm typecheck + - name: Unit tests + command: pnpm test:unit + - name: Knip + command: pnpm knip + name: ${{ matrix.name }} steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Setup + uses: complexdatacollective/github-actions/setup-pnpm@v1 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Restore pnpm cache - id: cache - uses: ./.github/actions/pnpm-cache - with: - mode: restore - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm lint - - - name: Save pnpm cache - if: always() - uses: ./.github/actions/pnpm-cache - with: - mode: save - cache-hit: ${{ steps.cache.outputs.cache-hit }} - cache-key: ${{ steps.cache.outputs.cache-key }} - - typecheck: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Restore pnpm cache - id: cache - uses: ./.github/actions/pnpm-cache - with: - mode: restore - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Type check - run: pnpm typecheck - - - name: Save pnpm cache - if: always() - uses: ./.github/actions/pnpm-cache - with: - mode: save - cache-hit: ${{ steps.cache.outputs.cache-hit }} - cache-key: ${{ steps.cache.outputs.cache-key }} - - test: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Restore pnpm cache - id: cache - uses: ./.github/actions/pnpm-cache - with: - mode: restore - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run unit tests - run: pnpm test:unit - - - name: Save pnpm cache - if: always() - uses: ./.github/actions/pnpm-cache - with: - mode: save - cache-hit: ${{ steps.cache.outputs.cache-hit }} - cache-key: ${{ steps.cache.outputs.cache-key }} - - knip: - env: - SKIP_ENV_VALIDATION: true - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Restore pnpm cache - id: cache - uses: ./.github/actions/pnpm-cache - with: - mode: restore - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run knip - run: pnpm knip - - - name: Save pnpm cache - if: always() - uses: ./.github/actions/pnpm-cache - with: - mode: save - cache-hit: ${{ steps.cache.outputs.cache-hit }} - cache-key: ${{ steps.cache.outputs.cache-key }} + - name: ${{ matrix.name }} + run: ${{ matrix.command }} diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index cb9cbba11..65620191c 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -10,17 +10,11 @@ jobs: name: Run Chromatic runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Setup + uses: complexdatacollective/github-actions/setup-pnpm@v1 with: - fetch-depth: 0 - - name: Install pnpm - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - cache: 'pnpm' - - name: Install dependencies - run: pnpm install --frozen-lockfile + fetch-depth: '0' + - name: Run Chromatic uses: chromaui/action@latest with: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9dcbf280c..00acb6b0a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -19,24 +19,13 @@ jobs: test-result: ${{ steps.test.outcome }} steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup + id: setup + uses: complexdatacollective/github-actions/setup-pnpm@v1 with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Restore pnpm cache - id: cache - uses: ./.github/actions/pnpm-cache - with: - mode: restore - path: .pnpm-docker-store - key-prefix: pnpm-docker-store + install: 'false' + cache-path: .pnpm-docker-store + cache-key-prefix: pnpm-docker-store - name: Run E2E tests id: test @@ -63,13 +52,11 @@ jobs: retention-days: 7 - name: Save pnpm cache - if: always() - uses: ./.github/actions/pnpm-cache + if: always() && steps.setup.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 with: - mode: save path: .pnpm-docker-store - cache-hit: ${{ steps.cache.outputs.cache-hit }} - cache-key: ${{ steps.cache.outputs.cache-key }} + key: ${{ steps.setup.outputs.cache-key }} # Deploy report to GitHub Pages on test failure (PR-specific subdirectory) deploy-report: @@ -159,7 +146,7 @@ jobs: uses: actions/deploy-pages@v4 - name: Comment on PR with report link - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@v5 with: issue-number: ${{ github.event.pull_request.number }} body: | From a731d830c795fc31357f473f6cd16d110a9dbd28 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Feb 2026 15:04:03 +0200 Subject: [PATCH 05/66] feat: add Netlify deploy preview with Neon database branching Creates a per-PR deploy preview workflow that: - Creates a Neon database branch for each PR - Sets branch-scoped env vars via Netlify CLI for runtime access - Creates .env for build-time env validation - Deploys to Netlify with PR-specific alias - Comments on PR with preview URL and Neon console link Also adds a cleanup workflow stub for future branch deletion. --- .github/workflows/netlify-cleanup-preview.yml | 15 +++++ .github/workflows/netlify-deploy-preview.yml | 62 +++++++++++++++++++ .gitignore | 4 ++ netlify.toml | 1 + 4 files changed, 82 insertions(+) create mode 100644 .github/workflows/netlify-cleanup-preview.yml create mode 100644 .github/workflows/netlify-deploy-preview.yml diff --git a/.github/workflows/netlify-cleanup-preview.yml b/.github/workflows/netlify-cleanup-preview.yml new file mode 100644 index 000000000..8d4e329ca --- /dev/null +++ b/.github/workflows/netlify-cleanup-preview.yml @@ -0,0 +1,15 @@ +name: Delete Preview Branch on Neon +on: + pull_request: + types: [closed] +permissions: + contents: read +jobs: + delete-preview: + runs-on: ubuntu-latest + steps: + - uses: oven-sh/setup-bun@v2 + - name: Delete Neon Branch + run: bunx neonctl branches delete preview/pr-${{ github.event.number }}-${{ github.event.pull_request.head.ref }} --project-id ${{ vars.NEON_PROJECT_ID }} + env: + api_key: ${{ secrets.NEON_API_KEY }} diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml new file mode 100644 index 000000000..c8c9d5193 --- /dev/null +++ b/.github/workflows/netlify-deploy-preview.yml @@ -0,0 +1,62 @@ +name: Deploy Preview + +on: [pull_request] + +permissions: + contents: read + pull-requests: write + +jobs: + deploy-preview: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + steps: + - name: Setup + uses: complexdatacollective/github-actions/setup-pnpm@v1 + + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v9 + + - name: Create Neon Branch + id: create-branch + uses: neondatabase/create-branch-action@v6 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: preview/pr-${{ github.event.number }}-${{ steps.branch-name.outputs.current_branch }} + database: ${{ vars.NEON_DATABASE_NAME }} + role: ${{ vars.NEON_DATABASE_USERNAME }} + api_key: ${{ secrets.NEON_API_KEY }} + prisma: true + + - name: Install Netlify CLI + run: pnpm add -g netlify-cli + + - name: Set Neon database URLs in Netlify + run: | + netlify env:set DATABASE_URL "${{ steps.create-branch.outputs.db_url_pooled }}" --context branch:${{ steps.branch-name.outputs.current_branch }} + netlify env:set DATABASE_URL_UNPOOLED "${{ steps.create-branch.outputs.db_url }}" --context branch:${{ steps.branch-name.outputs.current_branch }} + + - name: Create .env for build + run: | + echo "DATABASE_URL=${{ steps.create-branch.outputs.db_url_pooled }}" > .env + echo "DATABASE_URL_UNPOOLED=${{ steps.create-branch.outputs.db_url }}" >> .env + + - name: Build and deploy preview + id: deploy + run: | + netlify deploy --build --alias="pr-${{ github.event.number }}" --context branch:${{ steps.branch-name.outputs.current_branch }} --json > netlify-deploy.json + echo "deploy_url=$(jq -r '.deploy_url' netlify-deploy.json)" >> "$GITHUB_OUTPUT" + + - name: Comment on Pull Request + uses: peter-evans/create-or-update-comment@v5 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + | Resource | Link | + |----------|------| + | Netlify Preview | ${{ steps.deploy.outputs.deploy_url }} | + | Neon branch | https://console.neon.tech/app/projects/${{ vars.NEON_PROJECT_ID }}/branches/${{ steps.create-branch.outputs.branch_id }} | diff --git a/.gitignore b/.gitignore index 2bd897044..a31849b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,7 @@ storybook-static # Serena .serena .pnpm-store +.pnpm-docker-store + +# Local Netlify folder +.netlify diff --git a/netlify.toml b/netlify.toml index 6c11870e3..0f5ce60ac 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,2 +1,3 @@ [build] + publish = ".next" command = "pnpm build:platform" \ No newline at end of file From e0cbe30a5d5de89f935f9410fd41483fe62eb55f Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Feb 2026 15:04:10 +0200 Subject: [PATCH 06/66] fix: resolve Netlify deploy preview runtime errors - Replace $transaction with Promise.all for read-only queries in summaryStatistics, activityFeed, and setup page. Prisma driver adapters implement batch $transaction as interactive transactions with a 5s default timeout, which Neon cold starts can exceed. - Remove component reference passed across RSC boundary in SandboxCredentials (icon={KeyRound} passed from server component to client Alert component). --- app/(blobs)/(setup)/_components/SandboxCredentials.tsx | 2 -- app/(blobs)/(setup)/setup/page.tsx | 2 +- queries/activityFeed.ts | 5 ++--- queries/summaryStatistics.ts | 8 ++++---- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/(blobs)/(setup)/_components/SandboxCredentials.tsx b/app/(blobs)/(setup)/_components/SandboxCredentials.tsx index ff24262bc..02f690f02 100644 --- a/app/(blobs)/(setup)/_components/SandboxCredentials.tsx +++ b/app/(blobs)/(setup)/_components/SandboxCredentials.tsx @@ -1,4 +1,3 @@ -import { KeyRound } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; import { env } from '~/env'; @@ -6,7 +5,6 @@ export default function SandboxCredentials() { if (!env.SANDBOX_MODE) return null; return ( - Sandbox Credentials
diff --git a/app/(blobs)/(setup)/setup/page.tsx b/app/(blobs)/(setup)/setup/page.tsx index 66ac4c47f..c9173cc93 100644 --- a/app/(blobs)/(setup)/setup/page.tsx +++ b/app/(blobs)/(setup)/setup/page.tsx @@ -15,7 +15,7 @@ async function getSetupData() { 'allowAnonymousRecruitment', ); const limitInterviews = await getAppSetting('limitInterviews'); - const otherData = await prisma.$transaction([ + const otherData = await Promise.all([ prisma.protocol.count(), prisma.participant.count(), ]); diff --git a/queries/activityFeed.ts b/queries/activityFeed.ts index fd0441cff..bea8dfda9 100644 --- a/queries/activityFeed.ts +++ b/queries/activityFeed.ts @@ -30,8 +30,7 @@ export const getActivities = (rawSearchParams: unknown) => } : {}; - // Transaction is used to ensure both queries are executed in a single transaction - const [count, events] = await prisma.$transaction([ + const [count, events] = await Promise.all([ prisma.events.count({ where: { ...queryFilterParams, @@ -40,7 +39,7 @@ export const getActivities = (rawSearchParams: unknown) => prisma.events.findMany({ take: perPage, skip: offset, - orderBy: { [sortField]: sort }, + orderBy: [{ [sortField]: sort }, { id: sort }], where: { ...queryFilterParams, }, diff --git a/queries/summaryStatistics.ts b/queries/summaryStatistics.ts index 8e786a9b7..4a770201f 100644 --- a/queries/summaryStatistics.ts +++ b/queries/summaryStatistics.ts @@ -3,7 +3,7 @@ import { createCachedFunction } from '~/lib/cache'; import { prisma } from '~/lib/db'; export const getSummaryStatistics = createCachedFunction(async () => { - const counts = await prisma.$transaction([ + const [interviewCount, protocolCount, participantCount] = await Promise.all([ prisma.interview.count(), prisma.protocol.count({ where: { isPreview: false }, @@ -12,9 +12,9 @@ export const getSummaryStatistics = createCachedFunction(async () => { ]); return { - interviewCount: counts[0], - protocolCount: counts[1], - participantCount: counts[2], + interviewCount, + protocolCount, + participantCount, }; }, [ 'summaryStatistics', From cd5a220c9aa294b610005929aa3755efa698efb5 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Feb 2026 15:04:16 +0200 Subject: [PATCH 07/66] fix: remove duplicate setup spec that broke onboarding tests initial-setup.spec.ts and onboarding.spec.ts both tested the same setup wizard flow. Since they ran sequentially in the setup project, the first spec configured the app, causing the second to redirect to /signin instead of /setup. --- tests/e2e/specs/setup/initial-setup.spec.ts | 89 --------------------- 1 file changed, 89 deletions(-) delete mode 100644 tests/e2e/specs/setup/initial-setup.spec.ts diff --git a/tests/e2e/specs/setup/initial-setup.spec.ts b/tests/e2e/specs/setup/initial-setup.spec.ts deleted file mode 100644 index 35aa44215..000000000 --- a/tests/e2e/specs/setup/initial-setup.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { expect, test } from '@playwright/test'; - -const ADMIN_CREDENTIALS = { - username: 'testadmin', - password: 'TestAdmin123!', -}; - -// Longer timeout for heading assertions since step transitions may trigger -// server component re-renders via nuqs URL state changes -const STEP_TIMEOUT = 15_000; - -test.describe('Initial App Setup', () => { - test.describe.configure({ mode: 'serial' }); - - test('should redirect to setup page when not configured', async ({ - page, - }) => { - await page.goto('/'); - - // Should redirect to setup - await expect(page).toHaveURL(/\/setup/, { timeout: STEP_TIMEOUT }); - await expect(page.locator('h1, h2, h3')).toContainText( - /Welcome|Setup|Configure|Create.*Account/i, - ); - }); - - test('should complete initial configuration', async ({ page }) => { - await page.goto('/setup'); - - // Step 1: Create admin account - await expect( - page.getByRole('heading', { name: /Create.*Account/i, level: 2 }), - ).toBeVisible({ timeout: STEP_TIMEOUT }); - - await page - .getByRole('textbox', { name: 'Username' }) - .fill(ADMIN_CREDENTIALS.username); - await page - .getByRole('textbox', { name: 'Password' }) - .fill(ADMIN_CREDENTIALS.password); - await page - .getByRole('textbox', { name: 'Confirm password' }) - .fill(ADMIN_CREDENTIALS.password); - - await page.getByRole('button', { name: 'Create account' }).click(); - - // Step 2: UploadThing configuration - await expect( - page.getByRole('heading', { name: /Connect UploadThing/i, level: 2 }), - ).toBeVisible({ timeout: STEP_TIMEOUT }); - - await page - .getByRole('textbox', { name: /UPLOADTHING_TOKEN/i }) - .fill('UPLOADTHING_TOKEN=test_token_value_here_12345'); - await page.getByRole('button', { name: /save.*continue/i }).click(); - - // Step 3: Upload Protocol (optional) - skip - await expect( - page.getByRole('heading', { name: 'Import Protocols', level: 2 }), - ).toBeVisible({ timeout: STEP_TIMEOUT }); - - // Wait for the step to fully render before clicking Continue - await page.waitForLoadState('networkidle'); - await page.getByRole('button', { name: /^continue$/i }).click(); - - // Step 4: Configure Participation - skip - await expect( - page.getByRole('heading', { - name: 'Configure Participation', - level: 2, - }), - ).toBeVisible({ timeout: STEP_TIMEOUT }); - await page.getByRole('button', { name: /^continue$/i }).click(); - - // Step 5: Documentation (final step) - await expect( - page.getByRole('heading', { name: 'Documentation', level: 2 }), - ).toBeVisible({ timeout: STEP_TIMEOUT }); - - // Complete onboarding - await page.getByRole('button', { name: 'Go to the dashboard!' }).click(); - - // Should now be on the dashboard - await expect(page).toHaveURL(/\/dashboard/, { timeout: STEP_TIMEOUT }); - await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ - timeout: STEP_TIMEOUT, - }); - }); -}); From 1d79f290fcf34135696f1dbd0cd83cac6c86af30 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Feb 2026 18:25:59 +0200 Subject: [PATCH 08/66] Update docker-compose.prod.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 084ca2ba4..cd971c5ac 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -22,7 +22,7 @@ services: volumes: - postgres:/var/lib/postgresql/data:Z healthcheck: - test: ['CMD', 'pg_isready', '-U', 'postgres'] + test: ['CMD', 'pg_isready', '-U', '${POSTGRES_USER}'] interval: 5s timeout: 10s retries: 5 From cd823ad69c9e3ba6057b15c4cb68e9f1b0b3bc79 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Feb 2026 18:27:19 +0200 Subject: [PATCH 09/66] Update app/dashboard/_components/RecruitmentTestSection.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/dashboard/_components/RecruitmentTestSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/dashboard/_components/RecruitmentTestSection.tsx b/app/dashboard/_components/RecruitmentTestSection.tsx index 0646cc70e..37357c550 100644 --- a/app/dashboard/_components/RecruitmentTestSection.tsx +++ b/app/dashboard/_components/RecruitmentTestSection.tsx @@ -3,7 +3,7 @@ import type { Participant, Protocol } from '~/lib/db/generated/client'; import { type Route } from 'next'; import { useRouter } from 'next/navigation'; import { use, useEffect, useState } from 'react'; -import { SuperJSON } from 'superjson'; +import SuperJSON from 'superjson'; import { Button } from '~/components/ui/Button'; import { Select, From 33e560b9688040ac2ce27cae52e938b2990fbaf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:28:09 +0000 Subject: [PATCH 10/66] docs: update NodeBin docstring to include FAMILY_TREE_NODE Co-authored-by: jthrilly <1387940+jthrilly@users.noreply.github.com> --- lib/interviewer/components/NodeBin.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/interviewer/components/NodeBin.tsx b/lib/interviewer/components/NodeBin.tsx index a3d6e7ec7..50828ef7a 100644 --- a/lib/interviewer/components/NodeBin.tsx +++ b/lib/interviewer/components/NodeBin.tsx @@ -8,7 +8,7 @@ type NodeBinProps = { }; /** - * Renders a droppable NodeBin which accepts `EXISTING_NODE`. + * Renders a droppable NodeBin which accepts `EXISTING_NODE` and `FAMILY_TREE_NODE`. */ const NodeBin = ({ accepts, dropHandler }: NodeBinProps) => { const { dropProps, isOver, willAccept } = useDropTarget({ From 975fa2583adface60133d7dd0cf4293d466b0e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:33:01 +0000 Subject: [PATCH 11/66] Fix SuperJSON import to use default import instead of named import Co-authored-by: jthrilly <1387940+jthrilly@users.noreply.github.com> --- .../_components/ProtocolsTable/ProtocolsTableClient.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx b/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx index b180897d6..5e76f9fa0 100644 --- a/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx +++ b/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { use, useState } from 'react'; -import { SuperJSON } from 'superjson'; +import SuperJSON from 'superjson'; import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; import { DataTable } from '~/components/DataTable/DataTable'; import type { GetProtocolsQuery } from '~/queries/protocols'; From 348ad8de44ba0de9192c855f154cca1af99c043a Mon Sep 17 00:00:00 2001 From: buckhalt Date: Wed, 11 Feb 2026 10:25:08 -0800 Subject: [PATCH 12/66] fix: prevent unstable_cache key collisions by using tags as keyParts some cached functions were sharing cache keys which caused protocols and settings page crashes. adding tags to keyParts ensures unique cache keys. unit tests added to cover this --- lib/__tests__/cache-collision.test.ts | 149 ++++++++++++++++++++++++++ lib/__tests__/cache.test.ts | 52 ++++++++- lib/cache.ts | 8 +- 3 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/cache-collision.test.ts diff --git a/lib/__tests__/cache-collision.test.ts b/lib/__tests__/cache-collision.test.ts new file mode 100644 index 000000000..9a770e98d --- /dev/null +++ b/lib/__tests__/cache-collision.test.ts @@ -0,0 +1,149 @@ +/** + * Tests to verify that all cached query functions produce unique cache keys. + * + * This prevents cache key collisions where different functions return each + * other's data due to minification making function bodies identical. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +// Track all calls to unstable_cache with their keyParts +const capturedCacheCalls: Array<{ + keyParts: string[] | undefined; + tags: string[] | undefined; + functionName: string; +}> = []; + +// Mock unstable_cache to capture keyParts from all cached functions +vi.mock('next/cache', () => ({ + unstable_cache: vi.fn( + (fn: () => Promise, keyParts: string[] | undefined, options?: { tags?: string[] }) => { + capturedCacheCalls.push({ + keyParts, + tags: options?.tags, + // Try to extract a meaningful name from the function + functionName: fn.name || fn.toString().slice(0, 50), + }); + return fn; + }, + ), + revalidateTag: vi.fn(), +})); + +// Mock environment +vi.mock('~/env', () => ({ + env: { + DISABLE_NEXT_CACHE: false, + }, +})); + +// Mock Prisma client to prevent actual DB calls +vi.mock('~/lib/db', () => ({ + prisma: { + apiToken: { findMany: vi.fn().mockResolvedValue([]) }, + protocol: { findMany: vi.fn().mockResolvedValue([]), findFirst: vi.fn().mockResolvedValue(null) }, + participant: { findMany: vi.fn().mockResolvedValue([]) }, + interview: { count: vi.fn().mockResolvedValue(0) }, + $transaction: vi.fn().mockResolvedValue([0, 0, 0]), + }, +})); + +// Mock server-only (it throws if imported in non-server context) +vi.mock('server-only', () => ({})); + +describe('Cache Key Collision Prevention', () => { + beforeAll(async () => { + // Clear any previous calls + capturedCacheCalls.length = 0; + + // Import all query modules that use createCachedFunction + // This triggers the cached function creation at module load time + await import('~/queries/apiTokens'); + await import('~/queries/protocols'); + await import('~/queries/participants'); + await import('~/queries/summaryStatistics'); + }); + + it('should have captured multiple cached function registrations', () => { + // Sanity check: we should have captured calls from the imports + expect(capturedCacheCalls.length).toBeGreaterThan(0); + }); + + it('all cached functions should have unique keyParts', () => { + // Group functions by their keyParts + const keyPartsToFunctions = new Map(); + + for (const call of capturedCacheCalls) { + const keyPartsString = call.keyParts?.join(',') ?? '(empty)'; + const funcName = call.tags?.join(',') ?? call.functionName; + + if (!keyPartsToFunctions.has(keyPartsString)) { + keyPartsToFunctions.set(keyPartsString, []); + } + keyPartsToFunctions.get(keyPartsString)!.push(funcName); + } + + // Find collisions (keyParts with multiple functions) + const collisions: Array<{ keyParts: string; functions: string[] }> = []; + + for (const [keyParts, functions] of keyPartsToFunctions) { + if (functions.length > 1) { + collisions.push({ keyParts, functions }); + } + } + + // Log collisions for visibility + if (collisions.length > 0) { + // eslint-disable-next-line no-console + console.log('\nšŸ”“ CACHE KEY COLLISIONS DETECTED:'); + for (const collision of collisions) { + // eslint-disable-next-line no-console + console.log(` keyParts: "${collision.keyParts}"`); + // eslint-disable-next-line no-console + console.log(` functions sharing this key:`); + for (const func of collision.functions) { + // eslint-disable-next-line no-console + console.log(` - ${func}`); + } + } + } + + expect(collisions).toEqual([]); + }); + + it('getApiTokens and getProtocols should have different keyParts', () => { + const apiTokensCall = capturedCacheCalls.find((call) => + call.tags?.includes('getApiTokens'), + ); + const protocolsCall = capturedCacheCalls.find((call) => + call.tags?.includes('getProtocols') && !call.tags?.includes('getProtocolsByHash'), + ); + + expect(apiTokensCall).toBeDefined(); + expect(protocolsCall).toBeDefined(); + + const apiTokensKey = apiTokensCall?.keyParts?.join(','); + const protocolsKey = protocolsCall?.keyParts?.join(','); + + expect(apiTokensKey).not.toEqual(protocolsKey); + }); + + it('no cached function should have empty keyParts', () => { + const emptyKeyPartsCalls = capturedCacheCalls.filter( + (call) => !call.keyParts || call.keyParts.length === 0, + ); + + expect(emptyKeyPartsCalls).toEqual([]); + }); + + it('each cached function keyParts should contain at least one tag', () => { + for (const call of capturedCacheCalls) { + const keyParts = call.keyParts ?? []; + const tags = call.tags ?? []; + + // At least one tag should be in keyParts (our fix ensures tags are used as default keyParts) + const hasTagInKeyParts = tags.some((tag) => keyParts.includes(tag)); + + expect(hasTagInKeyParts).toBe(true); + } + }); +}); diff --git a/lib/__tests__/cache.test.ts b/lib/__tests__/cache.test.ts index 6ea884d38..379591a66 100644 --- a/lib/__tests__/cache.test.ts +++ b/lib/__tests__/cache.test.ts @@ -40,7 +40,8 @@ describe('createCachedFunction', () => { expect(mockUnstableCache).toHaveBeenCalledWith( testFn, - expect.arrayContaining(['custom-key', 'dpl_test123']), + // Tags + explicit keyParts + deployment ID + expect.arrayContaining(['appSettings', 'custom-key', 'dpl_test123']), expect.any(Object), ); }); @@ -84,7 +85,7 @@ describe('createCachedFunction', () => { expect(keyParts).toContain('dpl_test789'); }); - it('should pass empty array keyParts when VERCEL_DEPLOYMENT_ID is not set and no options provided', async () => { + it('should use tags as default keyParts when VERCEL_DEPLOYMENT_ID is not set and no options provided', async () => { vi.stubEnv('VERCEL_DEPLOYMENT_ID', ''); vi.resetModules(); @@ -99,7 +100,52 @@ describe('createCachedFunction', () => { ]; const keyParts = call[1]; - expect(keyParts).toEqual([]); + // Tags should be used as default keyParts to prevent cache collisions + expect(keyParts).toEqual(['appSettings']); + }); + + it('should use tags as default keyParts and append VERCEL_DEPLOYMENT_ID when set', async () => { + vi.stubEnv('VERCEL_DEPLOYMENT_ID', 'dpl_collision_test'); + + vi.resetModules(); + const { createCachedFunction } = await import('../cache'); + + const testFn = () => Promise.resolve('result'); + createCachedFunction(testFn, ['getProtocols', 'summaryStatistics']); + + const call = mockUnstableCache.mock.calls[0] as [ + () => Promise, + string[] | undefined, + ]; + const keyParts = call[1]; + + // Should include all tags plus deployment ID + expect(keyParts).toEqual([ + 'getProtocols', + 'summaryStatistics', + 'dpl_collision_test', + ]); + }); + + it('should combine tags with explicit keyParts when provided', async () => { + vi.stubEnv('VERCEL_DEPLOYMENT_ID', ''); + + vi.resetModules(); + const { createCachedFunction } = await import('../cache'); + + const testFn = () => Promise.resolve('result'); + createCachedFunction(testFn, ['appSettings'], { + keyParts: ['custom-key'], + }); + + const call = mockUnstableCache.mock.calls[0] as [ + () => Promise, + string[] | undefined, + ]; + const keyParts = call[1]; + + // Tags should always be included, with explicit keyParts appended + expect(keyParts).toEqual(['appSettings', 'custom-key']); }); it('should bypass cache entirely when DISABLE_NEXT_CACHE is true', async () => { diff --git a/lib/cache.ts b/lib/cache.ts index 57cfc6512..1ea552a0d 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -46,7 +46,13 @@ export function createCachedFunction( // eslint-disable-next-line no-process-env const VERCEL_DEPLOYMENT_ID = process.env.VERCEL_DEPLOYMENT_ID; - const keyParts = (options?.keyParts ?? []).concat( + + // Always include tags in keyParts to prevent cache key collisions between + // functions with similar structure after minification. + const keyParts = [ + ...tags, + ...(options?.keyParts ?? []), + ].concat( VERCEL_DEPLOYMENT_ID ? [VERCEL_DEPLOYMENT_ID] : [], ); From 44e8454e8bca65e686bfd95e7a22eaf8314376b2 Mon Sep 17 00:00:00 2001 From: Caden Buckhalt <75645391+buckhalt@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:50:26 -0800 Subject: [PATCH 13/66] Update lib/__tests__/cache-collision.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/__tests__/cache-collision.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/cache-collision.test.ts b/lib/__tests__/cache-collision.test.ts index 9a770e98d..dcb1894d0 100644 --- a/lib/__tests__/cache-collision.test.ts +++ b/lib/__tests__/cache-collision.test.ts @@ -1,8 +1,9 @@ /** - * Tests to verify that all cached query functions produce unique cache keys. + * Tests to verify that cached query functions exercised by this suite produce + * unique cache keys. * - * This prevents cache key collisions where different functions return each - * other's data due to minification making function bodies identical. + * This helps prevent cache key collisions where different functions return + * each other's data due to minification making function bodies identical. */ import { beforeAll, describe, expect, it, vi } from 'vitest'; From 05feb396c4a2985a78899680be3bac424a267538 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Wed, 11 Feb 2026 12:12:37 -0800 Subject: [PATCH 14/66] knip --- knip.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.config.ts b/knip.config.ts index 64df2b117..4080145b2 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -25,6 +25,7 @@ const config: KnipConfig = { ], ignoreBinaries: [ 'docker-compose', // Should be installed by developers if needed, not a project dependency + 'netlify', // Installed globally in CI workflow, not a project dependency ], ignoreIssues: { // TestFixtures/WorkerFixtures are used by Playwright via base.extend<>() generic type parameter. From 62328286e28ef19ff91af83ca7df475b0caa1634 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Wed, 11 Feb 2026 12:31:36 -0800 Subject: [PATCH 15/66] fix neon api key name https://neon.com/docs/reference/cli-auth#the-auth-command --- .github/workflows/netlify-cleanup-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/netlify-cleanup-preview.yml b/.github/workflows/netlify-cleanup-preview.yml index 8d4e329ca..fd376e180 100644 --- a/.github/workflows/netlify-cleanup-preview.yml +++ b/.github/workflows/netlify-cleanup-preview.yml @@ -12,4 +12,4 @@ jobs: - name: Delete Neon Branch run: bunx neonctl branches delete preview/pr-${{ github.event.number }}-${{ github.event.pull_request.head.ref }} --project-id ${{ vars.NEON_PROJECT_ID }} env: - api_key: ${{ secrets.NEON_API_KEY }} + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} From c07aa1837e762fc29908eb3fa67e451b4687de4e Mon Sep 17 00:00:00 2001 From: buckhalt Date: Wed, 11 Feb 2026 13:55:47 -0800 Subject: [PATCH 16/66] lint --- lib/__tests__/cache-collision.test.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/__tests__/cache-collision.test.ts b/lib/__tests__/cache-collision.test.ts index dcb1894d0..5d7ae1fce 100644 --- a/lib/__tests__/cache-collision.test.ts +++ b/lib/__tests__/cache-collision.test.ts @@ -8,16 +8,20 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; // Track all calls to unstable_cache with their keyParts -const capturedCacheCalls: Array<{ +const capturedCacheCalls: { keyParts: string[] | undefined; tags: string[] | undefined; functionName: string; -}> = []; +}[] = []; // Mock unstable_cache to capture keyParts from all cached functions vi.mock('next/cache', () => ({ unstable_cache: vi.fn( - (fn: () => Promise, keyParts: string[] | undefined, options?: { tags?: string[] }) => { + ( + fn: () => Promise, + keyParts: string[] | undefined, + options?: { tags?: string[] }, + ) => { capturedCacheCalls.push({ keyParts, tags: options?.tags, @@ -41,7 +45,10 @@ vi.mock('~/env', () => ({ vi.mock('~/lib/db', () => ({ prisma: { apiToken: { findMany: vi.fn().mockResolvedValue([]) }, - protocol: { findMany: vi.fn().mockResolvedValue([]), findFirst: vi.fn().mockResolvedValue(null) }, + protocol: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + }, participant: { findMany: vi.fn().mockResolvedValue([]) }, interview: { count: vi.fn().mockResolvedValue(0) }, $transaction: vi.fn().mockResolvedValue([0, 0, 0]), @@ -84,7 +91,7 @@ describe('Cache Key Collision Prevention', () => { } // Find collisions (keyParts with multiple functions) - const collisions: Array<{ keyParts: string; functions: string[] }> = []; + const collisions: { keyParts: string; functions: string[] }[] = []; for (const [keyParts, functions] of keyPartsToFunctions) { if (functions.length > 1) { @@ -115,8 +122,10 @@ describe('Cache Key Collision Prevention', () => { const apiTokensCall = capturedCacheCalls.find((call) => call.tags?.includes('getApiTokens'), ); - const protocolsCall = capturedCacheCalls.find((call) => - call.tags?.includes('getProtocols') && !call.tags?.includes('getProtocolsByHash'), + const protocolsCall = capturedCacheCalls.find( + (call) => + call.tags?.includes('getProtocols') && + !call.tags?.includes('getProtocolsByHash'), ); expect(apiTokensCall).toBeDefined(); From 8cedd3a45653326aae5e21a2bac531039175e97b Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 13 Feb 2026 10:53:43 +0200 Subject: [PATCH 17/66] fix silent migration failure and add build visibility setup-database.ts had a default `return false` that silently skipped migrations when prisma migrate diff exited with an unexpected code. Now throws on any non-0/2 exit code and logs stdout/stderr/exit code. Changed netlify deploy redirect from `>` to `| tee` so build output is visible in GitHub Actions logs. --- .github/workflows/netlify-deploy-preview.yml | 2 +- scripts/setup-database.ts | 25 +++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml index c8c9d5193..3e94b99b9 100644 --- a/.github/workflows/netlify-deploy-preview.yml +++ b/.github/workflows/netlify-deploy-preview.yml @@ -48,7 +48,7 @@ jobs: - name: Build and deploy preview id: deploy run: | - netlify deploy --build --alias="pr-${{ github.event.number }}" --context branch:${{ steps.branch-name.outputs.current_branch }} --json > netlify-deploy.json + netlify deploy --build --alias="pr-${{ github.event.number }}" --context branch:${{ steps.branch-name.outputs.current_branch }} --json | tee netlify-deploy.json echo "deploy_url=$(jq -r '.deploy_url' netlify-deploy.json)" >> "$GITHUB_OUTPUT" - name: Comment on Pull Request diff --git a/scripts/setup-database.ts b/scripts/setup-database.ts index a7408c683..af3ac4cc2 100644 --- a/scripts/setup-database.ts +++ b/scripts/setup-database.ts @@ -31,26 +31,27 @@ function checkForNeededMigrations(): boolean { '--exit-code', ]; + console.log(`Running: ${command} ${args.join(' ')}`); const result = spawnSync(command, args, { encoding: 'utf-8' }); if (result.error) { - console.error('Failed to run command:', result.error); + console.error('Failed to spawn command:', result.error); throw result.error; } - // Handling the exit code + if (result.stdout) console.log('stdout:', result.stdout); + if (result.stderr) console.log('stderr:', result.stderr); + console.log('Exit code:', result.status); + if (result.status === 0) { console.log('No differences between DB and schema detected.'); return false; } else if (result.status === 2) { console.log('There are differences between the schemas.'); return true; - } else if (result.status === 1) { - console.log('An error occurred.', result.stderr); - throw new Error(`Command failed with exit code ${result.status}`); } - return false; + throw new Error(`prisma migrate diff failed with exit code ${result.status}`); } /** @@ -75,7 +76,12 @@ async function shouldApplyWorkaround(): Promise { async function handleMigrations(): Promise { try { - if (await shouldApplyWorkaround()) { + console.log('Starting migration process...'); + + const applyWorkaround = await shouldApplyWorkaround(); + console.log('Should apply workaround:', applyWorkaround); + + if (applyWorkaround) { console.log( 'Workaround needed! Running: prisma migrate resolve --applied 0_init', ); @@ -84,12 +90,13 @@ async function handleMigrations(): Promise { }); } - // Determine if there are any migrations to run const needsMigrations = checkForNeededMigrations(); + console.log('Needs migrations:', needsMigrations); if (needsMigrations) { - console.log('Migrations needed! Running: prisma migrate deploy'); + console.log('Running: prisma migrate deploy'); execSync('npx prisma migrate deploy', { stdio: 'inherit' }); + console.log('Migrations applied successfully.'); } else { console.log('No migrations needed.'); } From e05a6a4a238b9cc08d73acfcf6b15dfbfc1c0526 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 13 Feb 2026 10:59:38 +0200 Subject: [PATCH 18/66] make e2e tests manually triggered instead of running on every PR commit Replace the pull_request trigger with workflow_dispatch so e2e tests can be run on-demand from the Actions tab. Add a resolve-pr job that looks up the PR number from the branch name for manual runs, so the failure report deployment and PR commenting still work. --- .github/workflows/e2e.yml | 48 +++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 00acb6b0a..c0620b71e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -7,9 +7,9 @@ permissions: pull-requests: write on: + workflow_dispatch: push: branches: [main, next] - pull_request: jobs: e2e: @@ -58,10 +58,38 @@ jobs: path: .pnpm-docker-store key: ${{ steps.setup.outputs.cache-key }} + # Resolve PR context for both pull_request and workflow_dispatch events + resolve-pr: + if: failure() && needs.e2e.outputs.test-result == 'failure' + needs: e2e + runs-on: ubuntu-latest + outputs: + pr-number: ${{ steps.pr.outputs.number }} + head-sha: ${{ steps.pr.outputs.head_sha }} + branch: ${{ steps.pr.outputs.branch }} + steps: + - name: Resolve PR context + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + echo "head_sha=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" + echo "branch=${{ github.head_ref }}" >> "$GITHUB_OUTPUT" + else + PR_NUMBER=$(gh pr list --repo "${{ github.repository }}" --head "${{ github.ref_name }}" --json number --jq '.[0].number') + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + fi + echo "head_sha=${{ github.sha }}" >> "$GITHUB_OUTPUT" + echo "branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + # Deploy report to GitHub Pages on test failure (PR-specific subdirectory) deploy-report: - if: failure() && github.event_name == 'pull_request' && needs.e2e.outputs.test-result == 'failure' - needs: e2e + if: failure() && needs.resolve-pr.outputs.pr-number != '' + needs: [e2e, resolve-pr] runs-on: ubuntu-latest # Single concurrency group for all Pages deployments to prevent race conditions concurrency: @@ -69,7 +97,7 @@ jobs: cancel-in-progress: false environment: name: github-pages - url: https://complexdatacollective.github.io/Fresco/pr-${{ github.event.pull_request.number }}/ + url: https://complexdatacollective.github.io/Fresco/pr-${{ needs.resolve-pr.outputs.pr-number }}/ steps: - name: Download playwright report @@ -97,8 +125,8 @@ jobs: fi # Create PR-specific subdirectory with new report - mkdir -p merged/pr-${{ github.event.pull_request.number }} - cp -r new-report/playwright-report/* merged/pr-${{ github.event.pull_request.number }}/ + mkdir -p merged/pr-${{ needs.resolve-pr.outputs.pr-number }} + cp -r new-report/playwright-report/* merged/pr-${{ needs.resolve-pr.outputs.pr-number }}/ # Generate index page listing all reports echo "
    " > merged/index.html.tmp @@ -148,19 +176,19 @@ jobs: - name: Comment on PR with report link uses: peter-evans/create-or-update-comment@v5 with: - issue-number: ${{ github.event.pull_request.number }} + issue-number: ${{ needs.resolve-pr.outputs.pr-number }} body: | ## Playwright E2E Test Report Tests failed. View the full report here: - **[https://complexdatacollective.github.io/Fresco/pr-${{ github.event.pull_request.number }}/](https://complexdatacollective.github.io/Fresco/pr-${{ github.event.pull_request.number }}/)** + šŸ‘‰ **[https://complexdatacollective.github.io/Fresco/pr-${{ needs.resolve-pr.outputs.pr-number }}/](https://complexdatacollective.github.io/Fresco/pr-${{ needs.resolve-pr.outputs.pr-number }}/)**
    Report details - **Workflow run:** [View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - - **Commit:** ${{ github.event.pull_request.head.sha }} - - **Branch:** `${{ github.head_ref }}` + - **Commit:** ${{ needs.resolve-pr.outputs.head-sha }} + - **Branch:** `${{ needs.resolve-pr.outputs.branch }}`
    From e95c5c4535258398b04c5ff5beb0e483847f6f8a Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 13 Feb 2026 11:00:26 +0200 Subject: [PATCH 19/66] run database migrations as separate visible step in deploy preview netlify deploy --build --json swallows all build subprocess output, making it impossible to see setup-database.ts logs. Run the database scripts as a dedicated step so output is visible in GH Actions. The netlify build re-runs them but they no-op since migrations are already applied. --- .github/workflows/netlify-deploy-preview.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml index 3e94b99b9..526712ae4 100644 --- a/.github/workflows/netlify-deploy-preview.yml +++ b/.github/workflows/netlify-deploy-preview.yml @@ -45,10 +45,13 @@ jobs: echo "DATABASE_URL=${{ steps.create-branch.outputs.db_url_pooled }}" > .env echo "DATABASE_URL_UNPOOLED=${{ steps.create-branch.outputs.db_url }}" >> .env + - name: Run database migrations + run: prisma generate && tsx ./scripts/setup-database.ts && tsx ./scripts/initialize.ts + - name: Build and deploy preview id: deploy run: | - netlify deploy --build --alias="pr-${{ github.event.number }}" --context branch:${{ steps.branch-name.outputs.current_branch }} --json | tee netlify-deploy.json + netlify deploy --build --alias="pr-${{ github.event.number }}" --context branch:${{ steps.branch-name.outputs.current_branch }} --json > netlify-deploy.json echo "deploy_url=$(jq -r '.deploy_url' netlify-deploy.json)" >> "$GITHUB_OUTPUT" - name: Comment on Pull Request From 29eb1519c455bc292ff61bc6220f149e9c13965c Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 13 Feb 2026 11:03:00 +0200 Subject: [PATCH 20/66] add --debug flag to netlify deploy for build visibility --- .github/workflows/netlify-deploy-preview.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml index 526712ae4..0ce5382ad 100644 --- a/.github/workflows/netlify-deploy-preview.yml +++ b/.github/workflows/netlify-deploy-preview.yml @@ -45,13 +45,10 @@ jobs: echo "DATABASE_URL=${{ steps.create-branch.outputs.db_url_pooled }}" > .env echo "DATABASE_URL_UNPOOLED=${{ steps.create-branch.outputs.db_url }}" >> .env - - name: Run database migrations - run: prisma generate && tsx ./scripts/setup-database.ts && tsx ./scripts/initialize.ts - - name: Build and deploy preview id: deploy run: | - netlify deploy --build --alias="pr-${{ github.event.number }}" --context branch:${{ steps.branch-name.outputs.current_branch }} --json > netlify-deploy.json + netlify deploy --build --debug --alias="pr-${{ github.event.number }}" --context branch:${{ steps.branch-name.outputs.current_branch }} --json > netlify-deploy.json echo "deploy_url=$(jq -r '.deploy_url' netlify-deploy.json)" >> "$GITHUB_OUTPUT" - name: Comment on Pull Request From 8b96a20b14c76b29713c17304eb0eea009a5b42d Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 13 Feb 2026 11:11:59 +0200 Subject: [PATCH 21/66] split netlify build and deploy for visible build output netlify deploy --build --json swallows all build subprocess stdout, hiding setup-database.ts logs and any next build errors. Split into netlify build (stdout visible in GH Actions) + netlify deploy (just uploads artifacts). --- .github/workflows/netlify-deploy-preview.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml index 0ce5382ad..775f50c73 100644 --- a/.github/workflows/netlify-deploy-preview.yml +++ b/.github/workflows/netlify-deploy-preview.yml @@ -45,10 +45,13 @@ jobs: echo "DATABASE_URL=${{ steps.create-branch.outputs.db_url_pooled }}" > .env echo "DATABASE_URL_UNPOOLED=${{ steps.create-branch.outputs.db_url }}" >> .env - - name: Build and deploy preview + - name: Build + run: netlify build --context branch:${{ steps.branch-name.outputs.current_branch }} + + - name: Deploy preview id: deploy run: | - netlify deploy --build --debug --alias="pr-${{ github.event.number }}" --context branch:${{ steps.branch-name.outputs.current_branch }} --json > netlify-deploy.json + netlify deploy --alias="pr-${{ github.event.number }}" --json > netlify-deploy.json echo "deploy_url=$(jq -r '.deploy_url' netlify-deploy.json)" >> "$GITHUB_OUTPUT" - name: Comment on Pull Request From cf85e0bd22dae4e889879a02e01c21f479f030d1 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Fri, 13 Feb 2026 09:04:31 -0800 Subject: [PATCH 22/66] trigger deploy preview From 39997bdef074894c270060aac5a13be441be2d67 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Fri, 13 Feb 2026 09:39:08 -0800 Subject: [PATCH 23/66] fix: use push trigger for deploy preview to run with merge conflicts pull_request workflows dont run when PRs have merge conflicts bc Github can't create the temporary merge commit. switch to push trigger, and resolve pr number with gh pr list --- .github/workflows/netlify-deploy-preview.yml | 25 ++++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml index 775f50c73..37584962c 100644 --- a/.github/workflows/netlify-deploy-preview.yml +++ b/.github/workflows/netlify-deploy-preview.yml @@ -1,6 +1,9 @@ name: Deploy Preview -on: [pull_request] +on: + push: + branches-ignore: + - main permissions: contents: read @@ -9,11 +12,23 @@ permissions: jobs: deploy-preview: runs-on: ubuntu-latest - if: github.event.pull_request.head.repo.full_name == github.repository env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} steps: + - name: Get PR number + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER=$(gh pr list --repo "${{ github.repository }}" --head "${{ github.ref_name }}" --json number --jq '.[0].number') + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + else + echo "No open PR for branch ${{ github.ref_name }}, skipping deploy" + exit 0 + fi + - name: Setup uses: complexdatacollective/github-actions/setup-pnpm@v1 @@ -26,7 +41,7 @@ jobs: uses: neondatabase/create-branch-action@v6 with: project_id: ${{ vars.NEON_PROJECT_ID }} - branch_name: preview/pr-${{ github.event.number }}-${{ steps.branch-name.outputs.current_branch }} + branch_name: preview/pr-${{ steps.pr.outputs.number }}-${{ steps.branch-name.outputs.current_branch }} database: ${{ vars.NEON_DATABASE_NAME }} role: ${{ vars.NEON_DATABASE_USERNAME }} api_key: ${{ secrets.NEON_API_KEY }} @@ -51,13 +66,13 @@ jobs: - name: Deploy preview id: deploy run: | - netlify deploy --alias="pr-${{ github.event.number }}" --json > netlify-deploy.json + netlify deploy --alias="pr-${{ steps.pr.outputs.number }}" --json > netlify-deploy.json echo "deploy_url=$(jq -r '.deploy_url' netlify-deploy.json)" >> "$GITHUB_OUTPUT" - name: Comment on Pull Request uses: peter-evans/create-or-update-comment@v5 with: - issue-number: ${{ github.event.pull_request.number }} + issue-number: ${{ steps.pr.outputs.number }} body: | | Resource | Link | |----------|------| From 177708cdba60027ec14a5619e8a787d0350e93f2 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Mon, 16 Feb 2026 14:16:28 -0800 Subject: [PATCH 24/66] fix: use pull_request trigger with head checkout for deploy preview --- .github/workflows/netlify-deploy-preview.yml | 47 +++++++++----------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml index 37584962c..07ace0ca5 100644 --- a/.github/workflows/netlify-deploy-preview.yml +++ b/.github/workflows/netlify-deploy-preview.yml @@ -1,9 +1,7 @@ name: Deploy Preview on: - push: - branches-ignore: - - main + pull_request: permissions: contents: read @@ -16,32 +14,29 @@ jobs: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} steps: - - name: Get PR number - id: pr - env: - GH_TOKEN: ${{ github.token }} - run: | - PR_NUMBER=$(gh pr list --repo "${{ github.repository }}" --head "${{ github.ref_name }}" --json number --jq '.[0].number') - if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then - echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - else - echo "No open PR for branch ${{ github.ref_name }}, skipping deploy" - exit 0 - fi + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - - name: Setup - uses: complexdatacollective/github-actions/setup-pnpm@v1 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' - - name: Get branch name - id: branch-name - uses: tj-actions/branch-names@v9 + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Create Neon Branch id: create-branch uses: neondatabase/create-branch-action@v6 with: project_id: ${{ vars.NEON_PROJECT_ID }} - branch_name: preview/pr-${{ steps.pr.outputs.number }}-${{ steps.branch-name.outputs.current_branch }} + branch_name: preview/pr-${{ github.event.pull_request.number }}-${{ github.head_ref }} database: ${{ vars.NEON_DATABASE_NAME }} role: ${{ vars.NEON_DATABASE_USERNAME }} api_key: ${{ secrets.NEON_API_KEY }} @@ -52,8 +47,8 @@ jobs: - name: Set Neon database URLs in Netlify run: | - netlify env:set DATABASE_URL "${{ steps.create-branch.outputs.db_url_pooled }}" --context branch:${{ steps.branch-name.outputs.current_branch }} - netlify env:set DATABASE_URL_UNPOOLED "${{ steps.create-branch.outputs.db_url }}" --context branch:${{ steps.branch-name.outputs.current_branch }} + netlify env:set DATABASE_URL "${{ steps.create-branch.outputs.db_url_pooled }}" --context branch:${{ github.head_ref }} + netlify env:set DATABASE_URL_UNPOOLED "${{ steps.create-branch.outputs.db_url }}" --context branch:${{ github.head_ref }} - name: Create .env for build run: | @@ -61,18 +56,18 @@ jobs: echo "DATABASE_URL_UNPOOLED=${{ steps.create-branch.outputs.db_url }}" >> .env - name: Build - run: netlify build --context branch:${{ steps.branch-name.outputs.current_branch }} + run: netlify build --context branch:${{ github.head_ref }} - name: Deploy preview id: deploy run: | - netlify deploy --alias="pr-${{ steps.pr.outputs.number }}" --json > netlify-deploy.json + netlify deploy --alias="pr-${{ github.event.pull_request.number }}" --json > netlify-deploy.json echo "deploy_url=$(jq -r '.deploy_url' netlify-deploy.json)" >> "$GITHUB_OUTPUT" - name: Comment on Pull Request uses: peter-evans/create-or-update-comment@v5 with: - issue-number: ${{ steps.pr.outputs.number }} + issue-number: ${{ github.event.pull_request.number }} body: | | Resource | Link | |----------|------| From 8f9ba95c60f2c4917082f36ce413e6280af3c0be Mon Sep 17 00:00:00 2001 From: buckhalt Date: Mon, 16 Feb 2026 14:17:17 -0800 Subject: [PATCH 25/66] fix: prevent reuse of assets from failed preview uploads --- app/api/preview/route.ts | 40 +++++++++++++++++++++++++++++++-- lib/preview-protocol-pruning.ts | 13 +++++------ queries/protocols.ts | 15 +++++++++++++ 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts index 7689e243e..d0bd9139a 100644 --- a/app/api/preview/route.ts +++ b/app/api/preview/route.ts @@ -12,6 +12,7 @@ import { generatePresignedUploadUrl, parseUploadThingToken, } from '~/lib/uploadthing/presigned'; +import { getUTApi } from '~/lib/uploadthing/server-helpers'; import { getExistingAssets } from '~/queries/protocols'; import { ensureError } from '~/utils/ensureError'; import { extractApikeyAssetsFromManifest } from '~/utils/protocolImport'; @@ -252,7 +253,7 @@ export async function POST( case 'abort-preview': { const { protocolId } = body; - // Find and delete the protocol + // Find the protocol const protocol = await prisma.protocol.findUnique({ where: { id: protocolId }, }); @@ -265,7 +266,42 @@ export async function POST( return jsonResponse(response, 404); } - // Delete the protocol (cascades to related entities) + // Find assets that are ONLY associated with this protocol + // Note: `every` alone would match orphaned assets (with no protocols), + // so we also require `some` to ensure the asset is actually linked to this protocol + const assetsToDelete = await prisma.asset.findMany({ + where: { + AND: [ + { protocols: { some: { id: protocolId } } }, + { protocols: { every: { id: protocolId } } }, + ], + }, + select: { key: true }, + }); + + // Delete assets from UploadThing (best effort) + if (assetsToDelete.length > 0) { + try { + const utapi = await getUTApi(); + await utapi.deleteFiles(assetsToDelete.map((a) => a.key)); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error deleting preview protocol assets:', error); + } + } + + // Delete assets from database + if (assetsToDelete.length > 0) { + await prisma.asset.deleteMany({ + where: { + key: { + in: assetsToDelete.map((a) => a.key), + }, + }, + }); + } + + // Delete the protocol await prisma.protocol.delete({ where: { id: protocolId }, }); diff --git a/lib/preview-protocol-pruning.ts b/lib/preview-protocol-pruning.ts index 15c25a888..73085014f 100644 --- a/lib/preview-protocol-pruning.ts +++ b/lib/preview-protocol-pruning.ts @@ -48,15 +48,14 @@ export async function prunePreviewProtocols(): Promise<{ const protocolIds = oldProtocols.map((p) => p.id); // Select assets that are ONLY associated with the protocols to be deleted + // Note: `every` alone would match orphaned assets (with no protocols), + // so we also require `some` to ensure the asset is actually linked to these protocols const assetKeysToDelete = await prisma.asset.findMany({ where: { - protocols: { - every: { - id: { - in: protocolIds, - }, - }, - }, + AND: [ + { protocols: { some: { id: { in: protocolIds } } } }, + { protocols: { every: { id: { in: protocolIds } } } }, + ], }, select: { key: true }, }); diff --git a/queries/protocols.ts b/queries/protocols.ts index bf9561436..7b01d5db9 100644 --- a/queries/protocols.ts +++ b/queries/protocols.ts @@ -38,12 +38,27 @@ export const getProtocolByHash = createCachedFunction( ['getProtocolsByHash', 'getProtocols'], ); +/** + * Find existing assets by assetId that are safe to reuse. + * Excludes assets that are ONLY associated with pending preview protocols, + * as these may have failed/stuck uploads with invalid URLs. + */ export const getExistingAssets = async (assetIds: string[]) => { return prisma.asset.findMany({ where: { assetId: { in: assetIds, }, + // Exclude assets that are ONLY associated with pending preview protocols + // An asset is safe to reuse if it has at least one non-pending protocol + protocols: { + some: { + OR: [ + { isPending: false }, + { isPreview: false }, + ], + }, + }, }, select: { assetId: true, From c9e024fa01cc520b41f0fba1b16ceef3f3121409 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Mon, 16 Feb 2026 14:25:08 -0800 Subject: [PATCH 26/66] Revert "fix: use pull_request trigger with head checkout for deploy preview" This reverts commit 177708cdba60027ec14a5619e8a787d0350e93f2. --- .github/workflows/netlify-deploy-preview.yml | 47 +++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml index 07ace0ca5..37584962c 100644 --- a/.github/workflows/netlify-deploy-preview.yml +++ b/.github/workflows/netlify-deploy-preview.yml @@ -1,7 +1,9 @@ name: Deploy Preview on: - pull_request: + push: + branches-ignore: + - main permissions: contents: read @@ -14,29 +16,32 @@ jobs: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} steps: - - name: Checkout PR head - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v4 + - name: Get PR number + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER=$(gh pr list --repo "${{ github.repository }}" --head "${{ github.ref_name }}" --json number --jq '.[0].number') + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + else + echo "No open PR for branch ${{ github.ref_name }}, skipping deploy" + exit 0 + fi - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' + - name: Setup + uses: complexdatacollective/github-actions/setup-pnpm@v1 - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v9 - name: Create Neon Branch id: create-branch uses: neondatabase/create-branch-action@v6 with: project_id: ${{ vars.NEON_PROJECT_ID }} - branch_name: preview/pr-${{ github.event.pull_request.number }}-${{ github.head_ref }} + branch_name: preview/pr-${{ steps.pr.outputs.number }}-${{ steps.branch-name.outputs.current_branch }} database: ${{ vars.NEON_DATABASE_NAME }} role: ${{ vars.NEON_DATABASE_USERNAME }} api_key: ${{ secrets.NEON_API_KEY }} @@ -47,8 +52,8 @@ jobs: - name: Set Neon database URLs in Netlify run: | - netlify env:set DATABASE_URL "${{ steps.create-branch.outputs.db_url_pooled }}" --context branch:${{ github.head_ref }} - netlify env:set DATABASE_URL_UNPOOLED "${{ steps.create-branch.outputs.db_url }}" --context branch:${{ github.head_ref }} + netlify env:set DATABASE_URL "${{ steps.create-branch.outputs.db_url_pooled }}" --context branch:${{ steps.branch-name.outputs.current_branch }} + netlify env:set DATABASE_URL_UNPOOLED "${{ steps.create-branch.outputs.db_url }}" --context branch:${{ steps.branch-name.outputs.current_branch }} - name: Create .env for build run: | @@ -56,18 +61,18 @@ jobs: echo "DATABASE_URL_UNPOOLED=${{ steps.create-branch.outputs.db_url }}" >> .env - name: Build - run: netlify build --context branch:${{ github.head_ref }} + run: netlify build --context branch:${{ steps.branch-name.outputs.current_branch }} - name: Deploy preview id: deploy run: | - netlify deploy --alias="pr-${{ github.event.pull_request.number }}" --json > netlify-deploy.json + netlify deploy --alias="pr-${{ steps.pr.outputs.number }}" --json > netlify-deploy.json echo "deploy_url=$(jq -r '.deploy_url' netlify-deploy.json)" >> "$GITHUB_OUTPUT" - name: Comment on Pull Request uses: peter-evans/create-or-update-comment@v5 with: - issue-number: ${{ github.event.pull_request.number }} + issue-number: ${{ steps.pr.outputs.number }} body: | | Resource | Link | |----------|------| From de61973dfafe5c47575338a0610815ad0152e699 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Tue, 17 Feb 2026 08:25:50 -0800 Subject: [PATCH 27/66] refactor: migrate preview API to versioned route structure --- .../_handlers/v1/handler.ts} | 58 +++++-------------- .../{ => [version]/_handlers/v1}/helpers.ts | 0 .../{ => [version]/_handlers/v1}/types.ts | 1 - app/api/preview/[version]/route.ts | 47 +++++++++++++++ fresco.config.ts | 1 - utils/semVer.ts | 11 ---- 6 files changed, 63 insertions(+), 55 deletions(-) rename app/api/preview/{route.ts => [version]/_handlers/v1/handler.ts} (85%) rename app/api/preview/{ => [version]/_handlers/v1}/helpers.ts (100%) rename app/api/preview/{ => [version]/_handlers/v1}/types.ts (98%) create mode 100644 app/api/preview/[version]/route.ts diff --git a/app/api/preview/route.ts b/app/api/preview/[version]/_handlers/v1/handler.ts similarity index 85% rename from app/api/preview/route.ts rename to app/api/preview/[version]/_handlers/v1/handler.ts index d0bd9139a..c37802db9 100644 --- a/app/api/preview/route.ts +++ b/app/api/preview/[version]/_handlers/v1/handler.ts @@ -1,8 +1,7 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; import { hash } from 'ohash'; import { addEvent } from '~/actions/activityFeed'; import { env } from '~/env'; -import { MIN_ARCHITECT_VERSION_FOR_PREVIEW } from '~/fresco.config'; import trackEvent from '~/lib/analytics'; import { prisma } from '~/lib/db'; import { Prisma } from '~/lib/db/generated/client'; @@ -16,30 +15,18 @@ import { getUTApi } from '~/lib/uploadthing/server-helpers'; import { getExistingAssets } from '~/queries/protocols'; import { ensureError } from '~/utils/ensureError'; import { extractApikeyAssetsFromManifest } from '~/utils/protocolImport'; -import { compareSemver, semverSchema } from '~/utils/semVer'; -import { checkPreviewAuth, corsHeaders, jsonResponse } from './helpers'; -import type { - AbortResponse, - CompleteResponse, - InitializeResponse, - PreviewRequest, - PreviewResponse, - ReadyResponse, - RejectedResponse, +import { checkPreviewAuth, jsonResponse } from './helpers'; +import { + type AbortResponse, + type CompleteResponse, + type InitializeResponse, + type PreviewRequest, + type ReadyResponse, + type RejectedResponse, } from './types'; -// Handle preflight OPTIONS request -export function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: corsHeaders, - }); -} - -export async function POST( - req: NextRequest, -): Promise> { - const authError = await checkPreviewAuth(req); +export async function v1(request: NextRequest) { + const authError = await checkPreviewAuth(request); if (authError) { return jsonResponse(authError.response, authError.status); @@ -51,25 +38,12 @@ export async function POST( }; try { - const body = (await req.json()) as PreviewRequest; + const body = (await request.json()) as PreviewRequest; const { type } = body; switch (type) { case 'initialize-preview': { - const { protocol: protocolJson, assetMeta, architectVersion } = body; - - // Check Architect version compatibility - const architectVer = semverSchema.parse(`v${architectVersion}`); - const minVer = semverSchema.parse( - `v${MIN_ARCHITECT_VERSION_FOR_PREVIEW}`, - ); - if (compareSemver(architectVer, minVer) < 0) { - const response: InitializeResponse = { - status: 'error', - message: `Architect versions below ${MIN_ARCHITECT_VERSION_FOR_PREVIEW} are not supported for preview mode`, - }; - return jsonResponse(response, 400); - } + const { protocol: protocolJson, assetMeta } = body; // Validate and migrate protocol const validationResult = await validateAndMigrateProtocol(protocolJson); @@ -98,7 +72,7 @@ export async function POST( // If protocol exists, return ready immediately if (existingPreview) { - const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + const url = new URL(env.PUBLIC_URL ?? request.nextUrl.clone()); url.pathname = `/preview/${existingPreview.id}`; const response: ReadyResponse = { @@ -198,7 +172,7 @@ export async function POST( // If no new assets to upload, return ready immediately if (presignedUrls.length === 0) { - const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + const url = new URL(env.PUBLIC_URL ?? request.nextUrl.clone()); url.pathname = `/preview/${protocol.id}`; const response: InitializeResponse = { @@ -240,7 +214,7 @@ export async function POST( void addEvent('Preview Mode', `Preview protocol upload completed`); - const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + const url = new URL(env.PUBLIC_URL ?? request.nextUrl.clone()); url.pathname = `/preview/${protocol.id}`; const response: CompleteResponse = { diff --git a/app/api/preview/helpers.ts b/app/api/preview/[version]/_handlers/v1/helpers.ts similarity index 100% rename from app/api/preview/helpers.ts rename to app/api/preview/[version]/_handlers/v1/helpers.ts diff --git a/app/api/preview/types.ts b/app/api/preview/[version]/_handlers/v1/types.ts similarity index 98% rename from app/api/preview/types.ts rename to app/api/preview/[version]/_handlers/v1/types.ts index a905f90ba..2415242eb 100644 --- a/app/api/preview/types.ts +++ b/app/api/preview/[version]/_handlers/v1/types.ts @@ -18,7 +18,6 @@ type InitializePreviewRequest = { type: 'initialize-preview'; protocol: VersionedProtocol; assetMeta: AssetMetadata[]; - architectVersion: string; }; type CompletePreviewRequest = { diff --git a/app/api/preview/[version]/route.ts b/app/api/preview/[version]/route.ts new file mode 100644 index 000000000..8844d463a --- /dev/null +++ b/app/api/preview/[version]/route.ts @@ -0,0 +1,47 @@ +import { type HTTP_METHOD } from 'next/dist/server/web/http'; +import { type NextRequest, NextResponse } from 'next/server'; +import { v1 } from './_handlers/v1/handler'; +import { corsHeaders } from './_handlers/v1/helpers'; + +// Preflight options are always accepted with CORS headers +export function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }); +} + +type Handler = (request: NextRequest) => Response | Promise; + +const handlers: Record> = { + v1: { POST: v1 }, +}; + +function createVersionedHandler(method: HTTP_METHOD) { + return async ( + request: NextRequest, + { params }: { params: Promise<{ version: string }> }, + ) => { + const { version } = await params; + + const versionHandlers = handlers[version]; + if (!versionHandlers) { + return Response.json( + { error: `Unsupported API version: ${version}` }, + { status: 404 }, + ); + } + + const handler = versionHandlers[method]; + if (!handler) { + return Response.json( + { error: `${method} not supported in ${version}` }, + { status: 405 }, + ); + } + + return handler(request); + }; +} + +export const POST = createVersionedHandler('POST'); diff --git a/fresco.config.ts b/fresco.config.ts index 0cc6747ba..6deac3a3f 100644 --- a/fresco.config.ts +++ b/fresco.config.ts @@ -1,6 +1,5 @@ export const PROTOCOL_EXTENSION = '.netcanvas'; export const APP_SUPPORTED_SCHEMA_VERSIONS = [7, 8]; -export const MIN_ARCHITECT_VERSION_FOR_PREVIEW = '7.0.1'; // If unconfigured, the app will shut down after 2 hours (7200000 ms) export const UNCONFIGURED_TIMEOUT = 7200000; diff --git a/utils/semVer.ts b/utils/semVer.ts index b5d185632..5f9efb45d 100644 --- a/utils/semVer.ts +++ b/utils/semVer.ts @@ -60,14 +60,3 @@ export function getSemverUpdateType( // If we reach this point, we know the current version is higher than the new version return null; } - -/** - * Compare two semantic versions. - * Returns -1 if a < b, 0 if a == b, 1 if a > b - */ -export function compareSemver(a: SemVer, b: SemVer): -1 | 0 | 1 { - if (a.major !== b.major) return a.major < b.major ? -1 : 1; - if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1; - if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1; - return 0; -} From 6c216313e9308c7f09724523184bea0079888365 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Mon, 16 Feb 2026 14:46:13 -0800 Subject: [PATCH 28/66] feat: add greaterThanOrEqualToVariable and lessThanOrEqualToVariable validations --- lib/form/utils/fieldValidation.ts | 46 ++++ .../utils/__tests__/field-validations.test.ts | 232 ++++++++++++++++++ lib/interviewer/utils/field-validation.ts | 42 ++++ 3 files changed, 320 insertions(+) diff --git a/lib/form/utils/fieldValidation.ts b/lib/form/utils/fieldValidation.ts index 84ab41938..18e6688fe 100644 --- a/lib/form/utils/fieldValidation.ts +++ b/lib/form/utils/fieldValidation.ts @@ -376,6 +376,52 @@ export const tanStackValidations = { ? `Your answer must be less than the value of "${variableName}"` : undefined; }, + + greaterThanOrEqualToVariable: + (variableId: string): TanStackValidator => + ({ value, fieldApi, validationContext }) => { + const variable = getVariable(variableId, validationContext); + + if (!variable) { + return 'Variable not found in codebook'; + } + + const { name: variableName, type: variableType } = variable; + + if (!variableName || !variableType) { + return 'Variable not found in codebook'; + } + + const allValues = fieldApi.form.store.state.values; + + return isNil(value) || + compareVariables(value, allValues[variableId], variableType) < 0 + ? `Your answer must be greater than or equal to the value of "${variableName}"` + : undefined; + }, + + lessThanOrEqualToVariable: + (variableId: string): TanStackValidator => + ({ value, fieldApi, validationContext }) => { + const variable = getVariable(variableId, validationContext); + + if (!variable) { + return 'Variable not found in codebook'; + } + + const { name: variableName, type: variableType } = variable; + + if (!variableName || !variableType) { + return 'Variable not found in codebook'; + } + + const allValues = fieldApi.form.store.state.values; + + return isNil(value) || + compareVariables(value, allValues[variableId], variableType) > 0 + ? `Your answer must be less than or equal to the value of "${variableName}"` + : undefined; + }, }; // TanStack-native validator function diff --git a/lib/interviewer/utils/__tests__/field-validations.test.ts b/lib/interviewer/utils/__tests__/field-validations.test.ts index f762c3186..539b9cd11 100644 --- a/lib/interviewer/utils/__tests__/field-validations.test.ts +++ b/lib/interviewer/utils/__tests__/field-validations.test.ts @@ -777,4 +777,236 @@ describe('TanStack Validations', () => { ).toBe(errorMessage('String Variable')); }); }); + + describe('greaterThanOrEqualToVariable()', () => { + const errorMessage = (value: string) => + `Your answer must be greater than or equal to the value of "${value}"`; + + it('fails for null or undefined', () => { + const subject1 = tanStackValidations.greaterThanOrEqualToVariable('uid1'); + expect( + subject1( + createMockValidatorParams(null, 'testField', mockOtherFormValues), + ), + ).toBe(errorMessage('Variable 1')); + expect( + subject1( + createMockValidatorParams( + undefined, + 'testField', + mockOtherFormValues, + ), + ), + ).toBe(errorMessage('Variable 1')); + }); + + it('passes if number is greater than', () => { + const subject1 = tanStackValidations.greaterThanOrEqualToVariable('uid1'); + expect( + subject1( + createMockValidatorParams(3, 'testField', mockOtherFormValues), + ), + ).toBe(undefined); + }); + + it('passes if number is equal to', () => { + const subject1 = tanStackValidations.greaterThanOrEqualToVariable('uid1'); + expect( + subject1( + createMockValidatorParams(1, 'testField', mockOtherFormValues), + ), + ).toBe(undefined); + }); + + it('fails if number is less than', () => { + const subject1 = tanStackValidations.greaterThanOrEqualToVariable('uid1'); + expect( + subject1( + createMockValidatorParams(0, 'testField', mockOtherFormValues), + ), + ).toBe(errorMessage('Variable 1')); + }); + + it('passes if date is greater than', () => { + const subject2 = tanStackValidations.greaterThanOrEqualToVariable('uid2'); + expect( + subject2( + createMockValidatorParams( + '2012-11-07', + 'testField', + mockOtherFormValues, + ), + ), + ).toBe(undefined); + }); + + it('passes if date is equal to', () => { + const subject2 = tanStackValidations.greaterThanOrEqualToVariable('uid2'); + expect( + subject2( + createMockValidatorParams( + '2012-10-07', + 'testField', + mockOtherFormValues, + ), + ), + ).toBe(undefined); + }); + + it('fails if date is less than', () => { + const subject2 = tanStackValidations.greaterThanOrEqualToVariable('uid2'); + expect( + subject2( + createMockValidatorParams( + '2012-09-07', + 'testField', + mockOtherFormValues, + ), + ), + ).toBe(errorMessage('Date Variable')); + }); + + it('passes if string is greater than', () => { + const subject3 = tanStackValidations.greaterThanOrEqualToVariable('uid3'); + expect( + subject3( + createMockValidatorParams('zebra', 'testField', mockOtherFormValues), + ), + ).toBe(undefined); + }); + + it('passes if string is equal to', () => { + const subject3 = tanStackValidations.greaterThanOrEqualToVariable('uid3'); + expect( + subject3( + createMockValidatorParams('word', 'testField', mockOtherFormValues), + ), + ).toBe(undefined); + }); + + it('fails if string is less than', () => { + const subject3 = tanStackValidations.greaterThanOrEqualToVariable('uid3'); + expect( + subject3( + createMockValidatorParams('diff', 'testField', mockOtherFormValues), + ), + ).toBe(errorMessage('String Variable')); + }); + }); + + describe('lessThanOrEqualToVariable()', () => { + const errorMessage = (value: string) => + `Your answer must be less than or equal to the value of "${value}"`; + + it('fails for null or undefined', () => { + const subject1 = tanStackValidations.lessThanOrEqualToVariable('uid1'); + expect( + subject1( + createMockValidatorParams(null, 'testField', mockOtherFormValues), + ), + ).toBe(errorMessage('Variable 1')); + expect( + subject1( + createMockValidatorParams( + undefined, + 'testField', + mockOtherFormValues, + ), + ), + ).toBe(errorMessage('Variable 1')); + }); + + it('passes if number is less than', () => { + const subject1 = tanStackValidations.lessThanOrEqualToVariable('uid1'); + expect( + subject1( + createMockValidatorParams(0, 'testField', mockOtherFormValues), + ), + ).toBe(undefined); + }); + + it('passes if number is equal to', () => { + const subject1 = tanStackValidations.lessThanOrEqualToVariable('uid1'); + expect( + subject1( + createMockValidatorParams(1, 'testField', mockOtherFormValues), + ), + ).toBe(undefined); + }); + + it('fails if number is greater than', () => { + const subject1 = tanStackValidations.lessThanOrEqualToVariable('uid1'); + expect( + subject1( + createMockValidatorParams(2, 'testField', mockOtherFormValues), + ), + ).toBe(errorMessage('Variable 1')); + }); + + it('passes if date is less than', () => { + const subject2 = tanStackValidations.lessThanOrEqualToVariable('uid2'); + expect( + subject2( + createMockValidatorParams( + '2012-09-07', + 'testField', + mockOtherFormValues, + ), + ), + ).toBe(undefined); + }); + + it('passes if date is equal to', () => { + const subject2 = tanStackValidations.lessThanOrEqualToVariable('uid2'); + expect( + subject2( + createMockValidatorParams( + '2012-10-07', + 'testField', + mockOtherFormValues, + ), + ), + ).toBe(undefined); + }); + + it('fails if date is greater than', () => { + const subject2 = tanStackValidations.lessThanOrEqualToVariable('uid2'); + expect( + subject2( + createMockValidatorParams( + '2012-11-07', + 'testField', + mockOtherFormValues, + ), + ), + ).toBe(errorMessage('Date Variable')); + }); + + it('passes if string is less than', () => { + const subject3 = tanStackValidations.lessThanOrEqualToVariable('uid3'); + expect( + subject3( + createMockValidatorParams('less', 'testField', mockOtherFormValues), + ), + ).toBe(undefined); + }); + + it('passes if string is equal to', () => { + const subject3 = tanStackValidations.lessThanOrEqualToVariable('uid3'); + expect( + subject3( + createMockValidatorParams('word', 'testField', mockOtherFormValues), + ), + ).toBe(undefined); + }); + + it('fails if string is greater than', () => { + const subject3 = tanStackValidations.lessThanOrEqualToVariable('uid3'); + expect( + subject3( + createMockValidatorParams('zebra', 'testField', mockOtherFormValues), + ), + ).toBe(errorMessage('String Variable')); + }); + }); }); diff --git a/lib/interviewer/utils/field-validation.ts b/lib/interviewer/utils/field-validation.ts index 7280e62a3..614afd510 100644 --- a/lib/interviewer/utils/field-validation.ts +++ b/lib/interviewer/utils/field-validation.ts @@ -355,6 +355,46 @@ const lessThanVariable = (variableId: string, store: AppStore) => { : undefined; }; +const greaterThanOrEqualToVariable = (variableId: string, store: AppStore) => { + const variable = getVariable(variableId, store); + + if (!variable) { + return () => 'Variable not found in codebook'; + } + + const { name: variableName, type: variableType } = variable; + + if (!variableName || !variableType) { + return () => 'Variable not found in codebook'; + } + + return (value: FieldValue, allValues: Record) => + isNil(value) || + compareVariables(value, allValues[variableId], variableType) < 0 + ? `Your answer must be greater than or equal to the value of "${variableName}"` + : undefined; +}; + +const lessThanOrEqualToVariable = (variableId: string, store: AppStore) => { + const variable = getVariable(variableId, store); + + if (!variable) { + return () => 'Variable not found in codebook'; + } + + const { name: variableName, type: variableType } = variable; + + if (!variableName || !variableType) { + return () => 'Variable not found in codebook'; + } + + return (value: FieldValue, allValues: Record) => + isNil(value) || + compareVariables(value, allValues[variableId], variableType) > 0 + ? `Your answer must be less than or equal to the value of "${variableName}"` + : undefined; +}; + // Type representing a variable with a validation object type VariableWithValidation = Extract; export type VariableValidation = NonNullable< @@ -375,6 +415,8 @@ const validations = { sameAs, greaterThanVariable, lessThanVariable, + greaterThanOrEqualToVariable, + lessThanOrEqualToVariable, }; /** From 855e859b4a2d7b875c3c514ccf0c1b63162e3b1d Mon Sep 17 00:00:00 2001 From: buckhalt Date: Tue, 17 Feb 2026 10:49:48 -0800 Subject: [PATCH 29/66] update protocol-validation --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3ba1f0287..fa0eba2ea 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@codaco/analytics": "8.0.0", - "@codaco/protocol-validation": "10.0.0", + "@codaco/protocol-validation": "10.1.0", "@codaco/shared-consts": "4.0.0", "@hookform/resolvers": "^5.2.2", "@lucia-auth/adapter-prisma": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc291dfe8..fcf8100f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 8.0.0 version: 8.0.0(next@14.2.35(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)) '@codaco/protocol-validation': - specifier: 10.0.0 - version: 10.0.0 + specifier: 10.1.0 + version: 10.1.0 '@codaco/shared-consts': specifier: 4.0.0 version: 4.0.0 @@ -582,8 +582,8 @@ packages: peerDependencies: next: 15.4.7 - '@codaco/protocol-validation@10.0.0': - resolution: {integrity: sha512-mLkBgaMDvt28yWkL9wU5NnYpo/7vp/wZRw7tggVgTria6DZfJBPm2Gr+uKF7dfbZ6rIaJcM0dbiG4/SOPsp05w==} + '@codaco/protocol-validation@10.1.0': + resolution: {integrity: sha512-toUjrjYtyTJbsbS10PdNlmNkpRjeAdy3VIHpRsCtx9RWjGdNd1afRNimJd1ZgkePiFAfrPiy5ZSvTxogaTlgpA==} hasBin: true '@codaco/shared-consts@4.0.0': @@ -6654,7 +6654,7 @@ snapshots: next: 14.2.35(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3) zod: 4.3.6 - '@codaco/protocol-validation@10.0.0': + '@codaco/protocol-validation@10.1.0': dependencies: '@codaco/shared-consts': 5.0.0 '@faker-js/faker': 10.3.0 From be380510b9cc1450a0cc9f2adfdd3c6f597ff575 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Tue, 17 Feb 2026 14:54:43 -0800 Subject: [PATCH 30/66] Fix numeric string comparison in field validation --- lib/interviewer/utils/field-validation.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/interviewer/utils/field-validation.ts b/lib/interviewer/utils/field-validation.ts index 614afd510..aa36df874 100644 --- a/lib/interviewer/utils/field-validation.ts +++ b/lib/interviewer/utils/field-validation.ts @@ -302,6 +302,15 @@ const compareVariables = ( } // check for numbers (could be number, ordinal, scalar, etc) + // Form inputs often return strings, so coerce to numbers when type indicates numeric + if (type === 'number' || type === 'scalar' || type === 'ordinal') { + const num1 = Number(value1); + const num2 = Number(value2); + if (!isNaN(num1) && !isNaN(num2)) { + return num1 - num2; + } + } + if (isNumber(value1) && isNumber(value2)) { return value1 - value2; } From d636990d42ae02a6b8ef798b164b106440126cc3 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Thu, 19 Feb 2026 11:40:02 -0800 Subject: [PATCH 31/66] add apiToken to reset app settings action --- actions/reset.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/reset.ts b/actions/reset.ts index 42ec4536d..370670603 100644 --- a/actions/reset.ts +++ b/actions/reset.ts @@ -21,6 +21,7 @@ export const resetAppSettings = async () => { prisma.appSettings.deleteMany(), prisma.events.deleteMany(), prisma.asset.deleteMany(), + prisma.apiToken.deleteMany(), ]); // add a new initializedAt date From 280b55357fd7325a8e4a2c171882939ac4b2a825 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Fri, 20 Feb 2026 10:51:05 -0800 Subject: [PATCH 32/66] Extract resetDatabase utility and add schema-driven test extract resetDatabase util for testability, unit test util by parsing schema for models, test fails if new models are added but not deleted in reset --- actions/reset.ts | 21 +----- lib/db/__tests__/resetDatabase.test.ts | 98 ++++++++++++++++++++++++++ lib/db/resetDatabase.ts | 31 ++++++++ 3 files changed, 131 insertions(+), 19 deletions(-) create mode 100644 lib/db/__tests__/resetDatabase.test.ts create mode 100644 lib/db/resetDatabase.ts diff --git a/actions/reset.ts b/actions/reset.ts index 370670603..97038ffd1 100644 --- a/actions/reset.ts +++ b/actions/reset.ts @@ -3,9 +3,9 @@ import { revalidatePath } from 'next/cache'; import { env } from 'process'; import { CacheTags, safeRevalidateTag } from '~/lib/cache'; +import { resetDatabase } from '~/lib/db/resetDatabase'; import { getUTApi } from '~/lib/uploadthing/server-helpers'; import { requireApiAuth } from '~/utils/auth'; -import { prisma } from '~/lib/db'; export const resetAppSettings = async () => { if (env.NODE_ENV !== 'development') { @@ -13,24 +13,7 @@ export const resetAppSettings = async () => { } try { - // Delete all data: - await Promise.all([ - prisma.user.deleteMany(), // Deleting a user will cascade to Session and Key - prisma.participant.deleteMany(), - prisma.protocol.deleteMany(), // Deleting protocol will cascade to Interviews - prisma.appSettings.deleteMany(), - prisma.events.deleteMany(), - prisma.asset.deleteMany(), - prisma.apiToken.deleteMany(), - ]); - - // add a new initializedAt date - await prisma.appSettings.create({ - data: { - key: 'initializedAt', - value: new Date().toISOString(), - }, - }); + await resetDatabase(); revalidatePath('/'); safeRevalidateTag(CacheTags); diff --git a/lib/db/__tests__/resetDatabase.test.ts b/lib/db/__tests__/resetDatabase.test.ts new file mode 100644 index 000000000..747e368cf --- /dev/null +++ b/lib/db/__tests__/resetDatabase.test.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const SCHEMA_PATH = path.resolve(__dirname, '../schema.prisma'); + +// Parse schema.prisma to get all model names and cascade-deleted models +function parseSchema(): { allModels: string[]; cascadeDeletedModels: string[] } { + const schema = fs.readFileSync(SCHEMA_PATH, 'utf-8'); + + // Extract all model names + const modelRegex = /^model\s+(\w+)\s*\{/gm; + const allModels: string[] = []; + let match; + while ((match = modelRegex.exec(schema)) !== null) { + if (match[1]) allModels.push(match[1]); + } + + // Find models that have onDelete: Cascade in their relations + // These models are automatically deleted when their parent is deleted + const cascadeDeletedModels: string[] = []; + const modelBlockRegex = /^model\s+(\w+)\s*\{([^}]+)\}/gm; + + while ((match = modelBlockRegex.exec(schema)) !== null) { + const modelName = match[1]; + const modelBody = match[2]; + if (modelName && modelBody && /onDelete:\s*Cascade/i.test(modelBody)) { + cascadeDeletedModels.push(modelName); + } + } + + return { allModels, cascadeDeletedModels }; +} + +const mockPrisma = { + user: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + participant: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + protocol: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + appSettings: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + create: vi.fn().mockResolvedValue({ key: 'initializedAt', value: '' }), + }, + events: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + asset: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + apiToken: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, +}; + +vi.mock('~/lib/db', () => ({ + prisma: mockPrisma, +})); + +describe('resetDatabase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should delete all models defined in the schema (except cascade-deleted ones)', async () => { + const { resetDatabase } = await import('~/lib/db/resetDatabase'); + const { allModels, cascadeDeletedModels } = parseSchema(); + + // Models that need explicit deletion (not cascade-deleted) + const modelsRequiringDeletion = allModels.filter( + (model) => !cascadeDeletedModels.includes(model), + ); + + await resetDatabase(); + + // Verify each model that requires deletion has deleteMany called + for (const model of modelsRequiringDeletion) { + const prismaKey = model.charAt(0).toLowerCase() + model.slice(1); + const mockModel = mockPrisma[prismaKey as keyof typeof mockPrisma]; + + expect( + mockModel, + `Missing mock for model "${model}" - add it to mockPrisma and resetDatabase()`, + ).toBeDefined(); + + expect( + 'deleteMany' in mockModel ? mockModel.deleteMany : undefined, + `Model "${model}" should have deleteMany called in resetDatabase()`, + ).toHaveBeenCalled(); + } + }); + + it('should create only initializedAt setting after clearing data', async () => { + const { resetDatabase } = await import('~/lib/db/resetDatabase'); + + await resetDatabase(); + + expect(mockPrisma.appSettings.create).toHaveBeenCalledOnce(); + expect(mockPrisma.appSettings.create).toHaveBeenCalledWith({ + data: { + key: 'initializedAt', + value: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }, + }); + }); +}); diff --git a/lib/db/resetDatabase.ts b/lib/db/resetDatabase.ts new file mode 100644 index 000000000..6d011619a --- /dev/null +++ b/lib/db/resetDatabase.ts @@ -0,0 +1,31 @@ +import { prisma } from '~/lib/db'; + +/** + * Deletes all data from the database and creates a fresh initializedAt setting. + * This is the core database reset logic, separated from orchestration concerns + * (auth, cache invalidation, file cleanup) for testability. + */ +export async function resetDatabase(): Promise { + // Delete all data from all tables + // Note: Some models cascade-delete their children: + // - User cascades to Session, Key + // - Protocol cascades to Interview + // - Participant cascades to Interview + await Promise.all([ + prisma.user.deleteMany(), + prisma.participant.deleteMany(), + prisma.protocol.deleteMany(), + prisma.appSettings.deleteMany(), + prisma.events.deleteMany(), + prisma.asset.deleteMany(), + prisma.apiToken.deleteMany(), + ]); + + // Create fresh initializedAt setting + await prisma.appSettings.create({ + data: { + key: 'initializedAt', + value: new Date().toISOString(), + }, + }); +} From 95aa71c73b9bdc41e252f1635525b5fdba84123f Mon Sep 17 00:00:00 2001 From: buckhalt Date: Fri, 20 Feb 2026 13:21:15 -0800 Subject: [PATCH 33/66] fix: family tree hydration to read labels and isEgo from node attributes need to read from node attributes onces nodes are committed --- .../FamilyTreeCensus/components/FamilyTreeShells.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/interviewer/containers/Interfaces/FamilyTreeCensus/components/FamilyTreeShells.tsx b/lib/interviewer/containers/Interfaces/FamilyTreeCensus/components/FamilyTreeShells.tsx index 59c5e47fa..21d140661 100644 --- a/lib/interviewer/containers/Interfaces/FamilyTreeCensus/components/FamilyTreeShells.tsx +++ b/lib/interviewer/containers/Interfaces/FamilyTreeCensus/components/FamilyTreeShells.tsx @@ -30,6 +30,7 @@ import { getEgoSexVariable, getNodeIsEgoVariable, getNodeSexVariable, + getRelationshipToEgoVariable, } from '~/lib/interviewer/containers/Interfaces/FamilyTreeCensus/utils/nodeUtils'; import { getNetworkEgo } from '~/lib/interviewer/selectors/session'; @@ -82,6 +83,7 @@ export const FamilyTreeShells = (props: { const relationshipVariable = useSelector(getRelationshipTypeVariable); const nodeSexVariable = useSelector(getNodeSexVariable); const nodeIsEgoVariable = useSelector(getNodeIsEgoVariable); + const relationshipToEgoVariable = useSelector(getRelationshipToEgoVariable); const ego = useSelector(getNetworkEgo); const egoSexVariable = useSelector(getEgoSexVariable); const [hydratedOnce, setHydratedOnce] = useState(false); @@ -156,14 +158,19 @@ export const FamilyTreeShells = (props: { for (const netNode of networkNodes) { const id = netNode._uid; const metadataNode = metadataByNetworkId.get(id); - const label = metadataNode?.label ?? ''; + const label = + metadataNode?.label ?? + (netNode.attributes?.[relationshipToEgoVariable] as string) ?? + ''; // Use sex from node attributes (primary source), fall back to metadata, then 'female' const attrSex = netNode.attributes?.[nodeSexVariable] as | 'male' | 'female' | undefined; const sex = attrSex ?? metadataNode?.sex ?? 'female'; - const isEgo = metadataNode?.isEgo; + const isEgo = + metadataNode?.isEgo ?? + (netNode.attributes?.[nodeIsEgoVariable] === true); const readOnly = metadataNode?.readOnly ?? false; const attributes = netNode.attributes ?? {}; const diseaseVars = From 0ceabf7df4317b4874e8308bcda5fef3cc23c65a Mon Sep 17 00:00:00 2001 From: buckhalt Date: Fri, 20 Feb 2026 13:24:17 -0800 Subject: [PATCH 34/66] fix: finish interview flow for preview mode finishInterview previously was failing silently when in preview, since preview interviews are not stored in db. this fix creates a better ux for preview mode by showing dedicated finish screen and closing interview. also implements getting interview ID from Redux instead of parsing url --- .../containers/Interfaces/FinishSession.js | 36 +++++++++++++++---- lib/interviewer/selectors/session.ts | 2 ++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/lib/interviewer/containers/Interfaces/FinishSession.js b/lib/interviewer/containers/Interfaces/FinishSession.js index e5870c632..46f1f444a 100644 --- a/lib/interviewer/containers/Interfaces/FinishSession.js +++ b/lib/interviewer/containers/Interfaces/FinishSession.js @@ -1,19 +1,20 @@ -import { usePathname, useRouter } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { finishInterview } from '~/actions/interviews'; import Loading from '~/lib/interviewer/components/Loading'; import Button from '~/lib/ui/components/Button'; -import { openDialog as openDialogActopm } from '../../ducks/modules/dialogs'; +import { openDialog as openDialogAction } from '../../ducks/modules/dialogs'; +import { getInterviewId } from '../../selectors/session'; const FinishSession = () => { const dispatch = useDispatch(); - const pathname = usePathname(); const router = useRouter(); const [loading, setLoading] = useState(false); - const interviewId = pathname.split('/').pop(); // TODO: this should come from redux + const interviewId = useSelector(getInterviewId); + const isPreview = interviewId?.startsWith('preview-'); const handleConfirmFinishInterview = async () => { if (!interviewId) { @@ -28,7 +29,7 @@ const FinishSession = () => { }; const openDialog = useCallback( - (dialog) => dispatch(openDialogActopm(dialog)), + (dialog) => dispatch(openDialogAction(dialog)), [dispatch], ); @@ -56,6 +57,29 @@ const FinishSession = () => { return ; } + if (isPreview) { + return ( +
    +
    +

    + End of Preview +

    +
    +

    + You have reached the end of the interview preview. In a live + interview, participant data would be saved here and they would see + a thank-you screen. +

    +
    + +
    + +
    +
    +
    + ); + } + return (
    diff --git a/lib/interviewer/selectors/session.ts b/lib/interviewer/selectors/session.ts index ed17852be..d23c4a6dd 100644 --- a/lib/interviewer/selectors/session.ts +++ b/lib/interviewer/selectors/session.ts @@ -21,6 +21,8 @@ const getActiveSession = (state: RootState) => { export const getStageIndex = (state: RootState) => state.session.currentStep; +export const getInterviewId = (state: RootState) => state.session.id; + export const getStageMetadata = createSelector( getActiveSession, getStageIndex, From bf01ac9e6f116ba6eaca71cce7fdab72f7ff3c65 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Mon, 23 Feb 2026 11:51:15 -0800 Subject: [PATCH 35/66] fix: restore default attribute merging in updateEgo ego variables were missing from data instead of null when question not answered. due to regression that removed merging with getDefaultAttributesForEntityType. this pattern is used in addNode and addEdge. tests added for all three --- .../ducks/modules/__tests__/session.test.ts | 194 +++++++++++++++++- lib/interviewer/ducks/modules/session.ts | 6 +- 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/lib/interviewer/ducks/modules/__tests__/session.test.ts b/lib/interviewer/ducks/modules/__tests__/session.test.ts index ecbec1e11..dd74b14f2 100644 --- a/lib/interviewer/ducks/modules/__tests__/session.test.ts +++ b/lib/interviewer/ducks/modules/__tests__/session.test.ts @@ -1,8 +1,10 @@ import { configureStore } from '@reduxjs/toolkit'; import { describe, expect, it } from 'vitest'; import { + addEdge, addNode, createInitialNetwork, + updateEgo, } from '~/lib/interviewer/ducks/modules/session'; /** @@ -238,15 +240,17 @@ describe('addNode', () => { }); describe('default attributes', () => { - it('merges default attributes from codebook', async () => { - // Setup + it('includes all codebook variables even when only some are provided', async () => { + // Setup: codebook has 3 node variables const store = createTestStore({ codebookVariables: { 'var-uuid-1': { name: 'firstName' }, + 'var-uuid-2': { name: 'lastName' }, + 'var-uuid-3': { name: 'age' }, }, }); - // Execute + // Execute: only provide value for one variable const result = await store.dispatch( addNode({ type: 'test-node-type-uuid', @@ -256,8 +260,190 @@ describe('addNode', () => { }), ); - // Verify + // Verify: all variables should be in the payload, missing ones as null expect(result.type).toBe('NETWORK/ADD_NODE/fulfilled'); + const payload = result.payload as { attributeData: Record }; + expect(payload.attributeData).toEqual({ + 'var-uuid-1': 'John', + 'var-uuid-2': null, + 'var-uuid-3': null, + }); + }); + }); +}); + +/** + * Creates a test store with ego variables configured in the codebook. + */ +function createTestStoreWithEgo(options: { + egoVariables?: Record; +}) { + const { egoVariables = {} } = options; + + return configureStore({ + reducer: { + session: (state = createTestSessionState()) => state, + protocol: (state = createTestProtocolState()) => state, + ui: (state = { passphrase: null }) => state, + }, + preloadedState: { + session: createTestSessionState(), + protocol: createTestProtocolState(egoVariables), + ui: { passphrase: null }, + }, + }); + + function createTestSessionState() { + return { + id: 'test-session', + startTime: new Date().toISOString(), + finishTime: null, + exportTime: null, + lastUpdated: new Date().toISOString(), + network: createInitialNetwork(), + currentStep: 0, + promptIndex: 0, + }; + } + + function createTestProtocolState( + egoVars: Record = {}, + ) { + return { + codebook: { + ego: { + variables: egoVars, + }, + node: {}, + }, + stages: [{ id: 'stage-1' }], + }; + } +} + +/** + * Creates a test store with edge types configured in the codebook. + */ +function createTestStoreWithEdge(options: { + edgeVariables?: Record; +}) { + const edgeTypeId = 'test-edge-type-uuid'; + const { edgeVariables = {} } = options; + + const network = createInitialNetwork(); + // Add two nodes so we can create edges between them + network.nodes = [ + { _uid: 'node-1', type: 'person', attributes: {} }, + { _uid: 'node-2', type: 'person', attributes: {} }, + ]; + + return configureStore({ + reducer: { + session: (state = createTestSessionState()) => state, + protocol: (state = createTestProtocolState()) => state, + ui: (state = { passphrase: null }) => state, + }, + preloadedState: { + session: createTestSessionState(), + protocol: createTestProtocolState(edgeTypeId, edgeVariables), + ui: { passphrase: null }, + }, + }); + + function createTestSessionState() { + return { + id: 'test-session', + startTime: new Date().toISOString(), + finishTime: null, + exportTime: null, + lastUpdated: new Date().toISOString(), + network, + currentStep: 0, + promptIndex: 0, + }; + } + + function createTestProtocolState( + typeId: string, + variables: Record = {}, + ) { + return { + codebook: { + edge: { + [typeId]: { + name: 'friendship', + variables, + }, + }, + node: {}, + }, + stages: [{ id: 'stage-1' }], + }; + } +} + +describe('addEdge', () => { + describe('default attributes', () => { + it('includes all codebook variables even when only some are provided', async () => { + // Setup: codebook has 3 edge variables + const store = createTestStoreWithEdge({ + edgeVariables: { + 'edge-var-1': { name: 'strength' }, + 'edge-var-2': { name: 'duration' }, + 'edge-var-3': { name: 'frequency' }, + }, + }); + + // Execute: only provide value for one variable + const result = await store.dispatch( + addEdge({ + type: 'test-edge-type-uuid', + from: 'node-1', + to: 'node-2', + attributeData: { + 'edge-var-1': 5, + }, + }), + ); + + // Verify: all variables should be in the payload, missing ones as null + expect(result.type).toBe('NETWORK/ADD_EDGE/fulfilled'); + const payload = result.payload as { attributeData: Record }; + expect(payload.attributeData).toEqual({ + 'edge-var-1': 5, + 'edge-var-2': null, + 'edge-var-3': null, + }); + }); + }); +}); + +describe('updateEgo', () => { + describe('default attributes', () => { + it('includes all codebook variables even when only some are provided', async () => { + // Setup: codebook has 3 ego variables + const store = createTestStoreWithEgo({ + egoVariables: { + 'ego-var-1': { name: 'age' }, + 'ego-var-2': { name: 'gender' }, + 'ego-var-3': { name: 'occupation' }, + }, + }); + + // Execute: only provide value for one variable + const result = await store.dispatch( + updateEgo({ + 'ego-var-1': 25, + }), + ); + + // Verify: all variables should be in the payload, missing ones as null + expect(result.type).toBe('NETWORK/UPDATE_EGO/fulfilled'); + expect(result.payload).toEqual({ + 'ego-var-1': 25, + 'ego-var-2': null, + 'ego-var-3': null, + }); }); }); }); diff --git a/lib/interviewer/ducks/modules/session.ts b/lib/interviewer/ducks/modules/session.ts index c05baf230..363f631c3 100644 --- a/lib/interviewer/ducks/modules/session.ts +++ b/lib/interviewer/ducks/modules/session.ts @@ -384,7 +384,11 @@ export const updateEgo = createAsyncThunk( `Invalid ego attributes: ${invalidKeys.join(', ')} do not exist in protocol codebook`, ); - return egoAttributes; + // Merge with default attributes to ensure all codebook variables exist + return { + ...getDefaultAttributesForEntityType(egoVariables), + ...egoAttributes, + }; }, ); From fa1c7bb6fa63d152603fa944a33a0f8e8daa4477 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Mon, 23 Feb 2026 12:06:08 -0800 Subject: [PATCH 36/66] lint & type check fixes --- .../ducks/modules/__tests__/session.test.ts | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/lib/interviewer/ducks/modules/__tests__/session.test.ts b/lib/interviewer/ducks/modules/__tests__/session.test.ts index dd74b14f2..d367d8a11 100644 --- a/lib/interviewer/ducks/modules/__tests__/session.test.ts +++ b/lib/interviewer/ducks/modules/__tests__/session.test.ts @@ -280,16 +280,24 @@ function createTestStoreWithEgo(options: { }) { const { egoVariables = {} } = options; + const sessionState = createTestSessionState(); + const protocolState = createTestProtocolState(egoVariables); + const uiState = { passphrase: null }; + + type SessionState = ReturnType; + type ProtocolState = ReturnType; + type UIState = typeof uiState; + return configureStore({ reducer: { - session: (state = createTestSessionState()) => state, - protocol: (state = createTestProtocolState()) => state, - ui: (state = { passphrase: null }) => state, + session: (state: SessionState = sessionState): SessionState => state, + protocol: (state: ProtocolState = protocolState): ProtocolState => state, + ui: (state: UIState = uiState): UIState => state, }, preloadedState: { - session: createTestSessionState(), - protocol: createTestProtocolState(egoVariables), - ui: { passphrase: null }, + session: sessionState, + protocol: protocolState, + ui: uiState, }, }); @@ -337,16 +345,24 @@ function createTestStoreWithEdge(options: { { _uid: 'node-2', type: 'person', attributes: {} }, ]; + const sessionState = createTestSessionState(); + const protocolState = createTestProtocolState(edgeTypeId, edgeVariables); + const uiState = { passphrase: null }; + + type SessionState = ReturnType; + type ProtocolState = ReturnType; + type UIState = typeof uiState; + return configureStore({ reducer: { - session: (state = createTestSessionState()) => state, - protocol: (state = createTestProtocolState()) => state, - ui: (state = { passphrase: null }) => state, + session: (state: SessionState = sessionState): SessionState => state, + protocol: (state: ProtocolState = protocolState): ProtocolState => state, + ui: (state: UIState = uiState): UIState => state, }, preloadedState: { - session: createTestSessionState(), - protocol: createTestProtocolState(edgeTypeId, edgeVariables), - ui: { passphrase: null }, + session: sessionState, + protocol: protocolState, + ui: uiState, }, }); From dbbb5a3e64e34c437f65125587481b605f2c7d56 Mon Sep 17 00:00:00 2001 From: buckhalt Date: Mon, 23 Feb 2026 14:01:32 -0800 Subject: [PATCH 37/66] fix: textarea data not saving --- lib/ui/components/Fields/TextArea.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ui/components/Fields/TextArea.tsx b/lib/ui/components/Fields/TextArea.tsx index 41780cfeb..569c27c4d 100644 --- a/lib/ui/components/Fields/TextArea.tsx +++ b/lib/ui/components/Fields/TextArea.tsx @@ -61,6 +61,7 @@ const TextArea = ({