feat: Okta(SCIM2.0) -> Attributes Sync and Group Option support (#17805)
This commit is contained in:
1 parent
d5ef3e1d72
commit
7e76770ba6
46 files changed
+3688
-307
No files matched your search
@@ -5,19 +5,27 @@ import handleGroupEvents from "@calcom/features/ee/dsync/lib/handleGroupEvents";
|
||||
import handleUserEvents from "@calcom/features/ee/dsync/lib/handleUserEvents";
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[scim]"] });
|
||||
|
||||
// This is the handler for the SCIM API requests
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const log = logger.getSubLogger({ prefix: ["[scim]"] });
|
||||
const { dsyncController } = await jackson();
|
||||
|
||||
log.debug("Request from SCIM", {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
cookies: req.cookies,
|
||||
});
|
||||
const { method, query, body } = req;
|
||||
|
||||
const [directoryId, path, resourceId] = query.directory as string[];
|
||||
|
||||
let responseBody: object | undefined = undefined;
|
||||
|
||||
if (body) {
|
||||
try {
|
||||
responseBody = JSON.parse(body);
|
||||
@@ -43,6 +51,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const { status, data } = await dsyncController.requests.handle(request, handleEvents);
|
||||
|
||||
log.debug("Response to SCIM", {
|
||||
status,
|
||||
data,
|
||||
});
|
||||
|
||||
res.status(status).json(data);
|
||||
}
|
||||
|
||||
@@ -55,6 +68,7 @@ export const extractAuthToken = (req: NextApiRequest): string | null => {
|
||||
|
||||
// Handle the SCIM events
|
||||
const handleEvents = async (event: DirectorySyncEvent) => {
|
||||
log.debug("handleEvents", safeStringify(event));
|
||||
const dSyncData = await prisma.dSyncData.findFirst({
|
||||
where: {
|
||||
directoryId: event.directory_id,
|
||||
|
||||
@@ -2834,5 +2834,9 @@
|
||||
"multi_select": "Multi Select",
|
||||
"group_meeting": "Group Meeting",
|
||||
"salesforce_ignore_guests": "Do not create new records for guests added to the booking",
|
||||
"lock_attribute_for_assignment": "Lock for assignment",
|
||||
"lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync",
|
||||
"attribute_edited_successfully": "Attribute edited successfully",
|
||||
"new_group_option": "New group option",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
@@ -91,7 +91,7 @@ describe("generate Csv for Org Users Table @test", () => {
|
||||
{
|
||||
...mockUser,
|
||||
teams: [{ id: 1, name: "Team1", slug: "team1" }],
|
||||
attributes: [{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1" }],
|
||||
attributes: [{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1", contains: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -115,8 +115,8 @@ describe("generate Csv for Org Users Table @test", () => {
|
||||
...mockUser,
|
||||
teams: [{ id: 1, name: "Team1", slug: "team1" }],
|
||||
attributes: [
|
||||
{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1" },
|
||||
{ id: "2", attributeId: "attr1", value: "value2", slug: "slug1" },
|
||||
{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1", contains: [] },
|
||||
{ id: "2", attributeId: "attr1", value: "value2", slug: "slug1", contains: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -166,7 +166,7 @@ describe("generate Csv for Org Users Table @test", () => {
|
||||
{
|
||||
...mockUser,
|
||||
teams: [{ id: 1, name: "Team,1", slug: "team1" }],
|
||||
attributes: [{ id: "1", attributeId: "attr1", value: "value,1", slug: "slug1" }],
|
||||
attributes: [{ id: "1", attributeId: "attr1", value: "value,1", slug: "slug1", contains: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAttributesForTeam } from "@calcom/lib/raqb/getAttributes";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { getAttributesForTeam } from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { DirectorySyncEvent } from "@boxyhq/saml-jackson";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import getAttributesFromScimPayload from "../getAttributesFromScimPayload";
|
||||
|
||||
describe("getAttributesFromScimPayload", () => {
|
||||
it("should return empty object for unsupported events", () => {
|
||||
const event = {
|
||||
event: "user.deleted",
|
||||
data: { raw: { schemas: [] } },
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
const result = getAttributesFromScimPayload(event);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("should extract string attributes from custom schemas", () => {
|
||||
const event = {
|
||||
event: "user.created",
|
||||
data: {
|
||||
raw: {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User", "custom:enterprise"],
|
||||
"custom:enterprise": {
|
||||
department: "Engineering",
|
||||
title: "Software Engineer",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
const result = getAttributesFromScimPayload(event);
|
||||
expect(result).toEqual({
|
||||
department: "Engineering",
|
||||
title: "Software Engineer",
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract string array attributes", () => {
|
||||
const event = {
|
||||
event: "user.updated",
|
||||
data: {
|
||||
raw: {
|
||||
schemas: ["custom:enterprise"],
|
||||
"custom:enterprise": {
|
||||
skills: ["JavaScript", "TypeScript", "React"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
const result = getAttributesFromScimPayload(event);
|
||||
expect(result).toEqual({
|
||||
skills: ["JavaScript", "TypeScript", "React"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore null values", () => {
|
||||
const event = {
|
||||
event: "user.created",
|
||||
data: {
|
||||
raw: {
|
||||
schemas: ["custom:enterprise"],
|
||||
"custom:enterprise": {
|
||||
department: "Engineering",
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
const result = getAttributesFromScimPayload(event);
|
||||
expect(result).toEqual({
|
||||
department: "Engineering",
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore an object value as well", () => {
|
||||
const event = {
|
||||
event: "user.created",
|
||||
data: {
|
||||
raw: {
|
||||
schemas: ["custom:enterprise"],
|
||||
"custom:enterprise": {
|
||||
department: { value: "Engineering" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
const result = getAttributesFromScimPayload(event);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle multiple schemas but ignore duplicates - You can only have department in one namespace right now", () => {
|
||||
const event = {
|
||||
event: "user.created",
|
||||
data: {
|
||||
raw: {
|
||||
schemas: ["custom:enterprise", "custom:org"],
|
||||
"custom:enterprise": {
|
||||
department: "Engineering",
|
||||
},
|
||||
"custom:org": {
|
||||
department: "Different Department", // This should be ignored as department already exists
|
||||
location: "Remote",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
const result = getAttributesFromScimPayload(event);
|
||||
expect(result).toEqual({
|
||||
department: "Engineering",
|
||||
location: "Remote",
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore non-string schema names", () => {
|
||||
const event = {
|
||||
event: "user.created",
|
||||
data: {
|
||||
raw: {
|
||||
schemas: ["custom:enterprise", 123], // Invalid schema
|
||||
"custom:enterprise": {
|
||||
department: "Engineering",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
const result = getAttributesFromScimPayload(event);
|
||||
expect(result).toEqual({
|
||||
department: "Engineering",
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract from core namespace as well.", () => {
|
||||
const event = {
|
||||
event: "user.created",
|
||||
data: {
|
||||
raw: {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
userName: "kush@acme.com",
|
||||
name: { givenName: "Kush", familyName: "Kush" },
|
||||
emails: [{ primary: true, value: "kush@acme.com" }],
|
||||
displayName: "Kush",
|
||||
territory: "XANAM",
|
||||
externalId: "00ulb1kpy4EMATtuS5d7",
|
||||
groups: [],
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
const result = getAttributesFromScimPayload(event);
|
||||
expect(result).toEqual({
|
||||
userName: "kush@acme.com",
|
||||
displayName: "Kush",
|
||||
territory: "XANAM",
|
||||
externalId: "00ulb1kpy4EMATtuS5d7",
|
||||
groups: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { DirectorySyncEvent } from "@boxyhq/saml-jackson";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getAttributesFromScimPayload"] });
|
||||
|
||||
type ScimUserAttributeName = string;
|
||||
type ScimUserAttributeValue = string | string[];
|
||||
/**
|
||||
* event.data.raw has this format
|
||||
* {
|
||||
* "schemas": [
|
||||
* "urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
* "segment",
|
||||
* "territory"
|
||||
* ],
|
||||
* "userName": "member@samldemo.com",
|
||||
* "name": {
|
||||
* "givenName": "Member SAML Demo",
|
||||
* "familyName": "Member SAML Demo"
|
||||
* },
|
||||
* "emails": [
|
||||
* {
|
||||
* "primary": true,
|
||||
* "value": "member@samldemo.com"
|
||||
* }
|
||||
* ],
|
||||
* "displayName": "Member SAML Demo",
|
||||
* "territory": {
|
||||
* "territory": "NAM"
|
||||
* },
|
||||
* "segment": {
|
||||
* "segment": "SMB"
|
||||
* },
|
||||
* "externalId": "00ukzk1wrsKZqofit5d7",
|
||||
* "groups": [],
|
||||
* "active": true,
|
||||
* "id": "b36ba9fa-783b-44e6-a770-a652cb9d71ba"
|
||||
* }
|
||||
*
|
||||
* Transforms above to
|
||||
* {
|
||||
* "territory": "NAM",
|
||||
* "segment": "SMB"
|
||||
* }
|
||||
*/
|
||||
function getAttributesFromScimPayload(
|
||||
event: DirectorySyncEvent
|
||||
): Record<ScimUserAttributeName, ScimUserAttributeValue> {
|
||||
const scimUserAttributes: Record<ScimUserAttributeName, ScimUserAttributeValue> = {};
|
||||
|
||||
if (event.event !== "user.created" && event.event !== "user.updated") {
|
||||
log.error("getAttributesFromScimPayload", `Unsupported event: ${event.event}`);
|
||||
return scimUserAttributes;
|
||||
}
|
||||
|
||||
const raw = event.data.raw;
|
||||
raw.schemas.forEach((schema: unknown) => {
|
||||
if (schema === "urn:ietf:params:scim:schemas:core:2.0:User") {
|
||||
// Core schema has payload in the root
|
||||
const { schemas: _schemas, ...namespaceData } = raw;
|
||||
collectAttributes(namespaceData);
|
||||
return;
|
||||
}
|
||||
const namespaceName = schema;
|
||||
if (typeof namespaceName !== "string") {
|
||||
log.error(
|
||||
"getAttributesFromScimPayload",
|
||||
`Namespace name is not a string ${safeStringify(namespaceName)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const namespaceData = raw[namespaceName];
|
||||
if (!namespaceData) {
|
||||
log.warn("getAttributesFromScimPayload", `Namespace data for ${namespaceName} is null. Ignoring it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
collectAttributes(namespaceData);
|
||||
});
|
||||
|
||||
log.debug("Collected attributes", `Attributes: ${safeStringify(scimUserAttributes)}`);
|
||||
|
||||
return scimUserAttributes;
|
||||
|
||||
function collectAttributes(data: Record<string, unknown>) {
|
||||
Object.entries(data).forEach(([customAttributeName, value]) => {
|
||||
if (!value) {
|
||||
log.warn(
|
||||
"getAttributesFromScimPayload",
|
||||
`Custom attribute ${customAttributeName} is null. Ignoring it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (scimUserAttributes[customAttributeName]) {
|
||||
log.warn(
|
||||
"getAttributesFromScimPayload",
|
||||
`Custom attribute ${customAttributeName} already exists. Might be coming from different namespace. Ignoring it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Support number as well as Attribute support number type
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
(value instanceof Array && value.every((item) => typeof item === "string"))
|
||||
) {
|
||||
scimUserAttributes[customAttributeName] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default getAttributesFromScimPayload;
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { DirectorySyncEvent, Group } from "@boxyhq/saml-jackson";
|
||||
|
||||
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
|
||||
@@ -12,7 +14,10 @@ import {
|
||||
|
||||
import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["dsync/handleGroupEvents"] });
|
||||
|
||||
const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: number) => {
|
||||
log.debug("called", safeStringify(event));
|
||||
// Find the group name associated with the event
|
||||
const eventData = event.data as Group;
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { DirectorySyncEvent, User } from "@boxyhq/saml-jackson";
|
||||
|
||||
import removeUserFromOrg from "@calcom/features/ee/dsync/lib/removeUserFromOrg";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { assignValueToUserInOrgBulk } from "@calcom/lib/service/attribute/server/assignValueToUser";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
@@ -10,11 +13,52 @@ import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/team
|
||||
import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
|
||||
import getAttributesFromScimPayload from "./getAttributesFromScimPayload";
|
||||
import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg";
|
||||
import dSyncUserSelect from "./users/dSyncUserSelect";
|
||||
import inviteExistingUserToOrg from "./users/inviteExistingUserToOrg";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["handleUserEvents"] });
|
||||
|
||||
async function syncCustomAttributesToUser({
|
||||
event,
|
||||
userEmail,
|
||||
org,
|
||||
directoryId,
|
||||
}: {
|
||||
event: DirectorySyncEvent;
|
||||
userEmail: string;
|
||||
org: {
|
||||
id: number;
|
||||
};
|
||||
directoryId: string;
|
||||
}) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: userEmail,
|
||||
},
|
||||
select: dSyncUserSelect,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
log.error(`User not found in DB ${userEmail}. Skipping custom attributes sync.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const customAttributes = getAttributesFromScimPayload(event);
|
||||
await assignValueToUserInOrgBulk({
|
||||
orgId: org.id,
|
||||
userId: user.id,
|
||||
attributeLabelToValueMap: customAttributes,
|
||||
updater: {
|
||||
dsyncId: directoryId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleUserEvents = async (event: DirectorySyncEvent, organizationId: number) => {
|
||||
log.debug("called", safeStringify(event));
|
||||
const directoryId = event.directory_id;
|
||||
const eventData = event.data as User;
|
||||
const userEmail = eventData.email;
|
||||
// Check if user exists in DB
|
||||
@@ -63,6 +107,13 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe
|
||||
currentUserParentTeamName: org?.parent?.name,
|
||||
orgSlug: org.slug,
|
||||
});
|
||||
|
||||
await syncCustomAttributesToUser({
|
||||
event,
|
||||
userEmail,
|
||||
org,
|
||||
directoryId,
|
||||
});
|
||||
} else {
|
||||
// If data.active is false then remove the user from the org
|
||||
await removeUserFromOrg({
|
||||
@@ -88,6 +139,13 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe
|
||||
teamId: organizationId,
|
||||
isOrg: true,
|
||||
});
|
||||
|
||||
await syncCustomAttributesToUser({
|
||||
event,
|
||||
userEmail,
|
||||
org,
|
||||
directoryId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Controller, useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { entries } from "@calcom/prisma/zod-utils";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
@@ -14,17 +15,26 @@ import {
|
||||
Input,
|
||||
Dialog,
|
||||
ConfirmationDialogContent,
|
||||
SettingsToggle,
|
||||
} from "@calcom/ui";
|
||||
|
||||
const AttributeSchema = z.object({
|
||||
const AttributeFormSchema = z.object({
|
||||
attrName: z.string().min(1),
|
||||
isLocked: z.boolean().optional(),
|
||||
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
|
||||
options: z.array(
|
||||
z.object({ value: z.string(), id: z.string().optional(), assignedUsers: z.number().optional() })
|
||||
z.object({
|
||||
value: z.string(),
|
||||
id: z.string().optional(),
|
||||
assignedUsers: z.number().optional(),
|
||||
isGroup: z.boolean().optional(),
|
||||
contains: z.array(z.string()).optional(),
|
||||
attributeOptionId: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof AttributeSchema>;
|
||||
type FormValues = z.infer<typeof AttributeFormSchema>;
|
||||
|
||||
const AttributeTypeOptions = [
|
||||
{ value: "TEXT", label: "Text" },
|
||||
@@ -39,6 +49,75 @@ interface AttributeFormProps {
|
||||
header: React.ReactNode;
|
||||
}
|
||||
|
||||
export function getGroupOptionUpdate({
|
||||
newGroupOptions,
|
||||
previousGroupOptions,
|
||||
subOption,
|
||||
allOptions,
|
||||
}: {
|
||||
newGroupOptions: { label: string; value: string | undefined }[];
|
||||
previousGroupOptions: { label: string; value: string | undefined }[];
|
||||
subOption: { attributeOptionId?: string };
|
||||
allOptions: { attributeOptionId?: string; contains?: string[] }[];
|
||||
}) {
|
||||
const nonGroupOptionId = subOption.attributeOptionId;
|
||||
if (!nonGroupOptionId) return {};
|
||||
// Find diff b/w previouslyChosenGroupOptions and chosenGroupOptions
|
||||
const removedGroupOptions = previousGroupOptions.filter(
|
||||
(option) => !newGroupOptions.map((o) => o.value).includes(option.value)
|
||||
);
|
||||
|
||||
const optionsUpdate: { [key: number]: string[] } = {};
|
||||
|
||||
// Update all groupOptions' contains as per the final state.
|
||||
newGroupOptions
|
||||
.filter((option): option is { label: string; value: string } => !!option.value)
|
||||
.forEach((newGroupOption) => {
|
||||
const { groupOption, index: indexOfGroupOptionInWatchedOptions } = getGroupOptionMatching({
|
||||
allOptions,
|
||||
toMatchWith: newGroupOption,
|
||||
});
|
||||
if (!groupOption) return;
|
||||
const existingContains = groupOption.contains;
|
||||
const newContains = [...(existingContains || []), nonGroupOptionId].filter(
|
||||
(value, index, self) => self.indexOf(value) === index
|
||||
);
|
||||
optionsUpdate[indexOfGroupOptionInWatchedOptions] = newContains;
|
||||
});
|
||||
|
||||
// Because certain groupOptions might have been removed, we need to update such groupOptions' contains as well.
|
||||
removedGroupOptions
|
||||
.filter((option): option is { label: string; value: string } => !!option.value)
|
||||
.forEach((removedGroupOption) => {
|
||||
const { groupOption, index: indexOfGroupOptionInWatchedOptions } = getGroupOptionMatching({
|
||||
allOptions,
|
||||
toMatchWith: removedGroupOption,
|
||||
});
|
||||
if (!groupOption) return;
|
||||
const existingContains = groupOption.contains || [];
|
||||
const newContains = existingContains.filter((value) => value !== nonGroupOptionId);
|
||||
optionsUpdate[indexOfGroupOptionInWatchedOptions] = newContains;
|
||||
});
|
||||
return optionsUpdate;
|
||||
|
||||
function getGroupOptionMatching({
|
||||
allOptions,
|
||||
toMatchWith,
|
||||
}: {
|
||||
allOptions: { attributeOptionId?: string; contains?: string[] }[];
|
||||
toMatchWith: { value: string };
|
||||
}) {
|
||||
const indexOfGroupOptionInWatchedOptions = allOptions.findIndex(
|
||||
(option) => option.attributeOptionId === toMatchWith.value
|
||||
);
|
||||
return {
|
||||
index: indexOfGroupOptionInWatchedOptions,
|
||||
groupOption:
|
||||
(allOptions[indexOfGroupOptionInWatchedOptions] as (typeof allOptions)[number] | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function AttributeForm({ initialValues, onSubmit, header }: AttributeFormProps) {
|
||||
const { t } = useLocale();
|
||||
const [deleteOptionDialog, setDeleteOptionDialog] = useState<{
|
||||
@@ -53,11 +132,25 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
|
||||
easing: "ease-in-out",
|
||||
});
|
||||
|
||||
// Needed because useFieldArray overrides the id field
|
||||
const initialValuesEnsuringThatOptionIdIsNotInId = initialValues
|
||||
? {
|
||||
...initialValues,
|
||||
options: initialValues.options.map((option) => ({
|
||||
...option,
|
||||
// Set attributeOptionId equal to id field because 'id' property is used by useFieldArray
|
||||
attributeOptionId: option.id,
|
||||
// Also, set id to undefined to make it clear that this id is not used by us.
|
||||
id: undefined,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(AttributeSchema),
|
||||
defaultValues: initialValues || {
|
||||
resolver: zodResolver(AttributeFormSchema),
|
||||
defaultValues: initialValuesEnsuringThatOptionIdIsNotInId || {
|
||||
attrName: "",
|
||||
options: [{ value: "" }],
|
||||
options: [{ value: "", isGroup: false }],
|
||||
type: "TEXT",
|
||||
},
|
||||
});
|
||||
@@ -67,8 +160,9 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
|
||||
name: "options",
|
||||
});
|
||||
|
||||
const watchedOptions = form.watch("options");
|
||||
const watchedGroupOptions = watchedOptions.filter((field) => field.isGroup);
|
||||
const watchedType = form.watch("type");
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
@@ -76,10 +170,29 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
|
||||
handleSubmit={(values) => {
|
||||
onSubmit({
|
||||
...values,
|
||||
options: values.options,
|
||||
options: values.options.map(({ attributeOptionId, ...option }) => ({
|
||||
...option,
|
||||
// Set back attributeOptionId in id field
|
||||
id: attributeOptionId,
|
||||
})),
|
||||
});
|
||||
}}>
|
||||
{header}
|
||||
<Controller
|
||||
name="isLocked"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("lock_attribute_for_assignment")}
|
||||
description={t("lock_attribute_for_assignment_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(checked);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<InputField label={t("name")} required {...form.register("attrName")} />
|
||||
<Controller
|
||||
name="type"
|
||||
@@ -99,34 +212,125 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
|
||||
{["SINGLE_SELECT", "MULTI_SELECT"].includes(watchedType) && (
|
||||
<div className="bg-muted border-muted mt-6 rounded-lg border p-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t("options")}</Label>
|
||||
<div ref={listRef}>
|
||||
{fields.map((field, index) => (
|
||||
<div className="flex items-center gap-2" key={field.id}>
|
||||
<Input {...form.register(`options.${index}.value`)} className="w-full" />
|
||||
<Input {...form.register(`options.${index}.id`)} className="hidden" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
StartIcon="x"
|
||||
color="minimal"
|
||||
className="mb-2"
|
||||
disabled={index === 0 && fields.length === 1}
|
||||
onClick={() => {
|
||||
if (field.assignedUsers && field.assignedUsers > 0) {
|
||||
setDeleteOptionDialog({ id: index, open: true });
|
||||
} else {
|
||||
remove(index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Label>{t("options")}</Label>
|
||||
<div ref={listRef}>
|
||||
{watchedOptions.map((nonGroupOption, index) => {
|
||||
const isAGroupOption = nonGroupOption.isGroup;
|
||||
if (isAGroupOption) return null;
|
||||
const groupOptionsSelectFieldSelectedValue = watchedGroupOptions
|
||||
?.filter(
|
||||
({ contains }) =>
|
||||
nonGroupOption.attributeOptionId &&
|
||||
contains?.includes(nonGroupOption.attributeOptionId)
|
||||
)
|
||||
.map((groupOption) => ({
|
||||
label: groupOption.value,
|
||||
value: groupOption.attributeOptionId,
|
||||
}));
|
||||
|
||||
const groupOptionsSelectFieldOptions = watchedGroupOptions
|
||||
.map((f) => ({
|
||||
label: f.value,
|
||||
value: f.attributeOptionId,
|
||||
}))
|
||||
.filter((option): option is { label: string; value: string } => !!option.value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2" key={nonGroupOption.id}>
|
||||
<div className="flex w-full justify-between gap-2">
|
||||
<Input {...form.register(`options.${index}.value`)} />
|
||||
<SelectField
|
||||
isMulti
|
||||
isDisabled={!nonGroupOption.attributeOptionId}
|
||||
placeholder="Add to group"
|
||||
options={groupOptionsSelectFieldOptions}
|
||||
value={groupOptionsSelectFieldSelectedValue}
|
||||
onChange={(chosenGroupOptions) => {
|
||||
// subOption is the option that is being added to groups
|
||||
const subOption = nonGroupOption;
|
||||
const optionsUpdate = getGroupOptionUpdate({
|
||||
// Spread makes it non-readonly
|
||||
newGroupOptions: [...chosenGroupOptions],
|
||||
previousGroupOptions: groupOptionsSelectFieldSelectedValue,
|
||||
subOption,
|
||||
allOptions: watchedOptions,
|
||||
});
|
||||
entries(optionsUpdate).forEach(([index, value]) => {
|
||||
form.setValue(`options.${index}.contains`, value);
|
||||
});
|
||||
}}
|
||||
className="min-w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
StartIcon="x"
|
||||
color="minimal"
|
||||
className="mb-2"
|
||||
disabled={index === 0 && fields.length === 1}
|
||||
onClick={() => {
|
||||
if (nonGroupOption.assignedUsers && nonGroupOption.assignedUsers > 0) {
|
||||
setDeleteOptionDialog({ id: index, open: true });
|
||||
} else {
|
||||
remove(index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Input {...form.register(`options.${index}.id`)} className="hidden" />
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button type="button" StartIcon="plus" color="secondary" onClick={() => append({ value: "" })}>
|
||||
{t("new_option")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label>{t("Group Options")}</Label>
|
||||
<div ref={listRef}>
|
||||
{watchedOptions.map((watchedOption, index) => {
|
||||
const isAGroupOption = watchedOption.isGroup;
|
||||
if (!isAGroupOption) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2" key={watchedOption.id}>
|
||||
<div className="flex w-full">
|
||||
<Input {...form.register(`options.${index}.value`)} className="w-full" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
StartIcon="x"
|
||||
color="minimal"
|
||||
className="mb-2"
|
||||
disabled={index === 0 && fields.length === 1}
|
||||
onClick={() => {
|
||||
if (watchedOption.assignedUsers && watchedOption.assignedUsers > 0) {
|
||||
setDeleteOptionDialog({ id: index, open: true });
|
||||
} else {
|
||||
remove(index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Input {...form.register(`options.${index}.id`)} className="hidden" />
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
StartIcon="plus"
|
||||
color="secondary"
|
||||
onClick={() => append({ value: "", isGroup: true })}>
|
||||
{t("new_group_option")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" StartIcon="plus" color="secondary" onClick={() => append({ value: "" })}>
|
||||
{t("new_option")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
@@ -142,7 +346,7 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
|
||||
loadingText={t("deleting_attribute")}>
|
||||
<>
|
||||
{t("delete_attribute_description", {
|
||||
numberOfUsers: fields[deleteOptionDialog.id as number]?.assignedUsers || 0,
|
||||
numberOfUsers: watchedOptions[deleteOptionDialog.id as number]?.assignedUsers || 0,
|
||||
})}
|
||||
</>
|
||||
</ConfirmationDialogContent>
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import "../../../../../../../ui/components/test-setup";
|
||||
import { getGroupOptionUpdate } from "../AttributesForm";
|
||||
|
||||
describe("getGroupOptionUpdate", () => {
|
||||
it("should add non-group option to new group's contains array", () => {
|
||||
const result = getGroupOptionUpdate({
|
||||
newGroupOptions: [{ label: "Group 1", value: "group1" }],
|
||||
previousGroupOptions: [],
|
||||
subOption: { attributeOptionId: "option1" },
|
||||
allOptions: [{ attributeOptionId: "group1", contains: [] }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
0: ["option1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove non-group option from removed group's contains array", () => {
|
||||
const result = getGroupOptionUpdate({
|
||||
newGroupOptions: [],
|
||||
previousGroupOptions: [{ label: "Group 1", value: "group1" }],
|
||||
subOption: { attributeOptionId: "option1" },
|
||||
allOptions: [{ attributeOptionId: "group1", contains: ["option1", "option2"] }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
0: ["option2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple group updates simultaneously", () => {
|
||||
const result = getGroupOptionUpdate({
|
||||
newGroupOptions: [
|
||||
{ label: "Group 1", value: "group1" },
|
||||
{ label: "Group 2", value: "group2" },
|
||||
],
|
||||
previousGroupOptions: [{ label: "Group 3", value: "group3" }],
|
||||
subOption: { attributeOptionId: "option1" },
|
||||
allOptions: [
|
||||
{ attributeOptionId: "group1", contains: ["option2"] },
|
||||
{ attributeOptionId: "group2", contains: [] },
|
||||
{ attributeOptionId: "group3", contains: ["option1", "option3"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
0: ["option2", "option1"],
|
||||
1: ["option1"],
|
||||
2: ["option3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should prevent duplicate entries in contains array", () => {
|
||||
const result = getGroupOptionUpdate({
|
||||
newGroupOptions: [{ label: "Group 1", value: "group1" }],
|
||||
previousGroupOptions: [],
|
||||
subOption: { attributeOptionId: "option1" },
|
||||
allOptions: [{ attributeOptionId: "group1", contains: ["option1"] }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
0: ["option1"], // Should not have duplicate "option1"
|
||||
});
|
||||
});
|
||||
|
||||
it("should not consider a group option with undefined value", () => {
|
||||
const result = getGroupOptionUpdate({
|
||||
newGroupOptions: [{ label: "Group 1", value: undefined }],
|
||||
previousGroupOptions: [],
|
||||
subOption: { attributeOptionId: "option1" },
|
||||
allOptions: [{ attributeOptionId: "1", contains: ["option1"] }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
+1
@@ -43,6 +43,7 @@ function CreateAttributesPage() {
|
||||
mutation.mutate({
|
||||
name: values.attrName,
|
||||
type: values.type,
|
||||
isLocked: values.isLocked,
|
||||
options: Array.from(uniqueAttributes).map((value) => ({ value })),
|
||||
});
|
||||
}}
|
||||
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -22,7 +22,6 @@ const CreateAttributeSchema = z.object({
|
||||
type FormValues = z.infer<typeof CreateAttributeSchema>;
|
||||
|
||||
function CreateAttributesPage() {
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
// Get the attribute id from the url
|
||||
@@ -38,7 +37,6 @@ function CreateAttributesPage() {
|
||||
id,
|
||||
});
|
||||
utils.viewer.attributes.list.invalidate();
|
||||
router.push("/settings/organizations/attributes");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
@@ -54,14 +52,15 @@ function CreateAttributesPage() {
|
||||
attrName: attribute.data.name,
|
||||
type: attribute.data.type,
|
||||
options: attribute.data.options,
|
||||
isLocked: attribute.data.isLocked,
|
||||
}}
|
||||
header={<EditAttributeHeader isPending={mutation.isPending} />}
|
||||
onSubmit={(values) => {
|
||||
const { attrName, ...rest } = values;
|
||||
mutation.mutate({
|
||||
...rest,
|
||||
name: attrName,
|
||||
attributeId: id,
|
||||
name: values.attrName,
|
||||
type: values.type,
|
||||
options: values.options,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { zodRoutes } from "@calcom/app-store/routing-forms/zod";
|
||||
import { getUsersAttributes } from "@calcom/lib/raqb/getAttributes";
|
||||
import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils";
|
||||
import { getUsersAttributes } from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
|
||||
|
||||
|
||||
@@ -283,6 +283,8 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) {
|
||||
attributeId: value.attributeId,
|
||||
value: value.value,
|
||||
slug: value.slug,
|
||||
contains: value.contains,
|
||||
isGroup: value.isGroup,
|
||||
}));
|
||||
newAttributes.push(...newAttributeValues);
|
||||
} else {
|
||||
@@ -292,6 +294,8 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) {
|
||||
attributeId: foundAttributeInCache?.id ?? "-1",
|
||||
value: selectedAttributeOptions[0],
|
||||
slug: slugify(selectedAttributeOptions[0]),
|
||||
contains: [],
|
||||
isGroup: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,15 @@ const stringOrNumber = z.string().or(z.number());
|
||||
|
||||
const attributeSchema = z.object({
|
||||
id: z.string(),
|
||||
options: z.array(z.object({ label: z.string().optional(), value: stringOrNumber.optional() })).optional(),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().optional(),
|
||||
value: stringOrNumber.optional(),
|
||||
createdByDSyncId: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
value: stringOrNumber.optional(),
|
||||
});
|
||||
|
||||
@@ -228,10 +236,10 @@ export function EditForm({
|
||||
);
|
||||
}
|
||||
|
||||
type AttributeType = z.infer<typeof attributeSchema>;
|
||||
type Attribute = z.infer<typeof attributeSchema>;
|
||||
|
||||
type DefaultValueType = {
|
||||
[key: `attributes.${number}`]: AttributeType;
|
||||
[key: `attributes.${number}`]: Attribute;
|
||||
};
|
||||
|
||||
function AttributesList(props: { selectedUserId: number }) {
|
||||
@@ -267,12 +275,22 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
} else if (attr.type === "MULTI_SELECT") {
|
||||
acc[key] = {
|
||||
id: attr.id,
|
||||
options: attr.options.map((option) => ({ label: option.value, value: option.id })),
|
||||
options: attr.options.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.id,
|
||||
createdByDSyncId: option.createdByDSyncId,
|
||||
})),
|
||||
};
|
||||
} else if (attr.type === "SINGLE_SELECT") {
|
||||
acc[key] = {
|
||||
id: attr.id,
|
||||
options: [{ label: attr.options[0]?.value, value: attr.options[0]?.id }],
|
||||
options: [
|
||||
{
|
||||
label: attr.options[0]?.value,
|
||||
value: attr.options[0]?.id,
|
||||
createdByDSyncId: attr.options[0]?.createdByDSyncId ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
acc[key] = {
|
||||
@@ -302,7 +320,8 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
control={control}
|
||||
key={attr.id}
|
||||
defaultValue={defaultValues[`attributes.${index}`]}
|
||||
render={({ field }) => {
|
||||
render={({ field: { value, ...field } }) => {
|
||||
const fieldValue = value as Attribute | undefined | null;
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center gap-2" key={attr.id}>
|
||||
{["TEXT", "NUMBER"].includes(attr.type) && (
|
||||
@@ -312,7 +331,7 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
labelClassName="text-emphasis mb-2 block text-sm font-medium leading-none"
|
||||
label={attr.name}
|
||||
type={attr.type === "TEXT" ? "text" : "number"}
|
||||
value={field.value?.value || ""}
|
||||
value={fieldValue?.value || ""}
|
||||
onChange={(e) => {
|
||||
field.onChange({
|
||||
id: attr.id,
|
||||
@@ -323,6 +342,7 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
)}
|
||||
{["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && (
|
||||
<SelectField
|
||||
isDisabled={attr.isLocked}
|
||||
name={field.name}
|
||||
containerClassName="w-full"
|
||||
isMulti={attr.type === "MULTI_SELECT"}
|
||||
@@ -331,19 +351,24 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
}}
|
||||
label={attr.name}
|
||||
options={getOptionsByAttributeId(attr.id)}
|
||||
value={attr.type === "MULTI_SELECT" ? field.value?.options : field.value?.options[0]}
|
||||
value={attr.type === "MULTI_SELECT" ? fieldValue?.options : fieldValue?.options?.[0]}
|
||||
onChange={(value) => {
|
||||
if (attr.type === "MULTI_SELECT") {
|
||||
field.onChange({
|
||||
id: attr.id,
|
||||
options: value.map((v: any) => ({ label: v.label, value: v.value })),
|
||||
});
|
||||
} else {
|
||||
field.onChange({
|
||||
id: attr.id,
|
||||
options: [{ label: value.label, value: value.value }],
|
||||
});
|
||||
}
|
||||
if (!value) return;
|
||||
const valueAsArray = value instanceof Array ? value : [value];
|
||||
|
||||
const updatedOptions =
|
||||
attr.type === "MULTI_SELECT"
|
||||
? valueAsArray.map((v) => ({ label: v.label, value: v.value }))
|
||||
: [{ label: valueAsArray[0].label, value: valueAsArray[0].value }];
|
||||
|
||||
field.onChange({
|
||||
id: attr.id,
|
||||
// It is also ensured in the backend that the options not owned by cal.com are not removed
|
||||
options: getOptionsEnsuringNotOwnedByCalcomNotRemoved({
|
||||
earlierOptions: fieldValue?.options || [],
|
||||
updatedOptions,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -356,3 +381,31 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that options that are not owned by cal.com are not removed
|
||||
* Such options are created by dsync and removed only through corresponding dsync
|
||||
*/
|
||||
function getOptionsEnsuringNotOwnedByCalcomNotRemoved<
|
||||
// Before assigning this option it can't have createdByDSyncId set
|
||||
TOptionToChoose extends { value: string | number | undefined },
|
||||
// Already set option can have createdByDSyncId set
|
||||
TOptionAlreadySet extends {
|
||||
value?: string | number | undefined;
|
||||
createdByDSyncId?: string | null | undefined;
|
||||
}
|
||||
>({
|
||||
earlierOptions,
|
||||
updatedOptions,
|
||||
}: {
|
||||
earlierOptions: TOptionAlreadySet[];
|
||||
updatedOptions: TOptionToChoose[];
|
||||
}) {
|
||||
const optionsNotOwnedByCalcom = earlierOptions.filter((option) => !!option.createdByDSyncId);
|
||||
|
||||
const newUniqueOptionsPlusNotOwnedByCalcom = [...optionsNotOwnedByCalcom, ...updatedOptions].filter(
|
||||
(option, index, self) => index === self.findIndex((o) => o.value === option.value && o.value)
|
||||
);
|
||||
|
||||
return newUniqueOptionsPlusNotOwnedByCalcom;
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { useMemo, useReducer, useRef, useState } from "react";
|
||||
@@ -185,11 +179,14 @@ export function UserListTable() {
|
||||
if (attributeValues.length === 0) return null;
|
||||
return (
|
||||
<div className={classNames(attribute.type === "NUMBER" ? "flex w-full justify-center" : "")}>
|
||||
{attributeValues.map((attributeValue, index) => (
|
||||
<Badge key={index} variant="gray" className="mr-1">
|
||||
{attributeValue.value}
|
||||
</Badge>
|
||||
))}
|
||||
{attributeValues.map((attributeValue, index) => {
|
||||
const isAGroupOption = attributeValue.contains?.length > 0;
|
||||
return (
|
||||
<Badge key={index} variant={isAGroupOption ? "orange" : "gray"} className="mr-1">
|
||||
{attributeValue.value}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -405,8 +402,6 @@ export function UserListTable() {
|
||||
rowSelection,
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// TODO(SEAN): We need to move filter state to the server so we can fetch more data when the filters change if theyre not in client cache
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getRowId: (row) => `${row.id}`,
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface UserTableUser {
|
||||
attributeId: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
contains: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ import {
|
||||
findTeamMembersMatchingAttributeLogic,
|
||||
TroubleshooterCase,
|
||||
} from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
|
||||
// import { EmailField } from "@calcom/ui";
|
||||
import * as getAttributesModule from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
import type { AttributeType } from "@calcom/prisma/enums";
|
||||
import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes";
|
||||
import type { AttributesQueryValue, FormFieldsQueryValue } from "@calcom/routing-forms/types/types";
|
||||
|
||||
// import { EmailField } from "@calcom/ui";
|
||||
import * as getAttributesModule from "./getAttributes";
|
||||
|
||||
vi.mock("./getAttributes");
|
||||
vi.mock("@calcom/lib/service/attribute/server/getAttributes");
|
||||
vi.mock("../../components/react-awesome-query-builder/widgets", () => ({
|
||||
default: {},
|
||||
}));
|
||||
@@ -32,18 +31,27 @@ function mockAttributesScenario({
|
||||
}) {
|
||||
vi.mocked(getAttributesModule.getAttributesForTeam).mockResolvedValue(attributes);
|
||||
vi.mocked(getAttributesModule.getTeamMembersWithAttributeOptionValuePerAttribute).mockResolvedValue(
|
||||
teamMembersWithAttributeOptionValuePerAttribute.map((member) => ({
|
||||
...member,
|
||||
attributes: Object.fromEntries(
|
||||
Object.entries(member.attributes).map(([attributeId, value]) => {
|
||||
return [
|
||||
attributeId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
{ value, type: attributes.find((attribute) => attribute.id === attributeId)?.type! },
|
||||
];
|
||||
})
|
||||
),
|
||||
}))
|
||||
teamMembersWithAttributeOptionValuePerAttribute.map((member) => {
|
||||
return {
|
||||
...member,
|
||||
attributes: Object.fromEntries(
|
||||
Object.entries(member.attributes).map(([attributeId, value]) => {
|
||||
return [
|
||||
attributeId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
{
|
||||
attributeOption:
|
||||
value instanceof Array
|
||||
? value.map((value) => ({ value, isGroup: false, contains: [] }))
|
||||
: { value, isGroup: false, contains: [] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
type: attributes.find((attribute) => attribute.id === attributeId)!.type,
|
||||
},
|
||||
];
|
||||
})
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -639,8 +647,6 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
attributesQueryValue: failingAttributesQueryValue,
|
||||
fallbackAttributesQueryValue: failingAttributesQueryValue,
|
||||
teamId: 1,
|
||||
attributesQueryValue: failingAttributesQueryValue,
|
||||
fallbackAttributesQueryValue: failingAttributesQueryValue,
|
||||
});
|
||||
|
||||
expect(checkedFallback).toEqual(true);
|
||||
|
||||
@@ -4,12 +4,16 @@ import type { Config } from "react-awesome-query-builder/lib";
|
||||
import { Utils as QbUtils } from "react-awesome-query-builder/lib";
|
||||
|
||||
import type { dynamicFieldValueOperands } from "@calcom/lib/raqb/types";
|
||||
import {
|
||||
getTeamMembersWithAttributeOptionValuePerAttribute,
|
||||
getAttributesForTeam,
|
||||
} from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
|
||||
import { RaqbLogicResult } from "./evaluateRaqbLogic";
|
||||
import { getTeamMembersWithAttributeOptionValuePerAttribute, getAttributesForTeam } from "./getAttributes";
|
||||
import jsonLogic from "./jsonLogic";
|
||||
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils";
|
||||
import type { Attribute, AttributesQueryValue } from "./types";
|
||||
import type { AttributesQueryValue } from "./types";
|
||||
|
||||
const {
|
||||
getAttributesData: getAttributes,
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// TODO: Queries in this file are not optimized. Need to optimize them.
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { Attribute } from "./types";
|
||||
|
||||
async function getAttributeToUserWithMembershipAndAttributesForTeam({ teamId }: { teamId: number }) {
|
||||
const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
|
||||
|
||||
const whereClauseForAttributesAssignedToMembersOfTeam = {
|
||||
member: {
|
||||
user: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
log.debug(
|
||||
safeStringify({
|
||||
teamId,
|
||||
whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
})
|
||||
);
|
||||
|
||||
const attributesToUser = await prisma.attributeToUser.findMany({
|
||||
where: whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
select: {
|
||||
member: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
attributeOption: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
attribute: {
|
||||
select: { id: true, name: true, type: true, slug: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log.debug("Returned attributesToUser", safeStringify({ attributesToUser }));
|
||||
return attributesToUser;
|
||||
}
|
||||
|
||||
async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) {
|
||||
const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
|
||||
|
||||
const whereClauseForAttributesAssignedToMembersOfTeam = {
|
||||
options: {
|
||||
some: {
|
||||
assignedUsers: {
|
||||
some: {
|
||||
member: {
|
||||
userId,
|
||||
user: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
log.debug(
|
||||
safeStringify({
|
||||
teamId,
|
||||
whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
})
|
||||
);
|
||||
|
||||
const attributesToUser = await prisma.attribute.findMany({
|
||||
where: whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
options: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
return attributesToUser;
|
||||
}
|
||||
|
||||
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
|
||||
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
|
||||
return attributes satisfies Attribute[];
|
||||
}
|
||||
|
||||
type AttributeId = string;
|
||||
type AttributeOptionValueWithType = {
|
||||
type: Attribute["type"];
|
||||
value: string | string[];
|
||||
};
|
||||
|
||||
type UserId = number;
|
||||
|
||||
export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) {
|
||||
const attributesToUser = await getAttributeToUserWithMembershipAndAttributesForTeam({ teamId });
|
||||
|
||||
const teamMembers = attributesToUser.reduce((acc, attributeToUser) => {
|
||||
const { userId } = attributeToUser.member;
|
||||
const { attribute, value } = attributeToUser.attributeOption;
|
||||
|
||||
if (!acc[userId]) {
|
||||
acc[userId] = { userId, attributes: {} };
|
||||
}
|
||||
|
||||
const attributes = acc[userId].attributes;
|
||||
const attributeValue = attributes[attribute.id]?.value;
|
||||
if (attributeValue instanceof Array) {
|
||||
// Value already exists, so push to it
|
||||
attributeValue.push(value);
|
||||
} else if (attributeValue) {
|
||||
// Value already exists, so push to it and also make it an array before pushing
|
||||
attributes[attribute.id] = {
|
||||
type: attribute.type,
|
||||
value: [attributeValue, value],
|
||||
};
|
||||
} else {
|
||||
// Set the first value
|
||||
attributes[attribute.id] = {
|
||||
type: attribute.type,
|
||||
value,
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<UserId, { userId: UserId; attributes: Record<AttributeId, AttributeOptionValueWithType> }>);
|
||||
|
||||
return Object.values(teamMembers);
|
||||
}
|
||||
|
||||
export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
return await getAttributesAssignedToMembersOfTeam({ teamId, userId });
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, describe, it } from "vitest";
|
||||
|
||||
import { getValueOfAttributeOption } from "./raqbUtils";
|
||||
|
||||
describe("getValueOfAttributeOption", () => {
|
||||
it("should return non-array value for non-array input - It is a requirement for RAQB to not unnecessarily transform single value to an array of one item", () => {
|
||||
const input = { value: "option1", isGroup: false, contains: [] };
|
||||
const result = getValueOfAttributeOption(input);
|
||||
expect(result).toEqual("option1");
|
||||
});
|
||||
|
||||
it("should return flat array of values for simple options", () => {
|
||||
const input = [
|
||||
{ value: "option1", isGroup: false, contains: [] },
|
||||
{ value: "option2", isGroup: false, contains: [] },
|
||||
];
|
||||
|
||||
const result = getValueOfAttributeOption(input);
|
||||
expect(result).toEqual(["option1", "option2"]);
|
||||
});
|
||||
|
||||
it("should flatten nested options from contains array", () => {
|
||||
const input = [
|
||||
{
|
||||
value: "group1",
|
||||
isGroup: true,
|
||||
contains: [
|
||||
{ value: "suboption1", id: "opt-1", slug: "option-1" },
|
||||
{ value: "suboption2", id: "opt-2", slug: "option-2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = getValueOfAttributeOption(input);
|
||||
expect(result).toEqual(["suboption1", "suboption2"]);
|
||||
});
|
||||
|
||||
it("should handle mix of simple and nested options", () => {
|
||||
const input = [
|
||||
{ value: "option1", isGroup: false, contains: [] },
|
||||
{
|
||||
value: "group1",
|
||||
isGroup: true,
|
||||
contains: [
|
||||
{ value: "suboption1", id: "opt-1", slug: "option-1" },
|
||||
{ value: "suboption2", id: "opt-2", slug: "option-2" },
|
||||
],
|
||||
},
|
||||
{ value: "option2", isGroup: false, contains: [] },
|
||||
];
|
||||
|
||||
const result = getValueOfAttributeOption(input);
|
||||
expect(result).toEqual(["option1", "suboption1", "suboption2", "option2"]);
|
||||
});
|
||||
|
||||
it("should remove duplicate values", () => {
|
||||
const input = [
|
||||
{ value: "option1", isGroup: false, contains: [] },
|
||||
{
|
||||
value: "group1",
|
||||
isGroup: true,
|
||||
contains: [
|
||||
{ value: "option1", id: "opt-1", slug: "option-1" },
|
||||
{ value: "suboption2", id: "opt-2", slug: "option-2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = getValueOfAttributeOption(input);
|
||||
expect(result).toEqual(["option1", "suboption2"]);
|
||||
});
|
||||
|
||||
it("should handle empty input array", () => {
|
||||
const input: {
|
||||
value: string;
|
||||
isGroup: boolean;
|
||||
contains: { value: string; id: string; slug: string }[];
|
||||
}[] = [];
|
||||
|
||||
const result = getValueOfAttributeOption(input);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should still use contains if contains is empty and isGroup=true", () => {
|
||||
const input = [{ value: "group1", isGroup: true, contains: [] }];
|
||||
|
||||
const result = getValueOfAttributeOption(input);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,13 @@ import { getQueryBuilderConfigForAttributes } from "@calcom/app-store/routing-fo
|
||||
import type { LocalRoute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { dynamicFieldValueOperands, dynamicFieldValueOperandsResponse } from "@calcom/lib/raqb/types";
|
||||
import type { Attribute, AttributesQueryValue } from "@calcom/lib/raqb/types";
|
||||
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type {
|
||||
AttributeOptionValueWithType,
|
||||
AttributeOptionValue,
|
||||
Attribute,
|
||||
} from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
import { AttributeType } from "@calcom/prisma/enums";
|
||||
|
||||
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/raqbUtils"] });
|
||||
@@ -194,31 +199,54 @@ const replaceFieldTemplateVariableWithOptionLabel = ({
|
||||
});
|
||||
};
|
||||
|
||||
export function getValueOfAttributeOption(
|
||||
attributeOptions:
|
||||
| Pick<AttributeOptionValue, "isGroup" | "contains" | "value">
|
||||
| Pick<AttributeOptionValue, "isGroup" | "contains" | "value">[]
|
||||
) {
|
||||
if (!(attributeOptions instanceof Array)) {
|
||||
return transformAttributeOption(attributeOptions);
|
||||
}
|
||||
return attributeOptions
|
||||
.map(transformAttributeOption)
|
||||
.flat()
|
||||
.filter((value, index, self) => self.indexOf(value) === index);
|
||||
|
||||
function transformAttributeOption(
|
||||
attributeOption: Pick<AttributeOptionValue, "isGroup" | "contains" | "value">
|
||||
) {
|
||||
if (attributeOption.isGroup) {
|
||||
const subOptions = attributeOption.contains.map((option) => option.value);
|
||||
console.log("A group option found. Using all its sub-options instead", safeStringify(subOptions));
|
||||
return subOptions;
|
||||
}
|
||||
return attributeOption.value;
|
||||
}
|
||||
}
|
||||
|
||||
function getAttributesData({
|
||||
attributesData,
|
||||
}: {
|
||||
attributesData: Record<
|
||||
string,
|
||||
{
|
||||
value: string | string[];
|
||||
type: Attribute["type"];
|
||||
}
|
||||
>;
|
||||
attributesData: Record<string, AttributeOptionValueWithType>;
|
||||
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
|
||||
}) {
|
||||
return Object.entries(attributesData).reduce((acc, [attributeId, { value, type: attributeType }]) => {
|
||||
const compatibleValueForAttributeAndFormFieldMatching = caseInsensitive(value);
|
||||
return Object.entries(attributesData).reduce(
|
||||
(acc, [attributeId, { type: attributeType, attributeOption }]) => {
|
||||
const compatibleValueForAttributeAndFormFieldMatching = caseInsensitive(
|
||||
getValueOfAttributeOption(attributeOption)
|
||||
);
|
||||
|
||||
// Right now we can't trust ensureAttributeValueToBeOfRaqbFieldValueType to give us the correct value
|
||||
acc[attributeId] =
|
||||
// multiselect attribute's value must be an array as all the operators multiselect_some_in, multiselect_all_in and their respective not operators expect an array
|
||||
// If we add an operator that doesn't expect an array, we need to somehow make it operator based.
|
||||
attributeType === AttributeType.MULTI_SELECT
|
||||
? ensureArray(compatibleValueForAttributeAndFormFieldMatching)
|
||||
: compatibleValueForAttributeAndFormFieldMatching;
|
||||
acc[attributeId] =
|
||||
// multiselect attribute's value must be an array as all the operators multiselect_some_in, multiselect_all_in and their respective not operators expect an array
|
||||
// If we add an operator that doesn't expect an array, we need to somehow make it operator based.
|
||||
attributeType === AttributeType.MULTI_SELECT
|
||||
? ensureArray(compatibleValueForAttributeAndFormFieldMatching)
|
||||
: compatibleValueForAttributeAndFormFieldMatching;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, string | string[]>);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | string[]>
|
||||
);
|
||||
}
|
||||
/** Gets the attributes that were used to generate the chosen route and their values */
|
||||
function getAttributesQueryValue({
|
||||
|
||||
Vendored
-14
@@ -1,7 +1,5 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { AttributeType } from "@calcom/prisma/client";
|
||||
|
||||
import type { raqbChildSchema, raqbQueryValueSchema, zodAttributesQueryValue } from "./zod";
|
||||
|
||||
export type RaqbChild = z.infer<typeof raqbChildSchema>;
|
||||
@@ -33,15 +31,3 @@ export type dynamicFieldValueOperands = {
|
||||
}[];
|
||||
response: dynamicFieldValueOperandsResponse;
|
||||
};
|
||||
|
||||
export type Attribute = {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: AttributeType;
|
||||
id: string;
|
||||
options: {
|
||||
id: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
}[];
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export class AttributeRepository {
|
||||
static async findManyByNamesAndOrgIdIncludeOptions({
|
||||
attributeNames,
|
||||
orgId,
|
||||
}: {
|
||||
attributeNames: string[];
|
||||
orgId: number;
|
||||
}) {
|
||||
return prisma.attribute.findMany({
|
||||
where: {
|
||||
name: { in: attributeNames, mode: "insensitive" },
|
||||
teamId: orgId,
|
||||
},
|
||||
include: {
|
||||
options: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export class AttributeOptionRepository {
|
||||
static async findMany({ orgId }: { orgId: number }) {
|
||||
return prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: {
|
||||
teamId: orgId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
attributeId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async createMany({ createManyInput }: { createManyInput: Prisma.AttributeOptionCreateManyInput[] }) {
|
||||
const { count } = await prisma.attributeOption.createMany({
|
||||
data: createManyInput,
|
||||
});
|
||||
|
||||
return {
|
||||
count,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export class AttributeToUserRepository {
|
||||
static async createManySkipDuplicates(data: Prisma.AttributeToUserCreateManyInput[]) {
|
||||
return await prisma.attributeToUser.createMany({ data, skipDuplicates: true });
|
||||
}
|
||||
|
||||
static async deleteMany(where: Prisma.AttributeToUserWhereInput) {
|
||||
if (Object.keys(where).length === 0) {
|
||||
throw new Error("Empty where clause provided to deleteMany. Potential data loss risk.");
|
||||
}
|
||||
return await prisma.attributeToUser.deleteMany({ where });
|
||||
}
|
||||
|
||||
static async findManyIncludeAttribute(where: Prisma.AttributeToUserWhereInput) {
|
||||
return await prisma.attributeToUser.findMany({
|
||||
where,
|
||||
include: {
|
||||
attributeOption: {
|
||||
select: {
|
||||
attribute: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
## BUGS
|
||||
- Test Attribute Edit. It seems to be doing weird things with the ids.
|
||||
|
||||
## TODO
|
||||
- [x] Sync from Okta to user could be in core namespace urn:ietf:params:scim:schemas:core:2.0:User and we might need to ask for the namespace to sync from.
|
||||
- [x] Deleting a sub-option, should remove it from option-group?
|
||||
- [x] Locked attribute are free to have its options completely reset by SCIM
|
||||
- [x] Unlocked attribute shouldn't get updates from SCIM but in case they still do, we follow this strategy
|
||||
- If attribute from SCIM, doesn't exist on user, we allow it to be freely set on the user
|
||||
- If attribute from SCIM, exists on user
|
||||
- [x] It is a single select or a non-enum attribute, we don't let it be overriden and completely ignore the SCIM update for that attribute
|
||||
- [x] It is a multi select, we remove the options that were created from SCIM and add the new options from SCIM. AttributeToUser.createdByDSyncId is set to SCIM directorySync id for those options. It allows both SCIM set and User set options to be assigned to the User.
|
||||
- [x] The options created by SCIM aren't deletable from Cal.com UI. User can only remove options created by Cal.com i.e. where AttributeToUser.createdByDSyncId is null.
|
||||
- Restrict setting values
|
||||
- [x] Allow setting any value for non enum attributes(i.e. non-dropdown attributes)
|
||||
- [x] Restrict setting values for enum attributes(i.e. dropdown attributes)
|
||||
- AttributeAssignment within App
|
||||
- [x] For locked field, shouldn't be able to delete an assignment or add a new assignment.
|
||||
- [x] For unlocked field, should be able to delete the assignments owned by cal.com users only. Should be able to add new assignments for any user.
|
||||
- [x] Support array update
|
||||
- [x] Ensure the already existing assignment(with same value) isn't recreated. To ensure weights aren't reset.
|
||||
- [x] Locked attributes
|
||||
- [x] Unlocked attributes
|
||||
|
||||
## Test
|
||||
- Okta to Cal.com
|
||||
1. Add a user with attribute
|
||||
2. Simular a failure in adding the user
|
||||
- Failure could be in user creation or attribute assignment creation
|
||||
3. Remove the user and Re-add the user and see it is correctly synced.
|
||||
- [x] Try removing a locked attribute assignment from a user.
|
||||
|
||||
|
||||
|
||||
## Documentation
|
||||
Okta stuff
|
||||
- Removing the assignment of a user from the app, doesn't remove the user from Cal.com
|
||||
- I observed that there were only GET request from Okta and no POST request.
|
||||
- As per https://boxyhq.com/docs/directory-sync/events Okta doesn't even send an event for deleting a user.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,511 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { AttributeRepository } from "../../../server/repository/attribute";
|
||||
import { AttributeOptionRepository } from "../../../server/repository/attributeOption";
|
||||
import { MembershipRepository } from "../../../server/repository/membership";
|
||||
import type { AttributeId, AttributeName, BulkAttributeAssigner, AttributeOptionAssignment } from "../types";
|
||||
import {
|
||||
doesSupportMultipleValues,
|
||||
isAssignmentForLockedAttribute,
|
||||
isAssignmentForTheSamePool,
|
||||
isAssignmentSame,
|
||||
buildSlugFromValue,
|
||||
canSetValueBeyondOptions,
|
||||
} from "../utils";
|
||||
import { findAssignmentsForMember } from "./utils";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["entity/attribute"] });
|
||||
|
||||
type AttributesIncludingOptions = Awaited<ReturnType<typeof findAttributesByName>>[number];
|
||||
type AttributeOptionsToAssign = {
|
||||
attribute: { id: AttributeId; isLocked: boolean };
|
||||
optionsToAssign: { id: string | null; label: string }[];
|
||||
};
|
||||
|
||||
type AttributeOptionsToAssignWithIdEnsured = {
|
||||
attribute: { id: AttributeId; isLocked: boolean };
|
||||
optionsToAssign: { id: string; label: string }[];
|
||||
};
|
||||
type AttributeLabelToValueMap = Record<AttributeName, string | string[]>;
|
||||
|
||||
const findAttributesByName = async ({
|
||||
orgId,
|
||||
attributeNames,
|
||||
}: {
|
||||
orgId: number;
|
||||
attributeNames: AttributeName[];
|
||||
}) => {
|
||||
const attributesFromDb = await AttributeRepository.findManyByNamesAndOrgIdIncludeOptions({
|
||||
attributeNames,
|
||||
orgId,
|
||||
});
|
||||
|
||||
return attributesFromDb.map((attribute) => {
|
||||
const { name, options, ...rest } = attribute;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
label: name,
|
||||
options: options.map((option) => {
|
||||
return {
|
||||
...option,
|
||||
label: option.value,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const lookupByLabels = <TWithLabel extends { label: string }>({
|
||||
labels,
|
||||
items,
|
||||
}: {
|
||||
labels: string[];
|
||||
items: TWithLabel[];
|
||||
}) => {
|
||||
const map = new Map<string, TWithLabel | null>();
|
||||
labels.forEach((label) => {
|
||||
map.set(label, items.find((item) => item.label.toLowerCase() === label.toLowerCase()) ?? null);
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const lookupByLabel = <TWithLabel extends { label: string }>({
|
||||
label,
|
||||
items,
|
||||
}: {
|
||||
label: string;
|
||||
items: TWithLabel[];
|
||||
}) => {
|
||||
const map = lookupByLabels({ labels: [label], items });
|
||||
return map.get(label);
|
||||
};
|
||||
|
||||
export const buildPrismaQueriesForAttributeOptionToUser = ({
|
||||
existingAttributeOptionAssignments,
|
||||
attributeOptionsToAssign,
|
||||
updater,
|
||||
memberId,
|
||||
}: {
|
||||
orgId: number;
|
||||
memberId: number;
|
||||
existingAttributeOptionAssignments: AttributeOptionAssignment[];
|
||||
attributeOptionsToAssign: AttributeOptionsToAssignWithIdEnsured[];
|
||||
updater: BulkAttributeAssigner;
|
||||
}) => {
|
||||
const assignmentsToUpdateIds: string[] = [];
|
||||
if (!attributeOptionsToAssign.length) {
|
||||
log.debug("No attribute options to assign, skipping");
|
||||
return {
|
||||
attributeToUserCreateManyInput: [],
|
||||
attributeToUserUpdateManyInput: null,
|
||||
attributeToUserDeleteQueries: {
|
||||
locked: null,
|
||||
unlocked: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { attributeToUserCreateManyInput } = attributeOptionsToAssign.reduce(
|
||||
(acc, attributeOptionsToAssign) => {
|
||||
const existingAttributeOptionAssignment = existingAttributeOptionAssignments.find(
|
||||
(existingAttributeOptionForMember) => existingAttributeOptionForMember.attributeOption.attribute.id
|
||||
);
|
||||
|
||||
// Prevent overriding the only value for an unlocked attribute
|
||||
if (
|
||||
existingAttributeOptionAssignment &&
|
||||
!isAssignmentForLockedAttribute({
|
||||
assignment: existingAttributeOptionAssignment,
|
||||
}) &&
|
||||
!doesSupportMultipleValues({
|
||||
attribute: existingAttributeOptionAssignment.attributeOption.attribute,
|
||||
}) &&
|
||||
!isAssignmentForTheSamePool({
|
||||
assignment: existingAttributeOptionAssignment,
|
||||
updater,
|
||||
})
|
||||
) {
|
||||
console.warn(
|
||||
`Attribute ${existingAttributeOptionAssignment.attributeOption.attribute.id} already assigned to user ${memberId} and is not a multi-select attribute. So, skipping`
|
||||
);
|
||||
return acc;
|
||||
}
|
||||
|
||||
attributeOptionsToAssign.optionsToAssign.forEach((optionToAssign) => {
|
||||
// Prevent unnecessary recreation of the assignment, to avoid resetting the "weight"
|
||||
if (
|
||||
existingAttributeOptionAssignment &&
|
||||
isAssignmentSame({
|
||||
existingAssignment: existingAttributeOptionAssignment,
|
||||
newOption: optionToAssign,
|
||||
})
|
||||
) {
|
||||
log.debug(
|
||||
`Updating existing assignment ${existingAttributeOptionAssignment.id} as it has same value`
|
||||
);
|
||||
assignmentsToUpdateIds.push(existingAttributeOptionAssignment.id);
|
||||
return;
|
||||
}
|
||||
|
||||
acc.attributeToUserCreateManyInput.push({
|
||||
memberId,
|
||||
attributeOptionId: optionToAssign.id,
|
||||
...("dsyncId" in updater
|
||||
? { createdByDSyncId: updater.dsyncId, createdById: null }
|
||||
: {
|
||||
createdById: updater.userId,
|
||||
createdByDSyncId: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
attributeToUserCreateManyInput: [] as {
|
||||
memberId: number;
|
||||
attributeOptionId: string;
|
||||
createdByDSyncId: string | null;
|
||||
createdById: number | null;
|
||||
}[],
|
||||
}
|
||||
);
|
||||
|
||||
const lockedAttributeIds = attributeOptionsToAssign
|
||||
.filter((option) => option.attribute.isLocked)
|
||||
.map((option) => option.attribute.id);
|
||||
|
||||
const unlockedAttributeIds = attributeOptionsToAssign
|
||||
.filter((option) => !option.attribute.isLocked)
|
||||
.map((option) => option.attribute.id);
|
||||
|
||||
const unlockedAttributesAssignmentIdsInThePool = existingAttributeOptionAssignments
|
||||
.filter((assignment) => {
|
||||
return (
|
||||
isAssignmentForTheSamePool({
|
||||
assignment,
|
||||
updater,
|
||||
}) && unlockedAttributeIds.includes(assignment.attributeOption.attribute.id)
|
||||
);
|
||||
})
|
||||
.map((existingAttributeOptionForMember) => existingAttributeOptionForMember.id);
|
||||
|
||||
const unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated =
|
||||
unlockedAttributesAssignmentIdsInThePool.filter((id) => !assignmentsToUpdateIds.includes(id));
|
||||
const attributeToUserDeleteQueryWhereClauseForUnlockedAttributes =
|
||||
unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated.length
|
||||
? scopedWhereClause({
|
||||
memberId,
|
||||
whereClause: {
|
||||
id: {
|
||||
in: unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const attributeToUserDeleteQueryWhereClauseForLockedAttributes = lockedAttributeIds.length
|
||||
? scopedWhereClause({
|
||||
memberId,
|
||||
whereClause: {
|
||||
...(assignmentsToUpdateIds.length
|
||||
? {
|
||||
id: {
|
||||
notIn: assignmentsToUpdateIds,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
// No matter who assigned the attribute(Cal.com or SCIM), we delete it because the source of truth is SCIM and only one SCIM can be be source of truth at one time.
|
||||
attributeOption: {
|
||||
attribute: {
|
||||
id: {
|
||||
in: lockedAttributeIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const attributeToUserUpdateManyInput = assignmentsToUpdateIds.length
|
||||
? {
|
||||
where: scopedWhereClause({
|
||||
memberId,
|
||||
whereClause: {
|
||||
id: {
|
||||
in: assignmentsToUpdateIds,
|
||||
},
|
||||
},
|
||||
}),
|
||||
data: {
|
||||
...("dsyncId" in updater
|
||||
? { updatedByDSyncId: updater.dsyncId, updatedById: null }
|
||||
: {
|
||||
updatedById: updater.userId,
|
||||
updatedByDSyncId: null,
|
||||
}),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
log.debug("buildPrismaQueriesForAttributeOptionToUser", {
|
||||
assignmentsToUpdateIds,
|
||||
lockedAttributeIds,
|
||||
unlockedAttributesAssignmentIdsInThePool,
|
||||
unlockedAttributeIds,
|
||||
existingAttributeOptionAssignments,
|
||||
updater,
|
||||
attributeToUserDeleteQueryWhereClauseForLockedAttributes: safeStringify(
|
||||
attributeToUserDeleteQueryWhereClauseForLockedAttributes
|
||||
),
|
||||
attributeToUserDeleteQueryWhereClauseForUnlockedAttributes: safeStringify(
|
||||
attributeToUserDeleteQueryWhereClauseForUnlockedAttributes
|
||||
),
|
||||
attributeToUserCreateManyInput: safeStringify(attributeToUserCreateManyInput),
|
||||
attributeToUserUpdateManyInput,
|
||||
});
|
||||
|
||||
return {
|
||||
attributeToUserUpdateManyInput,
|
||||
attributeToUserCreateManyInput,
|
||||
attributeToUserDeleteQueries: {
|
||||
locked: attributeToUserDeleteQueryWhereClauseForLockedAttributes,
|
||||
unlocked: attributeToUserDeleteQueryWhereClauseForUnlockedAttributes,
|
||||
},
|
||||
};
|
||||
|
||||
function scopedWhereClause<TWhereClause extends object | null>({
|
||||
memberId,
|
||||
whereClause,
|
||||
}: {
|
||||
memberId: number;
|
||||
whereClause: TWhereClause;
|
||||
}) {
|
||||
return {
|
||||
...whereClause,
|
||||
memberId,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const buildPrismaQueryForAttributeOptionCreation = ({
|
||||
optionsToCreate,
|
||||
}: {
|
||||
optionsToCreate: Record<AttributeId, string[]>;
|
||||
}) => {
|
||||
const attributeOptionCreateManyInput = Object.entries(optionsToCreate).map(([attributeId, options]) =>
|
||||
options.map((option) => ({
|
||||
attributeId,
|
||||
value: option,
|
||||
slug: buildSlugFromValue({ value: option }),
|
||||
}))
|
||||
);
|
||||
return attributeOptionCreateManyInput.flat();
|
||||
};
|
||||
|
||||
const createMissingOptionsAndReturnAlongWithExisting = async <
|
||||
TattributeOptionsToAssign extends AttributeOptionsToAssign
|
||||
>({
|
||||
attributeOptionsToAssignIncludingNonExistentOptions,
|
||||
orgId,
|
||||
}: {
|
||||
attributeOptionsToAssignIncludingNonExistentOptions: TattributeOptionsToAssign[];
|
||||
orgId: number;
|
||||
}) => {
|
||||
const attributeOptionCreateManyInput = buildPrismaQueryForAttributeOptionCreation({
|
||||
optionsToCreate: attributeOptionsToAssignIncludingNonExistentOptions.reduce(
|
||||
(acc, attributeOptionsToAssign) => {
|
||||
const { attribute, optionsToAssign } = attributeOptionsToAssign;
|
||||
acc[attribute.id] = optionsToAssign.filter((option) => !option.id).map((option) => option.label);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<AttributeId, string[]>
|
||||
),
|
||||
});
|
||||
|
||||
await AttributeOptionRepository.createMany({
|
||||
createManyInput: attributeOptionCreateManyInput,
|
||||
});
|
||||
|
||||
// We need fetch all the attribute options to ensure that we have the newly created options as well.
|
||||
const allAttributeOptions = (
|
||||
await AttributeOptionRepository.findMany({
|
||||
orgId,
|
||||
})
|
||||
).map((attributeOption) => ({
|
||||
...attributeOption,
|
||||
label: attributeOption.value,
|
||||
}));
|
||||
|
||||
return attributeOptionsToAssignIncludingNonExistentOptions.map(({ attribute, optionsToAssign }) => {
|
||||
const optionsWithIdsOfTheSameAttribute = optionsToAssign
|
||||
.map((option) =>
|
||||
allAttributeOptions.find(
|
||||
(attributeOption) =>
|
||||
attributeOption.attributeId === attribute.id &&
|
||||
attributeOption.label.toLowerCase() === option.label.toLowerCase()
|
||||
)
|
||||
)
|
||||
.filter((option): option is NonNullable<typeof option> => option !== null);
|
||||
return {
|
||||
attribute,
|
||||
optionsToAssign: optionsWithIdsOfTheSameAttribute,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const buildAttributeOptionsToAssign = async ({
|
||||
attributeLabelToValueMap,
|
||||
allAttributesBeingAssigned,
|
||||
orgId,
|
||||
}: {
|
||||
attributeLabelToValueMap: AttributeLabelToValueMap;
|
||||
allAttributesBeingAssigned: AttributesIncludingOptions[];
|
||||
orgId: number;
|
||||
}) => {
|
||||
const attributeOptionsToAssignIncludingNonExistentOptions = Object.entries(attributeLabelToValueMap)
|
||||
.map(([attributeLabel, value]) => {
|
||||
const valueAsArray = value instanceof Array ? value : [value];
|
||||
const attribute = lookupByLabel({ label: attributeLabel, items: allAttributesBeingAssigned });
|
||||
if (!attribute) {
|
||||
console.warn(`Attribute ${attributeLabel} not found, will not be assigned to user`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelToFoundOptionMap = lookupByLabels({ labels: valueAsArray, items: attribute.options });
|
||||
const labelsEntries = Array.from(labelToFoundOptionMap.entries());
|
||||
const optionsToAssign = labelsEntries
|
||||
.filter(([_, option]) => {
|
||||
return canSetValueBeyondOptions({ attribute }) ? true : !!option;
|
||||
})
|
||||
.map(([label]) => {
|
||||
const foundLabel = labelToFoundOptionMap.get(label) ?? null;
|
||||
return {
|
||||
label,
|
||||
...foundLabel,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
attribute,
|
||||
optionsToAssign: optionsToAssign.map((option) => ({
|
||||
label: option.label,
|
||||
id: option.id ?? null,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter((option): option is NonNullable<typeof option> => option !== null);
|
||||
|
||||
return createMissingOptionsAndReturnAlongWithExisting({
|
||||
attributeOptionsToAssignIncludingNonExistentOptions,
|
||||
orgId,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* For a user in an org, it assigns all the attributes with their values as per the attributeLabelToValueMap.
|
||||
*/
|
||||
export const assignValueToUserInOrgBulk = async ({
|
||||
orgId,
|
||||
userId,
|
||||
attributeLabelToValueMap,
|
||||
updater,
|
||||
}: {
|
||||
orgId: number;
|
||||
userId: number;
|
||||
attributeLabelToValueMap: AttributeLabelToValueMap;
|
||||
updater: BulkAttributeAssigner;
|
||||
}) => {
|
||||
const membership = await MembershipRepository.findFirstByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
|
||||
if (!membership) {
|
||||
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
|
||||
return defaultReturn;
|
||||
}
|
||||
|
||||
const { id: memberId } = membership;
|
||||
const attributeNames = Object.keys(attributeLabelToValueMap);
|
||||
|
||||
const allAttributesBeingAssigned = await findAttributesByName({
|
||||
orgId,
|
||||
attributeNames,
|
||||
});
|
||||
|
||||
if (!allAttributesBeingAssigned.length) {
|
||||
console.warn(`None of the attributes ${attributeNames.join(", ")} found in org ${orgId}`);
|
||||
return defaultReturn;
|
||||
}
|
||||
|
||||
const attributeOptionsToAssign = await buildAttributeOptionsToAssign({
|
||||
attributeLabelToValueMap,
|
||||
allAttributesBeingAssigned,
|
||||
orgId,
|
||||
});
|
||||
|
||||
// We need to fetch all the assigned options even those that aren't being set directly in call.
|
||||
// We need to decide on the fate of all existing assignments
|
||||
const existingAttributeOptionAssignments = await findAssignmentsForMember({ memberId });
|
||||
|
||||
const { attributeToUserUpdateManyInput, attributeToUserCreateManyInput, attributeToUserDeleteQueries } =
|
||||
buildPrismaQueriesForAttributeOptionToUser({
|
||||
orgId,
|
||||
memberId,
|
||||
attributeOptionsToAssign,
|
||||
existingAttributeOptionAssignments,
|
||||
updater,
|
||||
});
|
||||
|
||||
const { numOfAttributeOptionsDeleted, numOfAttributeOptionsSet, numOfAttributeOptionsUpdated } =
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// First delete all existing options, to allow them to be set again.
|
||||
const deleteQueriesResult = await Promise.all(
|
||||
Object.entries(attributeToUserDeleteQueries).map(([_, attributeToUserDeleteQuery]) =>
|
||||
attributeToUserDeleteQuery
|
||||
? tx.attributeToUser.deleteMany({
|
||||
where: attributeToUserDeleteQuery,
|
||||
})
|
||||
: Promise.resolve({ count: 0 })
|
||||
)
|
||||
);
|
||||
|
||||
// Then set the new options.
|
||||
const { count: numOfAttributeOptionsSet } = await tx.attributeToUser.createMany({
|
||||
data: attributeToUserCreateManyInput,
|
||||
// In case the values already exist, we skip them instead of throwing an error.
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
let numOfAttributeOptionsUpdated = 0;
|
||||
if (attributeToUserUpdateManyInput) {
|
||||
({ count: numOfAttributeOptionsUpdated } = await tx.attributeToUser.updateMany({
|
||||
where: attributeToUserUpdateManyInput?.where,
|
||||
data: attributeToUserUpdateManyInput?.data,
|
||||
}));
|
||||
}
|
||||
|
||||
const numOfAttributeOptionsDeleted = deleteQueriesResult.reduce(
|
||||
(acc, query) => acc + (query.count ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
numOfAttributeOptionsDeleted,
|
||||
numOfAttributeOptionsSet,
|
||||
numOfAttributeOptionsUpdated,
|
||||
};
|
||||
});
|
||||
|
||||
log.debug({
|
||||
numOfAttributeOptionsSet,
|
||||
numOfAttributeOptionsDeleted,
|
||||
numOfAttributeOptionsUpdated,
|
||||
});
|
||||
|
||||
return {
|
||||
numOfAttributeOptionsSet,
|
||||
numOfAttributeOptionsDeleted,
|
||||
numOfAttributeOptionsUpdated,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,446 @@
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import type { AttributeOption } from "@calcom/prisma/client";
|
||||
import { AttributeType, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
getAttributesForTeam,
|
||||
getTeamMembersWithAttributeOptionValuePerAttribute,
|
||||
getUsersAttributes,
|
||||
} from "./getAttributes";
|
||||
|
||||
// Helper functions to create test data
|
||||
async function createMockAttribute({
|
||||
orgId,
|
||||
options,
|
||||
...attributeData
|
||||
}: {
|
||||
orgId: number;
|
||||
id: string;
|
||||
type: AttributeType;
|
||||
name: string;
|
||||
slug: string;
|
||||
options: Pick<AttributeOption, "id" | "value" | "slug" | "contains" | "isGroup">[];
|
||||
}) {
|
||||
const attribute = await prismock.attribute.create({
|
||||
data: {
|
||||
...attributeData,
|
||||
teamId: orgId,
|
||||
enabled: true,
|
||||
usersCanEditRelation: true,
|
||||
isWeightsEnabled: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prismock.attributeOption.createMany({
|
||||
data: options.map((option) => ({
|
||||
...option,
|
||||
attributeId: attribute.id,
|
||||
})),
|
||||
});
|
||||
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async function createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId,
|
||||
}: {
|
||||
orgId: number;
|
||||
teamId: number;
|
||||
}) {
|
||||
const user = await prismock.user.create({
|
||||
data: {
|
||||
name: "Test User",
|
||||
email: "test@test.com",
|
||||
},
|
||||
});
|
||||
|
||||
// Create org membership
|
||||
const orgMembership = await prismock.membership.create({
|
||||
data: {
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
accepted: true,
|
||||
teamId: orgId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create team membership
|
||||
const teamMembership = await prismock.membership.create({
|
||||
data: {
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
accepted: true,
|
||||
teamId: teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { user, teamMembership, orgMembership };
|
||||
}
|
||||
|
||||
async function createMockTeam({ orgId }: { orgId: number | null }) {
|
||||
return await prismock.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
parentId: orgId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createMockAttributeAssignment({
|
||||
membershipId,
|
||||
attributeOptionId,
|
||||
}: {
|
||||
membershipId: number;
|
||||
attributeOptionId: string;
|
||||
}) {
|
||||
return await prismock.attributeToUser.create({
|
||||
data: {
|
||||
memberId: membershipId,
|
||||
attributeOptionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("getAttributes", () => {
|
||||
const orgId = 1001;
|
||||
|
||||
beforeEach(async () => {
|
||||
// @ts-expect-error reset is a method on Prismock
|
||||
await prismock.reset();
|
||||
});
|
||||
|
||||
describe("getAttributesForTeam", () => {
|
||||
it("should return empty array for team without parent (non-org team)", async () => {
|
||||
const teamWithoutParent = await createMockTeam({ orgId: null });
|
||||
await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Department",
|
||||
slug: "department",
|
||||
type: AttributeType.SINGLE_SELECT,
|
||||
options: [
|
||||
{ id: "opt1", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
|
||||
{ id: "opt2", value: "Sales", slug: "sales", isGroup: false, contains: [] },
|
||||
],
|
||||
});
|
||||
|
||||
const attributes = await getAttributesForTeam({ teamId: teamWithoutParent.id });
|
||||
expect(attributes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return attributes assigned to team members", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const attribute = await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Department",
|
||||
slug: "department",
|
||||
type: AttributeType.SINGLE_SELECT,
|
||||
options: [
|
||||
{ id: "opt1", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
|
||||
{ id: "opt2", value: "Sales", slug: "sales", isGroup: false, contains: [] },
|
||||
],
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
attributeOptionId: "opt1",
|
||||
});
|
||||
|
||||
const attributes = await getAttributesForTeam({ teamId: team.id });
|
||||
|
||||
expect(attributes).toHaveLength(1);
|
||||
expect(attributes[0]).toMatchObject({
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
type: attribute.type,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamMembersWithAttributeOptionValuePerAttribute", () => {
|
||||
it("should return team members with their assigned attributes", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Department",
|
||||
slug: "department",
|
||||
type: AttributeType.MULTI_SELECT,
|
||||
options: [
|
||||
{ id: "engineering-id", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
|
||||
{ id: "sales-id", value: "Sales", slug: "sales", isGroup: false, contains: [] },
|
||||
{
|
||||
id: "engineering-and-sales-id",
|
||||
value: "EngineeringAndSales",
|
||||
slug: "engineering-and-sales",
|
||||
isGroup: true,
|
||||
contains: ["engineering-id", "sales-id"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr2",
|
||||
name: "Territory",
|
||||
slug: "territory",
|
||||
type: AttributeType.SINGLE_SELECT,
|
||||
options: [
|
||||
{ id: "india-id", value: "India", slug: "india", isGroup: false, contains: [] },
|
||||
{ id: "china-id", value: "China", slug: "china", isGroup: false, contains: [] },
|
||||
],
|
||||
});
|
||||
|
||||
// Unassigned attribute
|
||||
await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr3",
|
||||
name: "Attr3",
|
||||
slug: "attr3",
|
||||
type: AttributeType.SINGLE_SELECT,
|
||||
options: [
|
||||
{ id: "opt1", value: "Opt1", slug: "opt1", isGroup: false, contains: [] },
|
||||
{ id: "opt2", value: "Opt2", slug: "opt2", isGroup: false, contains: [] },
|
||||
],
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
// Assigning a group option that has its sub-options not assigned directly to the user
|
||||
attributeOptionId: "engineering-and-sales-id",
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
attributeOptionId: "india-id",
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
attributeOptionId: "engineering-id",
|
||||
});
|
||||
|
||||
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
|
||||
|
||||
expect(teamMembers).toHaveLength(1);
|
||||
expect(teamMembers[0]).toEqual({
|
||||
userId: user.id,
|
||||
attributes: {
|
||||
attr1: {
|
||||
type: AttributeType.MULTI_SELECT,
|
||||
attributeOption: [
|
||||
{
|
||||
isGroup: true,
|
||||
value: "EngineeringAndSales",
|
||||
contains: [
|
||||
{
|
||||
id: "engineering-id",
|
||||
slug: "engineering",
|
||||
value: "Engineering",
|
||||
},
|
||||
{ id: "sales-id", slug: "sales", value: "Sales" },
|
||||
],
|
||||
},
|
||||
{
|
||||
isGroup: false,
|
||||
value: "Engineering",
|
||||
contains: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
attr2: {
|
||||
type: AttributeType.SINGLE_SELECT,
|
||||
attributeOption: { isGroup: false, value: "India", contains: [] },
|
||||
},
|
||||
// attr3 - unassigned isn't here
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return contains correctly for group options that have sub-options not assigned directly to the user", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Department",
|
||||
slug: "department",
|
||||
type: AttributeType.MULTI_SELECT,
|
||||
options: [
|
||||
{ id: "engineering-id", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
|
||||
{ id: "sales-id", value: "Sales", slug: "sales", isGroup: false, contains: [] },
|
||||
{
|
||||
id: "engineering-and-sales-id",
|
||||
value: "EngineeringAndSales",
|
||||
slug: "engineering-and-sales",
|
||||
isGroup: true,
|
||||
contains: ["engineering-id", "sales-id"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
// Assigning a group option that has its sub-options not assigned directly to the user
|
||||
attributeOptionId: "engineering-and-sales-id",
|
||||
});
|
||||
|
||||
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
|
||||
|
||||
expect(teamMembers).toHaveLength(1);
|
||||
expect(teamMembers[0]).toEqual({
|
||||
userId: user.id,
|
||||
attributes: {
|
||||
attr1: {
|
||||
type: AttributeType.MULTI_SELECT,
|
||||
attributeOption: {
|
||||
isGroup: true,
|
||||
value: "EngineeringAndSales",
|
||||
contains: [
|
||||
{
|
||||
id: "engineering-id",
|
||||
slug: "engineering",
|
||||
value: "Engineering",
|
||||
},
|
||||
{ id: "sales-id", slug: "sales", value: "Sales" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return no attributes for a different org", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const otherTeam = await createMockTeam({ orgId: 1002 });
|
||||
const { teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Department",
|
||||
slug: "department",
|
||||
type: AttributeType.MULTI_SELECT,
|
||||
options: [
|
||||
{ id: "engineering-id", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
|
||||
{ id: "sales-id", value: "Sales", slug: "sales", isGroup: false, contains: [] },
|
||||
{
|
||||
id: "engineering-and-sales-id",
|
||||
value: "EngineeringAndSales",
|
||||
slug: "engineering-and-sales",
|
||||
isGroup: true,
|
||||
contains: ["engineering-id", "sales-id"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
// Assigning a group option that has its sub-options not assigned directly to the user
|
||||
attributeOptionId: "engineering-and-sales-id",
|
||||
});
|
||||
|
||||
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: otherTeam.id });
|
||||
|
||||
expect(teamMembers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle multi-select attributes correctly", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Skills",
|
||||
slug: "skills",
|
||||
type: AttributeType.MULTI_SELECT,
|
||||
options: [
|
||||
{ id: "opt1", value: "JavaScript", slug: "javascript", isGroup: false, contains: [] },
|
||||
{ id: "opt2", value: "Python", slug: "python", isGroup: false, contains: [] },
|
||||
],
|
||||
});
|
||||
|
||||
// Assign multiple skills
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
attributeOptionId: "opt1",
|
||||
});
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
attributeOptionId: "opt2",
|
||||
});
|
||||
|
||||
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
|
||||
|
||||
expect(teamMembers).toHaveLength(1);
|
||||
expect(teamMembers[0].attributes.attr1.attributeOption).toEqual([
|
||||
{ value: "JavaScript", isGroup: false, contains: [] },
|
||||
{ value: "Python", isGroup: false, contains: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsersAttributes", () => {
|
||||
it("should return attributes assigned to specific user in team", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const attribute = await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Department",
|
||||
slug: "department",
|
||||
type: AttributeType.SINGLE_SELECT,
|
||||
options: [{ id: "opt1", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }],
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
attributeOptionId: "opt1",
|
||||
});
|
||||
|
||||
const userAttributes = await getUsersAttributes({ userId: user.id, teamId: team.id });
|
||||
|
||||
expect(userAttributes).toHaveLength(1);
|
||||
expect(userAttributes[0]).toMatchObject({
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
type: attribute.type,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
// TODO: Queries in this file are not optimized. Need to optimize them.
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AttributeType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { AttributeId } from "../types";
|
||||
import { findAllAttributesWithTheirOptions } from "./utils";
|
||||
|
||||
type UserId = number;
|
||||
|
||||
export type Attribute = {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: AttributeType;
|
||||
id: string;
|
||||
options: {
|
||||
id: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type AttributeOptionValue = {
|
||||
value: string;
|
||||
isGroup: boolean;
|
||||
contains: {
|
||||
id: string;
|
||||
slug: string;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type AttributeOptionValueWithType = {
|
||||
type: Attribute["type"];
|
||||
attributeOption: AttributeOptionValue | AttributeOptionValue[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Note: assignedAttributeOptions[x].attributeOption isn't unique. It is returned multiple times depending on how many users it is assigned to
|
||||
*/
|
||||
async function getAssignedAttributeOptions({ teamId }: { teamId: number }) {
|
||||
const log = logger.getSubLogger({ prefix: ["getAssignedAttributeOptions"] });
|
||||
const whereClauseForAssignment = {
|
||||
member: {
|
||||
user: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
log.debug(
|
||||
safeStringify({
|
||||
teamId,
|
||||
whereClauseForAssignment,
|
||||
})
|
||||
);
|
||||
|
||||
const assignedAttributeOptions = await prisma.attributeToUser.findMany({
|
||||
where: whereClauseForAssignment,
|
||||
select: {
|
||||
member: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
attributeOption: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
contains: true,
|
||||
isGroup: true,
|
||||
attribute: {
|
||||
select: { id: true, name: true, type: true, slug: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log.debug("Returned assignedAttributeOptions", safeStringify({ assignedAttributeOptions }));
|
||||
return assignedAttributeOptions;
|
||||
}
|
||||
|
||||
async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) {
|
||||
const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
|
||||
|
||||
const whereClauseForAttributesAssignedToMembersOfTeam = {
|
||||
options: {
|
||||
some: {
|
||||
assignedUsers: {
|
||||
some: {
|
||||
member: {
|
||||
userId,
|
||||
user: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
log.debug(
|
||||
safeStringify({
|
||||
teamId,
|
||||
whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
})
|
||||
);
|
||||
|
||||
const assignedAttributeOptions = await prisma.attribute.findMany({
|
||||
where: whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
options: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
return assignedAttributeOptions;
|
||||
}
|
||||
|
||||
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
|
||||
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
|
||||
return attributes satisfies Attribute[];
|
||||
}
|
||||
|
||||
export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) {
|
||||
const [assignedAttributeOptions, allAttributesWithTheirOptions] = await Promise.all([
|
||||
getAssignedAttributeOptions({ teamId }),
|
||||
// We need to fetch even the unassigned options, to know what all sub-options are there in an option group. Because it is possible that sub-options aren't assigned directly to the user
|
||||
findAllAttributesWithTheirOptions({ teamId }),
|
||||
]);
|
||||
|
||||
const teamMembersThatHaveOptionAssigned = assignedAttributeOptions.reduce((acc, attributeToUser) => {
|
||||
const { userId } = attributeToUser.member;
|
||||
const { attribute, ...attributeOption } = attributeToUser.attributeOption;
|
||||
|
||||
if (!acc[userId]) {
|
||||
acc[userId] = { userId, attributes: {} };
|
||||
}
|
||||
|
||||
const attributes = acc[userId].attributes;
|
||||
const currentAttributeOptionValue = attributes[attribute.id]?.attributeOption;
|
||||
const newAttributeOptionValue = {
|
||||
isGroup: attributeOption.isGroup,
|
||||
value: attributeOption.value,
|
||||
contains: tranformContains({ contains: attributeOption.contains, attribute }),
|
||||
};
|
||||
|
||||
if (currentAttributeOptionValue instanceof Array) {
|
||||
attributes[attribute.id].attributeOption = [...currentAttributeOptionValue, newAttributeOptionValue];
|
||||
} else if (currentAttributeOptionValue) {
|
||||
attributes[attribute.id].attributeOption = [
|
||||
currentAttributeOptionValue,
|
||||
{
|
||||
isGroup: attributeOption.isGroup,
|
||||
value: attributeOption.value,
|
||||
contains: tranformContains({ contains: attributeOption.contains, attribute }),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Set the first value
|
||||
attributes[attribute.id] = {
|
||||
type: attribute.type,
|
||||
attributeOption: newAttributeOptionValue,
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<UserId, { userId: UserId; attributes: Record<AttributeId, AttributeOptionValueWithType> }>);
|
||||
|
||||
return Object.values(teamMembersThatHaveOptionAssigned);
|
||||
|
||||
/**
|
||||
* Transforms ["optionId1", "optionId2"] to [{
|
||||
* id: "optionId1",
|
||||
* value: "optionValue1",
|
||||
* slug: "optionSlug1",
|
||||
* }, {
|
||||
* id: "optionId2",
|
||||
* value: "optionValue2",
|
||||
* slug: "optionSlug2",
|
||||
* }]
|
||||
*/
|
||||
function tranformContains({
|
||||
contains,
|
||||
attribute,
|
||||
}: {
|
||||
contains: string[];
|
||||
attribute: { id: string; name: string };
|
||||
}) {
|
||||
return contains
|
||||
.map((optionId) => {
|
||||
const allOptions = allAttributesWithTheirOptions.get(attribute.id);
|
||||
const option = allOptions?.find((option) => option.id === optionId);
|
||||
if (!option) {
|
||||
console.error(
|
||||
`Enriching "contains" for attribute ${
|
||||
attribute.name
|
||||
}: Option with id ${optionId} not found. Looked up in ${JSON.stringify(allOptions)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: option.id,
|
||||
value: option.value,
|
||||
slug: option.slug,
|
||||
};
|
||||
})
|
||||
.filter((option): option is NonNullable<typeof option> => option !== null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
return await getAttributesAssignedToMembersOfTeam({ teamId, userId });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
- To be moved to proper documentation location
|
||||
|
||||
1. When a user is assigned to the integration in Okta, they are created in Cal.com with appropriate attributes.
|
||||
2. Only attributes that are defined in Cal.com are synced.
|
||||
- Attributes are mapped by label, i.e., the external variable name of the custom attribute in Okta must match the label of the attribute in Cal.com (in a case-insensitive manner).
|
||||
3. Only two types of custom attributes are currently supported (Number type support is pending):
|
||||
- string
|
||||
- array of strings
|
||||
4. If an Okta custom attribute maps to a Multi-Select or Single-Select attribute in Cal.com, the provided values must be among the options defined for that attribute in Cal.com.
|
||||
- Options are matched by label in a case-insensitive manner, similar to attribute labels
|
||||
- Any option not defined on the attribute is ignored and not synced
|
||||
5. If the attribute is locked in Cal.com, sync from Okta completely overrides the attribute's value for that user in Cal.com. Okta serves as the source of truth in this case.
|
||||
- After sync, no values will remain in Cal.com for that user's custom attribute unless they are defined in Okta.
|
||||
6. If the attribute is not locked in Cal.com, the expectation is that the attribute is marked as 'Create Only' in Okta and shouldn't be synced back to Cal.com, after user's creation.
|
||||
- But in case if the attribute is still synced back to Cal.com, we handle it in following way:
|
||||
- We consider two pools of assignments for the attribute:
|
||||
- Directory Sync Pool: Assignments created by Directory Sync
|
||||
- Cal.com Pool: Assignments created by via Cal.com
|
||||
- A sync from Okta, would ensure that all assignments from Directory Sync Pool are removed and only those remain that are provided by Okta.
|
||||
- Any update in Cal.com is restricted to assignments in Cal.com Pool only and doesn't impact Directory Sync Pool.
|
||||
- In case an option already in Cal.com pool is assigned to a user through Directory sync, the ownership for that assignment is transferred to Directory Sync and it moves to Directory Sync Pool.
|
||||
@@ -0,0 +1,87 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { AttributeToUserRepository } from "../../../server/repository/attributeToUser";
|
||||
import type { AttributeId } from "../types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["entity/attribute"] });
|
||||
|
||||
async function findTeamById({ teamId }: { teamId: number }) {
|
||||
return prisma.team.findUnique({ where: { id: teamId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the options for all the attributes for a team
|
||||
*/
|
||||
const findAllAttributeOptions = async ({ teamId }: { teamId: number }) => {
|
||||
const team = await findTeamById({ teamId });
|
||||
|
||||
// A non-org team doesn't have attributes
|
||||
if (!team || !team.parentId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: {
|
||||
teamId: team.parentId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
contains: true,
|
||||
isGroup: true,
|
||||
attribute: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures all attributes are their with all their options(only assigned options) mapped to them
|
||||
*/
|
||||
export const findAllAttributesWithTheirOptions = async ({ teamId }: { teamId: number }) => {
|
||||
const allOptionsOfAllAttributes = await findAllAttributeOptions({ teamId });
|
||||
const attributeOptionsMap = new Map<
|
||||
AttributeId,
|
||||
{
|
||||
id: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
contains: string[];
|
||||
isGroup: boolean;
|
||||
}[]
|
||||
>();
|
||||
allOptionsOfAllAttributes.forEach((_attributeOption) => {
|
||||
const { attribute, ...attributeOption } = _attributeOption;
|
||||
const existingOptionsArray = attributeOptionsMap.get(attribute.id);
|
||||
if (!existingOptionsArray) {
|
||||
attributeOptionsMap.set(attribute.id, [attributeOption]);
|
||||
} else {
|
||||
// We already have the options for this attribute
|
||||
existingOptionsArray.push(attributeOption);
|
||||
}
|
||||
});
|
||||
return attributeOptionsMap;
|
||||
};
|
||||
|
||||
export const getWhereClauseForAttributeOptionsManagedByCalcom = () => {
|
||||
// Neither created nor updated by DSync
|
||||
return {
|
||||
createdByDSyncId: null,
|
||||
// An option created by Cal.com can be updated by DSync, in that case the ownership is transferred to DSync
|
||||
updatedByDSyncId: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const findAssignmentsForMember = async ({ memberId }: { memberId: number }) => {
|
||||
const assignments = await AttributeToUserRepository.findManyIncludeAttribute({ memberId });
|
||||
return assignments.map((assignment) => ({
|
||||
...assignment,
|
||||
attributeOption: {
|
||||
...assignment.attributeOption,
|
||||
label: assignment.attributeOption.value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import type { Attribute, AttributeToUser, AttributeOption } from "@calcom/prisma/client";
|
||||
|
||||
export type AttributeName = string;
|
||||
export type AttributeId = string;
|
||||
export type BulkAttributeAssigner =
|
||||
| {
|
||||
dsyncId: string;
|
||||
}
|
||||
| {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export type AttributeOptionAssignment = AttributeToUser & {
|
||||
attributeOption: Omit<AttributeOption, "value" | "isGroup" | "contains" | "id" | "attributeId"> & {
|
||||
label: string;
|
||||
attribute: Attribute;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Utility functions for attribute service. Shared across server and client.
|
||||
*/
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { AttributeType } from "@calcom/prisma/client";
|
||||
|
||||
import type { AttributeOptionAssignment, BulkAttributeAssigner } from "./types";
|
||||
|
||||
/**
|
||||
* What is a pool?
|
||||
* There can be two pools:
|
||||
* 1. SCIM Pool - All assignments in this pool are created/updated by SCIM.
|
||||
* 2. Cal.com User Pool - All assignments in this pool are created/updated by Cal.com Users.
|
||||
*/
|
||||
export const isAssignmentForTheSamePool = ({
|
||||
assignment,
|
||||
updater,
|
||||
}: {
|
||||
assignment: Pick<
|
||||
AttributeOptionAssignment,
|
||||
"createdByDSyncId" | "updatedByDSyncId" | "createdById" | "updatedById"
|
||||
>;
|
||||
updater: BulkAttributeAssigner;
|
||||
}) => {
|
||||
if ("dsyncId" in updater) {
|
||||
// Cal.com user updated an assignment created by SCIM. It no longer belongs to the SCIM pool.
|
||||
if (assignment.updatedById) {
|
||||
return false;
|
||||
}
|
||||
// Either SCIM created the assignment or updated it
|
||||
return !!assignment.updatedByDSyncId || !!assignment.createdByDSyncId;
|
||||
}
|
||||
// SCIM neither created nor updated the assignment, it has to belong to the only left pool(i.e. Cal.com User Pool)
|
||||
return !assignment.createdByDSyncId && !assignment.updatedByDSyncId;
|
||||
};
|
||||
|
||||
export const isAssignmentForLockedAttribute = ({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: {
|
||||
attributeOption: {
|
||||
attribute: {
|
||||
isLocked: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
return assignment.attributeOption.attribute.isLocked;
|
||||
};
|
||||
|
||||
export const isAssignmentSame = ({
|
||||
existingAssignment,
|
||||
newOption,
|
||||
}: {
|
||||
existingAssignment: { attributeOption: { label: string } };
|
||||
newOption: { label: string };
|
||||
}) => {
|
||||
return existingAssignment.attributeOption.label.toLowerCase() === newOption.label.toLowerCase();
|
||||
};
|
||||
|
||||
export const doesSupportMultipleValues = ({ attribute }: { attribute: { type: AttributeType } }) => {
|
||||
return attribute.type === AttributeType.MULTI_SELECT;
|
||||
};
|
||||
|
||||
export const buildSlugFromValue = ({ value }: { value: string }) => {
|
||||
return slugify(value);
|
||||
};
|
||||
|
||||
export const hasOptions = ({
|
||||
attribute,
|
||||
}: {
|
||||
attribute: {
|
||||
type: AttributeType;
|
||||
};
|
||||
}) => {
|
||||
return attribute.type === AttributeType.MULTI_SELECT || attribute.type === AttributeType.SINGLE_SELECT;
|
||||
};
|
||||
|
||||
export const canSetValueBeyondOptions = ({
|
||||
attribute,
|
||||
}: {
|
||||
attribute: {
|
||||
type: AttributeType;
|
||||
};
|
||||
}) => {
|
||||
return !hasOptions({ attribute });
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Attribute" ADD COLUMN "isLocked" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AttributeOption" ADD COLUMN "contains" TEXT[],
|
||||
ADD COLUMN "isGroup" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AttributeToUser" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "createdByDSyncId" TEXT,
|
||||
ADD COLUMN "createdById" INTEGER,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "updatedByDSyncId" TEXT,
|
||||
ADD COLUMN "updatedById" INTEGER;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_createdByDSyncId_fkey" FOREIGN KEY ("createdByDSyncId") REFERENCES "DSyncData"("directoryId") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_updatedByDSyncId_fkey" FOREIGN KEY ("updatedByDSyncId") REFERENCES "DSyncData"("directoryId") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -355,6 +355,8 @@ model User {
|
||||
referralLinkId String?
|
||||
features UserFeatures[]
|
||||
reassignedBookings Booking[] @relation("reassignByUser")
|
||||
createdAttributeToUsers AttributeToUser[] @relation("createdBy")
|
||||
updatedAttributeToUsers AttributeToUser[] @relation("updatedBy")
|
||||
createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations")
|
||||
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
|
||||
createdWatchlists Watchlist[] @relation("CreatedWatchlists")
|
||||
@@ -369,6 +371,7 @@ model User {
|
||||
@@index([identityProvider])
|
||||
@@index([identityProviderId])
|
||||
@@map(name: "users")
|
||||
|
||||
}
|
||||
|
||||
model NotificationsSubscriptions {
|
||||
@@ -1499,6 +1502,9 @@ model DSyncData {
|
||||
organizationId Int? @unique
|
||||
org OrganizationSettings? @relation(fields: [organizationId], references: [organizationId], onDelete: Cascade)
|
||||
teamGroupMapping DSyncTeamGroupMapping[]
|
||||
|
||||
createdAttributeToUsers AttributeToUser[] @relation("createdByDSync")
|
||||
updatedAttributeToUsers AttributeToUser[] @relation("updatedByDSync")
|
||||
}
|
||||
|
||||
model DSyncTeamGroupMapping {
|
||||
@@ -1580,6 +1586,12 @@ model AttributeOption {
|
||||
attributeId String
|
||||
value String
|
||||
slug String
|
||||
isGroup Boolean @default(false)
|
||||
// It is a list of AttributeOptions ids that are contained in the group option
|
||||
// You could think of a person having the group option to actually have all the options in the contains list.
|
||||
// We are not using relation here because it would be a many to many relation because a group option can contain many non-group options and a non-group option can be contained in many group options
|
||||
// Such a relation would require its own table to be managed and we don't need it for now.
|
||||
contains String[]
|
||||
assignedUsers AttributeToUser[]
|
||||
}
|
||||
|
||||
@@ -1602,6 +1614,7 @@ model Attribute {
|
||||
updatedAt DateTime @updatedAt
|
||||
options AttributeOption[]
|
||||
isWeightsEnabled Boolean @default(false)
|
||||
isLocked Boolean @default(false)
|
||||
}
|
||||
|
||||
model AttributeToUser {
|
||||
@@ -1612,7 +1625,21 @@ model AttributeToUser {
|
||||
|
||||
attributeOption AttributeOption @relation(fields: [attributeOptionId], references: [id], onDelete: Cascade)
|
||||
attributeOptionId String
|
||||
|
||||
weight Int?
|
||||
|
||||
// We don't intentionally delete assignments on deletion of a user/directory sync
|
||||
createdAt DateTime @default(now())
|
||||
createdById Int?
|
||||
createdBy User? @relation("createdBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
createdByDSyncId String?
|
||||
createdByDSync DSyncData? @relation("createdByDSync", fields: [createdByDSyncId], references: [directoryId], onDelete: SetNull)
|
||||
|
||||
updatedAt DateTime? @updatedAt
|
||||
updatedBy User? @relation("updatedBy", fields: [updatedById], references: [id], onDelete: SetNull)
|
||||
updatedById Int?
|
||||
updatedByDSyncId String?
|
||||
updatedByDSync DSyncData? @relation("updatedByDSync", fields: [updatedByDSyncId], references: [directoryId], onDelete: SetNull)
|
||||
|
||||
@@unique([memberId, attributeOptionId])
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getWhereClauseForAttributeOptionsManagedByCalcom } from "@calcom/lib/service/attribute/server/utils";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -32,9 +33,11 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
teamId: org.id,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
id: true,
|
||||
type: true,
|
||||
options: true,
|
||||
isLocked: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,7 +93,14 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
|
||||
// const promises: Promise<{ id: string }>[] = [];
|
||||
|
||||
input.attributes.map(async (attribute) => {
|
||||
const unlockedAttributesInInput = input.attributes.filter((attribute) => {
|
||||
const attributeFromDb = attributes.find((a) => a.id === attribute.id);
|
||||
return !attributeFromDb?.isLocked;
|
||||
});
|
||||
|
||||
const lockedAttributesFromDb = attributes.filter((attribute) => attribute.isLocked);
|
||||
|
||||
unlockedAttributesInInput.map(async (attribute) => {
|
||||
// TEXT, NUMBER
|
||||
if (attribute.value && !attribute.options) {
|
||||
const valueAsString = String(attribute.value);
|
||||
@@ -160,6 +170,7 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
},
|
||||
},
|
||||
memberId: membership.id,
|
||||
...getWhereClauseForAttributeOptionsManagedByCalcom(),
|
||||
NOT: {
|
||||
id: {
|
||||
in: options.map((option) => option.value),
|
||||
@@ -194,6 +205,7 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
await prisma.attributeToUser.deleteMany({
|
||||
where: {
|
||||
memberId: membership.id,
|
||||
...getWhereClauseForAttributeOptionsManagedByCalcom(),
|
||||
attributeOption: {
|
||||
attribute: {
|
||||
id: attribute.id,
|
||||
@@ -206,7 +218,11 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Attributes assigned successfully",
|
||||
message: lockedAttributesFromDb.length
|
||||
? `Attributes assigned successfully. Locked attributes ${lockedAttributesFromDb
|
||||
.map((attribute) => attribute.name)
|
||||
.join(", ")} were not assigned.`
|
||||
: "Attributes assigned successfully.",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const createAttributesHandler = async ({ input, ctx }: GetOptions) => {
|
||||
slug,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
isLocked: input.isLocked,
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
export const createAttributeSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
|
||||
isLocked: z.boolean().optional(),
|
||||
options: z.array(z.object({ value: z.string() })),
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
|
||||
});
|
||||
}
|
||||
|
||||
const options = input.options;
|
||||
// If an option is removed, it is to be removed from contains of corresponding group as well if any
|
||||
const options = getOptionsWithValidContains(input.options);
|
||||
|
||||
const foundAttribute = await prisma.attribute.findUnique({
|
||||
where: {
|
||||
@@ -50,6 +51,7 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
teamId: org.id,
|
||||
isLocked: input.isLocked,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -62,7 +64,6 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updateOptions = options.filter((option) => option.id !== undefined && option.id !== "");
|
||||
const updatedOptionsIds = updateOptions.map((option) => option.id!);
|
||||
|
||||
// We need to delete all options that are not present in this UpdateOptions.id (as they have been deleted)
|
||||
await tx.attributeOption.deleteMany({
|
||||
where: {
|
||||
@@ -87,6 +88,8 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
|
||||
data: {
|
||||
value: option.value,
|
||||
slug: slugify(option.value),
|
||||
isGroup: option.isGroup,
|
||||
contains: option.contains,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -97,6 +100,8 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
|
||||
data: {
|
||||
attributeId: attributes.id,
|
||||
value: option.value,
|
||||
isGroup: option.isGroup,
|
||||
contains: option.contains,
|
||||
slug: slugify(option.value),
|
||||
},
|
||||
});
|
||||
@@ -109,6 +114,29 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
|
||||
return attributes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures that contains has no non-existent sub-options
|
||||
*/
|
||||
function getOptionsWithValidContains(options: ZEditAttributeSchema["options"]) {
|
||||
return options.map(({ contains, ...option }) => {
|
||||
if (!contains)
|
||||
return {
|
||||
...option,
|
||||
contains: [],
|
||||
};
|
||||
const possibleSubOptions = options
|
||||
.filter((option) => !option.isGroup)
|
||||
.filter((option): option is typeof option & { id: string } => option.id !== undefined);
|
||||
|
||||
const possibleSubOptionsIds = possibleSubOptions.map((option) => option.id);
|
||||
|
||||
return {
|
||||
...option,
|
||||
contains: contains.filter((subOptionId) => possibleSubOptionsIds.includes(subOptionId)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function validateOptionsBelongToAttribute(
|
||||
options: ZEditAttributeSchema["options"],
|
||||
attributeId: string
|
||||
@@ -117,7 +145,6 @@ async function validateOptionsBelongToAttribute(
|
||||
const optionsWithId = options
|
||||
.filter((option) => option.id !== undefined && option.id !== "")
|
||||
.map((option) => option.id!);
|
||||
console.log("optionsWithId", optionsWithId);
|
||||
|
||||
// Check all ids of options passed in are owned by the attribute
|
||||
const optionsWithIdOwnedByAttribute = await prisma.attributeOption.findMany({
|
||||
|
||||
@@ -4,7 +4,15 @@ export const editAttributeSchema = z.object({
|
||||
attributeId: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
|
||||
options: z.array(z.object({ value: z.string(), id: z.string().optional() })),
|
||||
isLocked: z.boolean().optional(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
id: z.string().optional(),
|
||||
isGroup: z.boolean().optional(),
|
||||
contains: z.array(z.string()).optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type ZEditAttributeSchema = z.infer<typeof editAttributeSchema>;
|
||||
@@ -33,10 +33,13 @@ const getAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
isLocked: true,
|
||||
options: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
contains: true,
|
||||
isGroup: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignedUsers: true,
|
||||
@@ -61,9 +64,16 @@ const getAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
const attrReturnValue = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
isLocked: z.boolean().optional(),
|
||||
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
|
||||
options: z.array(
|
||||
z.object({ value: z.string(), id: z.string().optional(), assignedUsers: z.number().optional() })
|
||||
z.object({
|
||||
value: z.string(),
|
||||
id: z.string().optional(),
|
||||
isGroup: z.boolean().optional(),
|
||||
assignedUsers: z.number().optional(),
|
||||
contains: z.array(z.string()).optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type GroupedAttribute = {
|
||||
id: string;
|
||||
slug: string;
|
||||
value: string;
|
||||
createdByDSyncId: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -75,17 +76,18 @@ const getByUserIdHandler = async ({ input, ctx }: GetOptions) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
createdByDSyncId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const groupedAttributes = userAttributes.reduce<GroupedAttribute[]>((acc, attribute) => {
|
||||
const { attributeOption } = attribute;
|
||||
const groupedAttributes = userAttributes.reduce<GroupedAttribute[]>((acc, assignment) => {
|
||||
const { attributeOption, createdByDSyncId } = assignment;
|
||||
const { attribute: attrInfo, ...optionInfo } = attributeOption;
|
||||
|
||||
const optionInfoWithCreatedByDSyncId = { ...optionInfo, createdByDSyncId };
|
||||
const existingGroup = acc.find((group) => group.id === attrInfo.id);
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.options.push(optionInfo);
|
||||
existingGroup.options.push(optionInfoWithCreatedByDSyncId);
|
||||
} else {
|
||||
acc.push({
|
||||
id: attrInfo.id,
|
||||
@@ -93,7 +95,7 @@ const getByUserIdHandler = async ({ input, ctx }: GetOptions) => {
|
||||
type: attrInfo.type,
|
||||
options: [
|
||||
{
|
||||
...optionInfo,
|
||||
...optionInfoWithCreatedByDSyncId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -22,6 +22,29 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const expand = input.expand;
|
||||
const filters = input.filters || [];
|
||||
|
||||
const allAttributeOptions = await prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
attribute: {
|
||||
name: "asc",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const groupOptionsWithContainsOptionValues = allAttributeOptions
|
||||
.filter((option) => option.isGroup)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
contains: option.contains.map((optionId) => ({
|
||||
id: optionId,
|
||||
value: allAttributeOptions.find((o) => o.id === optionId)?.value,
|
||||
})),
|
||||
}));
|
||||
|
||||
if (!organizationId) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
|
||||
}
|
||||
@@ -76,13 +99,28 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
break;
|
||||
// We assume that if the filter is not one of the above, it must be an attribute filter
|
||||
default:
|
||||
const attributeOptionValues: string[] = [];
|
||||
if (filter.value instanceof Array) {
|
||||
filter.value.forEach((filterValueItem) => {
|
||||
attributeOptionValues.push(filterValueItem);
|
||||
groupOptionsWithContainsOptionValues.forEach((groupOption) => {
|
||||
if (groupOption.contains.find(({ value: containValue }) => containValue === filterValueItem)) {
|
||||
attributeOptionValues.push(groupOption.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
whereClause.AttributeToUser = {
|
||||
some: {
|
||||
attributeOption: {
|
||||
attribute: {
|
||||
id: filter.id,
|
||||
},
|
||||
...makeWhereClause("value", filter.value),
|
||||
...makeWhereClause(
|
||||
"value",
|
||||
attributeOptionValues.length > 0 ? attributeOptionValues : filter.value
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+1
-3
@@ -135,9 +135,7 @@ const workspaces = packagedEmbedTestsOnly
|
||||
test: {
|
||||
globals: true,
|
||||
name: "@calcom/routing-forms",
|
||||
include: [
|
||||
"packages/app-store/routing-forms/**/*.test.tsx",
|
||||
],
|
||||
include: ["packages/app-store/routing-forms/**/*.test.tsx"],
|
||||
environment: "jsdom",
|
||||
setupFiles: ["packages/ui/components/test-setup.ts"],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user