Skip to content

Commit 4c916e3

Browse files
feat(slack-search): stream enterprise answers through one Slack app
1 parent c8b632f commit 4c916e3

118 files changed

Lines changed: 33727 additions & 1049 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/files/uploads/purposes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ function requireSessionScope(value: string | null, label = 'scope'): string {
235235

236236
async function principalUserId(principal: Principal, workspaceId?: string): Promise<string> {
237237
switch (principal.kind) {
238+
case 'slack_app':
238239
case 'slack_installation':
239240
throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads')
240241
case 'session':
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { NextResponse } from 'next/server'
2+
import { slackSearchOAuthCallbackContract } from '@/lib/api/contracts/knowledge/slack'
3+
import { parseRequest } from '@/lib/api/server'
4+
import {
5+
InternalUnauthenticatedError,
6+
internalOrchestrationErrorPolicy,
7+
internalRateLimits,
8+
internalSessionAuth,
9+
} from '@/lib/api/server/routes'
10+
import { getBaseUrl } from '@/lib/core/utils/urls'
11+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { completeSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
13+
import { organizationRoutes } from '@/lib/navigation/paths'
14+
15+
/** OAuth is a redirect protocol; protected configuration remains in the application use case. */
16+
export const GET = withRouteHandler(async (request) => {
17+
try {
18+
const principal = await internalSessionAuth.authenticate()
19+
const rateResponse = await internalRateLimits
20+
.user({ bucketName: 'slack-search-settings' })
21+
.enforce(request, principal)
22+
if (rateResponse) return rateResponse
23+
const parsed = await parseRequest(slackSearchOAuthCallbackContract, request, {})
24+
if (!parsed.success) return parsed.response
25+
const result = await completeSlackSearchSetup.execute({
26+
principal,
27+
input: parsed.data.query,
28+
request,
29+
})
30+
const url = new URL(
31+
organizationRoutes(result.organizationId).settingsSection('search-slack'),
32+
getBaseUrl()
33+
)
34+
url.searchParams.set('slackSetup', 'complete')
35+
return NextResponse.redirect(url, 303)
36+
} catch (error) {
37+
if (error instanceof InternalUnauthenticatedError)
38+
return NextResponse.json(
39+
{ error: 'Sign in to Sim and restart Slack setup.' },
40+
{ status: 401 }
41+
)
42+
const projected = internalOrchestrationErrorPolicy.project(error)
43+
if (projected) return NextResponse.json(projected.body, { status: projected.status })
44+
throw error
45+
}
46+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { startSlackSearchOAuthContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { startSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: startSlackSearchOAuthContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.startSlackInstallation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ body }) => body,
18+
useCase: startSlackSearchSetup,
19+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { retrySlackSearchOnboardingContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import {
9+
retrySlackSearchOnboarding,
10+
slackSearchOnboardingOperations,
11+
} from '@/lib/knowledge/application/slack-search/onboarding'
12+
13+
export const POST = defineInternalJsonRoute({
14+
contract: retrySlackSearchOnboardingContract,
15+
auth: internalSessionAuth,
16+
operation: slackSearchOnboardingOperations.retry,
17+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-onboarding-retry' }),
18+
errorPolicy: internalOrchestrationErrorPolicy,
19+
staticResponseHeaders: { 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer' },
20+
mapInput: ({ body }) => body,
21+
useCase: retrySlackSearchOnboarding,
22+
})
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/** @vitest-environment node */
2+
import { authMockFns, createMockRequest } from '@sim/testing'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const mocks = vi.hoisted(() => ({ read: vi.fn(), retry: vi.fn() }))
6+
vi.mock('@/lib/knowledge/application/slack-search/onboarding', () => {
7+
const read = {
8+
id: 'knowledge.slack.onboarding.read',
9+
capability: 'none',
10+
principalKinds: ['session'],
11+
}
12+
const retry = {
13+
id: 'knowledge.slack.onboarding.retry',
14+
capability: 'knowledge.use',
15+
principalKinds: ['session'],
16+
}
17+
return {
18+
slackSearchOnboardingOperations: { read, retry },
19+
getSlackSearchOnboarding: { operation: read, execute: mocks.read },
20+
retrySlackSearchOnboarding: { operation: retry, execute: mocks.retry },
21+
}
22+
})
23+
24+
import { OrchestrationError } from '@/lib/core/orchestration/types'
25+
import { POST } from '@/app/api/knowledge/slack/onboarding/retry/route'
26+
import { GET } from '@/app/api/knowledge/slack/onboarding/route'
27+
28+
const token = 'bf1ff774-505b-4f2f-946d-9c54ed75de47'
29+
const url = `http://localhost/api/knowledge/slack/onboarding?token=${token}`
30+
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
authMockFns.mockGetSession.mockResolvedValue({
34+
user: { id: 'sender' },
35+
session: { id: 'session' },
36+
})
37+
mocks.read.mockResolvedValue({ status: 'membership_required' })
38+
mocks.retry.mockResolvedValue({ slackUrl: 'https://example.slack.com/archives/D1/p1' })
39+
})
40+
41+
describe('Slack onboarding routes', () => {
42+
it('authenticates both routes before parsing invalid input', async () => {
43+
authMockFns.mockGetSession.mockResolvedValue(null)
44+
expect((await GET(createMockRequest('GET'))).status).toBe(401)
45+
expect((await POST(createMockRequest('POST', {}))).status).toBe(401)
46+
expect(mocks.read).not.toHaveBeenCalled()
47+
expect(mocks.retry).not.toHaveBeenCalled()
48+
})
49+
50+
it('rejects malformed tokens before entering the application', async () => {
51+
expect((await GET(createMockRequest('GET'))).status).toBe(400)
52+
expect((await POST(createMockRequest('POST', { token: 'invalid' }))).status).toBe(400)
53+
expect(mocks.read).not.toHaveBeenCalled()
54+
expect(mocks.retry).not.toHaveBeenCalled()
55+
})
56+
57+
it('projects only the blocked view and keeps the response private', async () => {
58+
mocks.read.mockResolvedValue({
59+
status: 'membership_required',
60+
question: 'private question',
61+
organizationId: 'private organization',
62+
email: 'private@example.test',
63+
})
64+
const response = await GET(createMockRequest('GET', undefined, {}, url))
65+
expect(response.status).toBe(200)
66+
expect(await response.json()).toEqual({ status: 'membership_required' })
67+
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
68+
expect(response.headers.get('Referrer-Policy')).toBe('no-referrer')
69+
expect(mocks.read).toHaveBeenCalledWith(
70+
expect.objectContaining({
71+
principal: { kind: 'session', userId: 'sender', sessionId: 'session' },
72+
input: { token },
73+
})
74+
)
75+
expect(mocks.retry).not.toHaveBeenCalled()
76+
})
77+
78+
it('passes explicit retries to the same signed-in principal', async () => {
79+
const response = await POST(createMockRequest('POST', { token }))
80+
expect(response.status).toBe(200)
81+
expect(await response.json()).toEqual({
82+
slackUrl: 'https://example.slack.com/archives/D1/p1',
83+
})
84+
expect(mocks.retry).toHaveBeenCalledWith(
85+
expect.objectContaining({
86+
principal: { kind: 'session', userId: 'sender', sessionId: 'session' },
87+
input: { token },
88+
})
89+
)
90+
})
91+
92+
it('preserves authorization denial without returning a question', async () => {
93+
mocks.retry.mockRejectedValue(new OrchestrationError('forbidden', 'Complete account setup'))
94+
const response = await POST(createMockRequest('POST', { token }))
95+
expect(response.status).toBe(403)
96+
expect(await response.json()).not.toHaveProperty('slackUrl')
97+
})
98+
99+
it('conceals unexpected infrastructure errors', async () => {
100+
mocks.read.mockRejectedValue(new Error('private database connection'))
101+
const response = await GET(createMockRequest('GET', undefined, {}, url))
102+
expect(response.status).toBe(500)
103+
expect(await response.json()).toMatchObject({ error: 'Internal server error' })
104+
})
105+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getSlackSearchOnboardingContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import {
9+
getSlackSearchOnboarding,
10+
slackSearchOnboardingOperations,
11+
} from '@/lib/knowledge/application/slack-search/onboarding'
12+
13+
export const GET = defineInternalJsonRoute({
14+
contract: getSlackSearchOnboardingContract,
15+
auth: internalSessionAuth,
16+
operation: slackSearchOnboardingOperations.read,
17+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-onboarding' }),
18+
errorPolicy: internalOrchestrationErrorPolicy,
19+
staticResponseHeaders: { 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer' },
20+
mapInput: ({ query }) => query,
21+
useCase: getSlackSearchOnboarding,
22+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { prepareSlackSearchContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { prepareSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: prepareSlackSearchContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.prepareSlackInstallation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ body }) => body,
18+
useCase: prepareSlackSearchSetup,
19+
})

apps/sim/app/api/webhooks/outbox/process/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { processOutboxEvents } from '@/lib/core/outbox/service'
1313
import { generateRequestId } from '@/lib/core/utils/request'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1515
import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant'
16+
import { slackSearchOutboxHandlers } from '@/lib/knowledge/application/slack-search/outbox'
1617
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
1718
import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup'
1819
import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox'
@@ -27,6 +28,7 @@ export const dynamic = 'force-dynamic'
2728
export const maxDuration = 800
2829

2930
const handlers = {
31+
...slackSearchOutboxHandlers,
3032
...adminInvitationOperationOutboxHandlers,
3133
...adminMemberOperationOutboxHandlers,
3234
...billingOutboxHandlers,

0 commit comments

Comments
 (0)