Skip to content

Commit 8247ed3

Browse files
authored
Merge pull request #2756 from trycompai/fix/move-stripe-auto-approve-to-api
refactor(stripe): move upgrade-page auto-approval into API
2 parents 64813d9 + 17a8d92 commit 8247ed3

11 files changed

Lines changed: 643 additions & 222 deletions

File tree

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { awsConfig } from './config/aws.config';
1414
import { betterAuthConfig } from './config/better-auth.config';
1515
import { HealthModule } from './health/health.module';
1616
import { OrganizationModule } from './organization/organization.module';
17+
import { OrganizationAccessModule } from './organization-access/organization-access.module';
1718
import { PoliciesModule } from './policies/policies.module';
1819
import { RisksModule } from './risks/risks.module';
1920
import { TasksModule } from './tasks/tasks.module';
@@ -74,6 +75,7 @@ import { BillingModule } from './billing/billing.module';
7475
]),
7576
AuthModule,
7677
OrganizationModule,
78+
OrganizationAccessModule,
7779
PeopleModule,
7880
RisksModule,
7981
VendorsModule,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
Controller,
3+
HttpCode,
4+
HttpStatus,
5+
Post,
6+
UseGuards,
7+
} from '@nestjs/common';
8+
import { ApiOperation, ApiTags } from '@nestjs/swagger';
9+
import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
10+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
11+
import { PermissionGuard } from '../auth/permission.guard';
12+
import { RequirePermission } from '../auth/require-permission.decorator';
13+
import type { AuthContext as AuthContextType } from '../auth/types';
14+
import {
15+
AutoApproveResult,
16+
OrganizationAccessService,
17+
} from './organization-access.service';
18+
19+
@ApiTags('Organization')
20+
@Controller({ path: 'organization-access', version: '1' })
21+
@UseGuards(HybridAuthGuard, PermissionGuard)
22+
export class OrganizationAccessController {
23+
constructor(
24+
private readonly organizationAccessService: OrganizationAccessService,
25+
) {}
26+
27+
@Post('auto-approve')
28+
@HttpCode(HttpStatus.OK)
29+
@RequirePermission('organization', 'update')
30+
@ApiOperation({
31+
summary: 'Auto-approve organization access via domain or self-hosted check',
32+
description:
33+
'Grants hasAccess on the active organization if the requesting user is an internal trycomp.ai user, the deployment is self-hosted, or the user email domain matches the organization website domain and is an active Stripe customer.',
34+
})
35+
async autoApprove(
36+
@OrganizationId() organizationId: string,
37+
@AuthContext() authContext: AuthContextType,
38+
): Promise<AutoApproveResult> {
39+
return this.organizationAccessService.autoApproveAccess({
40+
organizationId,
41+
userEmail: authContext.userEmail,
42+
});
43+
}
44+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
import { AuthModule } from '../auth/auth.module';
3+
import { OrganizationAccessController } from './organization-access.controller';
4+
import { OrganizationAccessService } from './organization-access.service';
5+
6+
@Module({
7+
imports: [AuthModule],
8+
controllers: [OrganizationAccessController],
9+
providers: [OrganizationAccessService],
10+
})
11+
export class OrganizationAccessModule {}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { NotFoundException } from '@nestjs/common';
2+
import { db } from '@db';
3+
import { OrganizationAccessService } from './organization-access.service';
4+
5+
jest.mock('@db', () => ({
6+
db: {
7+
organization: {
8+
findUnique: jest.fn(),
9+
update: jest.fn(),
10+
},
11+
},
12+
}));
13+
14+
const mockedDb = db as unknown as {
15+
organization: {
16+
findUnique: jest.Mock;
17+
update: jest.Mock;
18+
};
19+
};
20+
21+
const buildService = (
22+
overrides: Partial<{ isDomainActiveCustomer: jest.Mock }> = {},
23+
) => {
24+
const isDomainActiveCustomer =
25+
overrides.isDomainActiveCustomer ?? jest.fn().mockResolvedValue(false);
26+
const stripeService = { isDomainActiveCustomer } as never;
27+
return {
28+
service: new OrganizationAccessService(stripeService),
29+
isDomainActiveCustomer,
30+
};
31+
};
32+
33+
describe('OrganizationAccessService', () => {
34+
const ORIGINAL_SELF_HOSTED = process.env.SELF_HOSTED;
35+
const ORIGINAL_NEXT_SELF_HOSTED = process.env.NEXT_PUBLIC_SELF_HOSTED;
36+
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
delete process.env.SELF_HOSTED;
40+
delete process.env.NEXT_PUBLIC_SELF_HOSTED;
41+
mockedDb.organization.update.mockResolvedValue({});
42+
});
43+
44+
afterAll(() => {
45+
if (ORIGINAL_SELF_HOSTED === undefined) {
46+
delete process.env.SELF_HOSTED;
47+
} else {
48+
process.env.SELF_HOSTED = ORIGINAL_SELF_HOSTED;
49+
}
50+
if (ORIGINAL_NEXT_SELF_HOSTED === undefined) {
51+
delete process.env.NEXT_PUBLIC_SELF_HOSTED;
52+
} else {
53+
process.env.NEXT_PUBLIC_SELF_HOSTED = ORIGINAL_NEXT_SELF_HOSTED;
54+
}
55+
});
56+
57+
it('throws NotFoundException when org does not exist', async () => {
58+
mockedDb.organization.findUnique.mockResolvedValue(null);
59+
const { service } = buildService();
60+
61+
await expect(
62+
service.autoApproveAccess({
63+
organizationId: 'org_x',
64+
userEmail: 'a@b.com',
65+
}),
66+
).rejects.toBeInstanceOf(NotFoundException);
67+
});
68+
69+
it('returns already-has-access without writing when org has access', async () => {
70+
mockedDb.organization.findUnique.mockResolvedValue({
71+
id: 'org_1',
72+
hasAccess: true,
73+
website: 'acme.com',
74+
});
75+
const { service } = buildService();
76+
77+
const result = await service.autoApproveAccess({
78+
organizationId: 'org_1',
79+
userEmail: 'user@acme.com',
80+
});
81+
82+
expect(result).toEqual({
83+
hasAccess: true,
84+
autoApproved: false,
85+
reason: 'already-has-access',
86+
});
87+
expect(mockedDb.organization.update).not.toHaveBeenCalled();
88+
});
89+
90+
it('grants on self-hosted via SELF_HOSTED env', async () => {
91+
process.env.SELF_HOSTED = 'true';
92+
mockedDb.organization.findUnique.mockResolvedValue({
93+
id: 'org_1',
94+
hasAccess: false,
95+
website: null,
96+
});
97+
const { service, isDomainActiveCustomer } = buildService();
98+
99+
const result = await service.autoApproveAccess({
100+
organizationId: 'org_1',
101+
userEmail: 'user@gmail.com',
102+
});
103+
104+
expect(result).toEqual({
105+
hasAccess: true,
106+
autoApproved: true,
107+
reason: 'self-hosted',
108+
});
109+
expect(mockedDb.organization.update).toHaveBeenCalledWith({
110+
where: { id: 'org_1' },
111+
data: { hasAccess: true },
112+
});
113+
expect(isDomainActiveCustomer).not.toHaveBeenCalled();
114+
});
115+
116+
it('grants on @trycomp.ai email without consulting Stripe', async () => {
117+
mockedDb.organization.findUnique.mockResolvedValue({
118+
id: 'org_1',
119+
hasAccess: false,
120+
website: 'acme.com',
121+
});
122+
const { service, isDomainActiveCustomer } = buildService();
123+
124+
const result = await service.autoApproveAccess({
125+
organizationId: 'org_1',
126+
userEmail: 'tofik@trycomp.ai',
127+
});
128+
129+
expect(result).toEqual({
130+
hasAccess: true,
131+
autoApproved: true,
132+
reason: 'trycomp-email',
133+
});
134+
expect(isDomainActiveCustomer).not.toHaveBeenCalled();
135+
expect(mockedDb.organization.update).toHaveBeenCalled();
136+
});
137+
138+
it('grants when user email domain matches org website AND is an active Stripe customer', async () => {
139+
mockedDb.organization.findUnique.mockResolvedValue({
140+
id: 'org_1',
141+
hasAccess: false,
142+
website: 'https://acme.com',
143+
});
144+
const { service, isDomainActiveCustomer } = buildService({
145+
isDomainActiveCustomer: jest.fn().mockResolvedValue(true),
146+
});
147+
148+
const result = await service.autoApproveAccess({
149+
organizationId: 'org_1',
150+
userEmail: 'cfo@acme.com',
151+
});
152+
153+
expect(result).toEqual({
154+
hasAccess: true,
155+
autoApproved: true,
156+
reason: 'stripe-customer',
157+
});
158+
expect(isDomainActiveCustomer).toHaveBeenCalledWith('acme.com');
159+
expect(mockedDb.organization.update).toHaveBeenCalled();
160+
});
161+
162+
it('does not grant when domain matches but Stripe says not an active customer', async () => {
163+
mockedDb.organization.findUnique.mockResolvedValue({
164+
id: 'org_1',
165+
hasAccess: false,
166+
website: 'acme.com',
167+
});
168+
const { service } = buildService({
169+
isDomainActiveCustomer: jest.fn().mockResolvedValue(false),
170+
});
171+
172+
const result = await service.autoApproveAccess({
173+
organizationId: 'org_1',
174+
userEmail: 'cfo@acme.com',
175+
});
176+
177+
expect(result).toEqual({
178+
hasAccess: false,
179+
autoApproved: false,
180+
reason: 'not-eligible',
181+
});
182+
expect(mockedDb.organization.update).not.toHaveBeenCalled();
183+
});
184+
185+
it('does not grant when user email domain mismatches org website', async () => {
186+
mockedDb.organization.findUnique.mockResolvedValue({
187+
id: 'org_1',
188+
hasAccess: false,
189+
website: 'acme.com',
190+
});
191+
const { service, isDomainActiveCustomer } = buildService();
192+
193+
const result = await service.autoApproveAccess({
194+
organizationId: 'org_1',
195+
userEmail: 'cfo@example.com',
196+
});
197+
198+
expect(result.autoApproved).toBe(false);
199+
expect(result.reason).toBe('not-eligible');
200+
expect(isDomainActiveCustomer).not.toHaveBeenCalled();
201+
});
202+
203+
it('does not grant when user domain is a public mailbox provider', async () => {
204+
mockedDb.organization.findUnique.mockResolvedValue({
205+
id: 'org_1',
206+
hasAccess: false,
207+
website: 'gmail.com', // pathological — even if website "matches", we refuse
208+
});
209+
const { service, isDomainActiveCustomer } = buildService();
210+
211+
const result = await service.autoApproveAccess({
212+
organizationId: 'org_1',
213+
userEmail: 'someone@gmail.com',
214+
});
215+
216+
expect(result.autoApproved).toBe(false);
217+
expect(isDomainActiveCustomer).not.toHaveBeenCalled();
218+
});
219+
220+
it('does not grant when user email is missing', async () => {
221+
mockedDb.organization.findUnique.mockResolvedValue({
222+
id: 'org_1',
223+
hasAccess: false,
224+
website: 'acme.com',
225+
});
226+
const { service } = buildService();
227+
228+
const result = await service.autoApproveAccess({
229+
organizationId: 'org_1',
230+
userEmail: undefined,
231+
});
232+
233+
expect(result).toEqual({
234+
hasAccess: false,
235+
autoApproved: false,
236+
reason: 'not-eligible',
237+
});
238+
});
239+
});

0 commit comments

Comments
 (0)