feat: personalized survey links for segment of users endpoint (#5032)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2025-04-08 11:24:27 +05:30
committed by GitHub
parent 81fc97c7e9
commit 4f276f0095
21 changed files with 2958 additions and 21 deletions

View File

@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element {
return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
@@ -38,7 +38,7 @@ export function Sidebar(): React.JSX.Element {
href={item.href}
className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
<a
key={item.name}
href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
className="group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6 text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name}
</a>

View File

@@ -96,10 +96,10 @@ export default function AppPage(): React.JSX.Element {
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
<Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
export { GET };

View File

@@ -0,0 +1,33 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKeys = reactCache((environmentId: string) =>
cache(
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
try {
const contactAttributeKeys = await prisma.contactAttributeKey.findMany({
where: { environmentId },
select: {
key: true,
},
});
const keys = contactAttributeKeys.map((key) => key.key);
return ok(keys);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contact attribute keys", issue: error.message }],
});
}
},
[`getContactAttributeKeys-contact-links-${environmentId}`],
{
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -0,0 +1,147 @@
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
import { getSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment";
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys";
import { TContactWithAttributes } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactsInSegment = reactCache(
(surveyId: string, segmentId: string, limit: number, skip: number, attributeKeys?: string) =>
cache(
async (): Promise<Result<ApiResponseWithMeta<TContactWithAttributes[]>, ApiErrorResponseV2>> => {
try {
const surveyResult = await getSurvey(surveyId);
if (!surveyResult.ok) {
return err(surveyResult.error);
}
const survey = surveyResult.data;
if (survey.type !== "link" || survey.status !== "inProgress") {
logger.error({ surveyId, segmentId }, "Survey is not a link survey or is not in progress");
const error: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
return err(error);
}
const segmentResult = await getSegment(segmentId);
if (!segmentResult.ok) {
return err(segmentResult.error);
}
const segment = segmentResult.data;
if (survey.environmentId !== segment.environmentId) {
logger.error({ surveyId, segmentId }, "Survey and segment are not in the same environment");
const error: ApiErrorResponseV2 = {
type: "bad_request",
details: [{ field: "segmentId", issue: "Environment mismatch" }],
};
return err(error);
}
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
segment.id,
segment.filters,
segment.environmentId
);
if (!segmentFilterToPrismaQueryResult.ok) {
return err(segmentFilterToPrismaQueryResult.error);
}
const { whereClause } = segmentFilterToPrismaQueryResult.data;
const contactAttributeKeysResult = await getContactAttributeKeys(segment.environmentId);
if (!contactAttributeKeysResult.ok) {
return err(contactAttributeKeysResult.error);
}
const allAttributeKeys = contactAttributeKeysResult.data;
const fieldArray = (attributeKeys || "").split(",").map((field) => field.trim());
const attributesToInclude = fieldArray.filter((field) => allAttributeKeys.includes(field));
const allowedAttributes = attributesToInclude.slice(0, 20);
const [totalContacts, contacts] = await prisma.$transaction([
prisma.contact.count({
where: whereClause,
}),
prisma.contact.findMany({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: allowedAttributes,
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
take: limit,
skip: skip,
orderBy: {
createdAt: "desc",
},
}),
]);
const contactsWithAttributes = contacts.map((contact) => {
const attributes = contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
return {
contactId: contact.id,
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
};
});
return ok({
data: contactsWithAttributes,
meta: {
total: totalContacts,
limit: limit,
offset: skip,
},
});
} catch (error) {
logger.error({ error, surveyId, segmentId }, "Error getting contacts in segment");
const apiError: ApiErrorResponseV2 = {
type: "internal_server_error",
};
return err(apiError);
}
},
[`getContactsInSegment-${surveyId}-${segmentId}-${attributeKeys}-${limit}-${skip}`],
{
tags: [segmentCache.tag.byId(segmentId), surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -0,0 +1,29 @@
import {
ZContactLinkResponse,
ZContactLinksBySegmentParams,
ZContactLinksBySegmentQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactLinksBySegment",
summary: "Get survey links for contacts in a segment",
description: "Generates personalized survey links for contacts in a segment.",
tags: ["Management API > Surveys > Contact Links"],
requestParams: {
path: ZContactLinksBySegmentParams,
query: ZContactLinksBySegmentQuery,
},
responses: {
"200": {
description: "Contact links generated successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZContactLinkResponse))),
},
},
},
},
};

View File

@@ -0,0 +1,36 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Segment } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSegment = reactCache(async (segmentId: string) =>
cache(
async (): Promise<Result<Pick<Segment, "id" | "environmentId" | "filters">, ApiErrorResponseV2>> => {
try {
const segment = await prisma.segment.findUnique({
where: { id: segmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
if (!segment) {
return err({ type: "not_found", details: [{ field: "segment", issue: "not found" }] });
}
return ok(segment);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "segment", issue: error.message }] });
}
},
[`contact-link-getSegment-${segmentId}`],
{
tags: [segmentCache.tag.byId(segmentId)],
}
)()
);

View File

@@ -0,0 +1,39 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Survey } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSurvey = reactCache(async (surveyId: string) =>
cache(
async (): Promise<
Result<Pick<Survey, "id" | "environmentId" | "type" | "status">, ApiErrorResponseV2>
> => {
try {
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
select: {
id: true,
environmentId: true,
type: true,
status: true,
},
});
if (!survey) {
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
}
return ok(survey);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
}
},
[`contact-link-getSurvey-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -0,0 +1,52 @@
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contactAttributeKey: {
findMany: vi.fn(),
},
},
}));
describe("getContactAttributeKeys", () => {
const mockEnvironmentId = "mock-env-123";
const mockContactAttributeKeys = [{ key: "email" }, { key: "name" }, { key: "userId" }];
beforeEach(() => {
vi.clearAllMocks();
});
test("successfully retrieves contact attribute keys", async () => {
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockContactAttributeKeys);
const result = await getContactAttributeKeys(mockEnvironmentId);
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
where: { environmentId: mockEnvironmentId },
select: { key: true },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(["email", "name", "userId"]);
}
});
test("handles database error gracefully", async () => {
const mockError = new Error("Database error");
vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(mockError);
const result = await getContactAttributeKeys(mockEnvironmentId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "contact attribute keys", issue: mockError.message }],
});
}
});
});

View File

@@ -0,0 +1,515 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { SurveyStatus, SurveyType } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TBaseFilters } from "@formbricks/types/segment";
import { getContactsInSegment } from "../contact";
import { getSegment } from "../segment";
import { getSurvey } from "../surveys";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findMany: vi.fn(),
count: vi.fn(),
},
contactAttributeKey: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.mock("../segment", () => ({
getSegment: vi.fn(),
}));
vi.mock("../surveys", () => ({
getSurvey: vi.fn(),
}));
describe("getContactsInSegment", () => {
const mockSurveyId = "survey-123";
const mockSegmentId = "segment-456";
const mockLimit = 10;
const mockSkip = 0;
const mockEnvironmentId = "env-789";
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
type: "link" as SurveyType,
status: "inProgress" as SurveyStatus,
};
// Define filters as a TBaseFilters array with correct structure
const mockFilters: TBaseFilters = [
{
id: "filter-1",
connector: null,
resource: {
id: "resource-1",
root: {
type: "attribute",
contactAttributeKey: "email",
},
value: "test@example.com",
qualifier: {
operator: "equals",
},
},
},
];
const mockSegment = {
id: mockSegmentId,
environmentId: mockEnvironmentId,
filters: mockFilters,
};
const mockContacts = [
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: mockSurvey,
});
vi.mocked(getSegment).mockResolvedValue({
ok: true,
data: mockSegment,
});
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([{ key: "email" }, { key: "name" }]);
vi.mocked(prisma.contact.count).mockResolvedValue(2);
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return contacts when all operations succeed", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
const attributeKeys = "email,name";
const result = await getContactsInSegment(
mockSurveyId,
mockSegmentId,
mockLimit,
mockSkip,
attributeKeys
);
const whereClause = {
AND: [
{
environmentId: "env-789",
},
{
AND: [
{
attributes: {
some: {
attributeKey: {
key: "email",
},
value: { equals: "test@example.com", mode: "insensitive" },
},
},
},
],
},
],
};
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
where: {
environmentId: mockEnvironmentId,
},
select: {
key: true,
},
});
expect(prisma.contact.count).toHaveBeenCalledWith({
where: whereClause,
});
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: whereClause,
select: {
id: true,
attributes: {
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
where: {
attributeKey: {
key: {
in: ["email", "name"],
},
},
},
},
},
take: mockLimit,
skip: mockSkip,
orderBy: {
createdAt: "desc",
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should filter contact attributes when fields parameter is provided", async () => {
const filteredMockContacts = [
{
id: "contact-1",
attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }],
},
{
id: "contact-2",
attributes: [{ attributeKey: { key: "email" }, value: "another@example.com" }],
},
];
vi.mocked(prisma.$transaction).mockResolvedValue([filteredMockContacts.length, filteredMockContacts]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email");
const whereClause = {
AND: [
{
environmentId: "env-789",
},
{
AND: [
{
attributes: {
some: {
attributeKey: {
key: "email",
},
value: { equals: "test@example.com", mode: "insensitive" },
},
},
},
],
},
],
};
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).toHaveBeenCalledWith({
where: whereClause,
});
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: ["email"],
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
take: mockLimit,
skip: mockSkip,
orderBy: {
createdAt: "desc",
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should handle multiple fields when fields parameter has comma-separated values", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email,name");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should return no attributes but still return contacts when fields parameter is empty", async () => {
const mockContactsWithoutAttributes = mockContacts.map((contact) => ({
...contact,
attributes: [],
}));
vi.mocked(prisma.$transaction).mockResolvedValue([
mockContactsWithoutAttributes.length,
mockContactsWithoutAttributes,
]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: mockContacts.map((contact) => ({
contactId: contact.id,
})),
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should return error when survey is not a link survey", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: {
...mockSurvey,
type: "web" as SurveyType,
},
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when survey is not active", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: {
...mockSurvey,
status: "completed" as SurveyStatus,
},
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when survey is not found", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: false,
error: surveyError,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when segment is not found", async () => {
const segmentError: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "segment", issue: "not found" }],
};
vi.mocked(getSegment).mockResolvedValue({
ok: false,
error: segmentError,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).not.toHaveBeenCalled();
expect(prisma.contact.findMany).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(segmentError);
}
});
test("should return error when survey and segment are in different environments", async () => {
const mockSegmentWithDifferentEnv = {
...mockSegment,
environmentId: "different-env",
};
vi.mocked(getSegment).mockResolvedValue({
ok: true,
data: mockSegmentWithDifferentEnv,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).not.toHaveBeenCalled();
expect(prisma.contact.findMany).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
details: [{ field: "segmentId", issue: "Environment mismatch" }],
});
}
});
test("should return error when database operation fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.contact.count).mockRejectedValue(dbError);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
});
}
});
});

View File

@@ -0,0 +1,129 @@
import { Segment } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { getSegment } from "../segment";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
segment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/lib/cache/segment", () => ({
segmentCache: {
tag: {
byId: vi.fn((id) => `segment-${id}`),
},
},
}));
describe("getSegment", () => {
const mockSegmentId = "segment-123";
const mockSegment: Pick<Segment, "id" | "environmentId" | "filters"> = {
id: mockSegmentId,
environmentId: "env-123",
filters: [
{
id: "filter-123",
connector: null,
resource: {
id: "attr_1",
root: {
type: "attribute",
contactAttributeKey: "email",
},
value: "test@example.com",
qualifier: { operator: "equals" },
},
},
],
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return segment data when segment is found", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
where: { id: mockSegmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockSegment);
}
expect(segmentCache.tag.byId).toHaveBeenCalledWith(mockSegmentId);
});
test("should return not_found error when segment doesn't exist", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(null);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
where: { id: mockSegmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "segment", issue: "not found" }],
});
}
});
test("should return internal_server_error when database throws an error", async () => {
const mockError = new Error("Database connection failed");
vi.mocked(prisma.segment.findUnique).mockRejectedValueOnce(mockError);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "segment", issue: "Database connection failed" }],
});
}
});
test("should use correct cache key", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
await getSegment(mockSegmentId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSegment-${mockSegmentId}`], {
tags: [`segment-${mockSegmentId}`],
});
});
});

View File

@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSurvey } from "../surveys";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/lib/survey/cache", () => ({
surveyCache: {
tag: {
byId: vi.fn((id) => `survey-${id}`),
},
},
}));
describe("getSurvey", () => {
const mockSurveyId = "survey-123";
const mockEnvironmentId = "env-456";
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return survey data when survey is found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: mockSurveyId },
select: {
id: true,
environmentId: true,
status: true,
type: true,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockSurvey);
}
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
tags: [`survey-${mockSurveyId}`],
});
});
test("should return not_found error when survey doesn't exist", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(null);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: mockSurveyId },
select: {
id: true,
environmentId: true,
status: true,
type: true,
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
});
}
});
test("should return internal_server_error when database throws an error", async () => {
const mockError = new Error("Database connection failed");
vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(mockError);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "survey", issue: "Database connection failed" }],
});
}
});
test("should use correct cache key and tags", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
await getSurvey(mockSurveyId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
tags: [`survey-${mockSurveyId}`],
});
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
});
});

View File

@@ -0,0 +1,116 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
import {
ZContactLinksBySegmentParams,
ZContactLinksBySegmentQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string; segmentId: string }> }
) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: ZContactLinksBySegmentParams,
query: ZContactLinksBySegmentQuery,
},
handler: async ({ authentication, parsedInput }) => {
const { params, query } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
],
});
}
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
}
// Get contacts based on segment
const contactsResult = await getContactsInSegment(
params.surveyId,
params.segmentId,
query?.limit || 10,
query?.skip || 0,
query?.attributeKeys
);
if (!contactsResult.ok) {
return handleApiError(request, contactsResult.error);
}
const { data: contacts, meta } = contactsResult.data;
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
expiresAt = expirationDate.toISOString();
}
// Generate survey links for each contact
const contactLinks = contacts
.map((contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = getContactSurveyLink(
contactId,
params.surveyId,
query?.expirationDays || undefined
);
if (!surveyUrlResult.ok) {
logger.error(
{ error: surveyUrlResult.error, contactId: contactId, surveyId: params.surveyId },
"Failed to generate survey URL for contact"
);
return null;
}
return {
contactId,
attributes,
surveyUrl: surveyUrlResult.data,
expiresAt,
};
})
.filter(Boolean);
return responses.successResponse({
data: contactLinks,
meta,
});
},
});

View File

@@ -0,0 +1,43 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
export const ZContactLinksBySegmentParams = z.object({
surveyId: z.string().cuid2().describe("The ID of the survey"),
segmentId: z.string().cuid2().describe("The ID of the segment"),
});
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
limit: true,
skip: true,
}).extend({
expirationDays: z.coerce
.number()
.min(1)
.max(365)
.nullish()
.default(null)
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
attributeKeys: z
.string()
.optional()
.describe(
"Comma-separated list of contact attribute keys to include in the response. You can have max 20 keys. If not provided, no attributes will be included."
)
.refine((fields) => {
if (!fields) return true;
const fieldsArray = fields.split(",");
return fieldsArray.length <= 20;
}, "You can have max 20 keys."),
});
export type TContactWithAttributes = {
contactId: string;
attributes?: Record<string, string>;
};
export const ZContactLinkResponse = z.object({
contactId: z.string().describe("The ID of the contact"),
surveyUrl: z.string().url().describe("Personalized survey link"),
expiresAt: z.string().nullable().describe("The date and time the link expires, null if no expiration"),
attributes: z.record(z.string(), z.string()).describe("The attributes of the contact"),
});

View File

@@ -0,0 +1,8 @@
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
import { ZodOpenApiPathsObject } from "zod-openapi";
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
get: getContactLinksBySegmentEndpoint,
},
};

View File

@@ -2,6 +2,7 @@ import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-at
import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
import { mePaths } from "@/modules/api/v2/me/lib/openapi";
@@ -43,6 +44,7 @@ const document = createDocument({
...contactAttributePaths,
...contactAttributeKeyPaths,
...surveyPaths,
...surveyContactLinksBySegmentPaths,
...webhookPaths,
...teamPaths,
...projectTeamPaths,
@@ -83,6 +85,10 @@ const document = createDocument({
name: "Management API > Surveys",
description: "Operations for managing surveys.",
},
{
name: "Management API > Surveys > Contact Links",
description: "Operations for generating personalized survey links for contacts.",
},
{
name: "Management API > Webhooks",
description: "Operations for managing webhooks.",

View File

@@ -1,12 +1,12 @@
import { z } from "zod";
export const ZGetFilter = z.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
limit: z.coerce.number().min(1).max(250).optional().default(50).describe("Number of items to return"),
skip: z.coerce.number().min(0).optional().default(0).describe("Number of items to skip"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt").describe("Sort by field"),
order: z.enum(["asc", "desc"]).optional().default("desc").describe("Sort order"),
startDate: z.coerce.date().optional().describe("Start date"),
endDate: z.coerce.date().optional().describe("End date"),
});
export type TGetFilter = z.infer<typeof ZGetFilter>;

View File

@@ -0,0 +1,291 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import {
TBaseFilters,
TSegmentAttributeFilter,
TSegmentDeviceFilter,
TSegmentFilter,
TSegmentPersonFilter,
TSegmentSegmentFilter,
} from "@formbricks/types/segment";
import { getSegment } from "../segments";
// Type for the result of the segment filter to prisma query generation
export type SegmentFilterQueryResult = {
whereClause: Prisma.ContactWhereInput;
};
/**
* Builds a Prisma where clause from a segment attribute filter
*/
const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
const { operator } = qualifier;
// This base query checks if the contact has an attribute with the specified key
const baseQuery = {
attributes: {
some: {
attributeKey: {
key: contactAttributeKey,
},
},
},
};
// Handle special operators that don't require a value
if (operator === "isSet") {
return baseQuery;
}
if (operator === "isNotSet") {
return {
NOT: baseQuery,
};
}
// For all other operators, we need to check the attribute value
const valueQuery = {
attributes: {
some: {
attributeKey: {
key: contactAttributeKey,
},
value: {},
},
},
} satisfies Prisma.ContactWhereInput;
// Apply the appropriate operator to the attribute value
switch (operator) {
case "equals":
valueQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
break;
case "notEquals":
valueQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
break;
case "contains":
valueQuery.attributes.some.value = { contains: String(value), mode: "insensitive" };
break;
case "doesNotContain":
valueQuery.attributes.some.value = { not: { contains: String(value) }, mode: "insensitive" };
break;
case "startsWith":
valueQuery.attributes.some.value = { startsWith: String(value), mode: "insensitive" };
break;
case "endsWith":
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
break;
case "greaterThan":
valueQuery.attributes.some.value = { gt: String(value) };
break;
case "greaterEqual":
valueQuery.attributes.some.value = { gte: String(value) };
break;
case "lessThan":
valueQuery.attributes.some.value = { lt: String(value) };
break;
case "lessEqual":
valueQuery.attributes.some.value = { lte: String(value) };
break;
default:
valueQuery.attributes.some.value = String(value);
}
return valueQuery;
};
/**
* Builds a Prisma where clause from a person filter
*/
const buildPersonFilterWhereClause = (filter: TSegmentPersonFilter): Prisma.ContactWhereInput => {
const { personIdentifier } = filter.root;
if (personIdentifier === "userId") {
const personFilter: TSegmentAttributeFilter = {
...filter,
root: {
type: "attribute",
contactAttributeKey: personIdentifier,
},
};
return buildAttributeFilterWhereClause(personFilter);
}
return {};
};
/**
* Builds a Prisma where clause from a device filter
*/
const buildDeviceFilterWhereClause = (filter: TSegmentDeviceFilter): Prisma.ContactWhereInput => {
const { root, qualifier, value } = filter;
const { type } = root;
const { operator } = qualifier;
const baseQuery = {
attributes: {
some: {
attributeKey: {
key: type,
},
value: {},
},
},
} satisfies Prisma.ContactWhereInput;
if (operator === "equals") {
baseQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
} else if (operator === "notEquals") {
baseQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
}
return baseQuery;
};
/**
* Builds a Prisma where clause from a segment filter
*/
const buildSegmentFilterWhereClause = async (
filter: TSegmentSegmentFilter,
segmentPath: Set<string>
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
const { segmentId } = root;
if (segmentPath.has(segmentId)) {
logger.error(
{ segmentId, path: Array.from(segmentPath) },
"Circular reference detected in segment filter"
);
return {};
}
const segment = await getSegment(segmentId);
if (!segment) {
logger.error({ segmentId }, "Segment not found");
return {};
}
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath);
};
/**
* Recursively processes a segment filter or group and returns a Prisma where clause
*/
const processSingleFilter = async (
filter: TSegmentFilter,
segmentPath: Set<string>
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
switch (root.type) {
case "attribute":
return buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
case "person":
return buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
case "device":
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter);
case "segment":
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath);
default:
return {};
}
};
/**
* Recursively processes filters and returns a combined Prisma where clause
*/
const processFilters = async (
filters: TBaseFilters,
segmentPath: Set<string>
): Promise<Prisma.ContactWhereInput> => {
if (filters.length === 0) return {};
const query: { AND: Prisma.ContactWhereInput[]; OR: Prisma.ContactWhereInput[] } = {
AND: [],
OR: [],
};
for (let i = 0; i < filters.length; i++) {
const { resource, connector } = filters[i];
let whereClause: Prisma.ContactWhereInput;
// Process the resource based on its type
if (isResourceFilter(resource)) {
// If it's a single filter, process it directly
whereClause = await processSingleFilter(resource, segmentPath);
} else {
// If it's a group of filters, process it recursively
whereClause = await processFilters(resource, segmentPath);
}
if (Object.keys(whereClause).length === 0) continue;
if (filters.length === 1) query.AND = [whereClause];
else {
if (i === 0) {
if (filters[1].connector === "and") query.AND.push(whereClause);
else query.OR.push(whereClause);
} else {
if (connector === "and") query.AND.push(whereClause);
else query.OR.push(whereClause);
}
}
}
return {
...(query.AND.length > 0 ? { AND: query.AND } : {}),
...(query.OR.length > 0 ? { OR: query.OR } : {}),
};
};
/**
* Transforms a segment filter into a Prisma query for contacts
*/
export const segmentFilterToPrismaQuery = reactCache(
async (segmentId: string, filters: TBaseFilters, environmentId: string) =>
cache(
async (): Promise<Result<SegmentFilterQueryResult, ApiErrorResponseV2>> => {
try {
const baseWhereClause = {
environmentId,
};
// Initialize an empty stack for tracking the current evaluation path
const segmentPath = new Set<string>([segmentId]);
const filtersWhereClause = await processFilters(filters, segmentPath);
const whereClause = {
AND: [baseWhereClause, filtersWhereClause],
};
return ok({ whereClause });
} catch (error) {
logger.error(
{ error, segmentId, environmentId },
"Error transforming segment filter to Prisma query"
);
return err({
type: "bad_request",
message: "Failed to convert segment filters to Prisma query",
details: [{ field: "segment", issue: "Invalid segment filters" }],
});
}
},
[`segmentFilterToPrismaQuery-${segmentId}-${environmentId}-${JSON.stringify(filters)}`],
{
tags: [segmentCache.tag.byEnvironmentId(environmentId), segmentCache.tag.byId(segmentId)],
}
)()
);

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,7 @@ export default defineConfig({
"modules/survey/lib/client-utils.ts",
"modules/survey/list/components/survey-card.tsx",
"modules/survey/list/components/survey-dropdown-menu.tsx",
"modules/ee/contacts/segments/lib/**/*.ts",
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
"modules/ee/sso/components/**/*.tsx",
],

View File

@@ -21,6 +21,8 @@ tags:
description: Operations for managing contact attributes keys.
- name: Management API > Surveys
description: Operations for managing surveys.
- name: Management API > Surveys > Contact Links
description: Operations for generating personalized survey links for contacts.
- name: Management API > Webhooks
description: Operations for managing webhooks.
- name: Organizations API > Teams
@@ -629,41 +631,53 @@ paths:
parameters:
- in: query
name: limit
description: Number of items to return
schema:
type: number
minimum: 1
maximum: 100
default: 10
maximum: 250
default: 50
description: Number of items to return
- in: query
name: skip
description: Number of items to skip
schema:
type: number
minimum: 0
default: 0
description: Number of items to skip
- in: query
name: sortBy
description: Sort by field
schema:
type: string
enum: &a6
- createdAt
- updatedAt
default: createdAt
description: Sort by field
- in: query
name: order
description: Sort order
schema:
type: string
enum: &a7
- asc
- desc
default: desc
description: Sort order
- in: query
name: startDate
description: Start date
schema:
type: string
description: Start date
- in: query
name: endDate
description: End date
schema:
type: string
description: End date
- in: query
name: surveyId
schema:
@@ -2233,6 +2247,106 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/survey"
/surveys/{surveyId}/contact-links/segments/{segmentId}:
get:
operationId: getContactLinksBySegment
summary: Get survey links for contacts in a segment
description: Generates personalized survey links for contacts in a segment.
tags:
- Management API > Surveys > Contact Links
parameters:
- in: path
name: surveyId
description: The ID of the survey
schema:
type: string
description: The ID of the survey
required: true
- in: path
name: segmentId
description: The ID of the segment
schema:
type: string
description: The ID of the segment
required: true
- in: query
name: limit
description: Number of items to return
schema:
type: number
minimum: 1
maximum: 250
default: 50
description: Number of items to return
- in: query
name: skip
description: Number of items to skip
schema:
type: number
minimum: 0
default: 0
description: Number of items to skip
- in: query
name: expirationDays
description: Number of days until the generated JWT expires. If not provided,
there is no expiration.
schema:
type:
- number
- "null"
minimum: 1
maximum: 365
default: null
description: Number of days until the generated JWT expires. If not provided,
there is no expiration.
- in: query
name: attributeKeys
schema:
type: string
description: Comma-separated list of contact attribute keys to include in the
response. You can have max 20 keys. If not provided, no attributes
will be included.
responses:
"200":
description: Contact links generated successfully.
content:
application/json:
schema:
type: array
items:
type: object
properties:
data:
type: array
items:
type: object
properties:
contactId:
type: string
description: The ID of the contact
surveyUrl:
type: string
format: uri
description: Personalized survey link
expiresAt:
type:
- string
- "null"
description: The date and time the link expires, null if no expiration
attributes:
type: object
additionalProperties:
type: string
description: The attributes of the contact
meta:
type: object
properties:
total:
type: number
limit:
type: number
offset:
type: number
/webhooks:
get:
operationId: getWebhooks
@@ -2243,37 +2357,49 @@ paths:
parameters:
- in: query
name: limit
description: Number of items to return
schema:
type: number
minimum: 1
maximum: 100
default: 10
maximum: 250
default: 50
description: Number of items to return
- in: query
name: skip
description: Number of items to skip
schema:
type: number
minimum: 0
default: 0
description: Number of items to skip
- in: query
name: sortBy
description: Sort by field
schema:
type: string
enum: *a6
default: createdAt
description: Sort by field
- in: query
name: order
description: Sort order
schema:
type: string
enum: *a7
default: desc
description: Sort order
- in: query
name: startDate
description: Start date
schema:
type: string
description: Start date
- in: query
name: endDate
description: End date
schema:
type: string
description: End date
- in: query
name: surveyIds
schema:
@@ -2680,37 +2806,49 @@ paths:
required: true
- in: query
name: limit
description: Number of items to return
schema:
type: number
minimum: 1
maximum: 100
default: 10
maximum: 250
default: 50
description: Number of items to return
- in: query
name: skip
description: Number of items to skip
schema:
type: number
minimum: 0
default: 0
description: Number of items to skip
- in: query
name: sortBy
description: Sort by field
schema:
type: string
enum: *a6
default: createdAt
description: Sort by field
- in: query
name: order
description: Sort order
schema:
type: string
enum: *a7
default: desc
description: Sort order
- in: query
name: startDate
description: Start date
schema:
type: string
description: Start date
- in: query
name: endDate
description: End date
schema:
type: string
description: End date
responses:
"200":
description: Teams retrieved successfully.
@@ -2972,37 +3110,49 @@ paths:
required: true
- in: query
name: limit
description: Number of items to return
schema:
type: number
minimum: 1
maximum: 100
default: 10
maximum: 250
default: 50
description: Number of items to return
- in: query
name: skip
description: Number of items to skip
schema:
type: number
minimum: 0
default: 0
description: Number of items to skip
- in: query
name: sortBy
description: Sort by field
schema:
type: string
enum: *a6
default: createdAt
description: Sort by field
- in: query
name: order
description: Sort order
schema:
type: string
enum: *a7
default: desc
description: Sort order
- in: query
name: startDate
description: Start date
schema:
type: string
description: Start date
- in: query
name: endDate
description: End date
schema:
type: string
description: End date
- in: query
name: teamId
schema:
@@ -3258,37 +3408,49 @@ paths:
required: true
- in: query
name: limit
description: Number of items to return
schema:
type: number
minimum: 1
maximum: 100
default: 10
maximum: 250
default: 50
description: Number of items to return
- in: query
name: skip
description: Number of items to skip
schema:
type: number
minimum: 0
default: 0
description: Number of items to skip
- in: query
name: sortBy
description: Sort by field
schema:
type: string
enum: *a6
default: createdAt
description: Sort by field
- in: query
name: order
description: Sort order
schema:
type: string
enum: *a7
default: desc
description: Sort order
- in: query
name: startDate
description: Start date
schema:
type: string
description: Start date
- in: query
name: endDate
description: End date
schema:
type: string
description: End date
- in: query
name: id
schema: