mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
feat: personalized survey links for segment of users endpoint (#5032)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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're connected with env:</p>
|
||||
<p className="mb-1 sm:mb-0 sm:mr-2">You'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}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
|
||||
|
||||
export { GET };
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
291
apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts
Normal file
291
apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts
Normal 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
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user