Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ packages/*/dist

# Generated Prisma Client
**/src/db/generated/
packages/db/prisma/src/generated/

# Release script
scripts/sync-release-branch.sh
Expand All @@ -97,3 +98,4 @@ scripts/sync-release-branch.sh

.superpowers/*
.claude/worktrees/
.worktrees/
10 changes: 7 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ COPY apps/app ./apps/app
# Bring in node_modules for build and prisma prebuild
COPY --from=deps /app/node_modules ./node_modules

# Pre-combine schemas for app build
RUN cd packages/db && node scripts/combine-schemas.js
RUN cp packages/db/dist/schema.prisma apps/app/prisma/schema.prisma
# Pre-combine schemas and generate the Prisma client into
# node_modules/@prisma/client. The deps stage ran `bun install` with
# `--ignore-scripts` so packages/db's postinstall was skipped; we run
# it explicitly here so `next build` can resolve the generated runtime
# + types when it imports @prisma/client.
RUN cd packages/db && node scripts/combine-schemas.js \
&& node scripts/generate-prisma-client-js.js

# Ensure Next build has required public env at build-time
ARG NEXT_PUBLIC_BETTER_AUTH_URL
Expand Down
49 changes: 26 additions & 23 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,57 @@
"@ai-sdk/anthropic": "^2.0.53",
"@ai-sdk/groq": "^2.0.32",
"@ai-sdk/openai": "^2.0.65",
"@aws-sdk/client-ec2": "^3.911.0",
"@aws-sdk/client-s3": "3.1013.0",
"@aws-sdk/client-acm": "^3.948.0",
"@aws-sdk/client-api-gateway": "^3.948.0",
"@aws-sdk/client-apigatewayv2": "^3.948.0",
"@aws-sdk/client-appflow": "^3.948.0",
"@aws-sdk/client-athena": "^3.948.0",
"@aws-sdk/client-backup": "^3.948.0",
"@aws-sdk/client-cloudfront": "^3.948.0",
"@aws-sdk/client-cloudtrail": "^3.948.0",
"@aws-sdk/client-cloudwatch": "^3.948.0",
"@aws-sdk/client-cost-explorer": "^3.948.0",
"@aws-sdk/client-cloudwatch-logs": "^3.948.0",
"@aws-sdk/client-codebuild": "^3.948.0",
"@aws-sdk/client-cognito-identity-provider": "^3.948.0",
"@aws-sdk/client-config-service": "^3.948.0",
"@aws-sdk/client-cost-explorer": "^3.948.0",
"@aws-sdk/client-dynamodb": "^3.948.0",
"@aws-sdk/client-ec2": "^3.911.0",
"@aws-sdk/client-ecr": "^3.948.0",
"@aws-sdk/client-ecs": "^3.948.0",
"@aws-sdk/client-efs": "^3.948.0",
"@aws-sdk/client-eks": "^3.948.0",
"@aws-sdk/client-elastic-beanstalk": "^3.948.0",
"@aws-sdk/client-elastic-load-balancing-v2": "^3.948.0",
"@aws-sdk/client-elasticache": "^3.948.0",
"@aws-sdk/client-emr": "^3.948.0",
"@aws-sdk/client-eventbridge": "^3.948.0",
"@aws-sdk/client-glue": "^3.948.0",
"@aws-sdk/client-guardduty": "^3.948.0",
"@aws-sdk/client-iam": "^3.948.0",
"@aws-sdk/client-inspector2": "^3.948.0",
"@aws-sdk/client-kafka": "^3.948.0",
"@aws-sdk/client-kinesis": "^3.948.0",
"@aws-sdk/client-kms": "^3.948.0",
"@aws-sdk/client-lambda": "^3.948.0",
"@aws-sdk/client-macie2": "^3.948.0",
"@aws-sdk/client-network-firewall": "^3.948.0",
"@aws-sdk/client-opensearch": "^3.948.0",
"@aws-sdk/client-rds": "^3.948.0",
"@aws-sdk/client-redshift": "^3.948.0",
"@aws-sdk/client-route-53": "^3.948.0",
"@aws-sdk/client-s3": "3.1013.0",
"@aws-sdk/client-sagemaker": "^3.948.0",
"@aws-sdk/client-secrets-manager": "^3.948.0",
"@aws-sdk/client-securityhub": "^3.948.0",
"@aws-sdk/client-sns": "^3.948.0",
"@aws-sdk/client-sqs": "^3.948.0",
"@aws-sdk/client-wafv2": "^3.948.0",
"@aws-sdk/client-api-gateway": "^3.948.0",
"@aws-sdk/client-apigatewayv2": "^3.948.0",
"@aws-sdk/client-appflow": "^3.948.0",
"@aws-sdk/client-athena": "^3.948.0",
"@aws-sdk/client-cloudfront": "^3.948.0",
"@aws-sdk/client-codebuild": "^3.948.0",
"@aws-sdk/client-cognito-identity-provider": "^3.948.0",
"@aws-sdk/client-elastic-beanstalk": "^3.948.0",
"@aws-sdk/client-elasticache": "^3.948.0",
"@aws-sdk/client-emr": "^3.948.0",
"@aws-sdk/client-eventbridge": "^3.948.0",
"@aws-sdk/client-glue": "^3.948.0",
"@aws-sdk/client-kafka": "^3.948.0",
"@aws-sdk/client-kinesis": "^3.948.0",
"@aws-sdk/client-network-firewall": "^3.948.0",
"@aws-sdk/client-sagemaker": "^3.948.0",
"@aws-sdk/client-sfn": "^3.948.0",
"@aws-sdk/client-shield": "^3.948.0",
"@aws-sdk/client-sns": "^3.948.0",
"@aws-sdk/client-sqs": "^3.948.0",
"@aws-sdk/client-ssm": "^3.948.0",
"@aws-sdk/client-sts": "^3.948.0",
"@aws-sdk/client-transfer": "^3.948.0",
"@aws-sdk/client-wafv2": "^3.948.0",
"@aws-sdk/s3-request-presigner": "3.1013.0",
"@browserbasehq/sdk": "2.6.0",
"@browserbasehq/stagehand": "^3.2.1",
Expand All @@ -81,6 +81,7 @@
"@trycompai/db": "workspace:*",
"@trycompai/email": "workspace:*",
"@trycompai/integration-platform": "workspace:*",
"@trycompai/utils": "workspace:*",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.34.2",
"@upstash/vector": "^1.2.2",
Expand All @@ -102,6 +103,7 @@
"nanoid": "^5.1.6",
"pdf-lib": "^1.17.1",
"playwright-core": "^1.57.0",
"posthog-node": "^5.29.2",
"prisma": "7.6.0",
"react": "^19.1.1",
"react-dom": "^19.1.0",
Expand Down Expand Up @@ -167,7 +169,8 @@
"^@trycompai/company$": "<rootDir>/../../../packages/company/src/index.ts",
"^@trycompai/db$": "@prisma/client",
"^@trycompai/email$": "<rootDir>/../../../packages/email/index.ts",
"^@trycompai/integration-platform$": "<rootDir>/../../../packages/integration-platform/src/index.ts"
"^@trycompai/integration-platform$": "<rootDir>/../../../packages/integration-platform/src/index.ts",
"^@trycompai/utils/(.*)$": "<rootDir>/../../../packages/utils/src/$1.ts"
}
},
"license": "UNLICENSED",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';

jest.mock('@db', () => ({
db: {
organization: {
findUnique: jest.fn(),
},
},
}));

jest.mock('../auth/platform-admin.guard', () => ({
PlatformAdminGuard: class MockGuard {
canActivate() {
return true;
}
},
}));

jest.mock('../admin-organizations/admin-audit-log.interceptor', () => ({
AdminAuditLogInterceptor: class MockInterceptor {
intercept(_ctx: unknown, next: { handle: () => unknown }) {
return next.handle();
}
},
}));

// eslint-disable-next-line @typescript-eslint/no-require-imports
import { AdminFeatureFlagsController } from './admin-feature-flags.controller';
import { AdminFeatureFlagsService } from './admin-feature-flags.service';
import { PlatformAdminGuard } from '../auth/platform-admin.guard';
import { AdminAuditLogInterceptor } from '../admin-organizations/admin-audit-log.interceptor';

// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const mockDb = require('@db').db as {
organization: { findUnique: jest.Mock };
};

describe('AdminFeatureFlagsController', () => {
let controller: AdminFeatureFlagsController;
let service: {
listForOrganization: jest.Mock;
setFlagForOrganization: jest.Mock;
};

beforeEach(async () => {
jest.clearAllMocks();
service = {
listForOrganization: jest.fn(),
setFlagForOrganization: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [AdminFeatureFlagsController],
providers: [{ provide: AdminFeatureFlagsService, useValue: service }],
})
.overrideGuard(PlatformAdminGuard)
.useValue({ canActivate: () => true })
.overrideInterceptor(AdminAuditLogInterceptor)
.useValue({ intercept: (_ctx: unknown, next: { handle: () => unknown }) => next.handle() })
.compile();

controller = module.get(AdminFeatureFlagsController);
});

describe('list', () => {
it('throws NotFoundException when the org does not exist', async () => {
mockDb.organization.findUnique.mockResolvedValue(null);
await expect(controller.list('org_missing')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(service.listForOrganization).not.toHaveBeenCalled();
});

it('returns flags wrapped in { data } when the org exists', async () => {
mockDb.organization.findUnique.mockResolvedValue({ id: 'org_1' });
service.listForOrganization.mockResolvedValue([
{
key: 'is-timeline-enabled',
name: 'is-timeline-enabled',
description: '',
active: true,
enabled: true,
createdAt: null,
},
]);

const result = await controller.list('org_1');

expect(service.listForOrganization).toHaveBeenCalledWith('org_1');
expect(result.data).toHaveLength(1);
expect(result.data[0].key).toBe('is-timeline-enabled');
});
});

describe('update', () => {
it('throws NotFoundException when the org does not exist', async () => {
mockDb.organization.findUnique.mockResolvedValue(null);
await expect(
controller.update('org_missing', {
flagKey: 'is-timeline-enabled',
enabled: true,
}),
).rejects.toBeInstanceOf(NotFoundException);
expect(service.setFlagForOrganization).not.toHaveBeenCalled();
});

it('delegates to the service with orgId, orgName, flagKey, and enabled', async () => {
mockDb.organization.findUnique.mockResolvedValue({
id: 'org_1',
name: 'Acme',
});
service.setFlagForOrganization.mockResolvedValue({
key: 'is-timeline-enabled',
enabled: false,
});

const result = await controller.update('org_1', {
flagKey: 'is-timeline-enabled',
enabled: false,
});

expect(service.setFlagForOrganization).toHaveBeenCalledWith({
orgId: 'org_1',
orgName: 'Acme',
flagKey: 'is-timeline-enabled',
enabled: false,
});
expect(result.data.enabled).toBe(false);
});
});
});
69 changes: 69 additions & 0 deletions apps/api/src/admin-feature-flags/admin-feature-flags.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Patch,
UseGuards,
UseInterceptors,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { db } from '@db';
import { PlatformAdminGuard } from '../auth/platform-admin.guard';
import { AdminAuditLogInterceptor } from '../admin-organizations/admin-audit-log.interceptor';
import { AdminFeatureFlagsService } from './admin-feature-flags.service';
import { UpdateFeatureFlagDto } from './dto/update-feature-flag.dto';

@ApiExcludeController()
@ApiTags('Admin - Feature Flags')
@Controller({ path: 'admin/organizations', version: '1' })
@UseGuards(PlatformAdminGuard)
@UseInterceptors(AdminAuditLogInterceptor)
@Throttle({ default: { ttl: 60000, limit: 60 } })
export class AdminFeatureFlagsController {
constructor(private readonly service: AdminFeatureFlagsService) {}

@Get(':orgId/feature-flags')
@ApiOperation({
summary:
'List all admin-managed feature flags with their current state for an organization',
})
async list(@Param('orgId') orgId: string) {
const org = await db.organization.findUnique({ where: { id: orgId } });
if (!org) throw new NotFoundException('Organization not found');

const flags = await this.service.listForOrganization(orgId);
return { data: flags };
}

@Patch(':orgId/feature-flags')
@ApiOperation({
summary: 'Enable or disable a feature flag for an organization',
})
@UsePipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
)
async update(
@Param('orgId') orgId: string,
@Body() dto: UpdateFeatureFlagDto,
) {
const org = await db.organization.findUnique({ where: { id: orgId } });
if (!org) throw new NotFoundException('Organization not found');

const result = await this.service.setFlagForOrganization({
orgId,
orgName: org.name,
flagKey: dto.flagKey,
enabled: dto.enabled,
});
return { data: result };
}
}
11 changes: 11 additions & 0 deletions apps/api/src/admin-feature-flags/admin-feature-flags.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AdminFeatureFlagsController } from './admin-feature-flags.controller';
import { AdminFeatureFlagsService } from './admin-feature-flags.service';
import { PostHogService } from './posthog.service';

@Module({
controllers: [AdminFeatureFlagsController],
providers: [AdminFeatureFlagsService, PostHogService],
exports: [AdminFeatureFlagsService, PostHogService],
})
export class AdminFeatureFlagsModule {}
Loading
Loading