.github/9
ISSUE_TEMPLATE/3bug_report.md config.yml feature_request.md
workflows/3ci.yml clean-clone.yml release.yml
CODEOWNERS dependabot.yml pull_request_template.md
apps/630
api/250
src/243
common/30
__tests__/3authorize.test.ts bigint-serialization.interceptor.test.ts files-limits.test.ts
filters/1all-exceptions.filter.ts
guards/3auth.guard.ts internal-service.guard.ts rate-limit.guard.ts
interceptors/2actor-context.interceptor.ts bigint-serialization.interceptor.ts
middleware/2idempotency.test.ts idempotency.ts
pipes/1zod-validation.pipe.ts
actor-context.ts audit.service.ts cache.test.ts cache.ts cookies.ts core.module.ts deprecation.ts email.service.ts errors.ts event-bus.ts log-scrubber.test.ts log-scrubber.ts logger.ts permission-cache-bridge.ts prisma.service.ts redis.service.ts trace-metadata.ts types.ts
config/1env.ts
lib/11
__tests__/5crypto.test.ts filter-builder.test.ts jwt.test.ts pagination.test.ts positions.test.ts
authorize.ts crypto.ts filter-builder.ts jwt.ts pagination.ts positions.ts
modules/197
ai/28
__tests__/4ai-writing.test.ts database-ai.test.ts generate-executor.test.ts prompts.test.ts
generator/4executor.ts generate.controller.ts generate.dto.ts generate.service.ts
ai-chat.service.ts ai-rate-limit.ts ai-writing.controller.ts ai-writing.dto.ts ai-writing.service.ts ai.controller.ts ai.module.ts ai.service.ts budget-guard.service.ts database-ai.controller.ts database-ai.dto.ts database-ai.service.ts db-prompts.ts dto.ts gateway-provider.ts ledger.service.ts prompts.ts rag.controller.ts rag.dto.ts rag.service.ts
api-keys/3api-keys.controller.ts api-keys.module.ts api-keys.service.ts
audit/2audit-log.controller.ts audit.module.ts
auth/4auth.controller.ts auth.module.ts auth.service.ts dto.ts
automations/5automations.controller.ts automations.module.ts automations.service.ts dto.ts webhook-incoming.controller.ts
blocks/5
__tests__/1batch-version-cas.test.ts
block.service.ts blocks.controller.ts blocks.module.ts dto.ts
comments/4comments.controller.ts comments.module.ts comments.service.ts dto.ts
databases/9
__tests__/3normalize-select-options.test.ts option-id-canonicalization.test.ts record-open-query-shape.test.ts
database.service.ts databases.controller.ts databases.module.ts dto.ts property-cache-schema.ts query.service.ts
docs/2docs.controller.ts docs.module.ts
files/10
__tests__/1quota.test.ts
dto.ts files.controller.ts files.module.ts files.service.ts quota.ts record.ts s3.ts uploads.controller.ts uploads.service.ts
forms/18debug.controller.ts form.dto.ts forms.controller.ts forms.module.ts forms.service.ts markdown.test.ts markdown.ts optional-auth.guard.ts public-forms.controller.ts public-forms.service.ts question.dto.ts questions.service.ts rate-limit.ts recaptcha.ts slug.ts submissions.controller.ts submissions.service.ts submit-schema.ts
health/3health.controller.ts health.module.ts health.service.ts
integrations/3integrations.controller.ts integrations.module.ts integrations.service.ts
internal/12
__tests__/2flush-query-shape.test.ts internal.endpoints.test.ts
ai-test-seam.controller.ts ai-test-seam.dto.ts ai-test-seam.service.ts internal.controller.ts internal.dto.ts internal.module.ts internal.service.ts test-seam.controller.ts test-seam.dto.ts test-seam.service.ts
notifications/3notifications.controller.ts notifications.module.ts notifications.service.ts
oauth/6
__tests__/1oauth.scopes.test.ts
oauth.controller.ts oauth.module.ts oauth.service.ts scopes.ts token-keys.ts
org/15audit.service.ts embed-preview.controller.ts guards.ts members.service.ts org-admin.controller.ts org-policy.service.ts org.module.ts overview.service.ts policy-effective.controller.ts policy.schema.ts policy.test.ts retention.service.ts totp.test.ts totp.ts two-factor.service.ts
orgs/5dto.ts orgs.controller.ts orgs.module.ts orgs.service.ts util.ts
pages/4dto.ts page.service.ts pages.controller.ts pages.module.ts
scim/15
__tests__/3email-mapping.test.ts etag.test.ts filters.test.ts
etag.ts filters.ts groups.service.ts schemas.ts scim-admin.controller.ts scim-admin.service.ts scim-auth.guard.ts scim-auth.service.ts scim-errors.ts scim.controller.ts scim.module.ts users.service.ts
search/4
__tests__/1search.service.test.ts
search.controller.ts search.module.ts search.service.ts
sessions/3sessions.controller.ts sessions.module.ts sessions.service.ts
share/3share.controller.ts share.module.ts share.service.ts
sso/16
__tests__/4enforcement.test.ts oidc.verify.test.ts provisioning.test.ts saml.validate.test.ts
enforcement.ts oidc.controller.ts oidc.service.ts oidc.ts provisioning.service.ts saml.controller.ts saml.ts schemas.ts sso-admin.controller.ts sso-admin.service.ts sso-secrets.ts sso.module.ts
teams/4dto.ts teams.controller.ts teams.module.ts teams.service.ts
users/4dto.ts users.controller.ts users.module.ts users.service.ts
webhooks/3webhooks.controller.ts webhooks.module.ts webhooks.service.ts
workspaces/4dto.ts workspaces.controller.ts workspaces.module.ts workspaces.service.ts
types/1xmldom.d.ts
app.module.ts constants.ts main.ts
_probe_rawbody.cjs .env.example Dockerfile package.json README.md tsconfig.json vitest.config.ts
realtime/33
src/17auth.ts comments.ts config.ts event-source.ts http-store.ts index.ts metrics.ts notifications.ts page-updated-source.ts presence.ts prisma-store.ts protocol.ts rate-limit.ts room-key.ts rooms.ts server.ts update-log.ts
test/11auth.test.ts config.test.ts event-source.test.ts http-store.test.ts metrics-merge.test.ts page-updated-source.test.ts presence.test.ts protocol.test.ts rate-limit.test.ts rooms.test.ts task-0094-builders.test.ts
.env.example Dockerfile package.json README.md tsconfig.json
web/255
src/244
app/45
__tests__/1theme-tokens.test.ts
(app)/26
w/25
[workspace]/25
ask/1page.tsx
databases/1
[id]/1page.tsx
favorites/1page.tsx
files/1page.tsx
notifications/1page.tsx
page/4
[id]/4
[block]/1page.tsx
share/1page.tsx
layout.tsx page.tsx
search/1page.tsx
settings/10
appearance/1page.tsx
automations/3
[id]/1page.tsx
new/1page.tsx
page.tsx
billing/1page.tsx
developers/1page.tsx
members/1page.tsx
permissions/1page.tsx
teams/1page.tsx
page.tsx
shared/1page.tsx
templates/1page.tsx
trash/1page.tsx
layout.tsx page.tsx
layout.tsx
(auth)/4
login/1page.tsx
register/1page.tsx
reset-password/1page.tsx
layout.tsx
api/2
metrics/1route.ts
v1/1
health/1route.ts
f/1
[slug]/1page.tsx
health/1
ready/1route.ts
org/2
[orgId]/2
admin/2
integrations/1page.tsx
page.tsx
share/1
[id]/1page.tsx
error.tsx global-error.tsx globals.css layout.tsx not-found.tsx page.tsx providers.tsx
components/118
admin/10
org-admin/8ApiKeysTab.tsx AuditTab.tsx MembersTab.tsx OverviewTab.tsx PoliciesTab.tsx RetentionTab.tsx WebhooksTab.tsx WorkspacePicker.tsx
ScimCard.tsx SsoTabs.tsx
ai/6ai-panel.tsx ask-ai-submenu.tsx db-ai-dialog.tsx generate-page-dialog.tsx writing-actions-menu.tsx writing-diff.tsx
automations/5action-editor.tsx condition-editor.tsx lib.ts run-history.tsx trigger-editor.tsx
collaboration/6CursorLayer.tsx OfflineBanner.tsx PresenceBar.tsx README.md ReviewFailedDialog.tsx StatusChip.tsx
comments/3comment-panel.tsx comment-thread.tsx mention-input.tsx
database/27
__tests__/2cell-editor.test.tsx cell-renderer.test.tsx
AiExtractDialog.tsx AiFormulaDialog.tsx AiGenerateDialog.tsx board-view.tsx buttons-panel.tsx calendar-view.tsx cell-editor.tsx cell-renderer.tsx chart-view.tsx database-page-view.tsx database-toolbar.tsx database-view-renderer.tsx db-embedded.tsx filter-builder.tsx form-builder-view.tsx formula-editor.tsx gallery-view.tsx group-builder.tsx list-view.tsx property-editor.tsx record-header.tsx record-panel.tsx sort-builder.tsx table-view.tsx timeline-view.tsx
dialogs/7
__tests__/1move-to-dialog.test.tsx
create-dialog.tsx dialog.tsx file-permissions-dialog.tsx invite-dialog.tsx move-to-dialog.tsx share-dialog.tsx
editor/19
__tests__/3code-block-text.test.ts code-block.test.tsx expandable.test.tsx
block-card.tsx block-renderer.tsx block-save-queue.ts block-toolbar.tsx context-menu.tsx expandable.tsx history-popover.tsx inline-editor.tsx insert-dialog.tsx page-canvas.tsx page-spinner.tsx page-view.tsx selection-toolbar.tsx slash-menu.tsx template-dialog.tsx type-menu.tsx
files/4FileBlock.tsx FileBrowser.tsx FilePicker.tsx PreviewModal.tsx
form/4form-field.tsx form-renderer.tsx form-responses-view.tsx form-view.tsx
layout/8
__tests__/1page-tree-icon.test.tsx
command-palette.tsx factory-logo.tsx page-tree-node.tsx sidebar.tsx theme-provider.tsx topbar.tsx workspace-shell.tsx
mentions/3
__tests__/1mention-model.test.ts
mention-model.ts MentionPopup.tsx
search/3ask-ai-search.tsx results-list.tsx search-dialog.tsx
settings/10
api-keys/4ApiKeyCreateDialog.tsx ApiKeyRevealDialog.tsx ApiKeyTable.tsx ScopesBadge.tsx
webhooks/5CurlExamples.tsx DeliveryInspector.tsx EventExplorer.tsx OverviewTab.tsx WebhooksTab.tsx
settings-shell.tsx
ui/3confirm-dialog.tsx form.tsx toaster.tsx
hooks/21
__tests__/4realtime-frame-handler.test.ts use-blocks.test.ts use-database-options.test.ts use-workspace.test.tsx
realtime-frame-handler.ts use-ai-stream.ts use-auth.ts use-blocks.ts use-database.ts use-db-ai-stream.ts use-forms.ts use-members.ts use-mention-sources.ts use-notifications.ts use-offline-status.ts use-pages.ts use-presence-announcer.ts use-presence-toasts.ts use-presence.ts use-realtime.ts use-workspace.ts
lib/50
__tests__/21ai-database.test.ts ai-generate.test.ts ai-sse.test.ts ai-writing.test.ts api-keys.test.ts api-members.test.ts auth-refresh.test.ts auth-store.test.ts batch-queue-drop.test.ts block-content.test.ts clipboard-import.test.ts code-highlight.test.ts offline-status.test.ts presence-announcer.test.ts presence-color.test.ts presence-cursor.test.ts presence-emitter.test.ts realtime-url.test.ts search.test.ts utils.test.ts writing-diff.test.ts
files/7
__tests__/2picker.test.ts upload.test.ts
api.ts picker.ts upload-store.ts upload.ts use-file-uploads.ts
ai-database.ts ai.ts api-keys.ts api.ts auth-store.ts batch-queue.ts block-content.ts clipboard-import.ts code-highlight.ts constants.ts offline-status.ts org-admin.ts presence-announcer.ts presence-color.ts presence-cursor.ts presence-editor-views.ts presence-emitter.ts query-client.ts realtime.ts search.ts types.ts utils.ts
stores/8
__tests__/1block-patch-store.test.ts
block-patch-store.ts comment-pill-store.ts database-store.ts editor-store.ts offlineQueue.ts presence-store.ts ui-store.ts
instrumentation.ts middleware.ts
.env.example Dockerfile next-env.d.ts next.config.mjs package.json postcss.config.mjs README.md tailwind.config.ts tsconfig.json tsconfig.tsbuildinfo vitest.config.ts
worker/92
src/87
__tests__/13ai-embedding.test.ts ai-request.test.ts ai-usage-budget.test.ts audit-export.test.ts email-decision.test.ts encrypt.test.ts health-search-index-lag.test.ts mime.test.ts retention.test.ts s3-sign.test.ts search-index.test.ts smtp.test.ts webhook-sign.test.ts
health/1server.ts
lib/12
__tests__/1clamav.test.ts
clamav.ts encrypt.ts job-otel.ts mime.ts notify.ts prisma.ts redis-stream.ts redis.ts s3.ts sharp.ts smtp.ts
processors/56
automations/28
__tests__/3condition-evaluator.spec.ts executors.spec.ts run-pipeline.spec.ts
executors/17assign-user.ts call-integration.ts change-status.ts classify.ts create-notification.ts create-page.ts email-cap.ts helpers.ts integration-registry.ts permission.ts record-write.ts registry.ts run-ai-action.ts send-email.ts send-webhook.ts types.ts update-record.ts
condition-evaluator.ts dead-letter.ts log.ts matcher.ts pipeline.ts run-state.ts schedule.ts watchdog.ts
files/15
__tests__/4errors.test.ts scan-gate.test.ts status.test.ts temp.test.ts
steps/4finalize.ts metadata.ts scan.ts thumbnails.ts
cleanup.ts errors.ts generateThumbs.ts processor.ts status.ts temp.ts types.ts
search-index/4builders.ts grants.ts index.ts processor.ts
ai-embedding.ts ai.processor.ts audit-export.ts automations.processor.ts email-decision.processor.ts email.processor.ts maintenance.processor.ts retention.ts webhooks.processor.ts
config.ts events.ts index.ts logger.ts queues.ts
.env.example Dockerfile package.json README.md tsconfig.json
docs/73
api/17ai.md authentication.md automations.md blocks.md comments.md databases.md files.md forms.md openapi.yaml org-admin.md pages.md scim.md search.md sso.md versioning.md webhooks.md workspaces.md
architecture/10ai-architecture.md automation.md backend-architecture.md database-architecture.md frontend-architecture.md realtime-architecture.md search-architecture.md security-architecture.md storage.md system-architecture.md
database/2database-architecture.md redis-keys.md
development/8admin-guide.md coding-standards.md deployment.md development-guide.md files-pipeline.md perf-report.md performance.md testing.md
product/4functional-requirements.md non-functional-requirements.md product-requirements.md ux-requirements.md
requirements/1requirements-matrix.md
security/5audit-report.md files.md forms.md license-allowlist.md security-architecture.md
tasks/16phase-00-foundation.md phase-01-authentication.md phase-02-workspace.md phase-03-pages.md phase-04-editor.md phase-05-databases.md phase-06-relations.md phase-07-collaboration.md phase-08-search.md phase-09-files.md phase-10-forms.md phase-11-automation.md phase-12-ai.md phase-13-api.md phase-14-enterprise.md phase-15-production.md
ui/9ai.md automations.md design-system.md developers.md files.md forms.md org-admin.md scim.md sso.md
support-matrix.md
infrastructure/71
caddy/1Caddyfile
docker/6
init/101-extensions.sql
opensearch/1init-indices.json
.env.docker docker-compose.yml opensearch-init.sh README.md
kubernetes/40
observability/20
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1sync-sources.mjs
alertmanager.yaml grafana.yaml kustomization.yaml namespace.yaml node-exporter.yaml otel-collector.yaml prometheus.yaml
postgres/7configmap.yaml cronjob-backup.yaml cronjob-opensearch-snapshot.yaml pgbouncer.yaml replica.yaml statefulset.yaml walg-iam-policy.yaml
api.yaml configmap.yaml ingress.yaml kustomization.yaml namespace.yaml networkpolicy.yaml pdb.yaml README.md realtime.yaml redis.yaml secret.example.yaml web.yaml worker.yaml
nginx/2nginx.conf README.md
observability/14
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1dashboard-completeness.mjs
README.md
terraform/8compute.tf data.tf main.tf network.tf outputs.tf README.md security.tf variables.tf
packages/289
ai/24
src/20
providers/7anthropic.ts google.ts http.ts index.ts openai-compatible.ts rag.test.ts writing.test.ts
schemas/2database.ts generate.ts
adapters.test.ts config.test.ts config.ts gateway.test.ts gateway.ts index.ts tools.test.ts tools.ts types.ts usage.test.ts usage.ts
package.json README.md tsconfig.json vitest.config.ts
auth/11
src/7index.ts passwords.test.ts passwords.ts sessions.test.ts sessions.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
collaboration/17
src/13awareness.ts codec.test.ts codec.ts index.ts lww.test.ts lww.ts mapping.ts offline.ts outbox.test.ts outbox.ts provider.test.ts provider.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
config/7
src/4eslint.base.js index.ts prettier.base.json tsconfig.base.json
package.json README.md tsconfig.json
database-client/5
src/2index.test.ts index.ts
package.json README.md tsconfig.json
database-engine/33
src/29
filter/2filter.test.ts index.ts
formula/8evaluator.ts formula.test.ts functions.ts index.ts lexer.ts parser.ts types.ts validate.ts
query/6group.ts index.ts paginate.ts pivot.ts query.test.ts sort.ts
relations/2index.ts relations.test.ts
rollups/2index.ts rollups.test.ts
values/5dates.ts index.ts registry.ts types.ts values.test.ts
views/3buttons.ts index.ts views.test.ts
index.ts
package.json README.md tsconfig.json vitest.config.ts
editor/25
src/21
marks/1highlight.ts
nodes/2equation.ts mention.ts
autosave.test.ts autosave.ts clipboard.test.ts clipboard.ts codec.test.ts codec.ts context-menu.test.ts context-menu.ts diff.test.ts diff.ts factory.ts history.test.ts history.ts index.ts positions.ts slash.test.ts slash.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
observability/20
src/16
__tests__/8db-redis-metrics.test.ts metrics.test.ts otel.test.ts pino-otel.test.ts prometheus.test.ts resource.test.ts scrubber.test.ts traceparent.test.ts
index.ts metrics.ts otel.ts pino-otel.ts prometheus.ts resource.ts scrubber.ts traceparent.ts
package.json README.md tsconfig.json vitest.config.ts
permissions/11
src/7index.ts levels.test.ts levels.ts resolve.test.ts resolve.ts rules.test.ts rules.ts
package.json README.md tsconfig.json vitest.config.ts
search/22
src/18chunking.test.ts chunking.ts client.test.ts client.ts embeddings.test.ts embeddings.ts hybrid.test.ts hybrid.ts index.ts indexer.test.ts indexer.ts mapping.test.ts mapping.ts queries.test.ts queries.ts rrf.test.ts rrf.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
shared/61
src/57
dto/17ai.dto.ts auth.dto.test.ts auth.dto.ts block.dto.ts browser-safety.test.ts comment.dto.ts database.dto.test.ts database.dto.ts file.dto.ts formula-ast.ts index.ts notification.dto.ts page.dto.ts search.dto.ts share.dto.ts webhook.dto.ts workspace.dto.ts
events/8audit-catalog.test.ts audit-catalog.ts audit-csv.test.ts audit-csv.ts audit-scope.test.ts audit-scope.ts catalog.test.ts catalog.ts
realtime/6comment-events.ts frames.test.ts frames.ts notification-policy.ts notification-prefs.ts task-0094.test.ts
automation.test.ts automation.ts constants.ts crypto.test.ts crypto.ts env.ts errors.ts events.test.ts events.ts forms.test.ts forms.ts index.ts markdown.test.ts markdown.ts mentions.test.ts mentions.ts net.test.ts net.ts positions.test.ts positions.ts presence.test.ts presence.ts security-headers.ts webhook-delivery.test.ts webhook-delivery.ts webhook-events.ts
package.json README.md tsconfig.json vitest.config.ts
storage/16
src/12client.test.ts client.ts index.ts keys.test.ts keys.ts mime.test.ts mime.ts presign.test.ts presign.ts thumbnails.test.ts thumbnails.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
ui/37
src/33
components/19avatar.tsx badge.tsx button.tsx checkbox.tsx command.tsx context-menu.tsx dialog.tsx dropdown-menu.tsx empty-state.tsx index.ts popover.tsx separator.tsx skeleton.tsx slider.tsx spinner.tsx switch.tsx tabs.tsx toast.tsx tooltip.tsx
color.ts command-filter.test.ts command-filter.ts focus.test.ts focus.ts formatters.test.ts formatters.ts index.ts theme.test.ts theme.ts toast-store.test.ts toast-store.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
prisma/16
migrations/13
20260927000000_init/1migration.sql
20260928000000_search_documents_content_hash/1migration.sql
20260929000000_workspace_usage_storage_bytes/1migration.sql
20260930000000_form_submitted_notification_type/1migration.sql
20261001000000_ai_phase12/1migration.sql
20261002000000_webhooks_secret_encrypted_dlq/1migration.sql
20261002111436_webhook_delivery_latency/1migration.sql
20261002120000_org_admin_2fa_retention_unique/1migration.sql
20261002215124_audit_export/1migration.sql
20261003210000_perf_indexes_hot_paths/1migration.sql
20261004004500_perf_indexes_hot_paths_2/1migration.sql
migration_lock.toml README.md
schema.prisma seed.ts verify.ts
scripts/7backup-health.ts docs-completeness.mjs generate-openapi.mjs minio-init.ts opensearch-reindex.ts storage-orphans.ts verify-clean-clone.sh
tests/169
e2e/63
.auth/1user.json
fixtures/7
files/6clip.mp4 clip.wav clip.webm hero-1400.png note.docx note.txt
realtime.ts
mocks/2oidc-issuer.ts saml-idp.ts
playwright-report/3
data/2a6557bae9c2714a658968d4eb8534195c08ebda8.png e3092d07d00a16063e46385c02a559a9054fa847.webm
index.html
reports/1files-security.json
specs/34ai-database.spec.ts ai-generate.spec.ts ai-rag.spec.ts ai-security.spec.ts ai-writing.spec.ts ai.spec.ts api-keys.spec.ts appearance-theme.spec.ts auth.spec.ts automation.spec.ts collab-two-tab.spec.ts comments.spec.ts databases.spec.ts default-workspace-alias.spec.ts developers.spec.ts editor.spec.ts files.spec.ts final-flow.spec.ts forms.spec.ts oauth.spec.ts org-admin.spec.ts pages.spec.ts performance.spec.ts presence-cursors.spec.ts qa-paste-toggle.spec.ts realtime.spec.ts scim.spec.ts search-index-lag.spec.ts search.spec.ts sharing.spec.ts sso.spec.ts task-0094-comments-notifications.spec.ts webhooks.spec.ts workspace.spec.ts
test-results-final-flow/3
final-flow-Release-v1-0-fi-6c06b-w-state-carries-sequential--chromium-desktop/2test-failed-1.png trace.zip
.last-run.json
utils/4api.ts dnd.ts search-lag.ts wait.ts
.gitignore fixtures.ts global-setup.ts package.json playwright.config.ts playwright.final-flow.config.ts README.md tsconfig.json
fixtures/1
files/1make-sparse.ts
integration/60
ai-capability/2ai-app.capability.test.ts gateway.capability.test.ts
drill/2seed-drill.mjs verify-restore.mjs
fixtures/5
files/5clip.mp4 clip.wav doc.pdf hero-1400.png note.txt
helpers/3api.ts containers.ts seed.ts
realtime/11control-channel.test.ts convergence.test.ts flush-rebase-http.test.ts handshake.test.ts heartbeat-limits.test.ts lifecycle.test.ts presence.test.ts rebase.test.ts resume.test.ts rt-helpers.ts task-0094-fanout.test.ts
ai-chat.integration.test.ts ai-database.integration.test.ts ai-generate.integration.test.ts ai-rag.integration.test.ts ai-writing.integration.test.ts api-keys.integration.test.ts audit-completeness.integration.test.ts audit-export-s3.integration.test.ts auth.integration.test.ts automation-pipeline.integration.test.ts automations.integration.test.ts blocks.integration.test.ts cache-invalidation.integration.test.ts comments.integration.test.ts databases.integration.test.ts files-lifecycle.integration.test.ts files-pipeline.integration.test.ts files-security.integration.test.ts files.integration.test.ts forms-responses.integration.test.ts forms.integration.test.ts oauth.integration.test.ts openapi.contract.test.ts org-admin.integration.test.ts package.json pages.integration.test.ts permissions.integration.test.ts README.md replica-routing.integration.test.ts retention-engine.integration.test.ts scim.integration.test.ts search.integration.test.ts sso.integration.test.ts tsconfig.json vitest.config.ts webhooks.integration.test.ts workspace.integration.test.ts
performance/28
fixtures/1search-100k.ts
helpers/2seed-100k.mjs seed-1k-load.mjs
reports/7api-load-smoke.json database-load-smoke.json editor-local.json realtime-load.json search-full.json search-last.json search-reduced.json
api-load.js api.js archive.tar database-load.js database.js editor-local.mjs extract-100k-env.mjs package.json README.md realtime-1k.js realtime-load.js realtime.js run-realtime-load.sh search-search.k6.js search-summary.json seed-100k.mjs threshold-breach-demo.js verify-zero-op-loss.mjs
security/16
helpers/1app.ts
audit-report.test.ts auth-paths.test.ts deps.test.ts formula-fuzz.test.ts gitleaks-config.test.ts headers.test.ts images.test.ts oracle.ts package.json permission-fuzz.test.ts rate-limits.test.ts README.md tsconfig.json upload-abuse.test.ts vitest.config.ts
README.md
.dockerignore .env .env.bak-task0125 .env.example .eslintrc.json .gitignore .npmrc .prettierignore .prettierrc.json AGENTS.md ARCHITECTURE.md CHANGELOG.md CONTRIBUTING.md gitleaks.toml LICENSE package.json pnpm-lock.yaml pnpm-workspace.yaml PRODUCT-SPEC.md README.md ROADMAP.md SECURITY.md tsconfig.json turbo.json
form-responses-view.tsxapps/web/src/components/form/form-responses-view.tsxraw← form25,176 B← Back to the app
1"use client";
2 
3/**
4 * FormResponsesView — the TASK-0109 "Responses" surface for a published form
5 * (lives inside the database's FORM view, next to the builder).
6 *
7 * API: GET /forms/:formId/submissions[?q&from&to&anonymousOnly&limit&cursor]
8 *      → { items: SubmissionDto[], nextCursor: string | null }
9 *      DELETE /forms/:formId/submissions/:id?confirm=true
10 *      → { deleted, recordDeleted, filesPurged }
11 *
12 * Design notes:
13 *  - WINDOWED LIST (no new dependency — the app has no virtualization lib):
14 *    rows have a deterministic height (desktop 48px, mobile 180px cards), so
15 *    a fixed-height window keeps the DOM to ~<30 rows while scrolling the
16 *    100-row dataset (TASK-0109 acceptance). "Load more" appends the next
17 *    cursor page (server caps at 100 per request).
18 *  - IP: the server masks by role (`1.2.***.***` for Member, full for
19 *    Owner/Admin); the UI only displays `row.ip` and shows `ipMasked` as a
20 *    small lock hint when true.
21 *  - Submitter: `submittedBy === null` → "Anonymous"; a deleted user's row
22 *    still renders the server's "Former member" name.
23 *  - Record link: resolves the record via useDatabaseRecord then hands the
24 *    RecordDto to the parent's panel/page callbacks (the submission only
25 *    carries a recordId).
26 *  - Export stub: the button is DISABLED with a tooltip — the API answers
27 *    501 { implementedIn: "phase-13" } (docs/api/forms.md §Export).
28 *  - Mobile (≤767px): the table becomes fixed-height answer cards; the
29 *    delete flow is identical.
30 */
31import { useCallback, useEffect, useMemo, useRef, useState } from "react";
32import {
33  ClipboardCopy,
34  Download,
35  ExternalLink,
36  Inbox,
37  Loader2,
38  Search,
39  Trash2
40} from "lucide-react";
41import { toast } from "@/stores/ui-store";
42import { ConfirmDialog } from "@/components/ui/confirm-dialog";
43import { apiFetch } from "@/lib/api";
44import { RecordDto, useDatabaseRecord } from "@/hooks/use-database";
45import { SubmissionDto, useDeleteSubmission, useSubmissions } from "@/hooks/use-forms";
46 
47// ---------------------------------------------------------------------------
48// Formatting helpers
49// ---------------------------------------------------------------------------
50 
51/** Render a raw answer value for a question type (display only). */
52function formatAnswer(value: unknown, type: string): string {
53  if (value == null) return "—";
54  const t = (type ?? "").toUpperCase();
55  if (t === "CHECKBOX") return value === true ? "Yes" : value === false ? "No" : String(value);
56  if (t === "SINGLE_SELECT" || t === "STATUS" || t === "SELECT") {
57    const s = Array.isArray(value) ? value[0] : value;
58    return s == null ? "—" : String(s);
59  }
60  if (t === "MULTI_SELECT") {
61    const s = Array.isArray(value) ? value : [value];
62    return s.map((x) => String(x)).join(", ");
63  }
64  if (t === "FILE") {
65    const s = Array.isArray(value) ? value : [value];
66    return s.length ? `${s.length} file${s.length > 1 ? "s" : ""}` : "—";
67  }
68  if (t === "DATE" || t === "DATE_RANGE") {
69    const d = new Date(String(value));
70    return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
71  }
72  if (typeof value === "string") return value;
73  const s = JSON.stringify(value);
74  return s.length > 80 ? `${s.slice(0, 77)}…` : s;
75}
76 
77/** "label: value" pairs for a submission, in question order (map insertion). */
78function answerPairs(s: SubmissionDto): { label: string; type: string; text: string }[] {
79  return Object.entries(s.answers).map(([qid, value]) => {
80    const q = s.questionLabels[qid];
81    const type = q?.type ?? "";
82    return {
83      label: q?.label ?? "Question",
84      type,
85      text: formatAnswer(value, type)
86    };
87  });
88}
89 
90function timeAgo(iso: string): string {
91  const d = new Date(iso);
92  if (Number.isNaN(d.getTime())) return "";
93  return d.toLocaleString(undefined, {
94    month: "short",
95    day: "numeric",
96    hour: "2-digit",
97    minute: "2-digit"
98  });
99}
100 
101/** yyyy-mm-dd (date input) → ISO datetime bounds for the API query. */
102function dateBounds(from?: string, to?: string): { from?: string; to?: string } {
103  const out: { from?: string; to?: string } = {};
104  if (from) out.from = `${from}T00:00:00.000Z`;
105  if (to) out.to = `${to}T23:59:59.999Z`;
106  return out;
107}
108 
109/** Small client-side debounce for the search box (the query refetches per key otherwise). */
110function useDebounced<T>(value: T, ms: number): T {
111  const [v, setV] = useState(value);
112  useEffect(() => {
113    const t = setTimeout(() => setV(value), ms);
114    return () => clearTimeout(t);
115  }, [value, ms]);
116  return v;
117}
118 
119function useMediaQuery(query: string): boolean {
120  const [match, setMatch] = useState(false);
121  useEffect(() => {
122    const mq = window.matchMedia(query);
123    setMatch(mq.matches);
124    const onChange = (e: MediaQueryListEvent) => setMatch(e.matches);
125    mq.addEventListener("change", onChange);
126    return () => mq.removeEventListener("change", onChange);
127  }, [query]);
128  return match;
129}
130 
131// ---------------------------------------------------------------------------
132// Component
133// ---------------------------------------------------------------------------
134 
135const ROW_H_DESKTOP = 48; // px — deterministic, so the window math is exact
136const ROW_H_MOBILE = 180; // px — fixed-height cards (line-clamped answers)
137const OVERSCAN = 6;
138const PAGE_SIZE = 50;
139 
140export interface FormResponsesViewProps {
141  formId: string;
142  /** The form's public slug (the copy-link button → /f/<slug>). */
143  slug: string;
144  databaseId: string;
145  /** Open the record in the right-hand panel (the submission's recordId, resolved). */
146  onOpenRecord?: (record: RecordDto) => void;
147  /** Navigate to the record's page (preferred over the panel when present). */
148  onOpenRecordPage?: (record: RecordDto) => void;
149}
150 
151export function FormResponsesView({
152  formId,
153  slug,
154  databaseId,
155  onOpenRecord,
156  onOpenRecordPage
157}: FormResponsesViewProps) {
158  const isMobile = useMediaQuery("(max-width: 767px)");
159  const rowH = isMobile ? ROW_H_MOBILE : ROW_H_DESKTOP;
160 
161  // ---- filters -------------------------------------------------------------
162  const [q, setQ] = useState("");
163  const [anonymousOnly, setAnonymousOnly] = useState(false);
164  const [from, setFrom] = useState("");
165  const [to, setTo] = useState("");
166  const [newestFirst, setNewestFirst] = useState(true);
167  const debouncedQ = useDebounced(q, 300);
168 
169  const bounds = dateBounds(from, to);
170  const filterActive = !!(debouncedQ || anonymousOnly || from || to);
171 
172  const { data, isLoading, isError, error } = useSubmissions(formId, {
173    q: debouncedQ,
174    anonymousOnly,
175    ...bounds,
176    limit: PAGE_SIZE
177  });
178 
179  // "Load more" — the cursor is opaque (a submission id); appending pages is
180  // the only pagination surface.
181  const [items, setItems] = useState<SubmissionDto[]>([]);
182  const [nextCursor, setNextCursor] = useState<string | null>(null);
183  const [loadingMore, setLoadingMore] = useState(false);
184 
185  // Reset the accumulated list whenever the query identity changes.
186  const queryKey = JSON.stringify([debouncedQ, anonymousOnly, from, to]);
187  useEffect(() => {
188    setItems(data?.items ?? []);
189    setNextCursor(data?.nextCursor ?? null);
190    // eslint-disable-next-line react-hooks/exhaustive-deps
191  }, [queryKey, data]);
192 
193  const loadMore = useCallback(async () => {
194    if (!nextCursor || loadingMore) return;
195    setLoadingMore(true);
196    try {
197      const page = await fetchSubmissionsPage(formId, {
198        q: debouncedQ,
199        anonymousOnly,
200        ...bounds,
201        limit: PAGE_SIZE,
202        cursor: nextCursor
203      });
204      setItems((prev) => [...prev, ...page.items]);
205      setNextCursor(page.nextCursor);
206    } catch (e) {
207      toast("error", "Load more failed", e instanceof Error ? e.message : undefined);
208    } finally {
209      setLoadingMore(false);
210    }
211  }, [nextCursor, loadingMore, formId, debouncedQ, anonymousOnly, bounds.from, bounds.to]);
212 
213  // Client-side sort on the loaded pages (the server orders by createdAt).
214  const sorted = useMemo(() => {
215    const list = [...items];
216    list.sort((a, b) =>
217      newestFirst
218        ? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
219        : new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
220    );
221    return list;
222  }, [items, newestFirst]);
223 
224  // ---- delete (confirm-gated; the API also requires ?confirm=true) ---------
225  const [pendingDelete, setPendingDelete] = useState<SubmissionDto | null>(null);
226  const deleteSubmission = useDeleteSubmission(formId);
227 
228  const confirmDelete = () => {
229    if (!pendingDelete) return;
230    const sub = pendingDelete;
231    deleteSubmission.mutate(sub.id, {
232      onSuccess: (res) => {
233        setPendingDelete(null);
234        toast(
235          "success",
236          "Submission deleted",
237          [
238            res.recordDeleted ? "record removed" : "record kept",
239            res.filesPurged > 0 ? `${res.filesPurged} file(s) purged` : ""
240          ]
241            .filter(Boolean)
242            .join(" · ")
243        );
244      },
245      onError: (e) => toast("error", "Delete failed", e instanceof Error ? e.message : undefined)
246    });
247  };
248 
249  // ---- record link (resolve the submission's recordId → RecordDto) ---------
250  const [linkingRecordId, setLinkingRecordId] = useState<string | null>(null);
251  const { data: linkedRecord } = useDatabaseRecord(
252    linkingRecordId ? databaseId : undefined,
253    linkingRecordId ?? undefined
254  );
255  useEffect(() => {
256    if (!linkedRecord) return;
257    const record = linkedRecord;
258    setLinkingRecordId(null);
259    if (onOpenRecordPage) onOpenRecordPage(record);
260    else if (onOpenRecord) onOpenRecord(record);
261    // eslint-disable-next-line react-hooks/exhaustive-deps
262  }, [linkedRecord]);
263 
264  // ---- copy the public link -------------------------------------------------
265  const copyLink = async () => {
266    const url = `${window.location.origin}/f/${slug}`;
267    try {
268      await navigator.clipboard.writeText(url);
269      toast("success", "Link copied", url);
270    } catch {
271      toast("error", "Could not copy the link", url);
272    }
273  };
274 
275  // ---- windowed list --------------------------------------------------------
276  const scrollRef = useRef<HTMLDivElement>(null);
277  const [scrollTop, setScrollTop] = useState(0);
278  const [viewH, setViewH] = useState(400);
279  useEffect(() => {
280    const el = scrollRef.current;
281    if (!el) return;
282    const ro = new ResizeObserver(() => setViewH(el.clientHeight));
283    ro.observe(el);
284    setViewH(el.clientHeight);
285    return () => ro.disconnect();
286  }, []);
287 
288  const total = sorted.length;
289  const start = Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN);
290  const count = Math.ceil(viewH / rowH) + OVERSCAN * 2;
291  const end = Math.min(total, start + count);
292  const slice = sorted.slice(start, end);
293 
294  // ---- error state ----------------------------------------------------------
295  if (isError) {
296    return (
297      <div className="flex h-full items-center justify-center p-6 text-center">
298        <div>
299          <p className="text-sm font-medium text-destructive">Could not load responses</p>
300          <p className="mt-1 text-xs text-muted-foreground">
301            {error instanceof Error ? error.message : "Unknown error"}
302          </p>
303        </div>
304      </div>
305    );
306  }
307 
308  return (
309    <div className="flex h-full min-h-0 flex-col">
310      {/* toolbar: search + filters + sort + export stub */}
311      <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
312        <div className="relative">
313          <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
314          <input
315            type="search"
316            value={q}
317            onChange={(e) => setQ(e.target.value)}
318            placeholder="Search responses…"
319            aria-label="Search responses"
320            className="w-44 rounded-md border border-input bg-background py-1 pl-7 pr-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring sm:w-56"
321          />
322        </div>
323        <input
324          type="date"
325          value={from}
326          onChange={(e) => setFrom(e.target.value)}
327          aria-label="From date"
328          className="rounded-md border border-input bg-background px-2 py-1 text-xs"
329        />
330        <span className="text-xs text-muted-foreground">–</span>
331        <input
332          type="date"
333          value={to}
334          onChange={(e) => setTo(e.target.value)}
335          aria-label="To date"
336          className="rounded-md border border-input bg-background px-2 py-1 text-xs"
337        />
338        <label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
339          <input
340            type="checkbox"
341            checked={anonymousOnly}
342            onChange={(e) => setAnonymousOnly(e.target.checked)}
343            className="size-3.5 rounded border-input"
344          />
345          Anonymous only
346        </label>
347        <div className="ml-auto flex items-center gap-2">
348          <select
349            value={newestFirst ? "newest" : "oldest"}
350            onChange={(e) => setNewestFirst(e.target.value === "newest")}
351            aria-label="Sort order"
352            className="rounded-md border border-input bg-background px-2 py-1 text-xs"
353          >
354            <option value="newest">Newest first</option>
355            <option value="oldest">Oldest first</option>
356          </select>
357          {filterActive && (
358            <button
359              type="button"
360              className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
361              onClick={() => {
362                setQ("");
363                setAnonymousOnly(false);
364                setFrom("");
365                setTo("");
366              }}
367            >
368              Reset
369            </button>
370          )}
371          {/* Export stub — the API answers 501 { implementedIn: "phase-13" }. */}
372          <button
373            type="button"
374            disabled
375            title="CSV export is not implemented yet (Phase 13)"
376            className="inline-flex cursor-not-allowed items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground opacity-50"
377          >
378            <Download className="size-3.5" aria-hidden /> Export
379          </button>
380        </div>
381      </div>
382 
383      {/* body — a BOUNDED scroll area (the app layout is natural-flow, so an
384          unbounded flex-1 would render every row and defeat the window). The
385          cap lets the windowed list keep its DOM to ~<30 rows while scrolling. */}
386      <div
387        data-testid="form-responses-scroll"
388        className="min-h-0 flex-1 overflow-y-auto max-h-[72dvh]"
389        ref={scrollRef}
390        onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
391      >
392        {isLoading ? (
393          <div className="space-y-2 p-3" aria-busy="true" aria-label="Loading responses">
394            {Array.from({ length: 6 }).map((_, i) => (
395              <div key={i} className="h-10 animate-pulse rounded-md bg-muted" />
396            ))}
397          </div>
398        ) : total === 0 && !filterActive ? (
399          // Empty state — the copy-link button is the primary affordance.
400          <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
401            <Inbox className="size-8 text-muted-foreground" aria-hidden />
402            <p className="text-sm text-muted-foreground">No responses yet.</p>
403            <p className="max-w-xs text-xs text-muted-foreground">
404              Share the public link and responses will appear here as they arrive.
405            </p>
406            <button
407              type="button"
408              data-testid="form-responses-copy-link"
409              className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90"
410              onClick={copyLink}
411            >
412              <ClipboardCopy className="size-3.5" aria-hidden /> Copy public link
413            </button>
414          </div>
415        ) : total === 0 ? (
416          <div className="flex h-full items-center justify-center p-6 text-center">
417            <p className="text-sm text-muted-foreground">No responses match the current filters.</p>
418          </div>
419        ) : (
420          <>
421            {/* desktop header */}
422            {!isMobile && (
423              <div className="sticky top-0 z-10 grid grid-cols-[120px_1fr_1fr_140px_44px] gap-2 border-b border-border bg-background px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
424                <span>Received</span>
425                <span>Submitter</span>
426                <span>Answers</span>
427                <span>IP</span>
428                <span aria-label="Actions" />
429              </div>
430            )}
431            <div style={{ height: total * rowH, position: "relative" }}>
432              <div style={{ transform: `translateY(${start * rowH}px)` }}>
433                {slice.map((s) => {
434                  const pairs = answerPairs(s);
435                  return isMobile ? (
436                    <MobileCard
437                      key={s.id}
438                      row={s}
439                      pairs={pairs}
440                      rowH={rowH}
441                      onOpenRecord={(rid) => rid && setLinkingRecordId(rid)}
442                      onDelete={() => setPendingDelete(s)}
443                    />
444                  ) : (
445                    <div
446                      key={s.id}
447                      data-testid="form-response-row"
448                      className="grid grid-cols-[120px_1fr_1fr_140px_44px] items-center gap-2 border-b border-border/50 px-3 text-xs"
449                      style={{ height: rowH }}
450                    >
451                      <span className="text-muted-foreground" title={new Date(s.createdAt).toUTCString()}>
452                        {timeAgo(s.createdAt)}
453                      </span>
454                      <SubmitterCell name={s.submittedBy?.name ?? "Anonymous"} email={s.submittedBy?.email} />
455                      <span className="truncate" title={pairs.map((p) => `${p.label}: ${p.text}`).join("\n")}>
456                        {pairs
457                          .slice(0, 2)
458                          .map((p) => `${p.label}: ${p.text}`)
459                          .join(" · ")}
460                        {pairs.length > 2 ? ` +${pairs.length - 2}` : ""}
461                      </span>
462                      <span className="flex items-center gap-1 truncate font-mono text-[11px] text-muted-foreground" title={s.ipMasked ? "IP address masked (owner/admin only)" : s.ip}>
463                        {s.ipMasked && <span aria-hidden>🔒</span>}
464                        {s.ip}
465                      </span>
466                      <span className="flex items-center gap-1">
467                        {s.recordId && (
468                          <button
469                            type="button"
470                            data-testid="form-response-record-link"
471                            className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
472                            onClick={() => setLinkingRecordId(s.recordId as string)}
473                            title="Open record"
474                            aria-label="Open record"
475                          >
476                            <ExternalLink className="size-3.5" />
477                          </button>
478                        )}
479                        <button
480                          type="button"
481                          data-testid="form-response-delete"
482                          className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
483                          onClick={() => setPendingDelete(s)}
484                          title="Delete submission"
485                          aria-label="Delete submission"
486                        >
487                          <Trash2 className="size-3.5" />
488                        </button>
489                      </span>
490                    </div>
491                  );
492                })}
493              </div>
494            </div>
495            {nextCursor && (
496              <div className="flex items-center justify-center border-t border-border p-3">
497                <button
498                  type="button"
499                  data-testid="form-responses-load-more"
500                  className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted"
501                  onClick={loadMore}
502                  disabled={loadingMore}
503                >
504                  {loadingMore && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
505                  Load more ({items.length} loaded)
506                </button>
507              </div>
508            )}
509          </>
510        )}
511      </div>
512 
513      {/* delete confirm (the API requires ?confirm=true; the gate is the UI) */}
514      <ConfirmDialog
515        open={!!pendingDelete}
516        title="Delete this submission?"
517        description={
518          <>
519            This permanently deletes the submission and its database record, and
520            purges any attached files. This cannot be undone.
521          </>
522        }
523        confirmLabel="Delete"
524        destructive
525        busy={deleteSubmission.isPending}
526        onConfirm={confirmDelete}
527        onClose={() => setPendingDelete(null)}
528      />
529    </div>
530  );
531}
532 
533// ---------------------------------------------------------------------------
534// Sub-components
535// ---------------------------------------------------------------------------
536 
537function SubmitterCell({ name, email }: { name: string; email?: string | null }) {
538  return (
539    <span className="flex min-w-0 items-center gap-1.5">
540      <span className="truncate text-foreground" title={email ?? name}>
541        {name}
542      </span>
543      {email && <span className="truncate text-[11px] text-muted-foreground">{email}</span>}
544    </span>
545  );
546}
547 
548function MobileCard({
549  row,
550  pairs,
551  rowH,
552  onOpenRecord,
553  onDelete
554}: {
555  row: SubmissionDto;
556  pairs: { label: string; type: string; text: string }[];
557  rowH: number;
558  onOpenRecord: (recordId: string | null) => void;
559  onDelete: () => void;
560}) {
561  return (
562    <div
563      data-testid="form-response-row"
564      className="mx-2 mb-2 flex flex-col justify-between overflow-hidden rounded-lg border border-border bg-card p-3"
565      style={{ height: rowH - 8 }}
566    >
567      <div className="flex items-center justify-between gap-2 text-xs">
568        <SubmitterCell name={row.submittedBy?.name ?? "Anonymous"} email={row.submittedBy?.email} />
569        <span className="text-[11px] text-muted-foreground">{timeAgo(row.createdAt)}</span>
570      </div>
571      <ul className="my-2 flex-1 space-y-1 overflow-hidden text-xs" aria-label="Answers">
572        {pairs.slice(0, 4).map((p, i) => (
573          <li key={i} className="truncate">
574            <span className="text-muted-foreground">{p.label}: </span>
575            {p.text}
576          </li>
577        ))}
578        {pairs.length > 4 && <li className="text-[11px] text-muted-foreground">+{pairs.length - 4} more</li>}
579      </ul>
580      <div className="flex items-center justify-between">
581        <span className="flex items-center gap-1 truncate font-mono text-[10px] text-muted-foreground">
582          {row.ipMasked && <span aria-hidden>🔒</span>}
583          {row.ip}
584        </span>
585        <span className="flex items-center gap-1">
586          {row.recordId && (
587            <button
588              type="button"
589              data-testid="form-response-record-link"
590              className="rounded p-1 text-muted-foreground hover:bg-muted"
591              onClick={() => onOpenRecord(row.recordId)}
592              title="Open record"
593              aria-label="Open record"
594            >
595              <ExternalLink className="size-3.5" />
596            </button>
597          )}
598          <button
599            type="button"
600            data-testid="form-response-delete"
601            className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
602            onClick={onDelete}
603            title="Delete submission"
604            aria-label="Delete submission"
605          >
606            <Trash2 className="size-3.5" />
607          </button>
608        </span>
609      </div>
610    </div>
611  );
612}
613 
614// ---------------------------------------------------------------------------
615// "Load more" fetch (the cursor continues the SAME filtered query the hook
616// opened; the query hook owns the first page, this helper fetches the rest).
617// ---------------------------------------------------------------------------
618 
619async function fetchSubmissionsPage(
620  formId: string,
621  f: { q?: string; anonymousOnly?: boolean; from?: string; to?: string; limit?: number; cursor?: string }
622): Promise<{ items: SubmissionDto[]; nextCursor: string | null }> {
623  const params = new URLSearchParams();
624  if (f.q) params.set("q", f.q);
625  if (f.anonymousOnly) params.set("anonymousOnly", "true");
626  if (f.from) params.set("from", f.from);
627  if (f.to) params.set("to", f.to);
628  if (f.limit) params.set("limit", String(f.limit));
629  if (f.cursor) params.set("cursor", f.cursor);
630  return apiFetch(`/forms/${formId}/submissions?${params.toString()}`);
631}
632