mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-18 09:41:32 -05:00
Compare commits
17 Commits
4.8.1
...
feat/v3-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb8e7d5d8f | ||
|
|
d99d70327d | ||
|
|
062d0da0dc | ||
|
|
85c75e0e5b | ||
|
|
e4a2ce77a7 | ||
|
|
3bb76aa99a | ||
|
|
47ed4286b0 | ||
|
|
53a6ceb01c | ||
|
|
bca15879ba | ||
|
|
0e0cb3946c | ||
|
|
605d8f7f1d | ||
|
|
992a971026 | ||
|
|
2ebc1b6b66 | ||
|
|
075fd3f8dd | ||
|
|
c0e3eed43b | ||
|
|
8789e9c6e7 | ||
|
|
d96cc1d005 |
324
apps/web/app/api/v3/lib/api-wrapper.test.ts
Normal file
324
apps/web/app/api/v3/lib/api-wrapper.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "./api-wrapper";
|
||||
|
||||
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
mockGetServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: mockGetServerSession,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("withV3ApiWrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("uses session auth first in both mode and injects request id into plain responses", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1", name: "Test", email: "t@example.com" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
|
||||
expect(authentication).toMatchObject({ user: { id: "user_1" } });
|
||||
expect(requestId).toBe("req-1");
|
||||
expect(instance).toBe("/api/v3/surveys");
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
|
||||
headers: { "x-request-id": "req-1" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-Request-Id")).toBe("req-1");
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ namespace: "api:v3" }),
|
||||
"user_1"
|
||||
);
|
||||
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to api key auth in both mode", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: false } },
|
||||
environmentPermissions: [],
|
||||
});
|
||||
|
||||
const handler = vi.fn(async ({ authentication }) => {
|
||||
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
headers: { "x-api-key": "fbk_test" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ namespace: "api:v3" }),
|
||||
"key_1"
|
||||
);
|
||||
expect(mockGetServerSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 401 problem response when authentication is required but missing", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
|
||||
});
|
||||
|
||||
test("returns 400 problem response for invalid query input", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
query: z.object({
|
||||
limit: z.coerce.number().int().positive(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
|
||||
headers: { "x-request-id": "req-invalid" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
const body = await response.json();
|
||||
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
|
||||
expect(body.requestId).toBe("req-invalid");
|
||||
});
|
||||
|
||||
test("parses body, repeated query params, and async route params", async () => {
|
||||
const handler = vi.fn(async ({ parsedInput }) => {
|
||||
expect(parsedInput).toEqual({
|
||||
body: { name: "Survey API" },
|
||||
query: { tag: ["a", "b"] },
|
||||
params: { workspaceId: "ws_123" },
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
"X-Request-Id": "handler-request-id",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
query: z.object({
|
||||
tag: z.array(z.string()),
|
||||
}),
|
||||
params: z.object({
|
||||
workspaceId: z.string(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Survey API" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({
|
||||
workspaceId: "ws_123",
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("returns 400 problem response for malformed JSON input", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
body: "{",
|
||||
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: "body",
|
||||
reason: "Malformed JSON input, please check your request body",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns 400 problem response for invalid route params", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
params: z.object({
|
||||
workspaceId: z.string().min(3),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
|
||||
params: Promise.resolve({
|
||||
workspaceId: "x",
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
const body = await response.json();
|
||||
expect(body.invalid_params).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 429 problem response when rate limited", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
});
|
||||
|
||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("Retry-After")).toBe("60");
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("too_many_requests");
|
||||
});
|
||||
|
||||
test("returns 500 problem response when the handler throws unexpectedly", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
headers: { "x-request-id": "req-boom" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
expect(body.requestId).toBe("req-boom");
|
||||
});
|
||||
});
|
||||
298
apps/web/app/api/v3/lib/api-wrapper.ts
Normal file
298
apps/web/app/api/v3/lib/api-wrapper.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import {
|
||||
type InvalidParam,
|
||||
problemBadRequest,
|
||||
problemInternalError,
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
} from "./response";
|
||||
import type { TV3Authentication } from "./types";
|
||||
|
||||
type TV3Schema = z.ZodTypeAny;
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
|
||||
|
||||
export type TV3Schemas = {
|
||||
body?: TV3Schema;
|
||||
query?: TV3Schema;
|
||||
params?: TV3Schema;
|
||||
};
|
||||
|
||||
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
|
||||
? {
|
||||
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
|
||||
}
|
||||
: Record<string, never>;
|
||||
|
||||
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
|
||||
req: NextRequest;
|
||||
props: TProps;
|
||||
authentication: TV3Authentication;
|
||||
parsedInput: TParsedInput;
|
||||
requestId: string;
|
||||
instance: string;
|
||||
};
|
||||
|
||||
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
|
||||
auth?: TV3AuthMode;
|
||||
schemas?: S;
|
||||
rateLimit?: boolean;
|
||||
customRateLimitConfig?: TRateLimitConfig;
|
||||
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
|
||||
};
|
||||
|
||||
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
|
||||
if (authMode === "session") {
|
||||
return "Session required";
|
||||
}
|
||||
|
||||
if (authMode === "apiKey") {
|
||||
return "API key required";
|
||||
}
|
||||
|
||||
return "Not authenticated";
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
|
||||
const query: Record<string, string | string[]> = {};
|
||||
|
||||
for (const key of new Set(searchParams.keys())) {
|
||||
const values = searchParams.getAll(key);
|
||||
query[key] = values.length > 1 ? values : (values[0] ?? "");
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
|
||||
if (!authentication) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ("user" in authentication && authentication.user?.id) {
|
||||
return authentication.user.id;
|
||||
}
|
||||
|
||||
if ("apiKeyId" in authentication) {
|
||||
return authentication.apiKeyId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: unknown): value is Promise<T> {
|
||||
return typeof value === "object" && value !== null && "then" in value;
|
||||
}
|
||||
|
||||
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
|
||||
if (!props || typeof props !== "object" || !("params" in props)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params = (props as { params?: unknown }).params;
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
|
||||
return typeof resolvedParams === "object" && resolvedParams !== null
|
||||
? (resolvedParams as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
|
||||
if (authMode === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMode === "both" && req.headers.has("x-api-key")) {
|
||||
const apiKeyAuth = await authenticateRequest(req);
|
||||
if (apiKeyAuth) {
|
||||
return apiKeyAuth;
|
||||
}
|
||||
}
|
||||
|
||||
if (authMode === "session" || authMode === "both") {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user?.id) {
|
||||
return session;
|
||||
}
|
||||
|
||||
if (authMode === "session") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (authMode === "apiKey" || authMode === "both") {
|
||||
return await authenticateRequest(req);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
|
||||
req: NextRequest,
|
||||
props: TProps,
|
||||
schemas: S | undefined,
|
||||
requestId: string,
|
||||
instance: string
|
||||
): Promise<
|
||||
| { ok: true; parsedInput: TV3ParsedInput<S> }
|
||||
| {
|
||||
ok: false;
|
||||
response: Response;
|
||||
}
|
||||
> {
|
||||
const parsedInput = {} as TV3ParsedInput<S>;
|
||||
|
||||
if (schemas?.body) {
|
||||
let bodyData: unknown;
|
||||
|
||||
try {
|
||||
bodyData = await req.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid request body", {
|
||||
instance,
|
||||
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const bodyResult = schemas.body.safeParse(bodyData);
|
||||
if (!bodyResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid request body", {
|
||||
instance,
|
||||
invalid_params: formatZodIssues(bodyResult.error, "body"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
|
||||
}
|
||||
|
||||
if (schemas?.query) {
|
||||
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
|
||||
if (!queryResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid query parameters", {
|
||||
instance,
|
||||
invalid_params: formatZodIssues(queryResult.error, "query"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
|
||||
}
|
||||
|
||||
if (schemas?.params) {
|
||||
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
|
||||
if (!paramsResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid route parameters", {
|
||||
instance,
|
||||
invalid_params: formatZodIssues(paramsResult.error, "params"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
|
||||
}
|
||||
|
||||
return { ok: true, parsedInput };
|
||||
}
|
||||
|
||||
function ensureRequestIdHeader(response: Response, requestId: string): Response {
|
||||
if (response.headers.get("X-Request-Id")) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("X-Request-Id", requestId);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
|
||||
params: TWithV3ApiWrapperParams<S, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
|
||||
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
|
||||
const instance = req.nextUrl.pathname;
|
||||
const log = logger.withContext({
|
||||
requestId,
|
||||
method: req.method,
|
||||
path: instance,
|
||||
});
|
||||
|
||||
try {
|
||||
const authentication = await authenticateV3Request(req, auth);
|
||||
if (!authentication && auth !== "none") {
|
||||
return problemUnauthorized(requestId, getUnauthenticatedDetail(auth), instance);
|
||||
}
|
||||
|
||||
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
|
||||
if (!parsedInputResult.ok) {
|
||||
return parsedInputResult.response;
|
||||
}
|
||||
|
||||
if (rateLimit) {
|
||||
const identifier = getRateLimitIdentifier(authentication);
|
||||
if (identifier) {
|
||||
try {
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v3, identifier);
|
||||
} catch (error) {
|
||||
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
|
||||
return problemTooManyRequests(
|
||||
requestId,
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
error instanceof TooManyRequestsError ? error.retryAfter : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await handler({
|
||||
req,
|
||||
props,
|
||||
authentication,
|
||||
parsedInput: parsedInputResult.parsedInput,
|
||||
requestId,
|
||||
instance,
|
||||
});
|
||||
|
||||
return ensureRequestIdHeader(response, requestId);
|
||||
} catch (error) {
|
||||
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
};
|
||||
};
|
||||
274
apps/web/app/api/v3/lib/auth.test.ts
Normal file
274
apps/web/app/api/v3/lib/auth.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { getEnvironment } from "@/lib/utils/services";
|
||||
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromProjectId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/services", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
const requestId = "req-123";
|
||||
|
||||
describe("requireSessionWorkspaceAccess", () => {
|
||||
test("returns 401 when authentication is null", async () => {
|
||||
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(401);
|
||||
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.status).toBe(401);
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 401 when authentication is API key (no user)", async () => {
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
|
||||
"proj_abc",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(401);
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"env_nonexistent",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(403);
|
||||
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
|
||||
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when user has no access to workspace", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
||||
id: "env_abc",
|
||||
projectId: "proj_abc",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
|
||||
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"env_abc",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(403);
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns workspace context when session is valid and user has access", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
||||
id: "env_abc",
|
||||
projectId: "proj_abc",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"env_abc",
|
||||
"readWrite",
|
||||
requestId
|
||||
);
|
||||
expect(result).not.toBeInstanceOf(Response);
|
||||
expect(result).toEqual({
|
||||
environmentId: "env_abc",
|
||||
projectId: "proj_abc",
|
||||
organizationId: "org_1",
|
||||
});
|
||||
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const keyBase = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_k",
|
||||
organizationAccess: { accessControl: { read: true, write: false } },
|
||||
};
|
||||
|
||||
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
|
||||
return {
|
||||
environmentId,
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "proj_k",
|
||||
projectName: "K",
|
||||
permission,
|
||||
};
|
||||
}
|
||||
|
||||
describe("requireV3WorkspaceAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env_k",
|
||||
projectId: "proj_k",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
|
||||
});
|
||||
|
||||
test("401 when authentication is null", async () => {
|
||||
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
|
||||
expect((r as Response).status).toBe(401);
|
||||
});
|
||||
|
||||
test("delegates to session flow when user is present", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
||||
id: "env_s",
|
||||
projectId: "proj_s",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
||||
const r = await requireV3WorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"env_s",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
expect(r).toEqual({
|
||||
environmentId: "env_s",
|
||||
projectId: "proj_s",
|
||||
organizationId: "org_s",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns context for API key with read on workspace", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
|
||||
expect(r).toEqual({
|
||||
environmentId: "ws_a",
|
||||
projectId: "proj_k",
|
||||
organizationId: "org_k",
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
|
||||
});
|
||||
|
||||
test("returns context for API key with write on workspace", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
|
||||
expect(r).toEqual({
|
||||
environmentId: "ws_b",
|
||||
projectId: "proj_k",
|
||||
organizationId: "org_k",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 403 when API key permission is lower than the required permission", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
|
||||
expect((r as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("403 when API key has no matching environment", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("other_env")],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
|
||||
expect((r as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("403 when API key permission is not list-eligible (runtime value)", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [
|
||||
{
|
||||
...envPerm("ws_c"),
|
||||
permission: "invalid" as unknown as ApiKeyPermission,
|
||||
},
|
||||
],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
|
||||
expect((r as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("returns context for API key with manage on workspace", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
|
||||
expect(r).toEqual({
|
||||
environmentId: "ws_m",
|
||||
projectId: "proj_k",
|
||||
organizationId: "org_k",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
|
||||
expect((r as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("401 when auth is neither session nor valid API key payload", async () => {
|
||||
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
|
||||
expect((r as Response).status).toBe(401);
|
||||
});
|
||||
});
|
||||
122
apps/web/app/api/v3/lib/auth.ts
Normal file
122
apps/web/app/api/v3/lib/auth.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* V3 API auth — session (browser) or API key with environment-scoped access.
|
||||
*/
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { problemForbidden, problemUnauthorized } from "./response";
|
||||
import type { TV3Authentication } from "./types";
|
||||
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
|
||||
|
||||
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
|
||||
const grantedRank = {
|
||||
[ApiKeyPermission.read]: 1,
|
||||
[ApiKeyPermission.write]: 2,
|
||||
[ApiKeyPermission.manage]: 3,
|
||||
}[permission];
|
||||
|
||||
const requiredRank = {
|
||||
read: 1,
|
||||
readWrite: 2,
|
||||
manage: 3,
|
||||
}[minPermission];
|
||||
|
||||
return grantedRank >= requiredRank;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
|
||||
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
|
||||
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
|
||||
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
|
||||
*/
|
||||
export async function requireSessionWorkspaceAccess(
|
||||
authentication: TV3Authentication,
|
||||
workspaceId: string,
|
||||
minPermission: TTeamPermission,
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3WorkspaceContext> {
|
||||
// --- Session checks ---
|
||||
if (!authentication) {
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
if (!("user" in authentication) || !authentication.user?.id) {
|
||||
return problemUnauthorized(requestId, "Session required", instance);
|
||||
}
|
||||
|
||||
const userId = authentication.user.id;
|
||||
const log = logger.withContext({ requestId, workspaceId });
|
||||
|
||||
try {
|
||||
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
|
||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
||||
|
||||
// Org + project-team access; we use internal IDs from context.
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId: context.organizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", projectId: context.projectId, minPermission },
|
||||
],
|
||||
});
|
||||
|
||||
return context;
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
|
||||
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, message);
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
|
||||
export async function requireV3WorkspaceAccess(
|
||||
authentication: TV3Authentication,
|
||||
workspaceId: string,
|
||||
minPermission: TTeamPermission,
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3WorkspaceContext> {
|
||||
if (!authentication) {
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
|
||||
if ("user" in authentication && authentication.user?.id) {
|
||||
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
|
||||
}
|
||||
|
||||
const keyAuth = authentication as TAuthenticationApiKey;
|
||||
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
|
||||
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
|
||||
|
||||
try {
|
||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
||||
const permission = keyAuth.environmentPermissions.find(
|
||||
(environmentPermission) => environmentPermission.environmentId === context.environmentId
|
||||
);
|
||||
|
||||
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
|
||||
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
return context;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
95
apps/web/app/api/v3/lib/response.test.ts
Normal file
95
apps/web/app/api/v3/lib/response.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
problemNotFound,
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
successListResponse,
|
||||
} from "./response";
|
||||
|
||||
describe("v3 problem responses", () => {
|
||||
test("problemBadRequest includes invalid_params", async () => {
|
||||
const res = problemBadRequest("rid", "bad", {
|
||||
invalid_params: [{ name: "x", reason: "y" }],
|
||||
instance: "/p",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get("X-Request-Id")).toBe("rid");
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("bad_request");
|
||||
expect(body.requestId).toBe("rid");
|
||||
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
|
||||
expect(body.instance).toBe("/p");
|
||||
});
|
||||
|
||||
test("problemUnauthorized default detail", async () => {
|
||||
const res = problemUnauthorized("r1");
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.detail).toBe("Not authenticated");
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
});
|
||||
|
||||
test("problemForbidden", async () => {
|
||||
const res = problemForbidden("r2", undefined, "/api/x");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(body.instance).toBe("/api/x");
|
||||
});
|
||||
|
||||
test("problemInternalError", async () => {
|
||||
const res = problemInternalError("r3", "oops", "/i");
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
expect(body.detail).toBe("oops");
|
||||
});
|
||||
|
||||
test("problemNotFound includes details", async () => {
|
||||
const res = problemNotFound("r4", "Survey", "s1", "/s");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("not_found");
|
||||
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
|
||||
});
|
||||
|
||||
test("problemTooManyRequests with Retry-After", async () => {
|
||||
const res = problemTooManyRequests("r5", "slow down", 60);
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("Retry-After")).toBe("60");
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("too_many_requests");
|
||||
});
|
||||
|
||||
test("problemTooManyRequests without Retry-After", async () => {
|
||||
const res = problemTooManyRequests("r6", "nope");
|
||||
expect(res.headers.get("Retry-After")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("successListResponse", () => {
|
||||
test("sets X-Request-Id and default cache", async () => {
|
||||
const res = successListResponse(
|
||||
[{ a: 1 }],
|
||||
{ limit: 10, nextCursor: "cursor-1" },
|
||||
{
|
||||
requestId: "req-x",
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-x");
|
||||
expect(res.headers.get("Cache-Control")).toContain("no-store");
|
||||
expect(await res.json()).toEqual({
|
||||
data: [{ a: 1 }],
|
||||
meta: { limit: 10, nextCursor: "cursor-1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("custom Cache-Control", async () => {
|
||||
const res = successListResponse([], { limit: 5, nextCursor: null }, { cache: "private, max-age=0" });
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
|
||||
});
|
||||
});
|
||||
149
apps/web/app/api/v3/lib/response.ts
Normal file
149
apps/web/app/api/v3/lib/response.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* V3 API response helpers — RFC 9457 Problem Details (application/problem+json)
|
||||
* and list envelope for success responses.
|
||||
*/
|
||||
|
||||
const PROBLEM_JSON = "application/problem+json" as const;
|
||||
const CACHE_NO_STORE = "private, no-store" as const;
|
||||
|
||||
export type InvalidParam = { name: string; reason: string };
|
||||
|
||||
export type ProblemExtension = {
|
||||
code?: string;
|
||||
requestId: string;
|
||||
details?: Record<string, unknown>;
|
||||
invalid_params?: InvalidParam[];
|
||||
};
|
||||
|
||||
export type ProblemBody = {
|
||||
type?: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
instance?: string;
|
||||
} & ProblemExtension;
|
||||
|
||||
function problemResponse(
|
||||
status: number,
|
||||
title: string,
|
||||
detail: string,
|
||||
requestId: string,
|
||||
options?: {
|
||||
type?: string;
|
||||
instance?: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
invalid_params?: InvalidParam[];
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
): Response {
|
||||
const body: ProblemBody = {
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
requestId,
|
||||
...(options?.type && { type: options.type }),
|
||||
...(options?.instance && { instance: options.instance }),
|
||||
...(options?.code && { code: options.code }),
|
||||
...(options?.details && { details: options.details }),
|
||||
...(options?.invalid_params && { invalid_params: options.invalid_params }),
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": PROBLEM_JSON,
|
||||
"Cache-Control": CACHE_NO_STORE,
|
||||
"X-Request-Id": requestId,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
return Response.json(body, { status, headers });
|
||||
}
|
||||
|
||||
export function problemBadRequest(
|
||||
requestId: string,
|
||||
detail: string,
|
||||
options?: { invalid_params?: InvalidParam[]; instance?: string }
|
||||
): Response {
|
||||
return problemResponse(400, "Bad Request", detail, requestId, {
|
||||
code: "bad_request",
|
||||
instance: options?.instance,
|
||||
invalid_params: options?.invalid_params,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemUnauthorized(
|
||||
requestId: string,
|
||||
detail: string = "Not authenticated",
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(401, "Unauthorized", detail, requestId, {
|
||||
code: "not_authenticated",
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemForbidden(
|
||||
requestId: string,
|
||||
detail: string = "You are not authorized to access this resource",
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(403, "Forbidden", detail, requestId, {
|
||||
code: "forbidden",
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
|
||||
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
|
||||
* Prefer problemForbidden with a generic message for those cases.
|
||||
*/
|
||||
export function problemNotFound(
|
||||
requestId: string,
|
||||
resourceType: string,
|
||||
resourceId: string | null,
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
|
||||
code: "not_found",
|
||||
details: { resource_type: resourceType, resource_id: resourceId },
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemInternalError(
|
||||
requestId: string,
|
||||
detail: string = "An unexpected error occurred.",
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(500, "Internal Server Error", detail, requestId, {
|
||||
code: "internal_server_error",
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
|
||||
const headers: Record<string, string> = {};
|
||||
if (retryAfter !== undefined) {
|
||||
headers["Retry-After"] = String(retryAfter);
|
||||
}
|
||||
return problemResponse(429, "Too Many Requests", detail, requestId, {
|
||||
code: "too_many_requests",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export function successListResponse<T, TMeta extends Record<string, unknown>>(
|
||||
data: T[],
|
||||
meta: TMeta,
|
||||
options?: { requestId?: string; cache?: string }
|
||||
): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
|
||||
};
|
||||
if (options?.requestId) {
|
||||
headers["X-Request-Id"] = options.requestId;
|
||||
}
|
||||
return Response.json({ data, meta }, { status: 200, headers });
|
||||
}
|
||||
4
apps/web/app/api/v3/lib/types.ts
Normal file
4
apps/web/app/api/v3/lib/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
export type TV3Authentication = TAuthenticationApiKey | Session | null;
|
||||
38
apps/web/app/api/v3/lib/workspace-context.test.ts
Normal file
38
apps/web/app/api/v3/lib/workspace-context.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { getEnvironment } from "@/lib/utils/services";
|
||||
import { resolveV3WorkspaceContext } from "./workspace-context";
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromProjectId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/services", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("resolveV3WorkspaceContext", () => {
|
||||
test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
||||
id: "env_abc",
|
||||
projectId: "proj_xyz",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
|
||||
const result = await resolveV3WorkspaceContext("env_abc");
|
||||
expect(result).toEqual({
|
||||
environmentId: "env_abc",
|
||||
projectId: "proj_xyz",
|
||||
organizationId: "org_123",
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env_abc");
|
||||
expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
|
||||
});
|
||||
|
||||
test("throws when workspace (environment) does not exist", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
|
||||
expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
50
apps/web/app/api/v3/lib/workspace-context.ts
Normal file
50
apps/web/app/api/v3/lib/workspace-context.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* V3 API workspace → internal IDs translation layer (retro-compatibility / future-proofing).
|
||||
*
|
||||
* Workspace is the default container for surveys. We are deprecating Environment and making
|
||||
* Workspace that container. In the API, workspaceId refers to that container.
|
||||
*
|
||||
* Today: workspaceId is mapped to environmentId (Environment is the current container for surveys).
|
||||
* When Environment is deprecated and Workspace exists: resolve workspaceId to the Workspace entity
|
||||
* (and derive environmentId or equivalent from it). Change only this file.
|
||||
*/
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { getEnvironment } from "@/lib/utils/services";
|
||||
|
||||
/**
|
||||
* Internal IDs derived from a V3 workspace identifier.
|
||||
* Today: environmentId is the workspace (Environment = container for surveys until Workspace exists).
|
||||
*/
|
||||
export type V3WorkspaceContext = {
|
||||
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
|
||||
environmentId: string;
|
||||
/** Project ID used for projectTeam auth. */
|
||||
projectId: string;
|
||||
/** Organization ID used for org-level auth. */
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
|
||||
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
|
||||
*
|
||||
* @throws ResourceNotFoundError if the workspace (environment) does not exist.
|
||||
*/
|
||||
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
|
||||
// Today: workspaceId is the environment id (survey container). Look it up.
|
||||
const environment = await getEnvironment(workspaceId);
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("environment", workspaceId);
|
||||
}
|
||||
|
||||
// Derive org for auth; project comes from the environment.
|
||||
const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
|
||||
|
||||
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
|
||||
return {
|
||||
environmentId: workspaceId,
|
||||
projectId: environment.projectId,
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
118
apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.test.ts
Normal file
118
apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { collectMultiValueQueryParam, parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
|
||||
|
||||
const wid = "clxx1234567890123456789012";
|
||||
|
||||
function params(qs: string): URLSearchParams {
|
||||
return new URLSearchParams(qs);
|
||||
}
|
||||
|
||||
describe("collectMultiValueQueryParam", () => {
|
||||
test("merges repeated keys and comma-separated values", () => {
|
||||
const sp = params("status=draft&status=inProgress&type=link,app");
|
||||
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft", "inProgress"]);
|
||||
expect(collectMultiValueQueryParam(sp, "type")).toEqual(["link", "app"]);
|
||||
});
|
||||
|
||||
test("dedupes", () => {
|
||||
const sp = params("status=draft&status=draft");
|
||||
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseV3SurveysListQuery", () => {
|
||||
test("rejects unsupported query parameters like filterCriteria", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filterCriteria={}`), {
|
||||
sessionUserId: "u1",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.invalid_params[0].name).toBe("filterCriteria");
|
||||
});
|
||||
|
||||
test("rejects unknown query parameters", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&foo=bar`), {
|
||||
sessionUserId: "u1",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok)
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "foo",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, name, status, type, createdBy, sortBy.",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects the legacy after query parameter", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&after=legacy-cursor`), {
|
||||
sessionUserId: "u1",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "after",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, name, status, type, createdBy, sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("parses minimal query", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}`), { sessionUserId: "u1" });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.limit).toBe(20);
|
||||
expect(r.cursor).toBeNull();
|
||||
expect(r.sortBy).toBe("updatedAt");
|
||||
expect(r.filterCriteria).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("builds filter from flat params and sets createdBy.userId from session", () => {
|
||||
const r = parseV3SurveysListQuery(
|
||||
params(
|
||||
`workspaceId=${wid}&name=Foo&status=inProgress&status=draft&type=link&createdBy=you&sortBy=updatedAt`
|
||||
),
|
||||
{ sessionUserId: "session_user" }
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.filterCriteria).toEqual({
|
||||
name: "Foo",
|
||||
status: ["inProgress", "draft"],
|
||||
type: ["link"],
|
||||
createdBy: { userId: "session_user", value: ["you"] },
|
||||
});
|
||||
expect(r.sortBy).toBe("updatedAt");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid status", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&status=notastatus`), {
|
||||
sessionUserId: "u1",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("createdBy not allowed without session user", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&createdBy=you`), {
|
||||
sessionUserId: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.invalid_params[0].name).toBe("createdBy");
|
||||
});
|
||||
|
||||
test("rejects an invalid cursor", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&cursor=not-a-real-cursor`), {
|
||||
sessionUserId: "u1",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.invalid_params).toEqual([
|
||||
{
|
||||
name: "cursor",
|
||||
reason: "The cursor is invalid.",
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
186
apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
Normal file
186
apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
|
||||
* Keeps HTTP parsing separate from the route handler and shared survey list service.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
type TSurveyFilterCriteria,
|
||||
ZSurveyFilters,
|
||||
ZSurveyStatus,
|
||||
ZSurveyType,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
type TSurveyListPageCursor,
|
||||
type TSurveyListSort,
|
||||
decodeSurveyListPageCursor,
|
||||
normalizeSurveyListSort,
|
||||
} from "@/modules/survey/list/lib/survey-page";
|
||||
|
||||
const V3_SURVEYS_DEFAULT_LIMIT = 20;
|
||||
const V3_SURVEYS_MAX_LIMIT = 100;
|
||||
|
||||
const SUPPORTED_QUERY_PARAMS = [
|
||||
"workspaceId",
|
||||
"limit",
|
||||
"cursor",
|
||||
"name",
|
||||
"status",
|
||||
"type",
|
||||
"createdBy",
|
||||
"sortBy",
|
||||
] as const;
|
||||
const SUPPORTED_QUERY_PARAM_SET = new Set<string>(SUPPORTED_QUERY_PARAMS);
|
||||
|
||||
type InvalidParam = { name: string; reason: string };
|
||||
|
||||
/** Collect repeated query keys and comma-separated values: `status=a&status=b` or `status=a,b`. */
|
||||
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
|
||||
const acc: string[] = [];
|
||||
for (const raw of searchParams.getAll(key)) {
|
||||
for (const part of raw.split(",")) {
|
||||
const t = part.trim();
|
||||
if (t) acc.push(t);
|
||||
}
|
||||
}
|
||||
return [...new Set(acc)];
|
||||
}
|
||||
|
||||
const ZV3SurveysListQuery = z.object({
|
||||
workspaceId: ZId,
|
||||
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
|
||||
cursor: z.string().min(1).optional(),
|
||||
name: z
|
||||
.string()
|
||||
.max(512)
|
||||
.optional()
|
||||
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
|
||||
status: z.array(ZSurveyStatus).optional(),
|
||||
type: z.array(ZSurveyType).optional(),
|
||||
createdBy: ZSurveyFilters.shape.createdBy.optional(),
|
||||
sortBy: ZSurveyFilters.shape.sortBy.optional(),
|
||||
});
|
||||
|
||||
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
|
||||
|
||||
export type TV3SurveysListQueryParseResult =
|
||||
| {
|
||||
ok: true;
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor: TSurveyListPageCursor | null;
|
||||
sortBy: TSurveyListSort;
|
||||
filterCriteria: TSurveyFilterCriteria | undefined;
|
||||
}
|
||||
| { ok: false; invalid_params: InvalidParam[] };
|
||||
|
||||
function getUnsupportedQueryParams(searchParams: URLSearchParams): InvalidParam[] {
|
||||
const unsupportedParams = [
|
||||
...new Set(Array.from(searchParams.keys()).filter((key) => !SUPPORTED_QUERY_PARAM_SET.has(key))),
|
||||
];
|
||||
|
||||
return unsupportedParams.map((name) => ({
|
||||
name,
|
||||
reason: `Unsupported query parameter. Use only ${SUPPORTED_QUERY_PARAMS.join(", ")}.`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildFilterCriteria(
|
||||
q: TV3SurveysListQuery,
|
||||
sessionUserId: string | null
|
||||
): TSurveyFilterCriteria | undefined {
|
||||
const f: TSurveyFilterCriteria = {};
|
||||
if (q.name) f.name = q.name;
|
||||
if (q.status?.length) f.status = q.status;
|
||||
if (q.type?.length) f.type = q.type;
|
||||
if (q.createdBy?.length && sessionUserId) {
|
||||
f.createdBy = { userId: sessionUserId, value: q.createdBy };
|
||||
}
|
||||
return Object.keys(f).length > 0 ? f : undefined;
|
||||
}
|
||||
|
||||
export type TV3SurveysListQueryParseOptions = {
|
||||
sessionUserId: string | null;
|
||||
};
|
||||
|
||||
export function parseV3SurveysListQuery(
|
||||
searchParams: URLSearchParams,
|
||||
options: TV3SurveysListQueryParseOptions
|
||||
): TV3SurveysListQueryParseResult {
|
||||
const { sessionUserId } = options;
|
||||
|
||||
const unsupportedQueryParams = getUnsupportedQueryParams(searchParams);
|
||||
if (unsupportedQueryParams.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
invalid_params: unsupportedQueryParams,
|
||||
};
|
||||
}
|
||||
|
||||
const statusVals = collectMultiValueQueryParam(searchParams, "status");
|
||||
const typeVals = collectMultiValueQueryParam(searchParams, "type");
|
||||
const createdByVals = collectMultiValueQueryParam(searchParams, "createdBy");
|
||||
|
||||
if (createdByVals.length > 0 && sessionUserId === null) {
|
||||
return {
|
||||
ok: false,
|
||||
invalid_params: [
|
||||
{
|
||||
name: "createdBy",
|
||||
reason: "The createdBy filter is only supported with session authentication (not API keys).",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const raw = {
|
||||
workspaceId: searchParams.get("workspaceId"),
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
cursor: searchParams.get("cursor")?.trim() || undefined,
|
||||
name: searchParams.get("name") ?? undefined,
|
||||
status: statusVals.length > 0 ? statusVals : undefined,
|
||||
type: typeVals.length > 0 ? typeVals : undefined,
|
||||
createdBy: createdByVals.length > 0 ? createdByVals : undefined,
|
||||
sortBy: searchParams.get("sortBy")?.trim() || undefined,
|
||||
};
|
||||
|
||||
const result = ZV3SurveysListQuery.safeParse(raw);
|
||||
if (!result.success) {
|
||||
return {
|
||||
ok: false,
|
||||
invalid_params: result.error.issues.map((issue) => ({
|
||||
name: issue.path.join(".") || "query",
|
||||
reason: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const q = result.data;
|
||||
const sortBy = normalizeSurveyListSort(q.sortBy);
|
||||
let cursor: TSurveyListPageCursor | null = null;
|
||||
|
||||
if (q.cursor) {
|
||||
try {
|
||||
cursor = decodeSurveyListPageCursor(q.cursor, sortBy);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
invalid_params: [
|
||||
{
|
||||
name: "cursor",
|
||||
reason: error instanceof Error ? error.message : "The cursor is invalid.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
workspaceId: q.workspaceId,
|
||||
limit: q.limit,
|
||||
cursor,
|
||||
sortBy,
|
||||
filterCriteria: buildFilterCriteria(q, sessionUserId),
|
||||
};
|
||||
}
|
||||
360
apps/web/app/api/v3/surveys/route.test.ts
Normal file
360
apps/web/app/api/v3/surveys/route.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { GET } from "./route";
|
||||
|
||||
const { mockAuthenticateRequest } = vi.hoisted(() => ({
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
|
||||
return { ...actual, authenticateRequest: mockAuthenticateRequest };
|
||||
});
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return { ...actual, AUDIT_LOG_ENABLED: false };
|
||||
});
|
||||
|
||||
vi.mock("@/app/api/v3/lib/auth", () => ({
|
||||
requireV3WorkspaceAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
|
||||
return {
|
||||
...actual,
|
||||
getSurveyListPage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
|
||||
|
||||
const validWorkspaceId = "clxx1234567890123456789012";
|
||||
const resolvedEnvironmentId = "clzz9876543210987654321098";
|
||||
|
||||
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
|
||||
const headers: Record<string, string> = { ...extraHeaders };
|
||||
if (requestId) headers["x-request-id"] = requestId;
|
||||
return new NextRequest(url, { headers });
|
||||
}
|
||||
|
||||
const apiKeyAuth = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: {
|
||||
accessControl: { read: true, write: false },
|
||||
},
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: validWorkspaceId,
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "proj_1",
|
||||
projectName: "P",
|
||||
permission: ApiKeyPermission.read,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("GET /api/v3/surveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: "user_1", name: "User", email: "u@example.com" },
|
||||
expires: "2026-01-01",
|
||||
} as any);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
|
||||
if (auth && "apiKeyId" in auth) {
|
||||
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
|
||||
if (!p) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
requestId: "req",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
|
||||
);
|
||||
}
|
||||
return {
|
||||
environmentId: workspaceId,
|
||||
projectId: p.projectId,
|
||||
organizationId: auth.organizationId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
environmentId: resolvedEnvironmentId,
|
||||
projectId: "proj_1",
|
||||
organizationId: "org_1",
|
||||
};
|
||||
});
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns 401 when no session and no API key", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 200 with session and valid workspaceId", async () => {
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/json");
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-456");
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: expect.any(Object) }),
|
||||
validWorkspaceId,
|
||||
"read",
|
||||
"req-456",
|
||||
"/api/v3/surveys"
|
||||
);
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
|
||||
limit: 20,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 200 with x-api-key when workspace is on the key", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
|
||||
"x-api-key": "fbk_test",
|
||||
});
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKeyId: "key_1" }),
|
||||
validWorkspaceId,
|
||||
"read",
|
||||
"req-k",
|
||||
"/api/v3/surveys"
|
||||
);
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
|
||||
limit: 20,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 403 when API key does not include workspace", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
...apiKeyAuth,
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "claa1111111111111111111111",
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "proj_x",
|
||||
projectName: "X",
|
||||
permission: ApiKeyPermission.read,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
|
||||
"x-api-key": "fbk_test",
|
||||
});
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("returns 400 when API key and createdBy filter", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&createdBy=you`,
|
||||
undefined,
|
||||
{ "x-api-key": "fbk_test" }
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.invalid_params?.some((p: { name: string }) => p.name === "createdBy")).toBe(true);
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 400 when workspaceId is missing", async () => {
|
||||
const req = createRequest("http://localhost/api/v3/surveys");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 400 when workspaceId is not cuid2", async () => {
|
||||
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 400 when limit exceeds max", async () => {
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("reflects limit and nextCursor in meta", async () => {
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({
|
||||
surveys: [],
|
||||
nextCursor: "cursor-123",
|
||||
});
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123" });
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
|
||||
limit: 10,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("passes filter query to getSurveyListPage", async () => {
|
||||
const filterCriteria = { status: ["inProgress"] };
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&status=inProgress&sortBy=updatedAt`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
|
||||
limit: 20,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria,
|
||||
});
|
||||
});
|
||||
|
||||
test("createdBy uses session user id", async () => {
|
||||
const expectedForDb = {
|
||||
createdBy: { userId: "user_1", value: ["you" as const] },
|
||||
};
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&createdBy=you&sortBy=updatedAt`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
|
||||
limit: 20,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria: expectedForDb,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 400 when filterCriteria is used", async () => {
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when auth returns 403", async () => {
|
||||
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
requestId: "req-789",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
|
||||
)
|
||||
);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("list items omit blocks and questions", async () => {
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({
|
||||
surveys: [
|
||||
{
|
||||
id: "s1",
|
||||
name: "Survey 1",
|
||||
environmentId: "env_1",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
responseCount: 0,
|
||||
creator: { name: "Test" },
|
||||
singleUse: null,
|
||||
} as any,
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
const body = await res.json();
|
||||
expect(body.data[0]).not.toHaveProperty("blocks");
|
||||
expect(body.data[0]).not.toHaveProperty("singleUse");
|
||||
expect(body.data[0].id).toBe("s1");
|
||||
});
|
||||
|
||||
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
|
||||
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
|
||||
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
});
|
||||
|
||||
test("returns 500 on unexpected error from getSurveyListPage", async () => {
|
||||
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
});
|
||||
});
|
||||
85
apps/web/app/api/v3/surveys/route.ts
Normal file
85
apps/web/app/api/v3/surveys/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* GET /api/v3/surveys — list surveys for a workspace.
|
||||
* Session cookie or x-api-key; scope by workspaceId only.
|
||||
*/
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import {
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
successListResponse,
|
||||
} from "@/app/api/v3/lib/response";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import type { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
|
||||
|
||||
/** V3 list payload omits `singleUse`. */
|
||||
function toV3SurveyListItem(survey: TSurvey): Omit<TSurvey, "singleUse"> {
|
||||
const { singleUse: _omit, ...rest } = survey;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export const GET = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler: async ({ req, authentication, requestId, instance }) => {
|
||||
const log = logger.withContext({ requestId });
|
||||
|
||||
try {
|
||||
const sessionUserId =
|
||||
"user" in authentication && authentication.user?.id ? authentication.user.id : null;
|
||||
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const parsed = parseV3SurveysListQuery(searchParams, { sessionUserId });
|
||||
if (!parsed.ok) {
|
||||
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
|
||||
return problemBadRequest(requestId, "Invalid query parameters", {
|
||||
invalid_params: parsed.invalid_params,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
parsed.workspaceId,
|
||||
"read",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const { environmentId } = authResult;
|
||||
|
||||
const { surveys, nextCursor } = await getSurveyListPage(environmentId, {
|
||||
limit: parsed.limit,
|
||||
cursor: parsed.cursor,
|
||||
sortBy: parsed.sortBy,
|
||||
filterCriteria: parsed.filterCriteria,
|
||||
});
|
||||
|
||||
return successListResponse(
|
||||
surveys.map(toV3SurveyListItem),
|
||||
{
|
||||
limit: parsed.limit,
|
||||
nextCursor,
|
||||
},
|
||||
{ requestId, cache: "private, no-store" }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
if (err instanceof DatabaseError) {
|
||||
log.error({ error: err, statusCode: 500 }, "Database error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -421,6 +421,38 @@ describe("withV1ApiWrapper", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { getServerSession } = await import("next-auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Session,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const custom401 = new Response(JSON.stringify({ title: "Custom", status: 401 }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/problem+json" },
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "https://api.test/api/v3/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({
|
||||
handler,
|
||||
unauthenticatedResponse: () => custom401,
|
||||
});
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res).toBe(custom401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(mockContextualLoggerError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
|
||||
@@ -38,6 +38,11 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
|
||||
action?: TAuditAction;
|
||||
targetType?: TAuditTarget;
|
||||
customRateLimitConfig?: TRateLimitConfig;
|
||||
/**
|
||||
* When the route requires auth but the client is unauthenticated, the wrapper normally returns
|
||||
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
|
||||
*/
|
||||
unauthenticatedResponse?: (req: NextRequest) => Response;
|
||||
}
|
||||
|
||||
enum ApiV1RouteTypeEnum {
|
||||
@@ -265,7 +270,7 @@ const getRouteType = (
|
||||
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { handler, action, targetType, customRateLimitConfig } = params;
|
||||
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
// === Audit Log Setup ===
|
||||
const saveAuditLog = action && targetType;
|
||||
@@ -287,6 +292,11 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
|
||||
const authentication = await handleAuthentication(authenticationMethod, req);
|
||||
|
||||
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
|
||||
if (unauthenticatedResponse) {
|
||||
const res = unauthenticatedResponse(req);
|
||||
await processResponse(res, req, auditLog);
|
||||
return res;
|
||||
}
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,17 @@ describe("endpoint-validator", () => {
|
||||
});
|
||||
|
||||
describe("isManagementApiRoute", () => {
|
||||
test("should return Both for v3 surveys routes", () => {
|
||||
expect(isManagementApiRoute("/api/v3/surveys")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Both,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v3/surveys/clxxxxxxxxxxxxxxxxxxxxxxxx")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Both,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct object for management API routes with API key authentication", () => {
|
||||
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
|
||||
isManagementApi: true,
|
||||
|
||||
@@ -22,6 +22,9 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
|
||||
export const isManagementApiRoute = (
|
||||
url: string
|
||||
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
|
||||
// V3 surveys: session cookie or x-api-key (same pattern as management storage)
|
||||
if (/^\/api\/v3\/surveys(?:\/|$)/.test(url))
|
||||
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
|
||||
if (url.includes("/api/v1/management/storage"))
|
||||
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
|
||||
if (url.includes("/api/v1/webhooks"))
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("helpers", () => {
|
||||
test("should allow request when rate limit check passes", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined();
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toEqual({ allowed: true });
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
@@ -127,7 +127,7 @@ describe("helpers", () => {
|
||||
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined();
|
||||
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toEqual({ allowed: true });
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier");
|
||||
});
|
||||
@@ -138,7 +138,7 @@ describe("helpers", () => {
|
||||
const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"];
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined();
|
||||
await expect(applyRateLimit(mockConfig, identifier)).resolves.toEqual({ allowed: true });
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("helpers", () => {
|
||||
(hashString as any).mockReturnValue("hashed-ip-123");
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined();
|
||||
await expect(applyIPRateLimit(mockConfig)).resolves.toEqual({ allowed: true });
|
||||
|
||||
expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1);
|
||||
expect(hashString).toHaveBeenCalledWith("192.168.1.1");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
import { type TRateLimitConfig } from "./types/rate-limit";
|
||||
import { type TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit";
|
||||
|
||||
/**
|
||||
* Get client identifier for rate limiting with IP hashing
|
||||
@@ -31,12 +31,20 @@ export const getClientIdentifier = async (): Promise<string> => {
|
||||
* @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.)
|
||||
* @throws {Error} When rate limit is exceeded or rate limiting system fails
|
||||
*/
|
||||
export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise<void> => {
|
||||
export const applyRateLimit = async (
|
||||
config: TRateLimitConfig,
|
||||
identifier: string
|
||||
): Promise<TRateLimitResponse> => {
|
||||
const result = await checkRateLimit(config, identifier);
|
||||
|
||||
if (!result.ok || !result.data.allowed) {
|
||||
throw new TooManyRequestsError("Maximum number of requests reached. Please try again later.");
|
||||
throw new TooManyRequestsError(
|
||||
"Maximum number of requests reached. Please try again later.",
|
||||
result.ok ? result.data.retryAfter : undefined
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -46,7 +54,7 @@ export const applyRateLimit = async (config: TRateLimitConfig, identifier: strin
|
||||
* @param config - Rate limit configuration to apply
|
||||
* @throws {Error} When rate limit is exceeded or IP hashing fails
|
||||
*/
|
||||
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<void> => {
|
||||
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<TRateLimitResponse> => {
|
||||
const identifier = await getClientIdentifier();
|
||||
await applyRateLimit(config, identifier);
|
||||
return await applyRateLimit(config, identifier);
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all API configurations", () => {
|
||||
const apiConfigs = Object.keys(rateLimitConfigs.api);
|
||||
expect(apiConfigs).toEqual(["v1", "v2", "client"]);
|
||||
expect(apiConfigs).toEqual(["v1", "v2", "v3", "client"]);
|
||||
});
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
@@ -127,7 +127,7 @@ describe("rateLimitConfigs", () => {
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
|
||||
const config = rateLimitConfigs.api.v1;
|
||||
await expect(applyRateLimit(config, "api-key-123")).resolves.toBeUndefined();
|
||||
await expect(applyRateLimit(config, "api-key-123")).resolves.toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
test("should enforce limits correctly for each config type", async () => {
|
||||
@@ -136,6 +136,7 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
|
||||
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
|
||||
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
|
||||
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
|
||||
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
|
||||
|
||||
@@ -11,6 +11,7 @@ export const rateLimitConfigs = {
|
||||
api: {
|
||||
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API)
|
||||
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
|
||||
v3: { interval: 60, allowedPerInterval: 100, namespace: "api:v3" }, // 100 per minute
|
||||
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API)
|
||||
},
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export const checkRateLimit = async (
|
||||
|
||||
const response: TRateLimitResponse = {
|
||||
allowed: isAllowed === 1,
|
||||
retryAfter: isAllowed === 1 ? undefined : ttlSeconds,
|
||||
};
|
||||
|
||||
// Log rate limit violations for security monitoring
|
||||
|
||||
@@ -13,6 +13,7 @@ export type TRateLimitConfig = z.infer<typeof ZRateLimitConfig>;
|
||||
|
||||
const ZRateLimitResponse = z.object({
|
||||
allowed: z.boolean().describe("Whether the request is allowed"),
|
||||
retryAfter: z.int().positive().optional().describe("Seconds until the current rate-limit window resets"),
|
||||
});
|
||||
|
||||
export type TRateLimitResponse = z.infer<typeof ZRateLimitResponse>;
|
||||
|
||||
323
apps/web/modules/survey/list/lib/survey-page.test.ts
Normal file
323
apps/web/modules/survey/list/lib/survey-page.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import { decodeSurveyListPageCursor, encodeSurveyListPageCursor, getSurveyListPage } from "./survey-page";
|
||||
|
||||
vi.mock("@/modules/survey/lib/utils", () => ({
|
||||
buildWhereClause: vi.fn(() => ({ AND: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "env_123";
|
||||
|
||||
function makeSurveyRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "survey_1",
|
||||
name: "Survey 1",
|
||||
environmentId,
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
creator: { name: "Alice" },
|
||||
singleUse: null,
|
||||
_count: { responses: 3 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("survey-page cursor helpers", () => {
|
||||
test("encodes and decodes an updatedAt cursor", () => {
|
||||
const encoded = encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2025-01-02T00:00:00.000Z",
|
||||
id: "survey_1",
|
||||
});
|
||||
|
||||
expect(decodeSurveyListPageCursor(encoded, "updatedAt")).toEqual({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2025-01-02T00:00:00.000Z",
|
||||
id: "survey_1",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a cursor that does not match the requested sort order", () => {
|
||||
const encoded = encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "name",
|
||||
value: "Survey 1",
|
||||
id: "survey_1",
|
||||
});
|
||||
|
||||
expect(() => decodeSurveyListPageCursor(encoded, "updatedAt")).toThrow(InvalidInputError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurveyListPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("uses a stable updatedAt order with a next cursor", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
makeSurveyRow({ id: "survey_2", updatedAt: new Date("2025-01-03T00:00:00.000Z") }),
|
||||
makeSurveyRow({ id: "survey_1", updatedAt: new Date("2025-01-02T00:00:00.000Z") }),
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
});
|
||||
|
||||
expect(buildWhereClause).toHaveBeenCalledWith(undefined);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId, AND: [] },
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 2,
|
||||
});
|
||||
expect(page.surveys).toHaveLength(1);
|
||||
expect(page.surveys[0].responseCount).toBe(3);
|
||||
expect(page.nextCursor).not.toBeNull();
|
||||
expect(decodeSurveyListPageCursor(page.nextCursor as string, "updatedAt")).toEqual({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2025-01-03T00:00:00.000Z",
|
||||
id: "survey_2",
|
||||
});
|
||||
});
|
||||
|
||||
test("applies a name cursor for forward pagination", async () => {
|
||||
const cursor = decodeSurveyListPageCursor(
|
||||
encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "name",
|
||||
value: "Bravo",
|
||||
id: "survey_b",
|
||||
}),
|
||||
"name"
|
||||
);
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
makeSurveyRow({ id: "survey_c", name: "Charlie" }),
|
||||
] as never);
|
||||
|
||||
await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
cursor,
|
||||
sortBy: "name",
|
||||
});
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
AND: [],
|
||||
OR: [{ name: { gt: "Bravo" } }, { name: "Bravo", id: { gt: "survey_b" } }],
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
take: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("paginates relevance by exhausting in-progress surveys before others", async () => {
|
||||
vi.mocked(prisma.survey.findMany)
|
||||
.mockResolvedValueOnce([
|
||||
makeSurveyRow({
|
||||
id: "survey_in_progress",
|
||||
status: "inProgress",
|
||||
updatedAt: new Date("2025-01-03T00:00:00.000Z"),
|
||||
}),
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
makeSurveyRow({
|
||||
id: "survey_other_1",
|
||||
status: "completed",
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
}),
|
||||
makeSurveyRow({
|
||||
id: "survey_other_2",
|
||||
status: "paused",
|
||||
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
cursor: null,
|
||||
sortBy: "relevance",
|
||||
});
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(1, {
|
||||
where: {
|
||||
environmentId,
|
||||
AND: [],
|
||||
status: "inProgress",
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 3,
|
||||
});
|
||||
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(2, {
|
||||
where: {
|
||||
environmentId,
|
||||
AND: [],
|
||||
status: { not: "inProgress" },
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 2,
|
||||
});
|
||||
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_in_progress", "survey_other_1"]);
|
||||
expect(decodeSurveyListPageCursor(page.nextCursor as string, "relevance")).toEqual({
|
||||
version: 1,
|
||||
sortBy: "relevance",
|
||||
bucket: "other",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
id: "survey_other_1",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an in-progress next cursor when the page fills before switching to other surveys", async () => {
|
||||
vi.mocked(prisma.survey.findMany)
|
||||
.mockResolvedValueOnce([
|
||||
makeSurveyRow({
|
||||
id: "survey_in_progress",
|
||||
status: "inProgress",
|
||||
updatedAt: new Date("2025-01-03T00:00:00.000Z"),
|
||||
}),
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
makeSurveyRow({
|
||||
id: "survey_other_1",
|
||||
status: "completed",
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "relevance",
|
||||
});
|
||||
|
||||
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_in_progress"]);
|
||||
expect(decodeSurveyListPageCursor(page.nextCursor as string, "relevance")).toEqual({
|
||||
version: 1,
|
||||
sortBy: "relevance",
|
||||
bucket: "inProgress",
|
||||
updatedAt: "2025-01-03T00:00:00.000Z",
|
||||
id: "survey_in_progress",
|
||||
});
|
||||
});
|
||||
|
||||
test("continues relevance pagination from the other bucket cursor", async () => {
|
||||
const cursor = decodeSurveyListPageCursor(
|
||||
encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "relevance",
|
||||
bucket: "other",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
id: "survey_other_1",
|
||||
}),
|
||||
"relevance"
|
||||
);
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
makeSurveyRow({
|
||||
id: "survey_other_2",
|
||||
status: "completed",
|
||||
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
cursor,
|
||||
sortBy: "relevance",
|
||||
});
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledOnce();
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
AND: [],
|
||||
status: { not: "inProgress" },
|
||||
OR: [
|
||||
{ updatedAt: { lt: new Date("2025-01-02T00:00:00.000Z") } },
|
||||
{
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
id: { lt: "survey_other_1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 3,
|
||||
});
|
||||
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_other_2"]);
|
||||
expect(page.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
test("wraps Prisma errors as DatabaseError", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("db failed", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
})
|
||||
).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting paginated surveys");
|
||||
});
|
||||
|
||||
test("rethrows InvalidInputError unchanged", async () => {
|
||||
const invalidInputError = new InvalidInputError("bad cursor");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(invalidInputError);
|
||||
|
||||
await expect(
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
})
|
||||
).rejects.toThrow(invalidInputError);
|
||||
});
|
||||
|
||||
test("rethrows unexpected errors unchanged", async () => {
|
||||
const unexpectedError = new Error("boom");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(unexpectedError);
|
||||
|
||||
await expect(
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
})
|
||||
).rejects.toThrow(unexpectedError);
|
||||
});
|
||||
});
|
||||
390
apps/web/modules/survey/list/lib/survey-page.ts
Normal file
390
apps/web/modules/survey/list/lib/survey-page.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import { buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import type { TSurvey } from "../types/surveys";
|
||||
import { surveySelect } from "./survey";
|
||||
|
||||
const SURVEY_LIST_CURSOR_VERSION = 1 as const;
|
||||
const IN_PROGRESS_BUCKET = "inProgress" as const;
|
||||
const OTHER_BUCKET = "other" as const;
|
||||
|
||||
type TSurveyRow = Prisma.SurveyGetPayload<{ select: typeof surveySelect }>;
|
||||
|
||||
const ZDateCursor = z.object({
|
||||
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
|
||||
sortBy: z.enum(["updatedAt", "createdAt"]),
|
||||
value: z.iso.datetime(),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const ZNameCursor = z.object({
|
||||
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
|
||||
sortBy: z.literal("name"),
|
||||
value: z.string(),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const ZRelevanceCursor = z.object({
|
||||
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
|
||||
sortBy: z.literal("relevance"),
|
||||
bucket: z.enum([IN_PROGRESS_BUCKET, OTHER_BUCKET]),
|
||||
updatedAt: z.iso.datetime(),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const ZSurveyListPageCursor = z.union([ZDateCursor, ZNameCursor, ZRelevanceCursor]);
|
||||
|
||||
export type TSurveyListSort = NonNullable<TSurveyFilterCriteria["sortBy"]>;
|
||||
export type TSurveyListPageCursor = z.infer<typeof ZSurveyListPageCursor>;
|
||||
|
||||
export type TSurveyListPage = {
|
||||
surveys: TSurvey[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
type TGetSurveyListPageOptions = {
|
||||
limit: number;
|
||||
cursor: TSurveyListPageCursor | null;
|
||||
sortBy: TSurveyListSort;
|
||||
filterCriteria?: TSurveyFilterCriteria;
|
||||
};
|
||||
|
||||
type TCursorDirection = "asc" | "desc";
|
||||
|
||||
export function normalizeSurveyListSort(sortBy?: TSurveyFilterCriteria["sortBy"]): TSurveyListSort {
|
||||
return sortBy ?? "updatedAt";
|
||||
}
|
||||
|
||||
export function encodeSurveyListPageCursor(cursor: TSurveyListPageCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
export function decodeSurveyListPageCursor(
|
||||
encodedCursor: string,
|
||||
sortBy: TSurveyListSort
|
||||
): TSurveyListPageCursor {
|
||||
try {
|
||||
const decodedJson = Buffer.from(encodedCursor, "base64url").toString("utf8");
|
||||
const parsedCursor = ZSurveyListPageCursor.parse(JSON.parse(decodedJson));
|
||||
|
||||
if (parsedCursor.sortBy !== sortBy) {
|
||||
throw new InvalidInputError("The cursor does not match the requested sort order.");
|
||||
}
|
||||
|
||||
return parsedCursor;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new InvalidInputError("The cursor is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function mapSurveyRows(rows: TSurveyRow[]): TSurvey[] {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
responseCount: row._count.responses,
|
||||
}));
|
||||
}
|
||||
|
||||
function getSurveyOrderBy(
|
||||
sortBy: Exclude<TSurveyListSort, "relevance">
|
||||
): Prisma.SurveyOrderByWithRelationInput[] {
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
return [{ name: "asc" }, { id: "asc" }];
|
||||
case "createdAt":
|
||||
return [{ createdAt: "desc" }, { id: "desc" }];
|
||||
case "updatedAt":
|
||||
default:
|
||||
return [{ updatedAt: "desc" }, { id: "desc" }];
|
||||
}
|
||||
}
|
||||
|
||||
function buildDateCursorWhere(
|
||||
field: "createdAt" | "updatedAt",
|
||||
cursorValue: string,
|
||||
cursorId: string,
|
||||
direction: TCursorDirection
|
||||
): Prisma.SurveyWhereInput {
|
||||
const comparisonOperator = direction === "desc" ? "lt" : "gt";
|
||||
const cursorDate = new Date(cursorValue);
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ [field]: { [comparisonOperator]: cursorDate } },
|
||||
{
|
||||
[field]: cursorDate,
|
||||
id: { [comparisonOperator]: cursorId },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildNameCursorWhere(cursorValue: string, cursorId: string): Prisma.SurveyWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ name: { gt: cursorValue } },
|
||||
{
|
||||
name: cursorValue,
|
||||
id: { gt: cursorId },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildStandardCursorWhere(
|
||||
sortBy: Exclude<TSurveyListSort, "relevance">,
|
||||
cursor: Extract<TSurveyListPageCursor, { sortBy: "updatedAt" | "createdAt" | "name" }>
|
||||
): Prisma.SurveyWhereInput {
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
return buildNameCursorWhere(cursor.value, cursor.id);
|
||||
case "createdAt":
|
||||
return buildDateCursorWhere("createdAt", cursor.value, cursor.id, "desc");
|
||||
case "updatedAt":
|
||||
default:
|
||||
return buildDateCursorWhere("updatedAt", cursor.value, cursor.id, "desc");
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseWhere(
|
||||
environmentId: string,
|
||||
filterCriteria?: TSurveyFilterCriteria,
|
||||
extraWhere?: Prisma.SurveyWhereInput
|
||||
): Prisma.SurveyWhereInput {
|
||||
return {
|
||||
environmentId,
|
||||
...buildWhereClause(filterCriteria),
|
||||
...extraWhere,
|
||||
};
|
||||
}
|
||||
|
||||
function getStandardNextCursor(
|
||||
survey: TSurveyRow,
|
||||
sortBy: Exclude<TSurveyListSort, "relevance">
|
||||
): TSurveyListPageCursor {
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
return {
|
||||
version: SURVEY_LIST_CURSOR_VERSION,
|
||||
sortBy,
|
||||
value: survey.name,
|
||||
id: survey.id,
|
||||
};
|
||||
case "createdAt":
|
||||
return {
|
||||
version: SURVEY_LIST_CURSOR_VERSION,
|
||||
sortBy,
|
||||
value: survey.createdAt.toISOString(),
|
||||
id: survey.id,
|
||||
};
|
||||
case "updatedAt":
|
||||
default:
|
||||
return {
|
||||
version: SURVEY_LIST_CURSOR_VERSION,
|
||||
sortBy,
|
||||
value: survey.updatedAt.toISOString(),
|
||||
id: survey.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getRelevanceNextCursor(
|
||||
survey: TSurveyRow,
|
||||
bucket: typeof IN_PROGRESS_BUCKET | typeof OTHER_BUCKET
|
||||
): TSurveyListPageCursor {
|
||||
return {
|
||||
version: SURVEY_LIST_CURSOR_VERSION,
|
||||
sortBy: "relevance",
|
||||
bucket,
|
||||
updatedAt: survey.updatedAt.toISOString(),
|
||||
id: survey.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function findSurveyRows(
|
||||
environmentId: string,
|
||||
limit: number,
|
||||
sortBy: Exclude<TSurveyListSort, "relevance">,
|
||||
filterCriteria?: TSurveyFilterCriteria,
|
||||
cursor?: Extract<TSurveyListPageCursor, { sortBy: "updatedAt" | "createdAt" | "name" }> | null,
|
||||
extraWhere?: Prisma.SurveyWhereInput
|
||||
): Promise<TSurveyRow[]> {
|
||||
const cursorWhere = cursor ? buildStandardCursorWhere(sortBy, cursor) : undefined;
|
||||
|
||||
return prisma.survey.findMany({
|
||||
where: buildBaseWhere(environmentId, filterCriteria, {
|
||||
...extraWhere,
|
||||
...cursorWhere,
|
||||
}),
|
||||
select: surveySelect,
|
||||
orderBy: getSurveyOrderBy(sortBy),
|
||||
take: limit + 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function getStandardSurveyListPage(
|
||||
environmentId: string,
|
||||
options: TGetSurveyListPageOptions & { sortBy: Exclude<TSurveyListSort, "relevance"> }
|
||||
): Promise<TSurveyListPage> {
|
||||
const surveyRows = await findSurveyRows(
|
||||
environmentId,
|
||||
options.limit,
|
||||
options.sortBy,
|
||||
options.filterCriteria,
|
||||
options.cursor as Extract<TSurveyListPageCursor, { sortBy: "updatedAt" | "createdAt" | "name" }> | null
|
||||
);
|
||||
|
||||
const hasMore = surveyRows.length > options.limit;
|
||||
const pageRows = hasMore ? surveyRows.slice(0, options.limit) : surveyRows;
|
||||
const nextCursor =
|
||||
hasMore && pageRows.length > 0
|
||||
? encodeSurveyListPageCursor(getStandardNextCursor(pageRows[pageRows.length - 1], options.sortBy))
|
||||
: null;
|
||||
|
||||
return {
|
||||
surveys: mapSurveyRows(pageRows),
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async function findRelevanceRows(
|
||||
environmentId: string,
|
||||
limit: number,
|
||||
filterCriteria: TSurveyFilterCriteria | undefined,
|
||||
bucket: typeof IN_PROGRESS_BUCKET | typeof OTHER_BUCKET,
|
||||
cursor: Extract<TSurveyListPageCursor, { sortBy: "relevance" }> | null
|
||||
): Promise<TSurveyRow[]> {
|
||||
const statusWhere: Prisma.SurveyWhereInput =
|
||||
bucket === IN_PROGRESS_BUCKET ? { status: "inProgress" } : { status: { not: "inProgress" } };
|
||||
const cursorWhere = cursor
|
||||
? buildDateCursorWhere("updatedAt", cursor.updatedAt, cursor.id, "desc")
|
||||
: undefined;
|
||||
|
||||
return prisma.survey.findMany({
|
||||
where: buildBaseWhere(environmentId, filterCriteria, {
|
||||
...statusWhere,
|
||||
...cursorWhere,
|
||||
}),
|
||||
select: surveySelect,
|
||||
orderBy: getSurveyOrderBy("updatedAt"),
|
||||
take: limit + 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function hasMoreRelevanceRowsInOtherBucket(
|
||||
environmentId: string,
|
||||
filterCriteria?: TSurveyFilterCriteria
|
||||
): Promise<boolean> {
|
||||
const otherRows = await findRelevanceRows(environmentId, 1, filterCriteria, OTHER_BUCKET, null);
|
||||
return otherRows.length > 0;
|
||||
}
|
||||
|
||||
async function getRelevanceSurveyListPage(
|
||||
environmentId: string,
|
||||
options: TGetSurveyListPageOptions & { sortBy: "relevance" }
|
||||
): Promise<TSurveyListPage> {
|
||||
const pageRows: TSurveyRow[] = [];
|
||||
let remaining = options.limit;
|
||||
|
||||
if (!options.cursor || options.cursor.bucket === IN_PROGRESS_BUCKET) {
|
||||
const inProgressRows = await findRelevanceRows(
|
||||
environmentId,
|
||||
remaining,
|
||||
options.filterCriteria,
|
||||
IN_PROGRESS_BUCKET,
|
||||
options.cursor?.bucket === IN_PROGRESS_BUCKET ? options.cursor : null
|
||||
);
|
||||
|
||||
const hasMoreInProgress = inProgressRows.length > remaining;
|
||||
const inProgressPageRows = hasMoreInProgress ? inProgressRows.slice(0, remaining) : inProgressRows;
|
||||
pageRows.push(...inProgressPageRows);
|
||||
|
||||
if (hasMoreInProgress && inProgressPageRows.length > 0) {
|
||||
return {
|
||||
surveys: mapSurveyRows(inProgressPageRows),
|
||||
nextCursor: encodeSurveyListPageCursor(
|
||||
getRelevanceNextCursor(inProgressPageRows[inProgressPageRows.length - 1], IN_PROGRESS_BUCKET)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
remaining -= inProgressPageRows.length;
|
||||
}
|
||||
|
||||
if (remaining <= 0) {
|
||||
const hasOtherRows =
|
||||
pageRows.length > 0 &&
|
||||
(!options.cursor || options.cursor.bucket === IN_PROGRESS_BUCKET) &&
|
||||
(await hasMoreRelevanceRowsInOtherBucket(environmentId, options.filterCriteria));
|
||||
|
||||
return {
|
||||
surveys: mapSurveyRows(pageRows),
|
||||
nextCursor:
|
||||
hasOtherRows && pageRows.length > 0
|
||||
? encodeSurveyListPageCursor(
|
||||
getRelevanceNextCursor(pageRows[pageRows.length - 1], IN_PROGRESS_BUCKET)
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const otherRows = await findRelevanceRows(
|
||||
environmentId,
|
||||
remaining,
|
||||
options.filterCriteria,
|
||||
OTHER_BUCKET,
|
||||
options.cursor?.bucket === OTHER_BUCKET ? options.cursor : null
|
||||
);
|
||||
|
||||
const hasMoreOther = otherRows.length > remaining;
|
||||
const otherPageRows = hasMoreOther ? otherRows.slice(0, remaining) : otherRows;
|
||||
pageRows.push(...otherPageRows);
|
||||
|
||||
return {
|
||||
surveys: mapSurveyRows(pageRows),
|
||||
nextCursor:
|
||||
hasMoreOther && otherPageRows.length > 0
|
||||
? encodeSurveyListPageCursor(
|
||||
getRelevanceNextCursor(otherPageRows[otherPageRows.length - 1], OTHER_BUCKET)
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSurveyListPage(
|
||||
environmentId: string,
|
||||
options: TGetSurveyListPageOptions
|
||||
): Promise<TSurveyListPage> {
|
||||
try {
|
||||
if (options.sortBy === "relevance") {
|
||||
return await getRelevanceSurveyListPage(environmentId, {
|
||||
...options,
|
||||
sortBy: "relevance",
|
||||
});
|
||||
}
|
||||
|
||||
return await getStandardSurveyListPage(environmentId, {
|
||||
...options,
|
||||
sortBy: options.sortBy,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting paginated surveys");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -605,22 +605,26 @@ export const copySurveyToOtherEnvironment = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getSurveyCount = reactCache(async (environmentId: string): Promise<number> => {
|
||||
validateInputs([environmentId, z.cuid2()]);
|
||||
try {
|
||||
const surveyCount = await prisma.survey.count({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
},
|
||||
});
|
||||
/** Count surveys in an environment, optionally with the same filter as getSurveys (so total matches list). */
|
||||
export const getSurveyCount = reactCache(
|
||||
async (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise<number> => {
|
||||
validateInputs([environmentId, z.cuid2()]);
|
||||
try {
|
||||
const surveyCount = await prisma.survey.count({
|
||||
where: {
|
||||
environmentId,
|
||||
...buildWhereClause(filterCriteria),
|
||||
},
|
||||
});
|
||||
|
||||
return surveyCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey count");
|
||||
throw new DatabaseError(error.message);
|
||||
return surveyCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey count");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
238
docs/api-v3-reference/openapi-surveys.yml
Normal file
238
docs/api-v3-reference/openapi-surveys.yml
Normal file
@@ -0,0 +1,238 @@
|
||||
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
|
||||
# Implementation: apps/web/app/api/v3/surveys/route.ts
|
||||
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
|
||||
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Formbricks API v3 (Surveys)
|
||||
description: |
|
||||
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
|
||||
|
||||
**Spec location:** `docs/api-v3-reference/openapi-surveys.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
|
||||
|
||||
**workspaceId (today)**
|
||||
Query param `workspaceId` is the **environment id** (survey container in the DB). The API uses the name *workspace* because the product is moving toward **Workspace** as the default container; until that exists, resolution is implemented in `workspace-context.ts` (single place to change when Environment is deprecated).
|
||||
|
||||
**Auth**
|
||||
Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation. **`createdBy` is session-only** (API key + `createdBy` → **400**).
|
||||
|
||||
**Pagination**
|
||||
Cursor-based pagination with **limit** + opaque **cursor** token. Responses return `meta.nextCursor`; pass that value back as `cursor` to fetch the next page. There is no `offset` or `total` count in this contract.
|
||||
|
||||
**Filtering**
|
||||
Filters and sort are **flat query parameters** (`name`, `status`, `type`, `createdBy`, `sortBy`). Arrays use repeated keys or comma-separated values (e.g. `status=draft&status=inProgress`).
|
||||
|
||||
**Security**
|
||||
Missing/forbidden workspace returns **403** with a generic message (not **404**) so resource existence is not leaked. List responses use `private, no-store`.
|
||||
|
||||
**OpenAPI**
|
||||
This YAML is **not** produced by `pnpm generate-api-specs` (that script only builds v2 → `docs/api-v2-reference/openapi.yml`). Update this file when the route contract changes.
|
||||
|
||||
**Next steps (out of scope for this spec)**
|
||||
Additional v3 survey endpoints (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
|
||||
version: 0.1.0
|
||||
x-implementation-notes:
|
||||
route: apps/web/app/api/v3/surveys/route.ts
|
||||
query-parser: apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
|
||||
auth: apps/web/app/api/v3/lib/auth.ts
|
||||
workspace-resolution: apps/web/app/api/v3/lib/workspace-context.ts
|
||||
openapi-generated: false
|
||||
pagination-model: cursor
|
||||
cursor-pagination: supported
|
||||
paths:
|
||||
/api/v3/surveys:
|
||||
get:
|
||||
operationId: getSurveysV3
|
||||
summary: List surveys
|
||||
description: Returns surveys for the workspace. Session cookie or x-api-key.
|
||||
tags:
|
||||
- V3 Surveys
|
||||
parameters:
|
||||
- in: query
|
||||
name: workspaceId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: |
|
||||
Workspace identifier. **Today:** pass the **environment id** (the environment that contains the surveys). When Workspace replaces Environment in the data model, clients may pass workspace ids instead; resolution is centralized in workspace-context.
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
description: Page size (max 100)
|
||||
- in: query
|
||||
name: cursor
|
||||
schema:
|
||||
type: string
|
||||
description: |
|
||||
Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request.
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: Case-insensitive substring match on survey name (same as in-app list filters).
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
style: form
|
||||
explode: true
|
||||
description: |
|
||||
Survey status filter. Repeat the parameter (`status=draft&status=inProgress`) or use comma-separated values (`status=draft,inProgress`). Invalid values → **400**.
|
||||
- in: query
|
||||
name: type
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [link, app]
|
||||
style: form
|
||||
explode: true
|
||||
description: Survey type filter (`link` / `app`). Same repeat-or-comma rules as `status`.
|
||||
- in: query
|
||||
name: createdBy
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [you, others]
|
||||
style: form
|
||||
explode: true
|
||||
description: |
|
||||
**Session only:** creator scope (`you` / `others`); server applies the signed-in user. **Not supported with API keys** (400).
|
||||
- in: query
|
||||
name: sortBy
|
||||
schema:
|
||||
type: string
|
||||
enum: [createdAt, updatedAt, name, relevance]
|
||||
description: Sort order. Defaults to `updatedAt`. The `cursor` token is bound to the selected sort order.
|
||||
responses:
|
||||
"200":
|
||||
description: Surveys retrieved successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
description: Request correlation ID
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: "private, no-store"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data, meta]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyListItem"
|
||||
meta:
|
||||
type: object
|
||||
required: [limit, nextCursor]
|
||||
properties:
|
||||
limit: { type: integer }
|
||||
nextCursor:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Opaque cursor for the next page. `null` when there are no more results.
|
||||
"400":
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"401":
|
||||
description: Not authenticated (no valid session or API key)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"403":
|
||||
description: Forbidden — no access, or workspace/environment does not exist (404 not used; avoids existence leak)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"429":
|
||||
description: Rate limit exceeded
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: { type: integer }
|
||||
description: Seconds until the current rate-limit window resets
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
sessionAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: next-auth.session-token
|
||||
description: |
|
||||
NextAuth session JWT cookie. **Development:** often `next-auth.session-token`.
|
||||
**Production (HTTPS):** often `__Secure-next-auth.session-token`. Send the cookie your browser receives after sign-in.
|
||||
apiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: x-api-key
|
||||
description: |
|
||||
Management API key; must include **workspaceId** as an allowed environment with read, write, or manage permission.
|
||||
schemas:
|
||||
SurveyListItem:
|
||||
type: object
|
||||
description: |
|
||||
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
|
||||
Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`.
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
environmentId: { type: string }
|
||||
type: { type: string, enum: [link, app, website, web] }
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
responseCount: { type: integer }
|
||||
creator: { type: object, nullable: true, properties: { name: { type: string } } }
|
||||
Problem:
|
||||
type: object
|
||||
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
|
||||
required: [title, status, detail, requestId]
|
||||
properties:
|
||||
type: { type: string, format: uri }
|
||||
title: { type: string }
|
||||
status: { type: integer }
|
||||
detail: { type: string }
|
||||
instance: { type: string }
|
||||
code:
|
||||
type: string
|
||||
enum: [bad_request, not_authenticated, forbidden, internal_server_error, too_many_requests]
|
||||
requestId: { type: string }
|
||||
details: { type: object }
|
||||
invalid_params:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
reason: { type: string }
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
handleHiddenFields,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
} from "@/lib/common/utils";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
|
||||
import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
@@ -61,24 +60,6 @@ export const renderWidget = async (
|
||||
|
||||
setIsSurveyRunning(true);
|
||||
|
||||
// Wait for pending user identification to complete before rendering
|
||||
const updateQueue = UpdateQueue.getInstance();
|
||||
if (updateQueue.hasPendingWork()) {
|
||||
logger.debug("Waiting for pending user identification before rendering survey");
|
||||
const identificationSucceeded = await updateQueue.waitForPendingWork();
|
||||
if (!identificationSucceeded) {
|
||||
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
|
||||
|
||||
if (hasSegmentFilters) {
|
||||
logger.debug("User identification failed. Skipping survey with segment filters.");
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.delay) {
|
||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ export class UpdateQueue {
|
||||
private static instance: UpdateQueue | null = null;
|
||||
private updates: TUpdates | null = null;
|
||||
private debounceTimeout: NodeJS.Timeout | null = null;
|
||||
private pendingFlush: Promise<void> | null = null;
|
||||
private readonly DEBOUNCE_DELAY = 500;
|
||||
private readonly PENDING_WORK_TIMEOUT = 5000;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -65,45 +63,17 @@ export class UpdateQueue {
|
||||
return !this.updates;
|
||||
}
|
||||
|
||||
public hasPendingWork(): boolean {
|
||||
return this.updates !== null || this.pendingFlush !== null;
|
||||
}
|
||||
|
||||
public async waitForPendingWork(): Promise<boolean> {
|
||||
if (!this.hasPendingWork()) return true;
|
||||
|
||||
const flush = this.pendingFlush ?? this.processUpdates();
|
||||
try {
|
||||
const succeeded = await Promise.race([
|
||||
flush.then(() => true as const),
|
||||
new Promise<false>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
}, this.PENDING_WORK_TIMEOUT);
|
||||
}),
|
||||
]);
|
||||
return succeeded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async processUpdates(): Promise<void> {
|
||||
const logger = Logger.getInstance();
|
||||
if (!this.updates) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a flush is already in flight, reuse it instead of creating a new promise
|
||||
if (this.pendingFlush) {
|
||||
return this.pendingFlush;
|
||||
}
|
||||
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
|
||||
const flushPromise = new Promise<void>((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = async (): Promise<void> => {
|
||||
try {
|
||||
let currentUpdates = { ...this.updates };
|
||||
@@ -177,10 +147,8 @@ export class UpdateQueue {
|
||||
}
|
||||
|
||||
this.clearUpdates();
|
||||
this.pendingFlush = null;
|
||||
resolve();
|
||||
} catch (error: unknown) {
|
||||
this.pendingFlush = null;
|
||||
logger.error(
|
||||
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
@@ -190,8 +158,5 @@ export class UpdateQueue {
|
||||
|
||||
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
|
||||
});
|
||||
|
||||
this.pendingFlush = flushPromise;
|
||||
return flushPromise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sanitize } from "isomorphic-dompurify";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import * as React from "react";
|
||||
import { cn, stripInlineStyles } from "@/lib/utils";
|
||||
|
||||
@@ -39,7 +39,7 @@ function Label({
|
||||
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
|
||||
const safeHtml =
|
||||
isHtml && strippedContent
|
||||
? sanitize(strippedContent, {
|
||||
? DOMPurify.sanitize(strippedContent, {
|
||||
ADD_ATTR: ["target"],
|
||||
FORBID_ATTR: ["style"],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { sanitize } from "isomorphic-dompurify";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { extendTailwindMerge } from "tailwind-merge";
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
@@ -27,16 +27,14 @@ export function cn(...inputs: ClassValue[]): string {
|
||||
export const stripInlineStyles = (html: string): string => {
|
||||
if (!html) return html;
|
||||
|
||||
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
|
||||
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
|
||||
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
|
||||
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
|
||||
// fixed quote delimiters, so no backtracking can occur.
|
||||
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
|
||||
|
||||
return sanitize(preStripped, {
|
||||
// Use DOMPurify to safely remove style attributes
|
||||
// This is more secure than regex-based approaches and handles edge cases properly
|
||||
return DOMPurify.sanitize(html, {
|
||||
FORBID_ATTR: ["style"],
|
||||
// Preserve the target attribute (e.g. target="_blank" on links) which is not
|
||||
// in DOMPurify's default allow-list but is explicitly required downstream.
|
||||
ADD_ATTR: ["target"],
|
||||
// Keep other attributes and tags as-is, only remove style attributes
|
||||
KEEP_CONTENT: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"baseUrl": ".",
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
|
||||
@@ -10,16 +10,14 @@ import DOMPurify from "isomorphic-dompurify";
|
||||
export const stripInlineStyles = (html: string): string => {
|
||||
if (!html) return html;
|
||||
|
||||
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
|
||||
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
|
||||
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
|
||||
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
|
||||
// fixed quote delimiters, so no backtracking can occur.
|
||||
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
|
||||
|
||||
return DOMPurify.sanitize(preStripped, {
|
||||
// Use DOMPurify to safely remove style attributes
|
||||
// This is more secure than regex-based approaches and handles edge cases properly
|
||||
return DOMPurify.sanitize(html, {
|
||||
FORBID_ATTR: ["style"],
|
||||
// Preserve the target attribute (e.g. target="_blank" on links) which is not
|
||||
// in DOMPurify's default allow-list but is explicitly required downstream.
|
||||
ADD_ATTR: ["target"],
|
||||
// Keep other attributes and tags as-is, only remove style attributes
|
||||
KEEP_CONTENT: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -87,9 +87,11 @@ class AuthorizationError extends Error {
|
||||
|
||||
class TooManyRequestsError extends Error {
|
||||
statusCode = 429;
|
||||
constructor(message: string) {
|
||||
retryAfter?: number;
|
||||
constructor(message: string, retryAfter?: number) {
|
||||
super(message);
|
||||
this.name = "TooManyRequestsError";
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { type z } from "zod";
|
||||
import type { TI18nString } from "../i18n";
|
||||
import type { TSurveyLanguage } from "./types";
|
||||
import { getTextContent } from "./validation";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parse } from "node-html-parser";
|
||||
import { z } from "zod";
|
||||
import { type z } from "zod";
|
||||
import type { TI18nString } from "../i18n";
|
||||
import type { TConditionGroup, TSingleCondition } from "./logic";
|
||||
import type {
|
||||
|
||||
Reference in New Issue
Block a user