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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions apps/web/app/api/v3/action-classes/lib/operations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getActionClasses } from "@/lib/actionClass/service";
import { listV3ActionClasses } from "./operations";

vi.mock("server-only", () => ({}));

vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));

vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
}));

vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));

const workspaceId = "clxx1234567890123456789012";

const actionClass = {
id: "claa1234567890123456789012",
name: "Checkout Complete",
description: null,
type: "code",
key: "checkout_complete",
noCodeConfig: null,
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
} as unknown as Awaited<ReturnType<typeof getActionClasses>>[number];

const params = {
workspaceId,
limit: 50,
authentication: null,
requestId: "req_1",
instance: "/api/v3/action-classes",
};

describe("listV3ActionClasses", () => {
beforeEach(() => {
vi.resetAllMocks();
});

test("returns the public action-class shape for an authorized workspace", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getActionClasses).mockResolvedValue([actionClass]);

const response = await listV3ActionClasses(params);

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
data: [
{
id: "claa1234567890123456789012",
name: "Checkout Complete",
description: null,
type: "code",
key: "checkout_complete",
},
],
meta: { limit: 50, nextCursor: null },
});
expect(getActionClasses).toHaveBeenCalledWith(workspaceId);
});

test("paginates with limit + cursor (nextCursor points to the next page)", async () => {
const second = {
...actionClass,
id: "clbb1234567890123456789012",
key: "second",
} as typeof actionClass;
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getActionClasses).mockResolvedValue([actionClass, second]);

const page1 = await listV3ActionClasses({ ...params, limit: 1 });
const body1 = await page1.json();
expect(body1.data).toHaveLength(1);
expect(body1.data[0].id).toBe("claa1234567890123456789012");
expect(typeof body1.meta.nextCursor).toBe("string");

const page2 = await listV3ActionClasses({ ...params, limit: 1, cursor: body1.meta.nextCursor });
const body2 = await page2.json();
expect(body2.data).toHaveLength(1);
expect(body2.data[0].id).toBe("clbb1234567890123456789012");
expect(body2.meta.nextCursor).toBeNull();
});

test("returns 400 on a malformed cursor", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getActionClasses).mockResolvedValue([actionClass]);

const response = await listV3ActionClasses({ ...params, cursor: "%%%%" });

expect(response.status).toBe(400);
});

test("returns the auth response and skips fetching when access is denied", async () => {
const denied = new Response("forbidden", { status: 403 });
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(denied);

const response = await listV3ActionClasses(params);

expect(response).toBe(denied);
expect(getActionClasses).not.toHaveBeenCalled();
});

test("returns 500 when fetching action classes fails", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getActionClasses).mockRejectedValue(new DatabaseError("boom"));

const response = await listV3ActionClasses(params);

expect(response.status).toBe(500);
});
});
13 changes: 13 additions & 0 deletions apps/web/app/api/v3/action-classes/lib/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import "server-only";
import { type TV3WorkspaceListParams, listV3WorkspaceResource } from "@/app/api/v3/lib/list-resource";
import { getActionClasses } from "@/lib/actionClass/service";
import { serializeV3ActionClass } from "../serializers";

export function listV3ActionClasses(params: TV3WorkspaceListParams): Promise<Response> {
return listV3WorkspaceResource({
...params,
resourceName: "action classes",
fetchAll: getActionClasses,
serialize: serializeV3ActionClass,
});
}
25 changes: 25 additions & 0 deletions apps/web/app/api/v3/action-classes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* /api/v3/action-classes - list a workspace's action classes (read-only).
* Session cookie or x-api-key; scope by workspaceId only. Lets API/MCP clients discover the
* action-class ids referenced by app-survey triggers (`distribution.triggers[].actionClassId`).
*/
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { ZV3WorkspaceListQuery } from "@/app/api/v3/lib/schemas";
import { listV3ActionClasses } from "./lib/operations";

export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
query: ZV3WorkspaceListQuery,
},
handler: async ({ authentication, parsedInput, requestId, instance }) => {
return await listV3ActionClasses({
workspaceId: parsedInput.query.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
authentication,
requestId,
instance,
});
},
});
24 changes: 24 additions & 0 deletions apps/web/app/api/v3/action-classes/serializers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { TActionClass } from "@formbricks/types/action-classes";

/**
* Public v3 action-class shape. Exposes only what a client needs to reference an action class from an
* app-survey trigger (`actionClassId`) and disambiguate it; internal fields (`noCodeConfig`,
* `workspaceId`, timestamps) stay out of the contract.
*/
export type TV3ActionClassResource = {
id: string;
name: string;
description: string | null;
type: TActionClass["type"];
key: string | null;
};

export function serializeV3ActionClass(actionClass: TActionClass): TV3ActionClassResource {
return {
id: actionClass.id,
name: actionClass.name,
description: actionClass.description,
type: actionClass.type,
key: actionClass.key,
};
}
164 changes: 164 additions & 0 deletions apps/web/app/api/v3/contact-attribute-keys/lib/operations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { listV3ContactAttributeKeys } from "./operations";

vi.mock("server-only", () => ({}));

vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));

vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn(),
}));

vi.mock("@/modules/ee/contacts/lib/contact-attribute-keys", () => ({
getContactAttributeKeys: vi.fn(),
}));

vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: vi.fn(),
}));

vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));

const workspaceId = "clxx1234567890123456789012";

const attributeKey = {
id: "clak1234567890123456789012",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
isUnique: false,
key: "plan",
name: "Plan",
description: "Subscription plan",
type: "custom",
dataType: "string",
workspaceId,
} as unknown as Awaited<ReturnType<typeof getContactAttributeKeys>>[number];

const params = {
workspaceId,
limit: 50,
authentication: null,
requestId: "req_1",
instance: "/api/v3/contact-attribute-keys",
};

describe("listV3ContactAttributeKeys", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({
id: "org_1",
} as Awaited<ReturnType<typeof getOrganizationByWorkspaceId>>);
vi.mocked(getIsContactsEnabled).mockResolvedValue(true);
});

test("returns the public attribute-key shape for an authorized workspace", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKey]);

const response = await listV3ContactAttributeKeys(params);

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
data: [
{
id: "clak1234567890123456789012",
key: "plan",
name: "Plan",
description: "Subscription plan",
type: "custom",
dataType: "string",
},
],
meta: { limit: 50, nextCursor: null },
});
expect(getContactAttributeKeys).toHaveBeenCalledWith(workspaceId);
});

test("paginates with limit + cursor (nextCursor points to the next page)", async () => {
const second = {
...attributeKey,
id: "clbk1234567890123456789012",
key: "role",
} as typeof attributeKey;
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKey, second]);

const page1 = await listV3ContactAttributeKeys({ ...params, limit: 1 });
const body1 = await page1.json();
expect(body1.data).toHaveLength(1);
expect(body1.data[0].id).toBe("clak1234567890123456789012");
expect(typeof body1.meta.nextCursor).toBe("string");

const page2 = await listV3ContactAttributeKeys({
...params,
limit: 1,
cursor: body1.meta.nextCursor,
});
const body2 = await page2.json();
expect(body2.data).toHaveLength(1);
expect(body2.data[0].id).toBe("clbk1234567890123456789012");
expect(body2.meta.nextCursor).toBeNull();
});

test("returns 400 on a malformed cursor", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKey]);

const response = await listV3ContactAttributeKeys({ ...params, cursor: "%%%%" });

expect(response.status).toBe(400);
});

test("returns the auth response and skips fetching when access is denied", async () => {
const denied = new Response("forbidden", { status: 403 });
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(denied);

const response = await listV3ContactAttributeKeys(params);

expect(response).toBe(denied);
expect(getContactAttributeKeys).not.toHaveBeenCalled();
});

test("returns 403 when the contacts feature is not enabled for the organization", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getIsContactsEnabled).mockResolvedValue(false);

const response = await listV3ContactAttributeKeys(params);

expect(response.status).toBe(403);
expect(getContactAttributeKeys).not.toHaveBeenCalled();
});

test("returns 500 when fetching attribute keys fails", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
} as Awaited<ReturnType<typeof requireV3WorkspaceAccess>>);
vi.mocked(getContactAttributeKeys).mockRejectedValue(new DatabaseError("boom"));

const response = await listV3ContactAttributeKeys(params);

expect(response.status).toBe(500);
});
});
30 changes: 30 additions & 0 deletions apps/web/app/api/v3/contact-attribute-keys/lib/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import "server-only";
import { type TV3WorkspaceListParams, listV3WorkspaceResource } from "@/app/api/v3/lib/list-resource";
import { problemForbidden } from "@/app/api/v3/lib/response";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { serializeV3ContactAttributeKey } from "../serializers";

export function listV3ContactAttributeKeys(params: TV3WorkspaceListParams): Promise<Response> {
const { requestId, instance } = params;
return listV3WorkspaceResource({
...params,
resourceName: "contact attribute keys",
fetchAll: getContactAttributeKeys,
serialize: serializeV3ContactAttributeKey,
// Contact attribute keys belong to the enterprise Contacts feature — gate the list the same way
// the UI and management API do, so an unentitled organization can't enumerate them through v3.
assertEntitlement: async (workspaceId) => {
const organization = await getOrganizationByWorkspaceId(workspaceId);
if (organization && (await getIsContactsEnabled(organization.id))) {
return null;
}
return problemForbidden(
requestId,
"The contacts feature is not enabled for this organization.",
instance
);
},
});
}
Loading
Loading