Skip to content

Commit 8b868be

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/quickbooks-integration
2 parents ef61470 + 7b4e737 commit 8b868be

17 files changed

Lines changed: 856 additions & 149 deletions

File tree

apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,30 @@ import { act } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockCal, mockCalComponent, mockConsent, mockGetCalApi, mockTrackGoogleEvent } = vi.hoisted(
9-
() => ({
10-
mockCal: vi.fn(),
11-
mockCalComponent: vi.fn(() => null),
12-
mockConsent: { marketing: true, measurement: true },
13-
mockGetCalApi: vi.fn(),
14-
mockTrackGoogleEvent: vi.fn(),
15-
})
16-
)
8+
const {
9+
mockCal,
10+
mockCalComponent,
11+
mockConsent,
12+
mockGetCalApi,
13+
mockTrackGoogleAdsConversion,
14+
mockTrackGoogleEvent,
15+
} = vi.hoisted(() => ({
16+
mockCal: vi.fn(),
17+
mockCalComponent: vi.fn(() => null),
18+
mockConsent: { marketing: true, measurement: true },
19+
mockGetCalApi: vi.fn(),
20+
mockTrackGoogleAdsConversion: vi.fn(),
21+
mockTrackGoogleEvent: vi.fn(),
22+
}))
1723

1824
vi.mock('@calcom/embed-react', () => ({
1925
default: mockCalComponent,
2026
getCalApi: mockGetCalApi,
2127
}))
22-
vi.mock('@/lib/analytics/google', () => ({ trackGoogleEvent: mockTrackGoogleEvent }))
28+
vi.mock('@/lib/analytics/google', () => ({
29+
trackGoogleAdsConversion: mockTrackGoogleAdsConversion,
30+
trackGoogleEvent: mockTrackGoogleEvent,
31+
}))
2332
vi.mock('@/lib/consent/scripts', () => ({ X_DEMO_BOOKED_EVENT_ID: 'demo-booked' }))
2433
vi.mock('@/lib/consent/tracking-consent', () => ({
2534
useTrackingConsent: () => mockConsent,
@@ -37,6 +46,18 @@ const LEAD = {
3746
notes: 'Company: Analytical Engines\nTopic: Demo',
3847
}
3948

49+
interface BookingRegistration {
50+
action: string
51+
callback: () => void
52+
}
53+
54+
/** The listener the scheduler registered with the Cal.com embed, if any. */
55+
function bookingRegistration(): BookingRegistration | undefined {
56+
return mockCal.mock.calls.find(([method]) => method === 'on')?.[1] as
57+
| BookingRegistration
58+
| undefined
59+
}
60+
4061
describe('DemoScheduler', () => {
4162
let container: HTMLDivElement
4263
let root: Root
@@ -101,9 +122,7 @@ describe('DemoScheduler', () => {
101122
await Promise.resolve()
102123
})
103124

104-
const registration = mockCal.mock.calls.find(([method]) => method === 'on')?.[1] as
105-
| { action: string; callback: () => void }
106-
| undefined
125+
const registration = bookingRegistration()
107126
expect(registration?.action).toBe('bookingSuccessfulV2')
108127

109128
registration?.callback()
@@ -112,6 +131,7 @@ describe('DemoScheduler', () => {
112131
form_name: 'sim_demo',
113132
booking_status: 'scheduled',
114133
})
134+
expect(mockTrackGoogleAdsConversion).toHaveBeenCalledWith('demo_booked')
115135
expect(trackXEvent).toHaveBeenCalledWith('event', 'demo-booked', {})
116136

117137
await act(async () => {
@@ -125,6 +145,23 @@ describe('DemoScheduler', () => {
125145
root = createRoot(container)
126146
})
127147

148+
it('sends measurement analytics but no ad conversion without marketing consent', async () => {
149+
mockConsent.marketing = false
150+
const trackXEvent = vi.fn()
151+
window.twq = trackXEvent
152+
153+
await act(async () => {
154+
root.render(<DemoScheduler lead={LEAD} />)
155+
await Promise.resolve()
156+
})
157+
158+
bookingRegistration()?.callback()
159+
160+
expect(mockTrackGoogleEvent).toHaveBeenCalledOnce()
161+
expect(mockTrackGoogleAdsConversion).not.toHaveBeenCalled()
162+
expect(trackXEvent).not.toHaveBeenCalled()
163+
})
164+
128165
it('does not register booking analytics without measurement or marketing consent', async () => {
129166
mockConsent.marketing = false
130167
mockConsent.measurement = false
@@ -138,7 +175,7 @@ describe('DemoScheduler', () => {
138175
hideEventTypeDetails: true,
139176
styles: { branding: { brandColor: '#6f3dfa' } },
140177
})
141-
expect(mockCal.mock.calls.some(([method]) => method === 'on')).toBe(false)
178+
expect(bookingRegistration()).toBeUndefined()
142179
})
143180

144181
it('preloads the configured booker only once', async () => {

apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useEffect } from 'react'
44
import Cal, { getCalApi } from '@calcom/embed-react'
5-
import { trackGoogleEvent } from '@/lib/analytics/google'
5+
import { trackGoogleAdsConversion, trackGoogleEvent } from '@/lib/analytics/google'
66
import { X_DEMO_BOOKED_EVENT_ID } from '@/lib/consent/scripts'
77
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
88
import type { DemoLead } from '@/app/(landing)/demo/components/demo-form'
@@ -102,7 +102,10 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
102102
booking_status: 'scheduled',
103103
})
104104
}
105-
if (marketing) window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
105+
if (marketing) {
106+
trackGoogleAdsConversion('demo_booked')
107+
window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
108+
}
106109
}
107110
const api = getCalApi({ namespace: CAL_NAMESPACE, embedJsUrl: CAL_EMBED.embedJsUrl })
108111
api

apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints'
14+
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
1415
import {
15-
acquirePermissionGroupOrgLock,
1616
authorizeOrgAccessControl,
1717
findScopeConflicts,
1818
formatScopeConflictError,

apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints'
14+
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
1415
import { isOrganizationMember } from '@/lib/workspaces/permissions/utils'
1516
import {
1617
type AllMembersConflict,
17-
acquirePermissionGroupOrgLock,
1818
authorizeOrgAccessControl,
1919
findAllMembersWorkspaceConflict,
2020
findScopeConflicts,

apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
type PermissionGroupConfig,
1616
parsePermissionGroupConfig,
1717
} from '@/lib/permission-groups/fields'
18+
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
1819
import {
1920
type AllMembersConflict,
20-
acquirePermissionGroupOrgLock,
2121
authorizeOrgAccessControl,
2222
findAllMembersWorkspaceConflict,
2323
findScopeConflicts,

apps/sim/app/api/organizations/[id]/permission-groups/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import {
2121
type PermissionGroupConfig,
2222
parsePermissionGroupConfig,
2323
} from '@/lib/permission-groups/fields'
24+
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
2425
import {
2526
type AllMembersConflict,
26-
acquirePermissionGroupOrgLock,
2727
authorizeOrgAccessControl,
2828
findAllMembersWorkspaceConflict,
2929
findWorkspacesNotInOrganization,

apps/sim/app/api/organizations/[id]/permission-groups/utils.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,36 +41,6 @@ export async function authorizeOrgAccessControl(
4141
return null
4242
}
4343

44-
const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000
45-
46-
/**
47-
* Serialize all permission-group membership and scope writes for an organization
48-
* via a transaction-scoped Postgres advisory lock. Callers acquire it at the top
49-
* of the transaction that both checks (`findScopeConflicts`) and mutates, so a
50-
* concurrent member add or scope change can't commit in the check-to-write
51-
* window and leave a user governed by two groups on the same workspace.
52-
*
53-
* The invariant (one effective group per user per workspace) spans users and
54-
* groups in ways a unique constraint can't express, and these are low-frequency
55-
* admin writes, so a single org-scoped lock is simpler and more obviously
56-
* correct than fine-grained per-user/per-group locks with acquire-ordering.
57-
*
58-
* `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled
59-
* connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03)
60-
* instead of hanging if a holder is stuck.
61-
*/
62-
export async function acquirePermissionGroupOrgLock(
63-
tx: DbOrTx,
64-
organizationId: string
65-
): Promise<void> {
66-
await tx.execute(
67-
sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)`
68-
)
69-
await tx.execute(
70-
sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))`
71-
)
72-
}
73-
7444
/** Load a permission group only if it belongs to the given organization. */
7545
export async function loadGroupInOrganization(
7646
groupId: string,

apps/sim/app/api/workspaces/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { type WorkspaceMode, workflow } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5+
import { getPostgresErrorCode } from '@sim/utils/errors'
56
import { and, eq, isNull } from 'drizzle-orm'
67
import { type NextRequest, NextResponse } from 'next/server'
78
import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
@@ -157,6 +158,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
157158
workspaceMode: creationPolicy.workspaceMode,
158159
billedAccountUserId: creationPolicy.billedAccountUserId,
159160
observedOrganizationId: creationPolicy.observedOrganizationId,
161+
governingPermissionGroupOrganizationId: creationPolicy.governingPermissionGroupOrganizationId,
160162
})
161163

162164
captureServerEvent(
@@ -207,6 +209,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
207209
{ status: 409 }
208210
)
209211
}
212+
/**
213+
* A lock timeout is contention, not a fault: creation serializes on the
214+
* organization's mutation locks and now also on `permission_group:<org>`,
215+
* so a concurrent create or a permission-group admin write can exhaust the
216+
* `lock_timeout` and abort this transaction. Answer 503 like the
217+
* permission-group routes do, rather than letting it reach the generic 500
218+
* below — the caller should retry, and a 500 tells them the opposite.
219+
*/
220+
if (getPostgresErrorCode(error) === '55P03') {
221+
return NextResponse.json(
222+
{ error: 'This organization is being updated by another request. Please try again.' },
223+
{ status: 503 }
224+
)
225+
}
210226
logger.error('Error creating workspace:', error)
211227
return NextResponse.json({ error: 'Failed to create workspace' }, { status: 500 })
212228
}
@@ -220,6 +236,7 @@ async function createDefaultWorkspace(
220236
workspaceMode: WorkspaceMode
221237
billedAccountUserId: string
222238
observedOrganizationId: string | null
239+
governingPermissionGroupOrganizationId: string | null
223240
}
224241
) {
225242
const firstName = userName?.split(' ')[0] || null
@@ -231,6 +248,7 @@ async function createDefaultWorkspace(
231248
workspaceMode: creationPolicy.workspaceMode,
232249
billedAccountUserId: creationPolicy.billedAccountUserId,
233250
observedOrganizationId: creationPolicy.observedOrganizationId,
251+
governingPermissionGroupOrganizationId: creationPolicy.governingPermissionGroupOrganizationId,
234252
})
235253
}
236254

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { trackGoogleAdsConversion } from '@/lib/analytics/google'
6+
import { GOOGLE_ADS_ID } from '@/lib/consent/scripts'
7+
8+
afterEach(() => {
9+
window.gtag = undefined
10+
})
11+
12+
describe('trackGoogleAdsConversion', () => {
13+
it('addresses the conversion to the Ads tag and its registered label', () => {
14+
const gtag = vi.fn()
15+
window.gtag = gtag
16+
17+
trackGoogleAdsConversion('demo_booked')
18+
19+
expect(gtag).toHaveBeenCalledWith('event', 'conversion', {
20+
send_to: `${GOOGLE_ADS_ID}/Xt8wCK7b1e4cEL_Zk99C`,
21+
})
22+
})
23+
24+
it('is a no-op when the Google tag has not loaded', () => {
25+
expect(() => trackGoogleAdsConversion('demo_booked')).not.toThrow()
26+
})
27+
})

apps/sim/lib/analytics/google.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { GOOGLE_ANALYTICS_ID } from '@/lib/consent/scripts'
1+
import { GOOGLE_ADS_ID, GOOGLE_ANALYTICS_ID } from '@/lib/consent/scripts'
2+
3+
/** Conversion labels registered in Google Ads, keyed by the action they measure. */
4+
const GOOGLE_ADS_CONVERSION_LABELS = {
5+
demo_booked: 'Xt8wCK7b1e4cEL_Zk99C',
6+
} as const
7+
8+
export type GoogleAdsConversion = keyof typeof GOOGLE_ADS_CONVERSION_LABELS
29

310
interface GoogleAnalyticsEventMap {
411
sign_up: { method: string }
@@ -17,6 +24,17 @@ export function trackGoogleEvent<E extends keyof GoogleAnalyticsEventMap>(
1724
window.gtag?.('event', name, parameters)
1825
}
1926

27+
/**
28+
* Records a Google Ads conversion, addressed as `<tag id>/<conversion label>`.
29+
* Call only after the caller has verified marketing consent: without it Consent
30+
* Mode keeps `ad_storage` denied and the hit could not be attributed to a click.
31+
*/
32+
export function trackGoogleAdsConversion(conversion: GoogleAdsConversion): void {
33+
window.gtag?.('event', 'conversion', {
34+
send_to: `${GOOGLE_ADS_ID}/${GOOGLE_ADS_CONVERSION_LABELS[conversion]}`,
35+
})
36+
}
37+
2038
export function trackGooglePageView(path: string): void {
2139
window.gtag?.('event', 'page_view', {
2240
page_path: path,

0 commit comments

Comments
 (0)