diff --git a/CHANGELOG.md b/CHANGELOG.md index 218bbedac4d..dfba4c975ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ All notable changes to the **Crove Cal** platform will be documented in this fil --- +## [2.2.0] - 2026-09-03 + +### Added +- **DOS.Me Organization -> Teams Hierarchy & Zero-Latency JIT Token Claims**: + - Adopted new JWT token claims structure with `claims.organizations`, `claims.teams`, and `claims.active_org_id`. + - Automatic sub-team hierarchy provisioning (`parentId` mapping from `dosTeamId` to parent `dosOrgId`). + - Automatic role mapping from SSO (`LEAD` and `ADMIN` map to Cal.com `MembershipRole.ADMIN`). + - Real-time webhook handlers for `team.created`, `team.updated`, `team.deleted`, `team.member_added`, `team.member_removed`. +- **Webhook Health & Realtime Monitoring**: + - Implemented `@calcom/lib/webhookMonitor` service tracking latency, success rates, event volumes, and delivery audit logs in an in-memory telemetry buffer. + - Added public `/api/webhooks/health` API endpoint supporting GET metrics and POST simulated test pings. + - Added dedicated Webhook Health & Monitoring Dashboard UI at `/settings/developer/webhooks/monitoring` with live auto-refresh. +- **Crove CRM Direct Integration (`crm.crove.com`)**: + - Added `@calcom/features/crove-crm` (`CroveCrmService`) for contact upsertion and booking activity timeline synchronization with Team/Org attribution. + - Added `/api/webhooks/crove-crm` webhook bridge endpoint. + - Registered native **Crove CRM** app card (`@calcom/crovecrm`) in the Cal.com App Store under the CRM category (`/apps/categories/crm`) with full `CrmServiceMap` integration. +- **Deep Database Health Check Endpoint**: + - Implemented `/api/health` probe endpoint returning DB connectivity, latency in milliseconds, uptime, and application version for container orchestration and uptime monitors. + +### Optimized & Fixed +- Removed unused `@ts-expect-error` in `useRouterQuery.ts` for clean ES2024 native `entries` iteration. +- Guarded `husky install` in Docker build stages when `.git` is absent. +- Guarded `required` jobs in `.github/workflows/pr.yml` and `all-checks.yml` (`if: github.repository == 'calcom/cal.diy'`), permanently eliminating failed notification emails on the repository fork. + +--- + +## [2.1.0] - 2026-09-01 + +### Added +- **TypeScript 6.0.3 Monorepo Upgrade**: + - Upgraded `typescript` to `6.0.3` across all 115 packages and applications in the Turborepo monorepo. + - Modernized compiler targets to `ES2024` / `ES2022` in `packages/tsconfig/base.json`, `nextjs.json`, and package-level configurations. + - Added `docs/TypeScript-7-Migration-Roadmap.md` with technical audit and migration plan for future TypeScript 7.x release. +- **Clean-Room Multi-Tenant Teams & Organizations**: + - Implemented `TeamService` and `OrganizationService` in `packages/features/teams` and `packages/features/organizations`. + - Added viewer tRPC routers `viewer.teams` and `viewer.organizations`. + - Added `/teams` frontend listing view with department creation dialog. +- **Universal Crove App Switcher**: + - Implemented `` component in `@calcom/ui` with comprehensive ecosystem directory (Crove Suite & DOS Ecosystem apps). + - Integrated App Switcher into `TopNav.tsx` and `SideBar.tsx`. +- **Expanded E2E Playwright Test Suite**: + - Added comprehensive E2E tests for DOS ID login, App Switcher, multi-tenant page protections, and webhook health checks. + +--- + ## [2.0.0] - 2026-08-26 ### Added diff --git a/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx b/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx index 1643fb8f7bc..30f07351c8b 100644 --- a/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx +++ b/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx @@ -45,7 +45,9 @@ const EventAvailabilityTab = dynamic(() => import("./tabs/availability/EventAvailabilityTabWebWrapper").then((mod) => mod) ); -const EventTeamAssignmentTab = dynamic(() => Promise.resolve((_props: Record) => null)); +const EventTeamAssignmentTab = dynamic(() => + import("./tabs/team/EventTeamAssignmentTabWebWrapper").then((mod) => mod) +); const EventLimitsTab = dynamic(() => import("./tabs/limits/EventLimitsTabWebWrapper").then((mod) => mod)); diff --git a/apps/web/modules/event-types/components/tabs/team/EventTeamAssignmentTabWebWrapper.tsx b/apps/web/modules/event-types/components/tabs/team/EventTeamAssignmentTabWebWrapper.tsx new file mode 100644 index 00000000000..0ecc83da6b8 --- /dev/null +++ b/apps/web/modules/event-types/components/tabs/team/EventTeamAssignmentTabWebWrapper.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; +import { EventTeamTab, type GenericTeamMember } from "./EventTeamTab"; + +export interface EventTeamAssignmentTabWebWrapperProps { + eventType: EventTypeSetupProps["eventType"]; + team: EventTypeSetupProps["team"]; + teamMembers: GenericTeamMember[]; + orgId?: number | null; +} + +export function EventTeamAssignmentTabWebWrapper({ + eventType, + team, + teamMembers, + orgId, +}: EventTeamAssignmentTabWebWrapperProps) { + return ( + + ); +} + +export default EventTeamAssignmentTabWebWrapper; diff --git a/apps/web/modules/event-types/components/tabs/team/EventTeamTab.tsx b/apps/web/modules/event-types/components/tabs/team/EventTeamTab.tsx new file mode 100644 index 00000000000..24421854677 --- /dev/null +++ b/apps/web/modules/event-types/components/tabs/team/EventTeamTab.tsx @@ -0,0 +1,287 @@ +"use client"; + +import AssignAllTeamMembers from "@calcom/features/eventtypes/components/AssignAllTeamMembers"; +import CheckedTeamSelect, { type CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect"; +import type { EventTypeSetupProps, FormValues } from "@calcom/features/eventtypes/lib/types"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { SchedulingType } from "@calcom/prisma/enums"; +import classNames from "@calcom/ui/classNames"; +import { Badge } from "@calcom/ui/components/badge"; +import { Label, SettingsToggle } from "@calcom/ui/components/form"; +import { RefreshCwIcon, UsersIcon, LayersIcon, ShieldCheckIcon, UserCheckIcon } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +export type GenericTeamMember = { + id?: number; + value?: string; + label?: string | null; + name?: string | null; + email: string; + avatar?: string | null; + avatarUrl?: string | null; + defaultScheduleId?: number | null; +}; + +export interface EventTeamTabProps { + eventType: EventTypeSetupProps["eventType"]; + team: EventTypeSetupProps["team"]; + teamMembers: GenericTeamMember[]; + orgId?: number | null; +} + +export function EventTeamTab({ eventType, team, teamMembers = [], orgId }: EventTeamTabProps) { + const { t } = useLocale(); + const form = useFormContext(); + + const watchSchedulingType = form.watch("schedulingType") || eventType.schedulingType || SchedulingType.ROUND_ROBIN; + const watchHosts = form.watch("hosts") || []; + const watchIsRRWeightsEnabled = form.watch("isRRWeightsEnabled") ?? false; + const watchAssignAll = form.watch("assignAllTeamMembers") ?? false; + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(watchAssignAll); + + // Convert TeamMembers to options for CheckedTeamSelect + const memberOptions: CheckedSelectOption[] = useMemo(() => { + return teamMembers.map((member) => { + const val = member.value ?? String(member.id ?? ""); + const lbl = member.label ?? member.name ?? member.email; + const avt = member.avatar ?? member.avatarUrl ?? ""; + return { + value: val, + label: lbl, + avatar: avt, + defaultScheduleId: member.defaultScheduleId ?? null, + groupId: null, + }; + }); + }, [teamMembers]); + + // Convert current selected hosts in form to CheckedSelectOption[] + const selectedHostOptions: CheckedSelectOption[] = useMemo(() => { + return watchHosts.map((host) => { + const member = teamMembers.find( + (m) => (m.value !== undefined && Number(m.value) === host.userId) || (m.id !== undefined && m.id === host.userId) + ); + return { + value: String(host.userId), + label: member?.label ?? member?.name ?? member?.email ?? `User #${host.userId}`, + avatar: member?.avatar ?? member?.avatarUrl ?? "", + defaultScheduleId: host.scheduleId ?? member?.defaultScheduleId ?? null, + priority: host.priority ?? 2, + weight: host.weight ?? 100, + isFixed: host.isFixed ?? false, + groupId: host.groupId ?? null, + }; + }); + }, [watchHosts, teamMembers]); + + const handleHostsChange = (newOptions: readonly CheckedSelectOption[]) => { + const updatedHosts = newOptions.map((opt) => ({ + userId: Number(opt.value), + isFixed: opt.isFixed ?? false, + priority: opt.priority ?? 2, + weight: opt.weight ?? 100, + scheduleId: opt.defaultScheduleId ?? null, + groupId: opt.groupId ?? null, + })); + + form.setValue("hosts", updatedHosts, { shouldDirty: true }); + }; + + const handleSelectSchedulingType = (type: SchedulingType) => { + form.setValue("schedulingType", type, { shouldDirty: true }); + }; + + const schedulingTypeCards = [ + { + type: SchedulingType.ROUND_ROBIN, + title: "Round-Robin", + description: "Distribute incoming bookings and leads among available team members (evenly or weighted).", + icon: RefreshCwIcon, + badge: "Most Popular", + }, + { + type: SchedulingType.COLLECTIVE, + title: "Collective", + description: "Allow clients to book a group meeting when ALL selected team members are free at once.", + icon: UsersIcon, + badge: "All-Hands", + }, + { + type: SchedulingType.MANAGED, + title: "Managed Event", + description: "Organization admin template distributed to all member calendars with locked settings.", + icon: LayersIcon, + badge: "Enterprise", + }, + ]; + + return ( +
+ {/* Section 1: Scheduling Type Strategy Selection */} +
+
+

Team Scheduling Strategy

+

+ Select how bookings for this team event type will be assigned among team members. +

+
+ +
+ {schedulingTypeCards.map((card) => { + const isSelected = watchSchedulingType === card.type; + const IconComponent = card.icon; + + return ( + + ); + })} +
+
+ + {/* Section 2: Round-Robin Advanced Distribution Controls */} + {watchSchedulingType === SchedulingType.ROUND_ROBIN && ( +
+
+ +

Round-Robin Assignment Settings

+
+ +
+ {/* Toggle: Weighted Lead Distribution */} +
+ ( + { + onChange(checked); + form.setValue("isRRWeightsEnabled", checked, { shouldDirty: true }); + }} + /> + )} + /> +
+ + {/* Toggle: Reschedule with Same Host */} +
+ ( + { + onChange(checked); + form.setValue("rescheduleWithSameRoundRobinHost", checked, { shouldDirty: true }); + }} + /> + )} + /> +
+ + {/* Toggle: Enable Per-Host Locations */} +
+ ( + { + onChange(checked); + form.setValue("enablePerHostLocations", checked, { shouldDirty: true }); + }} + /> + )} + /> +
+
+
+ )} + + {/* Section 3: Team Hosts Assignment */} +
+
+
+

Assigned Hosts

+

+ Choose which team members participate in this event type and customize their priorities. +

+
+ + { + const allHosts: CheckedSelectOption[] = memberOptions.map((opt) => ({ + ...opt, + priority: 2, + weight: 100, + isFixed: false, + })); + handleHostsChange(allHosts); + }} + onInactive={() => { + handleHostsChange([]); + }} + /> +
+ +
+ + + +
+
+
+ ); +} + +export default EventTeamTab; diff --git a/apps/web/modules/event-types/components/tabs/team/__tests__/EventTeamTab.test.tsx b/apps/web/modules/event-types/components/tabs/team/__tests__/EventTeamTab.test.tsx new file mode 100644 index 00000000000..b76c21e9098 --- /dev/null +++ b/apps/web/modules/event-types/components/tabs/team/__tests__/EventTeamTab.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; +import { describe, expect, it, vi } from "vitest"; +import { EventTeamTab } from "../EventTeamTab"; +import { SchedulingType } from "@calcom/prisma/enums"; + +vi.mock("@formkit/auto-animate/react", () => ({ + useAutoAnimate: () => [{ current: null }], +})); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +const mockTeamMembers = [ + { id: 1, name: "Alice Nguyen", email: "alice@crove.com", avatar: "avatar1.png" }, + { id: 2, name: "Bob Tran", email: "bob@crove.com", avatar: "avatar2.png" }, + { id: 3, name: "Charlie Le", email: "charlie@crove.com", avatar: "avatar3.png" }, +]; + +const mockEventType: any = { + id: 101, + title: "Team Discovery 30m", + slug: "team-discovery", + schedulingType: SchedulingType.ROUND_ROBIN, + hosts: [ + { userId: 1, priority: 1, weight: 100, isFixed: true, groupId: null }, + { userId: 2, priority: 2, weight: 50, isFixed: false, groupId: null }, + ], + assignAllTeamMembers: false, + isRRWeightsEnabled: true, +}; + +function TestWrapper({ defaultValues = {} }: { defaultValues?: any }) { + const form = useForm({ + defaultValues: { + schedulingType: SchedulingType.ROUND_ROBIN, + hosts: mockEventType.hosts, + isRRWeightsEnabled: true, + assignAllTeamMembers: false, + rescheduleWithSameRoundRobinHost: false, + enablePerHostLocations: false, + ...defaultValues, + }, + }); + + return ( + + + + + + ); +} + +describe("EventTeamTab Component", () => { + it("renders team scheduling strategy cards (Round-Robin, Collective, Managed)", () => { + render(); + + expect(screen.getByText("Team Scheduling Strategy")).toBeInTheDocument(); + expect(screen.getByText("Round-Robin")).toBeInTheDocument(); + expect(screen.getByText("Collective")).toBeInTheDocument(); + expect(screen.getByText("Managed Event")).toBeInTheDocument(); + }); + + it("displays Round-Robin settings (weights toggle, reschedule same host) when Round-Robin is selected", () => { + render(); + + expect(screen.getByText("Round-Robin Assignment Settings")).toBeInTheDocument(); + expect(screen.getByText("Enable Weighted Distribution")).toBeInTheDocument(); + expect(screen.getByText("Reschedule with Same Host")).toBeInTheDocument(); + }); + + it("displays assigned hosts and team members list", () => { + render(); + + expect(screen.getByText("Assigned Hosts")).toBeInTheDocument(); + expect(screen.getAllByText("Alice Nguyen").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Bob Tran").length).toBeGreaterThanOrEqual(1); + }); +});