Skip to content

Commit 3948df0

Browse files
committed
fix(shopify): preserve privacy obligations and recoverable connections
1 parent d9f53b2 commit 3948df0

19 files changed

Lines changed: 352 additions & 98 deletions

File tree

‎apps/sim/app/oauth/shopify/connect/connection-form.tsx‎

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

33
import { useState } from 'react'
44
import { Chip, ChipInput, ChipSelect, pageHeadingClassName } from '@sim/emcn'
5+
import { permissionSatisfies } from '@sim/platform-authz/predicates'
56
import { useCompleteShopifyInstall } from '@/hooks/queries/shopify-install'
67
import { useCreateWorkspace, useWorkspacesWithMetadata } from '@/hooks/queries/workspace'
78

@@ -17,7 +18,12 @@ export function ConnectionForm({ attemptId, shopDomain }: ConnectionFormProps) {
1718
const [workspaceId, setWorkspaceId] = useState('')
1819
const [displayName, setDisplayName] = useState(shopDomain)
1920
const [workspaceName, setWorkspaceName] = useState('')
20-
const available = workspaces.data?.workspaces ?? []
21+
const available = (workspaces.data?.workspaces ?? []).filter((workspace) =>
22+
permissionSatisfies(workspace.permissions, 'write')
23+
)
24+
const selectedWorkspaceId = available.some((workspace) => workspace.id === workspaceId)
25+
? workspaceId
26+
: ''
2127
const error = complete.error ?? createWorkspace.error ?? workspaces.error
2228
const busy = complete.isPending || createWorkspace.isPending
2329

@@ -26,9 +32,9 @@ export function ConnectionForm({ attemptId, shopDomain }: ConnectionFormProps) {
2632
className='flex w-full max-w-sm flex-col gap-5'
2733
onSubmit={(event) => {
2834
event.preventDefault()
29-
if (!workspaceId) return
35+
if (busy || !selectedWorkspaceId || !displayName.trim()) return
3036
complete.mutate(
31-
{ attemptId, workspaceId, displayName },
37+
{ attemptId, workspaceId: selectedWorkspaceId, displayName },
3238
{
3339
onSuccess: (result) =>
3440
window.location.assign(
@@ -56,7 +62,7 @@ export function ConnectionForm({ attemptId, shopDomain }: ConnectionFormProps) {
5662
<span className='text-[var(--text-muted)] text-small'>Workspace</span>
5763
<ChipSelect
5864
aria-label='Workspace'
59-
value={workspaceId}
65+
value={selectedWorkspaceId}
6066
onChange={setWorkspaceId}
6167
options={available.map((workspace) => ({ value: workspace.id, label: workspace.name }))}
6268
placeholder={workspaces.isPending ? 'Loading workspaces…' : 'Choose a workspace'}
@@ -99,7 +105,11 @@ export function ConnectionForm({ attemptId, shopDomain }: ConnectionFormProps) {
99105
{error.message}
100106
</p>
101107
)}
102-
<Chip type='submit' variant='primary' disabled={busy || !workspaceId || !displayName.trim()}>
108+
<Chip
109+
type='submit'
110+
variant='primary'
111+
disabled={busy || !selectedWorkspaceId || !displayName.trim()}
112+
>
103113
{complete.isPending ? 'Connecting…' : 'Connect Shopify'}
104114
</Chip>
105115
</form>

‎apps/sim/lib/billing/organizations/member-limits.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ afterAll(() => {
3030

3131
describe('getOrgMemberUsageForBillingPeriod', () => {
3232
it('counts immutable new rows plus bounded legacy rows exactly once', async () => {
33+
vi.spyOn(Date, 'now').mockReturnValue(Date.now())
3334
const billingPeriod = {
3435
start: new Date('2026-06-01T00:00:00.000Z'),
3536
end: new Date('2026-07-01T00:00:00.000Z'),

‎apps/sim/lib/core/outbox/processor.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
55
recover: vi.fn(),
66
reap: vi.fn(),
77
prune: vi.fn(),
8+
privacy: vi.fn(),
89
}))
910
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
1011
vi.mock('@/lib/core/outbox/retention', () => ({ pruneCompletedOutboxEvents: mocks.prune }))
@@ -18,6 +19,7 @@ vi.mock('@/lib/knowledge/connectors/connector-error', () => ({
1819
getConnectorFailureDiagnostic: () => undefined,
1920
}))
2021
vi.mock('@/lib/core/outbox/handlers', () => ({ OUTBOX_HANDLER_GROUPS: [] }))
22+
vi.mock('@/lib/shopify/privacy/outbox', () => ({ maintainShopifyPrivacy: mocks.privacy }))
2123

2224
import { runOutboxProcessor } from '@/lib/core/outbox/processor'
2325

@@ -40,6 +42,7 @@ describe('outbox processor recovery', () => {
4042
mocks.recover.mockResolvedValue(2)
4143
mocks.reap.mockResolvedValue(3)
4244
mocks.prune.mockResolvedValue(4)
45+
mocks.privacy.mockResolvedValue(undefined)
4346
})
4447
afterEach(() => vi.useRealTimers())
4548

@@ -73,6 +76,16 @@ describe('outbox processor recovery', () => {
7376
})
7477
})
7578

79+
it('retains completed delivery results when privacy maintenance fails', async () => {
80+
mocks.privacy.mockRejectedValueOnce(new Error('privacy database unavailable'))
81+
await expect(runOutboxProcessor()).resolves.toEqual({
82+
result,
83+
recoveredDocuments: 2,
84+
reapedBackgroundWork: 3,
85+
prunedEvents: 4,
86+
})
87+
})
88+
7689
it('skips document recovery after the processing budget is exhausted', async () => {
7790
mockProcessOutboxEvents.mockImplementationOnce(async () => {
7891
vi.advanceTimersByTime(770_000)

‎apps/sim/lib/core/outbox/processor.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,19 @@ export async function runOutboxProcessor(): Promise<OutboxProcessorResult> {
6464
logger.error('Completed outbox pruning failed', { error: toError(error).message })
6565
}
6666

67+
try {
68+
await maintainShopifyPrivacy()
69+
} catch {
70+
logger.error('Shopify privacy maintenance failed')
71+
}
72+
6773
const summary = {
6874
...result,
6975
reapedBackgroundWork,
7076
recoveredDocuments,
7177
prunedEvents,
7278
durationMs: Date.now() - startedAt,
7379
}
74-
await maintainShopifyPrivacy()
7580
/** Fail the run so a broken handler module stays as visible as the crash its static import caused. */
7681
if (result.unloadedEventTypes.length > 0) {
7782
const message = `Outbox handler modules failed to load; left pending: ${result.unloadedEventTypes.join(', ')}`

‎apps/sim/lib/credentials/application/complete-shopify-install.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { credentialOperations } from '@/lib/credentials/application/operations'
66
import { resumeConnectorsAfterCredentialReconnect } from '@/lib/knowledge/connectors/credential-recovery'
77
import { clearOAuthRefreshDeadFlag } from '@/lib/oauth/refresh-coordination'
88
import { claimShopifyInstall } from '@/lib/oauth/shopify-handoff'
9+
import { getShopifyRefreshScope } from '@/lib/oauth/shopify-installation'
910
import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
1011

1112
const logger = createLogger('CompleteShopifyInstall')
@@ -48,7 +49,7 @@ export const completeShopifyInstall = defineAuthorizedWorkspaceUseCase({
4849
afterSuccess: async ({ result }) => {
4950
if (!result.changed) return
5051
try {
51-
await clearOAuthRefreshDeadFlag(result.accountId)
52+
await clearOAuthRefreshDeadFlag(getShopifyRefreshScope(result.shopDomain))
5253
if (!result.created)
5354
await resumeConnectorsAfterCredentialReconnect(result.accountId, new Date())
5455
} catch {

‎apps/sim/lib/credentials/draft-hooks.ts‎

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion'
99
import { resumeConnectorsAfterCredentialReconnect } from '@/lib/knowledge/connectors/credential-recovery'
1010
import { clearOAuthRefreshDeadFlag } from '@/lib/oauth/refresh-coordination'
1111
import { captureServerEvent } from '@/lib/posthog/server'
12+
import { rememberShopifyCredentialScope } from '@/lib/shopify/privacy/installation-scopes'
1213

1314
const logger = createLogger('CredentialDraftHooks')
1415

@@ -26,17 +27,32 @@ export async function handleCreateCredentialFromDraft(params: {
2627
const credentialId = generateId()
2728

2829
try {
29-
await db.insert(schema.credential).values({
30-
id: credentialId,
31-
workspaceId: draft.workspaceId,
32-
type: 'oauth',
33-
displayName: draft.displayName,
34-
description: draft.description ?? null,
35-
providerId,
36-
accountId,
37-
createdBy: userId,
38-
createdAt: now,
39-
updatedAt: now,
30+
await db.transaction(async (tx) => {
31+
await tx.insert(schema.credential).values({
32+
id: credentialId,
33+
workspaceId: draft.workspaceId,
34+
type: 'oauth',
35+
displayName: draft.displayName,
36+
description: draft.description ?? null,
37+
providerId,
38+
accountId,
39+
createdBy: userId,
40+
createdAt: now,
41+
updatedAt: now,
42+
})
43+
await tx.insert(schema.credentialMember).values({
44+
id: generateId(),
45+
credentialId,
46+
userId,
47+
role: 'admin',
48+
status: 'active',
49+
joinedAt: now,
50+
invitedBy: userId,
51+
createdAt: now,
52+
updatedAt: now,
53+
})
54+
if (providerId === 'shopify')
55+
await rememberShopifyCredentialScope(accountId, credentialId, tx)
4056
})
4157
} catch (insertError: unknown) {
4258
if (
@@ -70,10 +86,14 @@ export async function handleCreateCredentialFromDraft(params: {
7086
accountId,
7187
})
7288

73-
await db
74-
.update(schema.credential)
75-
.set({ updatedAt: now })
76-
.where(eq(schema.credential.id, existingCredential.id))
89+
await db.transaction(async (tx) => {
90+
await tx
91+
.update(schema.credential)
92+
.set({ updatedAt: now })
93+
.where(eq(schema.credential.id, existingCredential.id))
94+
if (providerId === 'shopify')
95+
await rememberShopifyCredentialScope(accountId, existingCredential.id, tx)
96+
})
7797

7898
await clearOAuthRefreshDeadFlag(accountId)
7999
await resumeConnectorsAfterCredentialReconnect(accountId, now)
@@ -91,18 +111,6 @@ export async function handleCreateCredentialFromDraft(params: {
91111
return
92112
}
93113

94-
await db.insert(schema.credentialMember).values({
95-
id: generateId(),
96-
credentialId,
97-
userId,
98-
role: 'admin',
99-
status: 'active',
100-
joinedAt: now,
101-
invitedBy: userId,
102-
createdAt: now,
103-
updatedAt: now,
104-
})
105-
106114
logger.info('Created credential from draft', {
107115
credentialId,
108116
displayName: draft.displayName,
@@ -161,6 +169,7 @@ export async function handleReconnectCredential(params: {
161169
id: schema.credential.id,
162170
accountId: schema.credential.accountId,
163171
displayName: schema.credential.displayName,
172+
providerId: schema.credential.providerId,
164173
})
165174
.from(schema.credential)
166175
.where(eq(schema.credential.id, draft.credentialId))
@@ -194,10 +203,14 @@ export async function handleReconnectCredential(params: {
194203
}
195204
}
196205

197-
await db
198-
.update(schema.credential)
199-
.set({ accountId: newAccountId, updatedAt: now })
200-
.where(eq(schema.credential.id, draft.credentialId))
206+
await db.transaction(async (tx) => {
207+
await tx
208+
.update(schema.credential)
209+
.set({ accountId: newAccountId, updatedAt: now })
210+
.where(eq(schema.credential.id, existingCredential.id))
211+
if (existingCredential.providerId === 'shopify')
212+
await rememberShopifyCredentialScope(newAccountId, existingCredential.id, tx)
213+
})
201214

202215
logger.info(
203216
accountChanged

‎apps/sim/lib/credentials/organization-draft.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion'
1010
import { getCredentialCreationOrganizationContext } from '@/lib/credentials/organization'
1111
import { resumeConnectorsAfterCredentialReconnect } from '@/lib/knowledge/connectors/credential-recovery'
1212
import { clearOAuthRefreshDeadFlag } from '@/lib/oauth/refresh-coordination'
13+
import { rememberShopifyCredentialScope } from '@/lib/shopify/privacy/installation-scopes'
1314

1415
/** Completes the exact draft bound to the authenticated provider callback, rechecking current ownership under membership locks. */
1516
export async function completeOrganizationCredentialDraft(input: {
@@ -116,6 +117,9 @@ export async function completeOrganizationCredentialDraft(input: {
116117
updatedAt: now,
117118
})
118119
}
120+
if (input.providerId === 'shopify') {
121+
await rememberShopifyCredentialScope(input.accountId, credentialId, tx)
122+
}
119123
await tx.delete(pendingCredentialDraft).where(eq(pendingCredentialDraft.id, draft.id))
120124
return {
121125
credentialId,

‎apps/sim/lib/oauth/shopify-handoff.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export async function claimShopifyInstall(params: {
181181
workspaceId: params.workspaceId,
182182
created: false,
183183
changed: false,
184+
shopDomain: attempt.shopDomain,
184185
}
185186
}
186187
if (
@@ -305,6 +306,7 @@ export async function claimShopifyInstall(params: {
305306
workspaceId: params.workspaceId,
306307
created: !existingCredential,
307308
changed: true,
309+
shopDomain: attempt.shopDomain,
308310
}
309311
})
310312
}

0 commit comments

Comments
 (0)