diff --git a/Dockerfile b/Dockerfile index d7904cc76e..06aa8805e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,11 @@ WORKDIR /app COPY package.json bun.lock ./ # Copy package.json files for all packages (exclude local db; use published @trycompai/db) +COPY packages/auth/package.json ./packages/auth/ +COPY packages/billing/package.json ./packages/billing/ +COPY packages/company/package.json ./packages/company/ +COPY packages/db/package.json ./packages/db/ + COPY packages/kv/package.json ./packages/kv/ COPY packages/ui/package.json ./packages/ui/ COPY packages/email/package.json ./packages/email/ @@ -26,28 +31,25 @@ COPY apps/portal/package.json ./apps/portal/ RUN PRISMA_SKIP_POSTINSTALL_GENERATE=true bun install --ignore-scripts # ============================================================================= -# STAGE 2: Ultra-Minimal Migrator - Only Prisma +# STAGE 2: Migrator - built from local db source (not published npm package) # ============================================================================= -FROM oven/bun:1.2.8 AS migrator +FROM deps AS migrator WORKDIR /app -# Copy local Prisma schema and migrations from workspace -COPY packages/db/prisma ./packages/db/prisma - -# Create minimal package.json for Prisma runtime (also used by seeder) -RUN echo '{"name":"migrator","type":"module","dependencies":{"prisma":"^6.14.0","@prisma/client":"^6.14.0","@trycompai/db":"^1.3.4","zod":"^3.25.7"}}' > package.json +# Copy full local db package source (schema, scripts, seed data, prisma files) +COPY packages/db ./packages/db -# Install ONLY Prisma dependencies -RUN bun install +# Build local db package: generates Prisma Client from local schema files +# AND builds the combined dist/schema.prisma - both from source, not npm +RUN cd packages/db && bun run build -# Ensure Prisma can find migrations relative to the published schema path -# We copy the local migrations into the published package's dist directory -RUN cp -R packages/db/prisma/migrations node_modules/@trycompai/db/dist/ +# Real Node.js for tsx seed (Prisma 7 query compiler crashes under Bun WASM). +# Copy a pinned runtime from the official Node image instead of curl|bash installers. +COPY --from=node:22.13.1-bookworm-slim /usr/local/bin/node /usr/local/bin/node +RUN bun add -g tsx@4.19.3 -# Run migrations against the combined schema published by @trycompai/db -RUN echo "Running migrations against @trycompai/db combined schema" -CMD ["bunx", "prisma", "migrate", "deploy", "--schema=node_modules/@trycompai/db/dist/schema.prisma"] +CMD ["sh", "-lc", "cd packages/db && bunx prisma migrate deploy"] # ============================================================================= # STAGE 3: App Builder @@ -68,8 +70,13 @@ COPY --from=deps /app/node_modules ./node_modules # `--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 +# Build local workspace packages in dependency order (db first, others depend on it) +RUN cd packages/db && bun run build +RUN cd packages/auth && bun run build +RUN cd packages/company && bun run build +RUN cd packages/billing && bun run build + +RUN cd apps/app && bun run db:getschema # Ensure Next build has required public env at build-time ARG NEXT_PUBLIC_BETTER_AUTH_URL @@ -104,7 +111,7 @@ COPY --from=app-builder /app/apps/app/.next/static ./apps/app/.next/static COPY --from=app-builder /app/apps/app/public ./apps/app/public EXPOSE 3000 -CMD ["node", "apps/app/server.js"] +CMD ["node", "--max-old-space-size=8192", "apps/app/server.js"] # ============================================================================= # STAGE 5: Portal Builder @@ -119,14 +126,21 @@ COPY apps/portal ./apps/portal # Bring in node_modules for build and prisma prebuild COPY --from=deps /app/node_modules ./node_modules +# Build local workspace packages in dependency order (db first, others depend on it) +RUN cd packages/db && bun run build + +RUN cd packages/auth && bun run build +RUN cd packages/company && bun run build +RUN cd packages/billing && bun run build # Pre-combine schemas for portal build -RUN cd packages/db && node scripts/combine-schemas.js -RUN cp packages/db/dist/schema.prisma apps/portal/prisma/schema.prisma +RUN cd apps/portal && bun run db:getschema # Ensure Next build has required public env at build-time ARG NEXT_PUBLIC_BETTER_AUTH_URL +ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL \ + NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ NEXT_TELEMETRY_DISABLED=1 NODE_ENV=production \ NEXT_OUTPUT_STANDALONE=true \ NODE_OPTIONS=--max_old_space_size=6144 @@ -147,6 +161,6 @@ COPY --from=portal-builder /app/apps/portal/.next/static ./apps/portal/.next/sta COPY --from=portal-builder /app/apps/portal/public ./apps/portal/public EXPOSE 3000 -CMD ["node", "apps/portal/server.js"] +CMD ["node", "--max-old-space-size=8192", "apps/portal/server.js"] # (Trigger.dev hosted; no local runner stage) diff --git a/apps/api/.env.example b/apps/api/.env.example index 8913358e40..3e7eedcdf5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -12,6 +12,7 @@ APP_AWS_ACCESS_KEY_ID= APP_AWS_SECRET_ACCESS_KEY= APP_AWS_ORG_ASSETS_BUCKET= APP_AWS_ENDPOINT="" # optional for using services like MinIO +APP_AWS_PUBLIC_ENDPOINT="" # Browser-reachable S3/MinIO endpoint for presigned URLs # Microsoft sign-in (Entra ID / Azure AD) AUTH_MICROSOFT_CLIENT_ID= @@ -97,3 +98,16 @@ SECURITY_HUB_GOVCLOUD_ACCESS_KEY_ID= SECURITY_HUB_GOVCLOUD_SECRET_ACCESS_KEY= # Optional: only set when using temporary GovCloud credentials. Leave unset for long-lived IAM user keys. # SECURITY_HUB_GOVCLOUD_SESSION_TOKEN= + +# SMTP (optional — if SMTP_HOST is set, Resend is ignored) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_SECURE=false +SMTP_FROM=noreply@yourdomain.com + +# BunMail (optional — REST email API; takes priority over SMTP/Resend) +BUNMAIL_API_URL= +BUNMAIL_API_KEY= +BUNMAIL_FROM=noreply@yourdomain.com diff --git a/apps/api/package.json b/apps/api/package.json index 27bca8836d..4af048a02b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,216 +1,218 @@ { - "name": "@trycompai/api", - "description": "", - "version": "0.0.1", - "author": "", - "dependencies": { - "@1password/sdk": "0.4.0", - "@ai-sdk/anthropic": "^3.0.75", - "@ai-sdk/groq": "^3.0.38", - "@ai-sdk/openai": "^3.0.62", - "@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-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-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/lib-storage": "3.1013.0", - "@aws-sdk/s3-request-presigner": "3.1013.0", - "@browserbasehq/sdk": "2.6.0", - "@browserbasehq/stagehand": "^3.7.0", - "@inference/tracing": "^0.0.21", - "@maced/api-client": "^0.9.2", - "@mendable/firecrawl-js": "^4.9.3", - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.1.5", - "@nestjs/swagger": "^11.4.5", - "@nestjs/throttler": "^6.5.0", - "@prisma/adapter-pg": "7.6.0", - "@prisma/client": "7.6.0", - "@prisma/instrumentation": "7.6.0", - "@react-email/components": "^0.0.41", - "@react-email/render": "^2.0.4", - "@thallesp/nestjs-better-auth": "^2.4.0", - "@trigger.dev/build": "4.4.3", - "@trigger.dev/sdk": "4.4.3", - "@trycompai/auth": "workspace:*", - "@trycompai/billing": "workspace:*", - "@trycompai/company": "workspace:*", - "@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", - "adm-zip": "^0.6.0", - "ai": "^6.0.175", - "archiver": "^7.0.1", - "axios": "^1.16.0", - "better-auth": "^1.6.13", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.2", - "docx": "^9.7.1", - "dotenv": "^17.2.3", - "esbuild": "^0.27.1", - "exceljs": "^4.4.0", - "express": "^4.21.2", - "helmet": "^8.1.0", - "jose": "^6.0.12", - "jspdf": "^4.2.0", - "jspdf-autotable": "^5.0.8", - "mammoth": "^1.8.0", - "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", - "reflect-metadata": "^0.2.2", - "resend": "^6.4.2", - "rxjs": "^7.8.1", - "safe-stable-stringify": "^2.5.0", - "stripe": "^20.4.0", - "swagger-ui-express": "^5.0.1", - "zod": "^4.0.14" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.18.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", - "@types/adm-zip": "^0.5.7", - "@types/archiver": "^6.0.3", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/multer": "^1.4.12", - "@types/node": "^24.0.3", - "@types/supertest": "^6.0.2", - "@types/swagger-ui-express": "^4.1.8", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "globals": "^17.3.0", - "jest": "^30.0.0", - "prettier": "^3.5.3", - "source-map-support": "^0.5.21", - "supertest": "^7.0.0", - "trigger.dev": "4.4.3", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.8.3", - "typescript-eslint": "^8.20.0" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts", - "tsx" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)sx?$": "ts-jest" + "name": "@trycompai/api", + "description": "", + "version": "0.0.1", + "author": "", + "dependencies": { + "@1password/sdk": "0.4.0", + "@ai-sdk/anthropic": "^3.0.75", + "@ai-sdk/groq": "^3.0.38", + "@ai-sdk/openai": "^3.0.62", + "@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-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-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/lib-storage": "3.1013.0", + "@aws-sdk/s3-request-presigner": "3.1013.0", + "@browserbasehq/sdk": "2.6.0", + "@browserbasehq/stagehand": "^3.7.0", + "@inference/tracing": "^0.0.21", + "@maced/api-client": "^0.9.2", + "@mendable/firecrawl-js": "^4.9.3", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/platform-express": "^11.1.5", + "@nestjs/swagger": "^11.4.5", + "@nestjs/throttler": "^6.5.0", + "@prisma/adapter-pg": "7.6.0", + "@prisma/client": "7.6.0", + "@prisma/instrumentation": "7.6.0", + "@react-email/components": "^0.0.41", + "@react-email/render": "^2.0.4", + "@thallesp/nestjs-better-auth": "^2.4.0", + "@trigger.dev/build": "4.4.3", + "@trigger.dev/sdk": "4.4.3", + "@trycompai/auth": "workspace:*", + "@trycompai/billing": "workspace:*", + "@trycompai/company": "workspace:*", + "@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", + "adm-zip": "^0.6.0", + "ai": "^6.0.175", + "archiver": "^7.0.1", + "axios": "^1.16.0", + "better-auth": "^1.6.13", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "docx": "^9.7.1", + "dotenv": "^17.2.3", + "esbuild": "^0.27.1", + "exceljs": "^4.4.0", + "express": "^4.21.2", + "helmet": "^8.1.0", + "jose": "^6.0.12", + "jspdf": "^4.2.0", + "jspdf-autotable": "^5.0.8", + "mammoth": "^1.8.0", + "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", + "reflect-metadata": "^0.2.2", + "resend": "^6.4.2", + "nodemailer": "^6.10.1", + "@types/nodemailer": "^6.4.17", + "rxjs": "^7.8.1", + "safe-stable-stringify": "^2.5.0", + "stripe": "^20.4.0", + "swagger-ui-express": "^5.0.1", + "zod": "^4.0.14" }, - "transformIgnorePatterns": [ - "node_modules/(?!(@maced/api-client|better-auth)/)" - ], - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node", - "moduleNameMapper": { - "^@db$": "/../prisma/index", - "^@/(.*)$": "/$1", - "^\\./sku-definitions\\.js$": "/../../../packages/billing/src/sku-definitions.ts", - "^@trycompai/auth/participation$": "/../../../packages/auth/src/participation.ts", - "^@trycompai/auth$": "/../../../packages/auth/src/index.ts", - "^@trycompai/billing$": "/../../../packages/billing/src/index.ts", - "^@trycompai/company$": "/../../../packages/company/src/index.ts", - "^@trycompai/db$": "@prisma/client", - "^@trycompai/email$": "/../../../packages/email/index.ts", - "^@trycompai/integration-platform$": "/../../../packages/integration-platform/src/index.ts", - "^@trycompai/utils/(.*)$": "/../../../packages/utils/src/$1.ts" + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/adm-zip": "^0.5.7", + "@types/archiver": "^6.0.3", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/multer": "^1.4.12", + "@types/node": "^24.0.3", + "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.8", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.3.0", + "jest": "^30.0.0", + "prettier": "^3.5.3", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "trigger.dev": "4.4.3", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts", + "tsx" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)sx?$": "ts-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(@maced/api-client|better-auth)/)" + ], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node", + "moduleNameMapper": { + "^@db$": "/../prisma/index", + "^@/(.*)$": "/$1", + "^\\./sku-definitions\\.js$": "/../../../packages/billing/src/sku-definitions.ts", + "^@trycompai/auth/participation$": "/../../../packages/auth/src/participation.ts", + "^@trycompai/auth$": "/../../../packages/auth/src/index.ts", + "^@trycompai/billing$": "/../../../packages/billing/src/index.ts", + "^@trycompai/company$": "/../../../packages/company/src/index.ts", + "^@trycompai/db$": "@prisma/client", + "^@trycompai/email$": "/../../../packages/email/index.ts", + "^@trycompai/integration-platform$": "/../../../packages/integration-platform/src/index.ts", + "^@trycompai/utils/(.*)$": "/../../../packages/utils/src/$1.ts" + } + }, + "license": "UNLICENSED", + "private": true, + "scripts": { + "build": "nest build", + "build:docker": "bunx prisma generate --schema=prisma/schema && nest build", + "db:generate": "bun run db:getschema && bunx prisma generate --schema=prisma/schema", + "db:getschema": "find prisma/schema -name '*.prisma' ! -name 'schema.prisma' -delete && find ../../packages/db/prisma/schema -name '*.prisma' ! -name 'schema.prisma' -exec cp {} prisma/schema/ \\;", + "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api", + "deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy", + "dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"trigger dev\"", + "dev:nest": "nest start --watch", + "dev:no-trigger": "nest start --watch", + "dev:trigger": "trigger dev", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "prebuild": "bun run db:generate", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "test": "jest", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "test:watch": "jest --watch", + "typecheck": "tsc --noEmit" } - }, - "license": "UNLICENSED", - "private": true, - "scripts": { - "build": "nest build", - "build:docker": "bunx prisma generate --schema=prisma/schema && nest build", - "db:generate": "bun run db:getschema && bunx prisma generate --schema=prisma/schema", - "db:getschema": "find prisma/schema -name '*.prisma' ! -name 'schema.prisma' -delete && find ../../packages/db/prisma/schema -name '*.prisma' ! -name 'schema.prisma' -exec cp {} prisma/schema/ \\;", - "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api", - "deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy", - "dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"trigger dev\"", - "dev:nest": "nest start --watch", - "dev:no-trigger": "nest start --watch", - "dev:trigger": "trigger dev", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "prebuild": "bun run db:generate", - "start": "nest start", - "start:debug": "nest start --debug --watch", - "start:dev": "nest start --watch", - "start:prod": "node dist/main", - "test": "jest", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", - "test:watch": "jest --watch", - "typecheck": "tsc --noEmit" - } } diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts index 5f3c1738d2..c582179664 100644 --- a/apps/api/prisma/client.ts +++ b/apps/api/prisma/client.ts @@ -13,9 +13,11 @@ function stripSslMode(connectionString: string): string { function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - // Strip square brackets from IPv6 host form (e.g. [::1] → ::1) - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { // Malformed URL — be conservative and treat as remote so we don't @@ -61,7 +63,7 @@ function createPrismaClient(): PrismaClient { } // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; - const adapter = new PrismaPg({ connectionString: url, ssl }); + const adapter = new PrismaPg({ connectionString: url, ssl: ssl ?? false }); return new PrismaClient({ adapter, transactionOptions: { diff --git a/apps/api/src/app/s3.ts b/apps/api/src/app/s3.ts index cf0b43350f..9cdddd7dd9 100644 --- a/apps/api/src/app/s3.ts +++ b/apps/api/src/app/s3.ts @@ -15,18 +15,34 @@ import '../config/load-env'; * and @aws-sdk/s3-request-presigner even when pinned to the same version. * The runtime types are fully compatible — only the TypeScript class identity differs. */ -export const getSignedUrl = _getSignedUrl as unknown as ( +const _getSignedUrlTyped = _getSignedUrl as unknown as ( client: S3Client, command: GetObjectCommand | PutObjectCommand, options?: { expiresIn?: number }, ) => Promise; +/** Use public-endpoint client for presigned URLs only when explicitly configured. */ +export const getSignedUrl = ( + client: S3Client, + command: GetObjectCommand | PutObjectCommand, + options?: { expiresIn?: number }, +): Promise => + _getSignedUrlTyped( + APP_AWS_PUBLIC_ENDPOINT && s3SigningClientInstance + ? s3SigningClientInstance + : client, + command, + options, + ); + const logger = new Logger('S3'); const APP_AWS_REGION = process.env.APP_AWS_REGION; const APP_AWS_ACCESS_KEY_ID = process.env.APP_AWS_ACCESS_KEY_ID; const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY; const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT; +/** Optional browser-reachable URL used to sign presigned URLs; when unset, presigning uses the caller-provided S3 client. */ +const APP_AWS_PUBLIC_ENDPOINT = process.env.APP_AWS_PUBLIC_ENDPOINT?.trim() || undefined; export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME; export const APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET = @@ -36,6 +52,7 @@ export const APP_AWS_KNOWLEDGE_BASE_BUCKET = export const APP_AWS_ORG_ASSETS_BUCKET = process.env.APP_AWS_ORG_ASSETS_BUCKET; let s3ClientInstance: S3Client | null = null; +let s3SigningClientInstance: S3Client | null = null; try { if ( @@ -61,12 +78,25 @@ try { }, forcePathStyle: !!APP_AWS_ENDPOINT, }); + + if (APP_AWS_PUBLIC_ENDPOINT) { + s3SigningClientInstance = new S3Client({ + endpoint: APP_AWS_PUBLIC_ENDPOINT, + region: APP_AWS_REGION, + credentials: { + accessKeyId: APP_AWS_ACCESS_KEY_ID, + secretAccessKey: APP_AWS_SECRET_ACCESS_KEY, + }, + forcePathStyle: true, + }); + } } catch (error) { logger.error( 'FAILED TO INITIALIZE S3 CLIENT', error instanceof Error ? error.stack : error, ); s3ClientInstance = null; + s3SigningClientInstance = null; logger.error( '[S3] Creating dummy S3 client - file uploads will fail until credentials are fixed', ); diff --git a/apps/api/src/email/email-transport.ts b/apps/api/src/email/email-transport.ts new file mode 100644 index 0000000000..7c461b752c --- /dev/null +++ b/apps/api/src/email/email-transport.ts @@ -0,0 +1,304 @@ +import nodemailer from 'nodemailer'; +import type Mail from 'nodemailer/lib/mailer'; +import { resend } from './resend'; + +export interface EmailAttachment { + filename: string; + content: Buffer | string; + contentType?: string; +} + +export type EmailChannel = 'marketing' | 'system' | 'trustPortal' | 'default'; + +export function isBunMailConfigured(): boolean { + return Boolean(process.env.BUNMAIL_API_URL?.trim()); +} + +export function isSmtpConfigured(): boolean { + return Boolean(process.env.SMTP_HOST?.trim()); +} + +export function resolveFromAddressForChannel( + channel: EmailChannel | undefined, +): string | undefined { + const bunMailFrom = process.env.BUNMAIL_FROM?.trim(); + if (bunMailFrom) return bunMailFrom; + + const smtpFrom = process.env.SMTP_FROM?.trim(); + if (smtpFrom) return smtpFrom; + + const fromMarketing = process.env.RESEND_FROM_MARKETING; + const fromSystem = process.env.RESEND_FROM_SYSTEM; + const fromDefault = process.env.RESEND_FROM_DEFAULT; + const fromTrustPortal = process.env.RESEND_FROM_TRUST_PORTAL; + + switch (channel) { + case 'trustPortal': + return fromTrustPortal ?? fromSystem; + case 'marketing': + return fromMarketing; + case 'system': + return fromSystem; + case 'default': + return fromDefault; + default: + return undefined; + } +} + +function resolveSmtpTransport() { + const host = process.env.SMTP_HOST!.trim(); + const port = Number(process.env.SMTP_PORT || 587); + const secure = + process.env.SMTP_SECURE === 'true' || + process.env.SMTP_SECURE === '1' || + port === 465; + const user = process.env.SMTP_USER?.trim(); + const pass = process.env.SMTP_PASS; + + return nodemailer.createTransport({ + host, + port, + secure, + auth: user ? { user, pass: pass ?? '' } : undefined, + }); +} + +/** Trigger.dev serializes attachment bytes as base64 strings in task payloads. */ +function attachmentContent(content: Buffer | string): Buffer { + if (Buffer.isBuffer(content)) { + return content; + } + return Buffer.from(content, 'base64'); +} + +function normalizeAttachments( + attachments?: EmailAttachment[], +): Mail.Attachment[] | undefined { + return attachments?.map((att) => ({ + filename: att.filename, + content: attachmentContent(att.content), + contentType: att.contentType, + })); +} + +async function sendViaBunMail(params: { + from: string; + to: string; + subject: string; + html: string; + cc?: string | string[]; +}): Promise<{ id: string }> { + const apiUrl = process.env.BUNMAIL_API_URL!.trim().replace(/\/$/, ''); + const apiKey = process.env.BUNMAIL_API_KEY?.trim(); + if (!apiKey) { + throw new Error('BUNMAIL_API_KEY is required when BUNMAIL_API_URL is set'); + } + + const cc = Array.isArray(params.cc) ? params.cc.join(',') : params.cc; + + const response = await fetch(`${apiUrl}/api/v1/emails/send`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: params.from, + to: params.to, + cc, + subject: params.subject, + html: params.html, + }), + }); + + const body = (await response.json().catch(() => null)) as + | { + success?: boolean; + data?: { id?: string }; + error?: string; + message?: string; + } + | null; + + if (!response.ok || !body?.success) { + const message = + body?.error || body?.message || `BunMail API error (${response.status})`; + throw new Error(message); + } + + return { id: body.data?.id ?? 'bunmail' }; +} + +async function sendViaSmtp(params: { + from: string; + to: string; + subject: string; + html: string; + cc?: string | string[]; + headers?: Record; + attachments?: EmailAttachment[]; +}): Promise<{ id: string }> { + const transport = resolveSmtpTransport(); + const info = await transport.sendMail({ + from: params.from, + to: params.to, + cc: params.cc, + subject: params.subject, + html: params.html, + headers: params.headers, + attachments: normalizeAttachments(params.attachments), + }); + + return { id: info.messageId || 'smtp' }; +} + +async function sendViaResend(params: { + from: string; + to: string; + subject: string; + html: string; + cc?: string | string[]; + headers?: Record; + scheduledAt?: string; + attachments?: EmailAttachment[]; +}): Promise<{ id: string }> { + if (!resend) { + throw new Error( + 'Email not configured: set BUNMAIL_API_URL, SMTP_HOST, or RESEND_API_KEY in environment variables', + ); + } + + const { data, error } = await resend.emails.send({ + from: params.from, + to: params.to, + cc: params.cc, + subject: params.subject, + html: params.html, + headers: params.headers, + scheduledAt: params.scheduledAt, + attachments: params.attachments?.map((att) => ({ + filename: att.filename, + content: att.content, + contentType: att.contentType, + })), + }); + + if (error) { + console.error('Resend API error:', error); + throw new Error(`Failed to send email: ${error.message}`); + } + + return { id: data?.id ?? 'resend' }; +} + +export async function sendHtmlEmail(params: { + to: string; + subject: string; + html: string; + channel?: EmailChannel; + from?: string; + cc?: string | string[]; + headers?: Record; + scheduledAt?: string; + attachments?: EmailAttachment[]; +}): Promise<{ id: string }> { + const fromAddress = + params.from ?? + resolveFromAddressForChannel(params.channel) ?? + process.env.BUNMAIL_FROM ?? + process.env.SMTP_FROM ?? + process.env.RESEND_FROM_SYSTEM ?? + process.env.RESEND_FROM_DEFAULT; + const toAddress = params.to; + + if (!fromAddress) { + throw new Error( + 'Missing FROM address: set BUNMAIL_FROM, SMTP_FROM, or RESEND_FROM_DEFAULT in environment variables', + ); + } + if (!toAddress) { + throw new Error('Missing TO address in environment variables'); + } + + if (params.scheduledAt) { + if (resend) { + return sendViaResend({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + scheduledAt: params.scheduledAt, + attachments: params.attachments, + }); + } + throw new Error( + 'Scheduled email delivery requires RESEND_API_KEY or Trigger.dev', + ); + } + + const hasAttachments = Boolean(params.attachments?.length); + + if (isBunMailConfigured() && !hasAttachments) { + return sendViaBunMail({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + }); + } + + if (isBunMailConfigured() && hasAttachments) { + if (isSmtpConfigured()) { + return sendViaSmtp({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + attachments: params.attachments, + }); + } + if (resend) { + return sendViaResend({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + attachments: params.attachments, + }); + } + throw new Error( + 'BunMail cannot send attachments; configure SMTP_HOST or RESEND_API_KEY', + ); + } + + if (isSmtpConfigured()) { + return sendViaSmtp({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + attachments: params.attachments, + }); + } + + return sendViaResend({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + scheduledAt: params.scheduledAt, + attachments: params.attachments, + }); +} diff --git a/apps/api/src/email/trigger-email.ts b/apps/api/src/email/trigger-email.ts index 79866968e2..83ad70b3b7 100644 --- a/apps/api/src/email/trigger-email.ts +++ b/apps/api/src/email/trigger-email.ts @@ -1,8 +1,10 @@ import { render } from '@react-email/render'; import { tasks } from '@trigger.dev/sdk'; import type { ReactElement } from 'react'; +import { buildUnsubscribeHeaders } from './unsubscribe-headers'; import type { EmailChannel, sendEmailTask } from '../trigger/email/send-email'; import type { EmailAttachment } from './resend'; +import { sendHtmlEmail } from './email-transport'; type TriggerEmailFlags = { marketing?: boolean; @@ -17,6 +19,32 @@ function resolveChannel(flags: TriggerEmailFlags): EmailChannel { return 'default'; } +async function sendEmailDirect(params: { + to: string; + subject: string; + html: string; + channel: EmailChannel; + cc?: string | string[]; + scheduledAt?: string; + attachments?: EmailAttachment[]; +}): Promise<{ id: string }> { + if (params.scheduledAt) { + throw new Error( + 'Scheduled email delivery requires Trigger.dev (TRIGGER_SECRET_KEY)', + ); + } + + return sendHtmlEmail({ + to: params.to, + subject: params.subject, + html: params.html, + channel: params.channel, + cc: params.cc, + attachments: params.attachments, + headers: buildUnsubscribeHeaders(params.to), + }); +} + export async function triggerEmail(params: { to: string; subject: string; @@ -30,16 +58,26 @@ export async function triggerEmail(params: { }): Promise<{ id: string }> { try { const html = await render(params.react); - const channel = resolveChannel(params); - - const handle = await tasks.trigger('send-email', { + const payload = { to: params.to, subject: params.subject, html, channel, cc: params.cc, scheduledAt: params.scheduledAt, + attachments: params.attachments, + }; + + if (!process.env.TRIGGER_SECRET_KEY) { + console.log( + 'TRIGGER_SECRET_KEY not set; sending email directly via configured transport', + ); + return sendEmailDirect(payload); + } + + const handle = await tasks.trigger('send-email', { + ...payload, attachments: params.attachments?.map((att) => ({ filename: att.filename, content: @@ -52,7 +90,7 @@ export async function triggerEmail(params: { return { id: handle.id }; } catch (error) { - console.error('[triggerEmail] Failed to trigger email task', { + console.error('Failed to send/trigger email', { to: params.to, subject: params.subject, error: error instanceof Error ? error.message : String(error), diff --git a/apps/api/src/email/unsubscribe-headers.ts b/apps/api/src/email/unsubscribe-headers.ts new file mode 100644 index 0000000000..1116af8590 --- /dev/null +++ b/apps/api/src/email/unsubscribe-headers.ts @@ -0,0 +1,13 @@ +import { generateUnsubscribeToken } from '@trycompai/email'; + +export function buildUnsubscribeHeaders(to: string): Record { + const apiBaseUrl = ( + process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai' + ).replace(/\/+$/, ''); + const token = generateUnsubscribeToken(to); + const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(to)}&token=${encodeURIComponent(token)}`; + return { + 'List-Unsubscribe': `<${oneClickUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }; +} diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 37ce027843..945ec2789a 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -677,9 +677,7 @@ export class PeopleInviteService { } private buildPortalUrl(organizationId: string): string { - const portalUrl = - process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'; - return `${portalUrl}/${organizationId}`; + return `${getPortalBaseUrl()}/${organizationId}`; } private buildInviteLink(invitationId: string): string { diff --git a/apps/api/src/trigger/email/send-batch-email.ts b/apps/api/src/trigger/email/send-batch-email.ts index e095e5a2ca..266ed20c67 100644 --- a/apps/api/src/trigger/email/send-batch-email.ts +++ b/apps/api/src/trigger/email/send-batch-email.ts @@ -1,7 +1,7 @@ import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; import { resend } from '../../email/resend'; -import { generateUnsubscribeToken } from '@trycompai/email'; +import { buildUnsubscribeHeaders } from '../../email/unsubscribe-headers'; const RESEND_BATCH_LIMIT = 100; @@ -41,8 +41,6 @@ export const sendBatchEmailTask = schemaTask({ } const toTest = process.env.RESEND_TO_TEST; - const apiBaseUrl = - process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; let totalSent = 0; let totalFailed = 0; @@ -50,22 +48,14 @@ export const sendBatchEmailTask = schemaTask({ for (let i = 0; i < params.emails.length; i += RESEND_BATCH_LIMIT) { const chunk = params.emails.slice(i, i + RESEND_BATCH_LIMIT); - const payload = chunk.map((email) => { - const token = generateUnsubscribeToken(email.to); - const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(email.to)}&token=${encodeURIComponent(token)}`; - - return { - from: email.from ?? fromDefault, - to: toTest ?? email.to, - cc: email.cc, - subject: email.subject, - html: email.html, - headers: { - 'List-Unsubscribe': `<${oneClickUrl}>`, - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', - }, - }; - }); + const payload = chunk.map((email) => ({ + from: email.from ?? fromDefault, + to: toTest ?? email.to, + cc: email.cc, + subject: email.subject, + html: email.html, + headers: buildUnsubscribeHeaders(email.to), + })); const { data, error } = await resend.batch.send(payload, { batchValidation: 'permissive', diff --git a/apps/api/src/trigger/email/send-email.ts b/apps/api/src/trigger/email/send-email.ts index d01181cee2..830d6ad8a0 100644 --- a/apps/api/src/trigger/email/send-email.ts +++ b/apps/api/src/trigger/email/send-email.ts @@ -1,7 +1,7 @@ import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; -import { resend } from '../../email/resend'; -import { generateUnsubscribeToken } from '@trycompai/email'; +import { sendHtmlEmail } from '../../email/email-transport'; +import { buildUnsubscribeHeaders } from '../../email/unsubscribe-headers'; const emailQueue = queue({ name: 'send-email', @@ -16,28 +16,6 @@ export const emailChannelSchema = z.enum([ ]); export type EmailChannel = z.infer; -function resolveFromAddressForChannel( - channel: EmailChannel | undefined, -): string | undefined { - const fromMarketing = process.env.RESEND_FROM_MARKETING; - const fromSystem = process.env.RESEND_FROM_SYSTEM; - const fromDefault = process.env.RESEND_FROM_DEFAULT; - const fromTrustPortal = process.env.RESEND_FROM_TRUST_PORTAL; - - switch (channel) { - case 'trustPortal': - return fromTrustPortal ?? fromSystem; - case 'marketing': - return fromMarketing; - case 'system': - return fromSystem; - case 'default': - return fromDefault; - default: - return undefined; - } -} - export const sendEmailTask = schemaTask({ id: 'send-email', queue: emailQueue, @@ -63,48 +41,18 @@ export const sendEmailTask = schemaTask({ .optional(), }), run: async (params) => { - if (!resend) { - logger.error('Resend not initialized - missing RESEND_API_KEY', { - to: params.to, - subject: params.subject, - }); - throw new Error('Resend not initialized - missing API key'); - } - - const toTest = process.env.RESEND_TO_TEST; - const fromSystem = process.env.RESEND_FROM_SYSTEM; - const fromDefault = process.env.RESEND_FROM_DEFAULT; - - const fromAddress = - params.from ?? - resolveFromAddressForChannel(params.channel) ?? - fromSystem ?? - fromDefault; - const toAddress = toTest ?? params.to; - - if (!fromAddress) { - throw new Error('Missing FROM address in environment variables'); - } - try { - // Build List-Unsubscribe headers for Gmail/RFC 8058 one-click compliance - const apiBaseUrl = - process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; - const token = generateUnsubscribeToken(params.to); - const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`; - const headers: Record = { - 'List-Unsubscribe': `<${oneClickUrl}>`, - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', - }; + const headers = buildUnsubscribeHeaders(params.to); - const { data, error } = await resend.emails.send({ - from: fromAddress, - to: toAddress, - cc: params.cc, + const result = await sendHtmlEmail({ + to: params.to, subject: params.subject, html: params.html, - headers, + channel: params.channel, + from: params.from, + cc: params.cc, scheduledAt: params.scheduledAt, + headers, attachments: params.attachments?.map((att) => ({ filename: att.filename, content: att.content, @@ -112,21 +60,11 @@ export const sendEmailTask = schemaTask({ })), }); - if (error) { - logger.error('Resend API error', { - error, - to: params.to, - subject: params.subject, - }); - throw new Error(`Failed to send email: ${error.message}`); - } - - logger.info('Email sent', { to: params.to, id: data?.id }); + logger.info('Email sent', { to: params.to, id: result.id }); - // Throttle: hold the concurrency slot for 1s to space out sends await new Promise((r) => setTimeout(r, 1000)); - return { id: data?.id }; + return { id: result.id }; } catch (error) { logger.error('Email sending failed', { to: params.to, diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index 759a0d655f..df82930d08 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -13,8 +13,11 @@ function stripSslMode(connectionString: string): string { function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { return false; @@ -46,7 +49,7 @@ function createPrismaClient(): PrismaClient { : { checkServerIdentity: () => undefined }; const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; - const adapter = new PrismaPg({ connectionString: url, ssl }); + const adapter = new PrismaPg({ connectionString: url, ssl: ssl ?? false }); return new PrismaClient({ adapter, transactionOptions: { diff --git a/apps/app/src/app/(app)/no-access/page.tsx b/apps/app/src/app/(app)/no-access/page.tsx index ceecef54da..7a79c11513 100644 --- a/apps/app/src/app/(app)/no-access/page.tsx +++ b/apps/app/src/app/(app)/no-access/page.tsx @@ -4,6 +4,7 @@ import { serverApi } from '@/lib/api-server'; import type { OrganizationFromMe } from '@/types'; import { auth } from '@/utils/auth'; import { headers } from 'next/headers'; +import { getPortalBaseUrl } from '@trycompai/email/lib/get-portal-base-url'; import Link from 'next/link'; import { redirect } from 'next/navigation'; @@ -26,6 +27,8 @@ export default async function NoAccess() { ]); const organizations = meRes.data?.organizations ?? []; + const portalBase = getPortalBaseUrl(); + const portalLabel = portalBase.replace(/^https?:\/\//, ''); const currentOrg = orgRes.data ?? null; return ( @@ -36,8 +39,8 @@ export default async function NoAccess() {

Your current role doesn't have access to the app. If you're looking for the employee portal, go to{' '} - - portal.trycomp.ai + + {portalLabel} .

diff --git a/apps/framework-editor/prisma/client.ts b/apps/framework-editor/prisma/client.ts index fa5986368b..9395fb2fce 100644 --- a/apps/framework-editor/prisma/client.ts +++ b/apps/framework-editor/prisma/client.ts @@ -13,8 +13,11 @@ function stripSslMode(connectionString: string): string { function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { return false; diff --git a/apps/portal/.env.example b/apps/portal/.env.example index b69f2b8c3f..fdbed9a4c8 100644 --- a/apps/portal/.env.example +++ b/apps/portal/.env.example @@ -16,6 +16,7 @@ APP_AWS_SECRET_ACCESS_KEY="" # AWS Secret Access Key APP_AWS_REGION="" # AWS Region APP_AWS_BUCKET_NAME="" # AWS Bucket Name APP_AWS_ENDPOINT="" # optional for using services like MinIO +APP_AWS_PUBLIC_ENDPOINT="" # Browser-reachable S3/MinIO endpoint for presigned URLs # Microsoft sign-in AUTH_MICROSOFT_CLIENT_ID= diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index e00b91fae3..ef589f1711 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -13,8 +13,11 @@ function stripSslMode(connectionString: string): string { function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { return false; @@ -37,7 +40,7 @@ function createPrismaClient(): PrismaClient { : { checkServerIdentity: () => undefined }; const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; - const adapter = new PrismaPg({ connectionString: url, ssl }); + const adapter = new PrismaPg({ connectionString: url, ssl: ssl ?? false }); return new PrismaClient({ adapter, transactionOptions: { diff --git a/apps/portal/src/app/api/download-agent/route.ts b/apps/portal/src/app/api/download-agent/route.ts index a300982dc7..cccd63076b 100644 --- a/apps/portal/src/app/api/download-agent/route.ts +++ b/apps/portal/src/app/api/download-agent/route.ts @@ -3,8 +3,6 @@ import { s3Client } from '@/utils/s3'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { client as kv } from '@trycompai/kv'; import { type NextRequest, NextResponse } from 'next/server'; -import { Readable } from 'stream'; - import { DOWNLOAD_TARGETS } from './constants'; import type { SupportedOS } from './types'; @@ -109,8 +107,7 @@ const handleDownload = async (req: NextRequest, isHead: boolean) => { await kv.del(`download:${token}`); - const s3Stream = s3Response.Body as Readable; - const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream; + const webStream = s3Response.Body.transformToWebStream(); return new NextResponse(webStream, { headers: buildResponseHeaders(target, s3Response.ContentLength ?? null), diff --git a/apps/portal/src/utils/s3.ts b/apps/portal/src/utils/s3.ts index dd2bc18e26..5eedd4274b 100644 --- a/apps/portal/src/utils/s3.ts +++ b/apps/portal/src/utils/s3.ts @@ -8,7 +8,7 @@ import { getSignedUrl as _getSignedUrl } from '@aws-sdk/s3-request-presigner'; * and @aws-sdk/s3-request-presigner even when pinned to the same version. * The runtime types are fully compatible — only the TypeScript class identity differs. */ -export const getSignedUrl = _getSignedUrl as unknown as ( +const _getSignedUrlTyped = _getSignedUrl as unknown as ( client: S3Client, command: GetObjectCommand | PutObjectCommand, options?: { expiresIn?: number }, @@ -18,6 +18,7 @@ const APP_AWS_REGION = process.env.APP_AWS_REGION; const APP_AWS_ACCESS_KEY_ID = process.env.APP_AWS_ACCESS_KEY_ID; const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY; const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT; +const APP_AWS_PUBLIC_ENDPOINT = process.env.APP_AWS_PUBLIC_ENDPOINT?.trim() || undefined; export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME; export const APP_AWS_ORG_ASSETS_BUCKET = process.env.APP_AWS_ORG_ASSETS_BUCKET; @@ -40,6 +41,25 @@ export const s3Client = new S3Client({ forcePathStyle: !!APP_AWS_ENDPOINT, }); +const s3SigningClient = APP_AWS_PUBLIC_ENDPOINT + ? new S3Client({ + endpoint: APP_AWS_PUBLIC_ENDPOINT, + region: APP_AWS_REGION!, + credentials: { + accessKeyId: APP_AWS_ACCESS_KEY_ID!, + secretAccessKey: APP_AWS_SECRET_ACCESS_KEY!, + }, + forcePathStyle: true, + }) + : null; + +export const getSignedUrl = ( + client: S3Client, + command: GetObjectCommand | PutObjectCommand, + options?: { expiresIn?: number }, +): Promise => + _getSignedUrlTyped(s3SigningClient ?? client, command, options); + // Ensure BUCKET_NAME is exported and non-null checked if needed elsewhere explicitly if (!BUCKET_NAME && process.env.NODE_ENV === 'production') { console.error('AWS_BUCKET_NAME is not defined.'); diff --git a/docker-compose.yml b/docker-compose.yml index 399879bafc..a6aa45c69d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: target: migrator env_file: - packages/db/.env - command: sh -lc "bunx prisma generate --schema=node_modules/@trycompai/db/dist/schema.prisma && bun packages/db/prisma/seed/seed.js" + command: sh -lc "tsx packages/db/prisma/seed/seed.ts" logging: *default-logging app: build: @@ -30,17 +30,37 @@ services: target: app args: NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_PORTAL_URL: ${BETTER_AUTH_URL_PORTAL} ports: - '3000:3000' env_file: - apps/app/.env restart: unless-stopped healthcheck: - test: ['CMD-SHELL', 'curl -f http://localhost:3000/api/health || exit 1'] + test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/api/health || exit 1'] + interval: 30s + timeout: 10s + retries: 3 + command: sh -lc "node --max-old-space-size=8192 apps/app/server.js" + logging: *default-logging + api: + build: + context: . + dockerfile: apps/api/Dockerfile.multistage + target: production + ports: + - '3333:3333' + env_file: + - apps/api/.env + volumes: + - ./apps/api/.env:/app/.env:ro + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'wget -qO- http://localhost:3333/v1/health || exit 1'] interval: 30s timeout: 10s retries: 3 - command: sh -lc "node apps/app/server.js" logging: *default-logging portal: build: @@ -49,14 +69,67 @@ services: target: portal args: NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL_PORTAL} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} ports: - '3002:3000' env_file: - apps/portal/.env restart: unless-stopped healthcheck: - test: ['CMD-SHELL', 'curl -f http://localhost:3000/ || exit 1'] + test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/ || exit 1'] interval: 30s timeout: 10s retries: 3 logging: *default-logging + minio: + image: minio/minio:RELEASE.2025-01-20T14-49-07Z + profiles: + - minio + command: > + /bin/sh -c " + if [ -z "$$MINIO_ROOT_USER" ] || [ -z "$$MINIO_ROOT_PASSWORD" ]; then + echo 'MINIO_ROOT_USER and MINIO_ROOT_PASSWORD are required for the minio profile' >&2; + exit 1; + fi; + exec minio server /data --console-address ':9001' + " + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + ports: + - '9000:9000' + - '9001:9001' + volumes: + - minio_data:/data + restart: unless-stopped + logging: *default-logging + minio-init: + image: minio/mc:RELEASE.2025-01-17T23-25-50Z + profiles: + - minio + depends_on: + - minio + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + APP_AWS_BUCKET_NAME: ${APP_AWS_BUCKET_NAME} + entrypoint: > + /bin/sh -c " + set -e; + if [ -z "$$MINIO_ROOT_USER" ] || [ -z "$$MINIO_ROOT_PASSWORD" ] || [ -z "$$APP_AWS_BUCKET_NAME" ]; then + echo 'MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, and APP_AWS_BUCKET_NAME are required for the minio profile' >&2; + exit 1; + fi; + for i in 1 2 3 4 5 6 7 8 9 10; do + mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && break; + sleep 2; + done; + mc mb -p local/$$APP_AWS_BUCKET_NAME 2>/dev/null || true; + mc stat local/$$APP_AWS_BUCKET_NAME >/dev/null; + echo bucket $$APP_AWS_BUCKET_NAME ready + " + restart: 'no' + logging: *default-logging + +volumes: + minio_data: diff --git a/packages/db/scripts/combine-schemas.js b/packages/db/scripts/combine-schemas.js index 1b7212a41c..ad09a12551 100755 --- a/packages/db/scripts/combine-schemas.js +++ b/packages/db/scripts/combine-schemas.js @@ -62,7 +62,17 @@ function stripSslMode(connectionString: string): string { function createPrismaClient(): PrismaClient { const rawUrl = process.env.DATABASE_URL!; - const isLocalhost = /localhost|127\\.0\\.0\\.1|::1/.test(rawUrl); + let isLocalhost = false; + try { + const dbUrl = new URL(rawUrl); + isLocalhost = + dbUrl.searchParams.get('sslmode') === 'disable' || + /^(localhost|127\\.0\\.0\\.1|::1)$/.test( + dbUrl.hostname.replace(/^\\[/, '').replace(/\\]$/, ''), + ); + } catch { + // Malformed URL — treat as remote so TLS is not accidentally disabled. + } const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS; const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false }; const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 43a9d130e2..146baeb0bd 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -17,7 +17,7 @@ function createPrismaClient(): PrismaClient { const rawUrl = process.env.DATABASE_URL!; const ssl = resolveSslConfig(rawUrl); const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; - const adapter = new PrismaPg({ connectionString: url, ssl }); + const adapter = new PrismaPg({ connectionString: url, ssl: ssl ?? false }); return new PrismaClient({ adapter, transactionOptions: { timeout: 60000 }, diff --git a/packages/db/src/ssl-config.ts b/packages/db/src/ssl-config.ts index 6c23d160d9..90768a2cb8 100644 --- a/packages/db/src/ssl-config.ts +++ b/packages/db/src/ssl-config.ts @@ -7,8 +7,11 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { // Malformed URL — be conservative and treat as remote so we don't @@ -23,15 +26,5 @@ export function resolveSslConfig( ): SslConfig { if (isLocalhostUrl(databaseUrl)) return undefined; if (env.PRISMA_ALLOW_INSECURE_TLS === '1') return { rejectUnauthorized: false }; - // Verified TLS via Node's default trust store, which includes Amazon Root - // CA 1 — where AWS RDS Proxy chains terminate. Hostname check is skipped - // because connections traverse an AWS NLB whose hostname isn't in the RDS - // Proxy cert's SAN list; the chain check still rejects forged or wrong-CA - // certs. - // - // Previously this returned `{ ca: RDS_CA_BUNDLE, ... }` — but `ssl.ca` - // *replaces* Node's trust store rather than augmenting it, and the bundle - // only contains regional RDS CAs (not Amazon Root CA 1), so RDS Proxy - // chain validation failed at runtime (P1011 / TlsConnectionError). return { checkServerIdentity: () => undefined }; } diff --git a/packages/email/emails/all-policy-notification.tsx b/packages/email/emails/all-policy-notification.tsx index cc2852ddf6..415cebbfe6 100644 --- a/packages/email/emails/all-policy-notification.tsx +++ b/packages/email/emails/all-policy-notification.tsx @@ -1,3 +1,4 @@ +import { getPortalBaseUrl } from '../lib/get-portal-base-url'; import { Body, Button, @@ -28,7 +29,7 @@ export const AllPolicyNotificationEmail = ({ organizationName, organizationId, }: Props) => { - const link = `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${organizationId}`; + const link = `${getPortalBaseUrl()}/${organizationId}`; const subjectText = 'Please review and accept the policies'; return ( diff --git a/packages/email/emails/policy-acknowledgment-digest.tsx b/packages/email/emails/policy-acknowledgment-digest.tsx index c7b6b5aa82..c8c3da0d8c 100644 --- a/packages/email/emails/policy-acknowledgment-digest.tsx +++ b/packages/email/emails/policy-acknowledgment-digest.tsx @@ -1,3 +1,4 @@ +import { getPortalBaseUrl } from '../lib/get-portal-base-url'; import { Body, Button, @@ -60,9 +61,7 @@ export const PolicyAcknowledgmentDigestEmail = ({ const [firstOrg] = orgsWithPolicies; if (!firstOrg) return null; - const portalBase = ( - process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai' - ).replace(/\/+$/, ''); + const portalBase = getPortalBaseUrl(); const subjectText = computePolicyAcknowledgmentDigestSubject(orgsWithPolicies); const isMultiOrg = orgsWithPolicies.length > 1; diff --git a/packages/email/emails/policy-notification.tsx b/packages/email/emails/policy-notification.tsx index 2c5315e5fe..a5a00c7f80 100644 --- a/packages/email/emails/policy-notification.tsx +++ b/packages/email/emails/policy-notification.tsx @@ -1,3 +1,4 @@ +import { getPortalBaseUrl } from '../lib/get-portal-base-url'; import { Body, Button, @@ -32,7 +33,7 @@ export const PolicyNotificationEmail = ({ organizationId, notificationType, }: Props) => { - const link = `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${organizationId}`; + const link = `${getPortalBaseUrl()}/${organizationId}`; const subjectText = 'Please review and accept this policy'; const getBodyText = () => { diff --git a/packages/email/lib/get-portal-base-url.ts b/packages/email/lib/get-portal-base-url.ts new file mode 100644 index 0000000000..8c82f1d602 --- /dev/null +++ b/packages/email/lib/get-portal-base-url.ts @@ -0,0 +1,9 @@ +/** Self-hosted installs set PORTAL_URL; cloud uses NEXT_PUBLIC_PORTAL_URL. */ +export function getPortalBaseUrl(): string { + return ( + process.env.PORTAL_URL ?? + process.env.NEXT_PUBLIC_PORTAL_URL ?? + process.env.TRUST_APP_URL ?? + 'https://portal.trycomp.ai' + ).replace(/\/+$/, ''); +}