Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions .github/workflows/formbricks-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,7 @@ jobs:
- helm-chart-release
if: ${{ !github.event.release.prerelease }}
permissions:
contents: write
pull-requests: write
contents: read
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
Expand All @@ -177,6 +176,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
persist-credentials: false

- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
Expand Down Expand Up @@ -206,20 +206,46 @@ jobs:

echo "changed=true" >> "$GITHUB_OUTPUT"

- name: Validate Helm update PR app configuration
if: steps.update.outputs.changed == 'true'
env:
APP_CLIENT_ID: ${{ vars.HELM_APP_VERSION_PR_APP_CLIENT_ID }}
APP_PRIVATE_KEY: ${{ secrets.HELM_APP_VERSION_PR_APP_PRIVATE_KEY }}
run: |
set -euo pipefail

if [[ -z "$APP_CLIENT_ID" || -z "$APP_PRIVATE_KEY" ]]; then
echo "::error::Configure HELM_APP_VERSION_PR_APP_CLIENT_ID as a repository variable and HELM_APP_VERSION_PR_APP_PRIVATE_KEY as a repository secret."
exit 1
fi

- name: Create GitHub App token for Helm update PR
id: helm-update-pr-token
if: steps.update.outputs.changed == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.HELM_APP_VERSION_PR_APP_CLIENT_ID }}
private-key: ${{ secrets.HELM_APP_VERSION_PR_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write

- name: Create Helm app version PR
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.helm-update-pr-token.outputs.token }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
APP_SLUG: ${{ steps.helm-update-pr-token.outputs.app-slug }}
run: |
set -euo pipefail

branch="chore/update-helm-app-version-${VERSION}"
title="chore: update Helm app version to ${VERSION}"
body_file="$(mktemp)"
app_user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
gh auth setup-git
git config user.name "${APP_SLUG}[bot]"
git config user.email "${app_user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
git commit -m "$title"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,19 @@ export const ResponseCardModal = ({
</DialogDescription>
</VisuallyHidden>
<DialogBody>
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
user={user}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
<div className="my-3">
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
user={user}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader";

Expand Down Expand Up @@ -46,15 +47,16 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
<Table>
<TableHeader className="bg-slate-100">
<TableRow>
<TableHead className="w-1/4">{t("common.user")}</TableHead>
<TableHead className="w-2/4">{t("common.response")}</TableHead>
<TableHead className="w-1/4">{t("common.time")}</TableHead>
<TableHead className="w-1/5">{t("common.user")}</TableHead>
<TableHead className="w-2/5">{t("common.response")}</TableHead>
<TableHead className="w-1/5">{t("common.time")}</TableHead>
<TableHead className="w-1/5">{t("common.response_id")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell className="w-1/4">
<TableCell className="w-1/5">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
Expand All @@ -75,14 +77,17 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
</div>
)}
</TableCell>
<TableCell className="w-2/4 font-medium">
<TableCell className="w-2/5 font-medium">
{typeof response.value === "string"
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell className="w-1/4">
<TableCell className="w-1/5">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/5">
<IdBadge id={response.id} />
</TableCell>
</TableRow>
))}
</TableBody>
Expand Down
42 changes: 41 additions & 1 deletion apps/web/app/api/v3/lib/api-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ describe("withV3ApiWrapper", () => {
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
workspacePermissions: [],
});

const wrapped = withV3ApiWrapper({
Expand Down Expand Up @@ -512,6 +512,46 @@ describe("withV3ApiWrapper", () => {
);
});

test("preserves machine-readable validation metadata from Zod issues", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.unknown().superRefine((_value, ctx) => {
ctx.addIssue({
code: "custom",
message: "Unsupported field 'extra'",
path: ["extra"],
params: { code: "unsupported_field" },
});
}),
},
handler,
});

const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: JSON.stringify({ extra: true }),
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);

expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "extra",
reason: "Unsupported field 'extra'",
code: "unsupported_field",
},
]);
});

test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
Expand Down
19 changes: 15 additions & 4 deletions apps/web/app/api/v3/lib/api-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
isInvalidParamCode,
problemBadRequest,
problemInternalError,
problemPayloadTooLarge,
Expand Down Expand Up @@ -72,11 +73,21 @@ function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
return "Not authenticated";
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
return error.issues.map((issue) => {
const params = "params" in issue && isPlainObject(issue.params) ? issue.params : {};
const code = isInvalidParamCode(params.code) ? params.code : undefined;

return {
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
...(code ? { code } : {}),
};
});
}

type TV3InputParseFailure = {
Expand Down
22 changes: 22 additions & 0 deletions apps/web/app/api/v3/lib/response.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
createdResponse,
noContentResponse,
problemBadRequest,
problemForbidden,
Expand Down Expand Up @@ -120,6 +121,27 @@ describe("successResponse", () => {
});
});

describe("createdResponse", () => {
test("returns 201 with Location, request id, and data envelope", async () => {
const res = createdResponse(
{ id: "survey_1" },
{
location: "/api/v3/surveys/survey_1",
requestId: "req-created",
}
);

expect(res.status).toBe(201);
expect(res.headers.get("Location")).toBe("/api/v3/surveys/survey_1");
expect(res.headers.get("X-Request-Id")).toBe("req-created");
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
});

describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
Expand Down
66 changes: 65 additions & 1 deletion apps/web/app/api/v3/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,46 @@
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;

export type InvalidParam = { name: string; reason: string; identifier?: string };
export const INVALID_PARAM_CODES = [
"dangling_reference",
"duplicate_identifier",
"duplicate_locale",
"forbidden_identifier",
"immutable_identifier",
"invalid_locale",
"invalid_reference",
"missing_required_field",
"missing_translation",
"unsupported_field",
"unsupported_locale",
] as const;

export type InvalidParamCode = (typeof INVALID_PARAM_CODES)[number];

const INVALID_PARAM_CODE_SET = new Set<InvalidParamCode>(INVALID_PARAM_CODES);

export function isInvalidParamCode(value: unknown): value is InvalidParamCode {
return typeof value === "string" && INVALID_PARAM_CODE_SET.has(value as InvalidParamCode);
}

export type InvalidParam = {
name: string;
reason: string;
code?: InvalidParamCode;
identifier?: string;
referenceType?:
| "block"
| "element"
| "ending"
| "hiddenField"
| "language"
| "variable"
| "variableName"
| "recall";
missingId?: string;
firstUsedAt?: string;
conflictsWith?: string;
};

export type ProblemExtension = {
code?: string;
Expand Down Expand Up @@ -183,6 +222,31 @@ export function successResponse<T>(
);
}

export function createdResponse<T>(
data: T,
options: { location: string; requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options.cache ?? CACHE_NO_STORE,
Location: options.location,
};

if (options.requestId) {
headers["X-Request-Id"] = options.requestId;
}

return Response.json(
{
data,
},
{
status: 201,
headers,
}
);
}

export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
Expand Down
Loading
Loading