Survey response persistence
When a respondent finishes a question and the client SDK fires a POST, this is the route that takes the hit: apps/web/app/api/v2/client/[environmentId]/responses/route.ts. It validates, checks survey eligibility, enforces license and quota, writes the row through PrismaPrismaThe TypeScript ORM HiveCFM uses to talk to Postgres. The schema lives at packages/database/schema.prisma., and fans out pipeline events for downstream workers. Six slices; roughly one per responsibility.
The mental model for .NET readers: think of this as an ASP.NET Core minimal-API endpoint with a stack of middleware (validation → authz → quota → persistence → outbox). In Next.jsNext.jsReact framework used by HiveCFM Core. Handles routing, server rendering, and API routes in one bundle. App Router the “middleware” is just sequential code in the handler, but the ordering matters for the same reasons.
1. Imports — the stack on one screen
hivecfm-core/apps/web/app/api/v2/client/[environmentId]/responses/route.tslines 1–22View full file ↗import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@hivecfm/logger";
import { ZEnvironmentId } from "@hivecfm/types/environment";
import { InvalidInputError } from "@hivecfm/types/errors";
import { TResponseWithQuotaFull } from "@hivecfm/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { checkLicenseValid } from "@/lib/tenant/license-enforcement";
import { checkCompletedResponseQuota } from "@/lib/tenant/quota-enforcement";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { createResponseWithQuotaEvaluation } from "./lib/response";
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
Reading the imports top-to-bottom tells you exactly what this route touches: the Next headers() helper, a User-Agent parser, the structured logger, Zod schemas (ZEnvironmentId, ZResponseInputV2), the shared responses.* response helpers, pipeline dispatch, organization + survey services, license + quota enforcement, multi-choice validation, contacts feature gating, and the local createResponseWithQuotaEvaluation that does the actual PrismaPrismaThe TypeScript ORM HiveCFM uses to talk to Postgres. The schema lives at packages/database/schema.prisma. write.
If you are trying to understand HiveCFM’s domain boundaries, read a route file’s imports before its body. Every @/ import is an internal module, and every @hivecfm/ import crosses a package boundary in the monorepo.
2. Route shape, body parse, and Zod validation
hivecfm-core/apps/web/app/api/v2/client/[environmentId]/responses/route.tslines 39–67View full file ↗export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await request.json();
} catch (error) {
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
);
}[environmentId] is the dynamic route segment. In App Router it arrives as context.params — a Promise in newer Next versions, which is why we await it. Forgetting that await is one of the most common subtle bugs when porting handlers between versions.
The body parse is defensive: request.json() can throw on malformed JSON, and we respond with a clean 400 rather than letting it bubble into a 500.
Two Zod schemas run next. ZEnvironmentId validates just the route param — shape, length, charset. ZResponseInputV2 validates the full body (merged with the route param so server-truth wins over anything the client put in the body). Both failures turn into a 400 with a details payload produced by transformErrorToDetails; that shape is exactly what the SDK’s error UI expects. Keep the two checks separate: a mis-encoded id gives a different (and better) error message than a bad body.
3. Survey fetch, content validity, character-limit guard
hivecfm-core/apps/web/app/api/v2/client/[environmentId]/responses/route.tslines 87–110View full file ↗ // get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
if (surveyCheckResult) return surveyCheckResult;
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks),
responseLanguage: responseInputData.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}SurveySurveyThe core HiveCFM object. A definition of questions, logic, and targeting — shown to respondents to collect feedback. lookup goes through getSurvey, a service method that reads PrismaPrismaThe TypeScript ORM HiveCFM uses to talk to Postgres. The schema lives at packages/database/schema.prisma. with a consistent select projection. checkSurveyValidity is where we reject responses targeted at archived surveys, wrong environments, or surveys whose schedule window has closed — the common thread is “the survey exists but you can’t post to it right now.”
Then we walk the other option for every multiple-choice question. The client is supposed to enforce the limit; the server enforces it again because clients can lie. If the check finds an offending question, we return a 400 that names the question id, which the SDK uses to jump focus back to that widget.
4. License and quota pre-flight
hivecfm-core/apps/web/app/api/v2/client/[environmentId]/responses/route.tslines 112–129View full file ↗ // Pre-flight license enforcement: check validity and response limits before creating response
const organization = await getOrganizationByEnvironmentId(environmentId);
if (organization) {
const licenseValid = await checkLicenseValid(organization.id);
if (!licenseValid.valid) {
return responses.forbiddenResponse(licenseValid.reason || "License validation failed", true);
}
if (responseInput.finished) {
const quotaCheck = await checkCompletedResponseQuota(organization.id);
if (!quotaCheck.allowed) {
return responses.forbiddenResponse(
`Completed response limit reached (${quotaCheck.current}/${quotaCheck.limit})`,
true
);
}
}
}Before PrismaPrismaThe TypeScript ORM HiveCFM uses to talk to Postgres. The schema lives at packages/database/schema.prisma. is touched, we resolve the response’s organization (via the environment) and check two gates:
checkLicenseValidconfirms the org’s HiveCFM license is still valid — useful in self-hosted deployments where a license can expire between requests.checkCompletedResponseQuotais only relevant when the incoming payload says the response isfinished: true. Partial responses do not count against the quota, so we don’t spend a Redis/Postgres roundtrip on them.
This is deliberately pre-flight: we want to say “no” before allocating a row. A post-flight quota check would force us to roll back on rejection — slower, and a race against pipeline events that have already fired.
5. Meta assembly and Prisma create
hivecfm-core/apps/web/app/api/v2/client/[environmentId]/responses/route.tslines 131–162View full file ↗ let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
// Capture IP address if the survey has IP capture enabled
// Server-derived IP always overwrites any client-provided value
if (survey.isCaptureIpEnabled) {
const ipAddress = await getClientIpFromHeaders();
meta.ipAddress = ipAddress;
}
response = await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(error.message);
}meta is a server-derived object. The client SDK supplies source, url, and action, but userAgent, country, and ipAddress are filled in from request headers we trust. Note if (survey.isCaptureIpEnabled) — we only persist the IP when the survey owner has explicitly opted in, which matters for GDPR.
createResponseWithQuotaEvaluation is the function that actually runs a PrismaPrismaThe TypeScript ORM HiveCFM uses to talk to Postgres. The schema lives at packages/database/schema.prisma. create. It lives in ./lib/response.ts and returns the created row plus a quotaFull signal used for client-side UX (so the UI can say “this was the final response your quota allowed”). The Prisma call is wrapped in a transaction internally — both the Response insert and any quota-counter updates commit atomically, so the pipeline events we fire nextNext.jsReact framework used by HiveCFM Core. Handles routing, server rendering, and API routes in one bundle. can trust that the row is durable.
The try/catch distinguishes validation errors (InvalidInputError → 400) from unexpected server errors (log + 500). Everything else — PrismaPrismaThe TypeScript ORM HiveCFM uses to talk to Postgres. The schema lives at packages/database/schema.prisma. constraint violations, network errors against RedisRedisIn-memory key-value store used for caching, sessions, and rate limiting. Runs on port 6380 locally. — hits the 500 branch; we deliberately don’t expose those messages to the SDK.
6. Pipeline fan-out
hivecfm-core/apps/web/app/api/v2/client/[environmentId]/responses/route.tslines 163–189View full file ↗ const { quotaFull, ...responseData } = response;
sendToPipeline({
event: "responseCreated",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
if (responseData.finished) {
sendToPipeline({
event: "responseFinished",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return responses.successResponse(responseDataWithQuota, true);
};Finally, sendToPipeline fires one or two events to the HubHubThe Go service that owns background processing, integrations, and the admin API. Sibling to Core. pipeline: always responseCreated, and additionally responseFinished when the response is marked complete. These are the events that downstream workers — webhook dispatch, embedding generation, sentiment analysis — subscribe to.
sendToPipeline is fire-and-forget by design. It enqueues to RedisRedisIn-memory key-value store used for caching, sessions, and rate limiting. Runs on port 6380 locally. and returns; we do not await it, because a slow downstream must never block the hot write path. The pipeline worker (in hivecfm-hub/) owns retries, dead-letter handling, and observability for delivery. Walking the HubHubThe Go service that owns background processing, integrations, and the admin API. Sibling to Core. worker for embeddings is covered in the Hub Go worker walkthrough.
The response to the SDK is the bare minimum: the new row’s id plus a quota snapshot. We deliberately do not echo the whole stored response — the SDK already has the payload it sent, and trimming the response body keeps this route cheap even under sustained load.
What to read next
- Hub Go worker (embeddings) — one of the workers that consumes these pipeline events.
- Data flow diagram — the write path in picture form.
- Schema viewer — the
Responsetable and its neighbors.