diff --git a/.env.example b/.env.example
index 97d912c95..2feb47d6c 100644
--- a/.env.example
+++ b/.env.example
@@ -1,14 +1,16 @@
-# Database Configuration
+# Database Configuration - Required
DATABASE_URL="postgresql://postgres:pass@127.0.0.1:5432/comp"
-# Authentication
+# Authentication - Required
AUTH_SECRET=your-secret-auth-key-here-min-32-chars
+
+# Optional
AUTH_GOOGLE_ID=your-google-oauth-client-id
AUTH_GOOGLE_SECRET=your-google-oauth-client-secret
AUTH_GITHUB_ID=your-github-oauth-app-id
AUTH_GITHUB_SECRET=your-github-oauth-app-secret
-# Email Service
+# Email Service - Required for OTP and Magic Link
RESEND_API_KEY=re_your_resend_api_key_here
# Application URLs
@@ -18,7 +20,7 @@ NEXT_PUBLIC_VERCEL_URL=http://localhost:3000
# Security
REVALIDATION_SECRET=your-revalidation-secret-here
-# Optional - Redis/Upstash (for caching)
+# Required - Redis/Upstash (for caching)
UPSTASH_REDIS_REST_URL=your-upstash-redis-url
UPSTASH_REDIS_REST_TOKEN=your-upstash-redis-token
@@ -28,16 +30,18 @@ APP_AWS_SECRET_ACCESS_KEY=your-aws-secret-key
APP_AWS_REGION=us-east-1
APP_AWS_BUCKET_NAME=your-s3-bucket-name
-# Optional - OpenAI
+# Optional - for AI features
OPENAI_API_KEY=sk-your-openai-api-key
# Optional - Analytics
NEXT_PUBLIC_POSTHOG_KEY=your-posthog-key
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
-# Optional - External Services
+# Required - External Services
TRIGGER_SECRET_KEY=your-trigger-secret
TRIGGER_API_KEY=your-trigger-api-key
+
+# Required - Chat and research with AI
GROQ_API_KEY=your-groq-api-key
FIRECRAWL_API_KEY=your-firecrawl-key
diff --git a/Dockerfile b/Dockerfile
index 19027bc71..0d513484d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,8 +8,7 @@ WORKDIR /app
# Copy workspace configuration
COPY package.json bun.lock ./
-# Copy package.json files for all packages
-COPY packages/db/package.json ./packages/db/
+# Copy package.json files for all packages (exclude local db; use published @trycompai/db)
COPY packages/kv/package.json ./packages/kv/
COPY packages/ui/package.json ./packages/ui/
COPY packages/email/package.json ./packages/email/
@@ -23,7 +22,7 @@ COPY apps/app/package.json ./apps/app/
COPY apps/portal/package.json ./apps/portal/
# Install all dependencies
-RUN PRISMA_SKIP_POSTINSTALL_GENERATE=true bun install --frozen-lockfile
+RUN PRISMA_SKIP_POSTINSTALL_GENERATE=true bun install
# =============================================================================
# STAGE 2: Ultra-Minimal Migrator - Only Prisma
@@ -32,20 +31,18 @@ FROM oven/bun:1.2.8 AS migrator
WORKDIR /app
-# Copy Prisma schema and migration files
+# Copy local Prisma schema and migrations from workspace
COPY packages/db/prisma ./packages/db/prisma
-# Create minimal package.json for Prisma
-RUN echo '{"name":"migrator","type":"module","dependencies":{"prisma":"^6.13.0","@prisma/client":"^6.13.0"}}' > package.json
+# 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
# Install ONLY Prisma dependencies
RUN bun install
-# Generate Prisma client
-RUN cd packages/db && bunx prisma generate
-
-# Default command for migrations
-CMD ["bunx", "prisma", "migrate", "deploy", "--schema=packages/db/prisma/schema.prisma"]
+# 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"]
# =============================================================================
# STAGE 3: App Builder
@@ -58,8 +55,33 @@ WORKDIR /app
COPY packages ./packages
COPY apps/app ./apps/app
-# Generate Prisma client in the full workspace context
-RUN cd packages/db && bunx prisma generate
+# Bring in node_modules for build and prisma prebuild
+COPY --from=deps /app/node_modules ./node_modules
+
+# Ensure Next build has required public env at build-time
+ARG NEXT_PUBLIC_BETTER_AUTH_URL
+ARG NEXT_PUBLIC_PORTAL_URL
+ARG NEXT_PUBLIC_POSTHOG_KEY
+ARG NEXT_PUBLIC_POSTHOG_HOST
+ARG NEXT_PUBLIC_IS_DUB_ENABLED
+ARG NEXT_PUBLIC_GTM_ID
+ARG NEXT_PUBLIC_LINKEDIN_PARTNER_ID
+ARG NEXT_PUBLIC_LINKEDIN_CONVERSION_ID
+ARG NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL
+ARG NEXT_PUBLIC_API_URL
+ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL \
+ NEXT_PUBLIC_PORTAL_URL=$NEXT_PUBLIC_PORTAL_URL \
+ NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \
+ NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \
+ NEXT_PUBLIC_IS_DUB_ENABLED=$NEXT_PUBLIC_IS_DUB_ENABLED \
+ NEXT_PUBLIC_GTM_ID=$NEXT_PUBLIC_GTM_ID \
+ NEXT_PUBLIC_LINKEDIN_PARTNER_ID=$NEXT_PUBLIC_LINKEDIN_PARTNER_ID \
+ NEXT_PUBLIC_LINKEDIN_CONVERSION_ID=$NEXT_PUBLIC_LINKEDIN_CONVERSION_ID \
+ NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL=$NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL \
+ 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
# Build the app
RUN cd apps/app && SKIP_ENV_VALIDATION=true bun run build
@@ -67,19 +89,18 @@ RUN cd apps/app && SKIP_ENV_VALIDATION=true bun run build
# =============================================================================
# STAGE 4: App Production
# =============================================================================
-FROM oven/bun:1.2.8 AS app
+FROM node:22-alpine AS app
WORKDIR /app
-# Copy the built app and all necessary dependencies from builder
-COPY --from=app-builder /app/apps/app/.next ./apps/app/.next
-COPY --from=app-builder /app/apps/app/package.json ./apps/app/
-COPY --from=app-builder /app/package.json ./
-COPY --from=app-builder /app/node_modules ./node_modules
-COPY --from=app-builder /app/packages ./packages
+# Copy Next standalone output
+COPY --from=app-builder /app/apps/app/.next/standalone ./
+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 ["bun", "run", "--cwd", "apps/app", "start"]
+CMD ["node", "apps/app/server.js"]
# =============================================================================
# STAGE 5: Portal Builder
@@ -92,8 +113,15 @@ WORKDIR /app
COPY packages ./packages
COPY apps/portal ./apps/portal
-# Generate Prisma client
-RUN cd packages/db && bunx prisma generate
+# Bring in node_modules for build and prisma prebuild
+COPY --from=deps /app/node_modules ./node_modules
+
+# Ensure Next build has required public env at build-time
+ARG NEXT_PUBLIC_BETTER_AUTH_URL
+ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL \
+ NEXT_TELEMETRY_DISABLED=1 NODE_ENV=production \
+ NEXT_OUTPUT_STANDALONE=true \
+ NODE_OPTIONS=--max_old_space_size=6144
# Build the portal
RUN cd apps/portal && SKIP_ENV_VALIDATION=true bun run build
@@ -101,16 +129,16 @@ RUN cd apps/portal && SKIP_ENV_VALIDATION=true bun run build
# =============================================================================
# STAGE 6: Portal Production
# =============================================================================
-FROM oven/bun:1.2.8 AS portal
+FROM node:22-alpine AS portal
WORKDIR /app
-# Copy the built portal and all necessary dependencies from builder
-COPY --from=portal-builder /app/apps/portal/.next ./apps/portal/.next
-COPY --from=portal-builder /app/apps/portal/package.json ./apps/portal/
-COPY --from=portal-builder /app/package.json ./
-COPY --from=portal-builder /app/node_modules ./node_modules
-COPY --from=portal-builder /app/packages ./packages
+# Copy Next standalone output for portal
+COPY --from=portal-builder /app/apps/portal/.next/standalone ./
+COPY --from=portal-builder /app/apps/portal/.next/static ./apps/portal/.next/static
+COPY --from=portal-builder /app/apps/portal/public ./apps/portal/public
EXPOSE 3000
-CMD ["bun", "run", "--cwd", "apps/portal", "start"]
\ No newline at end of file
+CMD ["node", "apps/portal/server.js"]
+
+# (Trigger.dev hosted; no local runner stage)
\ No newline at end of file
diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md
new file mode 100644
index 000000000..b686689c6
--- /dev/null
+++ b/SELF_HOSTING.md
@@ -0,0 +1,254 @@
+## Self-hosting Comp (Apps + Portal)
+
+This guide walks you through running the Comp app and portal with Docker.
+
+### Overview
+
+- You will run two services: `app` (primary) and `portal` (customer portal).
+- You must bring your own externally hosted PostgreSQL database. The stack does not run a local DB in production mode.
+- You must provide email (Resend) and Trigger.dev credentials for email login and automated workflows.
+
+### Prerequisites
+
+- Docker Desktop (or Docker Engine) installed
+- Externally hosted PostgreSQL 14+ (e.g., DigitalOcean, Neon, RDS) with SSL
+- Resend account and API key for transactional email (magic links, OTP)
+- Trigger.dev account and project for automated workflows
+
+### Required environment variables
+
+Set these in `docker-compose.yml` under each service as shown below.
+
+App (`apps/app`):
+
+- `DATABASE_URL` (required): External Postgres URL. Example: `postgresql://user:pass@host:5432/db?sslmode=require`
+- `AUTH_SECRET` (required): 32-byte base64. Generate with `openssl rand -base64 32`
+- `RESEND_API_KEY` (required): From Resend dashboard
+- `REVALIDATION_SECRET` (required): Any random string
+- `BETTER_AUTH_URL` (required): Base URL of the app server (e.g., `http://localhost:3000`)
+- `NEXT_PUBLIC_BETTER_AUTH_URL` (required): Same as above for client code
+- `NEXT_PUBLIC_PORTAL_URL` (required): Base URL of the portal server (e.g., `http://localhost:3002`)
+- `TRIGGER_SECRET_KEY` (required for workflows): From Trigger.dev project settings
+- Optional (infrastructure): `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`
+
+Portal (`apps/portal`):
+
+- `DATABASE_URL` (required): Same external Postgres URL
+- `BETTER_AUTH_SECRET` (required): A secret used by portal auth (distinct from app `AUTH_SECRET`)
+- `BETTER_AUTH_URL` (required): Base URL of the portal (e.g., `http://localhost:3002`)
+- `NEXT_PUBLIC_BETTER_AUTH_URL` (required): Same as portal base URL for client code
+- `RESEND_API_KEY` (required): Same Resend key
+
+### Optional environment variables
+
+App (`apps/app`):
+
+- **OPENAI_API_KEY**: Enables AI features that call OpenAI models.
+- **UPSTASH_REDIS_REST_URL**, **UPSTASH_REDIS_REST_TOKEN**: Optional Redis (Upstash) used for rate limiting/queues/caching.
+- **NEXT_PUBLIC_POSTHOG_KEY**, **NEXT_PUBLIC_POSTHOG_HOST**: Client analytics via PostHog; leave unset to disable.
+- **NEXT_PUBLIC_GTM_ID**: Google Tag Manager container ID for client tracking.
+- **NEXT_PUBLIC_LINKEDIN_PARTNER_ID**, **NEXT_PUBLIC_LINKEDIN_CONVERSION_ID**: LinkedIn insights/conversion tracking.
+- **NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL**: Google Ads conversion tracking label.
+- **ZAPIER_HUBSPOT_WEBHOOK_URL**: Zapier inbound webhook for HubSpot events.
+- **HUBSPOT_ACCESS_TOKEN**: Server-side HubSpot API access for CRM sync.
+- **DUB_API_KEY**, **DUB_REFER_URL**: Dub.co link shortener/referral features.
+- **FIRECRAWL_API_KEY**, **GROQ_API_KEY**: Optional LLM/crawling providers for research features.
+- **SLACK_SALES_WEBHOOK**: Slack webhook for sales/lead notifications.
+- **GA4_API_SECRET**, **GA4_MEASUREMENT_ID**: Google Analytics 4 server/client tracking.
+- **NEXT_PUBLIC_API_URL**: Override client API base URL (defaults to same origin).
+
+Portal (`apps/portal`):
+
+- **NEXT_PUBLIC_POSTHOG_KEY**, **NEXT_PUBLIC_POSTHOG_HOST**: Client analytics via PostHog for portal.
+- **UPSTASH_REDIS_REST_URL**, **UPSTASH_REDIS_REST_TOKEN**: Optional Redis if you enable portal-side rate limiting/queues.
+
+### docker-compose.yml uses `.env` (no direct edits needed)
+
+We keep `docker-compose.yml` generic and read values from `.env`:
+
+```yaml
+services:
+ migrator:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: migrator
+ env_file:
+ - .env
+
+ seeder:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: migrator
+ env_file:
+ - .env
+ command: sh -lc "bunx prisma generate --schema=node_modules/@trycompai/db/dist/schema.prisma && bun packages/db/prisma/seed/seed.js"
+
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: app
+ args:
+ NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL}
+ ports: ['3000:3000']
+ env_file: [.env]
+ restart: unless-stopped
+ healthcheck:
+ test: ['CMD-SHELL', 'curl -f http://localhost:3000/api/health || exit 1']
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ portal:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: portal
+ args:
+ NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL_PORTAL}
+ ports: ['3002:3000']
+ env_file: [.env]
+ restart: unless-stopped
+ healthcheck:
+ test: ['CMD-SHELL', 'curl -f http://localhost:3002/ || exit 1']
+ interval: 30s
+ timeout: 10s
+ retries: 3
+```
+
+#### `.env` example
+
+Create a `.env` file at the repo root with your values (never commit real secrets):
+
+```bash
+# External PostgreSQL (required)
+DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require
+
+# App auth + URLs (required)
+AUTH_SECRET=
+BETTER_AUTH_URL=http://localhost:3000
+NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
+NEXT_PUBLIC_PORTAL_URL=http://localhost:3002
+REVALIDATION_SECRET=
+
+# Email (required)
+RESEND_API_KEY=
+
+# Workflows (Trigger.dev hosted)
+TRIGGER_SECRET_KEY=
+
+# Portal auth + URLs (required)
+BETTER_AUTH_SECRET=
+BETTER_AUTH_URL_PORTAL=http://localhost:3002
+NEXT_PUBLIC_BETTER_AUTH_URL_PORTAL=http://localhost:3002
+
+# Optional
+# OPENAI_API_KEY=
+# UPSTASH_REDIS_REST_URL=
+# UPSTASH_REDIS_REST_TOKEN=
+# NEXT_PUBLIC_POSTHOG_KEY=
+# NEXT_PUBLIC_POSTHOG_HOST=
+# NEXT_PUBLIC_GTM_ID=
+# NEXT_PUBLIC_LINKEDIN_PARTNER_ID=
+# NEXT_PUBLIC_LINKEDIN_CONVERSION_ID=
+# NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL=
+# ZAPIER_HUBSPOT_WEBHOOK_URL=
+# HUBSPOT_ACCESS_TOKEN=
+# DUB_API_KEY=
+# DUB_REFER_URL=
+# FIRECRAWL_API_KEY=
+# GROQ_API_KEY=
+# SLACK_SALES_WEBHOOK=
+# GA4_API_SECRET=
+# GA4_MEASUREMENT_ID=
+# NEXT_PUBLIC_API_URL=
+```
+
+#### What the `migrator` and `seeder` services do
+
+- **migrator**: Runs `prisma migrate deploy` using the combined schema from `@trycompai/db`.
+ - Purpose: create/update tables, indexes, and constraints in your hosted Postgres.
+ - Safe to run repeatedly (Prisma applies only pending migrations).
+
+- **seeder**: Generates a Prisma client from the same combined schema and executes the app’s seed script.
+ - Purpose: load application reference data (frameworks, controls, relations).
+ - Behavior: idempotent upserts by `id`. It does not delete rows; existing rows with matching ids are updated, and relations are connected if missing.
+
+Notes:
+
+- The stack migrates with `@trycompai/db` combined Prisma schema and then seeds. Seeding is idempotent: records are upserted by id and relations are connected; nothing is deleted.
+- Ensure your DB user has privileges to create/alter tables in the target database.
+
+### Trigger.dev (hosted runner)
+
+Trigger.dev powers AI automations and background workflows.
+
+Steps:
+
+1. Create an account at `https://cloud.trigger.dev`
+2. Create a project and copy `TRIGGER_SECRET_KEY`
+3. From your workstation (not inside Docker):
+ ```bash
+ cd apps/app
+ bunx trigger.dev@latest login
+ bunx trigger.dev@latest deploy
+ ```
+4. Set `TRIGGER_SECRET_KEY` in the `app` service environment.
+
+### Resend (email)
+
+- Create a Resend account and get `RESEND_API_KEY`
+- Add a domain if you plan to send emails from a custom domain
+- Set `RESEND_API_KEY` in both `app` and `portal` services
+
+### Build & run
+
+#### Prepare environment
+
+Copy the example and fill real values (kept out of git):
+
+```bash
+cp .env.example .env
+# edit .env with your production secrets and URLs
+```
+
+#### Fresh install (optional clean):
+
+```bash
+docker compose down --rmi all --volumes --remove-orphans
+docker builder prune --all --force
+```
+
+#### Build images:
+
+```bash
+docker compose build --no-cache
+```
+
+#### Run migrations & seed (against your hosted DB):
+
+```bash
+docker compose run --rm migrator
+docker compose run --rm seeder
+```
+
+#### Start the apps:
+
+```bash
+docker compose up -d app portal
+```
+
+Verify health:
+
+```bash
+curl -s http://localhost:3000/api/health
+```
+
+### Production tips
+
+- Set real domains and HTTPS (behind a reverse proxy / load balancer)
+- Update `BETTER_AUTH_URL`, `NEXT_PUBLIC_BETTER_AUTH_URL`, and portal equivalents to the public domains
+- Use strong secrets and rotate them periodically
+- Ensure the hosted Postgres requires SSL and restricts network access (VPC, IP allowlist, or private networking)
diff --git a/apps/api/package.json b/apps/api/package.json
index a51c1f10d..a4790896e 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -32,7 +32,7 @@
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.1.5",
"@nestjs/swagger": "^11.2.0",
- "@trycompai/db": "^1.3.2",
+ "@trycompai/db": "^1.3.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"jose": "^6.0.12",
@@ -49,7 +49,7 @@
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
- "@types/node": "^22.10.7",
+ "@types/node": "^24.0.3",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.8",
"eslint": "^9.18.0",
@@ -57,14 +57,14 @@
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
- "prettier": "^3.4.2",
+ "prettier": "^3.5.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
- "typescript": "^5.7.3",
+ "typescript": "^5.8.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile
deleted file mode 100644
index 97e83b6c6..000000000
--- a/apps/app/Dockerfile
+++ /dev/null
@@ -1,29 +0,0 @@
-# Use Node.js Alpine for smaller runtime image
-FROM node:18-alpine AS runtime
-
-WORKDIR /app
-
-# Install curl for health checks
-RUN apk add --no-cache curl
-
-# Set production environment variables
-ENV NODE_ENV=production
-ENV NEXT_TELEMETRY_DISABLED=1
-ENV PORT=3000
-ENV HOSTNAME="0.0.0.0"
-ENV NODE_TLS_REJECT_UNAUTHORIZED=0
-
-# Copy the standalone build files (already prepared in container-build/)
-COPY . ./
-
-# Create non-root user for security
-RUN addgroup -g 1001 -S nodejs && \
- adduser -S nextjs -u 1001 && \
- chown -R nextjs:nodejs /app
-
-USER nextjs
-
-EXPOSE 3000
-
-# Use node to run the standalone server
-CMD ["node", "server.js"]
\ No newline at end of file
diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts
index 70247ad33..7201d19cd 100644
--- a/apps/app/next.config.ts
+++ b/apps/app/next.config.ts
@@ -1,6 +1,9 @@
import type { NextConfig } from 'next';
+import path from 'path';
import './src/env.mjs';
+const isStandalone = process.env.NEXT_OUTPUT_STANDALONE === 'true';
+
const config: NextConfig = {
// Use S3 bucket for static assets with app-specific path
assetPrefix:
@@ -30,7 +33,20 @@ const config: NextConfig = {
},
authInterrupts: true,
optimizePackageImports: ['@trycompai/db', '@trycompai/ui'],
+ // Reduce build peak memory
+ webpackMemoryOptimizations: true,
},
+ outputFileTracingRoot: path.join(__dirname, '../../'),
+
+ // Reduce memory usage during production build
+ productionBrowserSourceMaps: false,
+ // If builds still OOM, uncomment the next line to disable SWC minification (larger output, less memory)
+ // swcMinify: false,
+ ...(isStandalone
+ ? {
+ output: 'standalone' as const,
+ }
+ : {}),
// PostHog proxy for better tracking
async rewrites() {
diff --git a/apps/app/package.json b/apps/app/package.json
index c9c32f4bb..4ad5a7d64 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -8,9 +8,9 @@
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/react": "^2.0.0",
"@ai-sdk/rsc": "^1.0.0",
- "@aws-sdk/client-s3": "^3.806.0",
+ "@aws-sdk/client-s3": "^3.859.0",
"@aws-sdk/client-sts": "^3.808.0",
- "@aws-sdk/s3-request-presigner": "^3.832.0",
+ "@aws-sdk/s3-request-presigner": "^3.859.0",
"@azure/core-rest-pipeline": "^1.21.0",
"@browserbasehq/sdk": "^2.5.0",
"@calcom/atoms": "^1.0.102-framer",
@@ -44,9 +44,9 @@
"@tiptap/extension-table-cell": "^2.22.3",
"@tiptap/extension-table-header": "^2.22.3",
"@tiptap/extension-table-row": "^2.22.3",
- "@trigger.dev/react-hooks": "3.3.17",
- "@trigger.dev/sdk": "3.3.17",
- "@trycompai/db": "^1.3.3",
+ "@trigger.dev/react-hooks": "4",
+ "@trigger.dev/sdk": "4",
+ "@trycompai/db": "^1.3.4",
"@types/canvas-confetti": "^1.9.0",
"@types/three": "^0.177.0",
"@uploadthing/react": "^7.3.0",
@@ -62,7 +62,7 @@
"geist": "^1.3.1",
"lucide-react": "^0.534.0",
"motion": "^12.9.2",
- "next": "15.4.2-canary.16",
+ "next": "^15.4.6",
"next-safe-action": "^8.0.3",
"next-themes": "^0.4.4",
"nuqs": "^2.4.3",
@@ -91,6 +91,7 @@
"use-long-press": "^3.3.0",
"xml2js": "^0.6.2",
"zaraz-ts": "^1.2.0",
+ "zod": "^4.0.17",
"zustand": "^5.0.3"
},
"devDependencies": {
@@ -100,12 +101,12 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
- "@trigger.dev/build": "3.3.17",
+ "@trigger.dev/build": "4",
"@types/d3": "^7.4.3",
"@types/node": "^24.0.3",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/ui": "^3.2.4",
- "eslint": "^9",
+ "eslint": "^9.18.0",
"eslint-config-next": "15.4.2-canary.16",
"fleetctl": "^4.68.1",
"glob": "^11.0.3",
@@ -135,7 +136,7 @@
"build": "next build",
"db:generate": "bun run db:getschema && prisma generate",
"db:getschema": "cp ../../node_modules/@trycompai/db/dist/schema.prisma prisma/schema.prisma",
- "deploy:trigger-prod": "npx trigger.dev@latest deploy",
+ "deploy:trigger-prod": "npx trigger.dev@4.0.0 deploy",
"dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bun run trigger:dev\"",
"lint": "next lint && prettier --check .",
"prebuild": "bun run db:generate",
@@ -152,7 +153,7 @@
"test:e2e:ui": "playwright test --ui",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch",
- "trigger:dev": "npx trigger.dev@latest dev",
+ "trigger:dev": "npx trigger.dev@4.0.0 dev",
"typecheck": "tsc --noEmit"
}
}
diff --git a/apps/app/src/actions/organization/accept-invitation.ts b/apps/app/src/actions/organization/accept-invitation.ts
index e80edd1f9..cdb36a893 100644
--- a/apps/app/src/actions/organization/accept-invitation.ts
+++ b/apps/app/src/actions/organization/accept-invitation.ts
@@ -2,6 +2,7 @@
import { db } from '@db';
import { revalidatePath, revalidateTag } from 'next/cache';
+import { redirect } from 'next/navigation';
import { Resend } from 'resend';
import { z } from 'zod';
import { authActionClientWithoutOrg } from '../safe-action';
@@ -88,13 +89,8 @@ export const completeInvitation = authActionClientWithoutOrg
},
});
- return {
- success: true,
- data: {
- accepted: true,
- organizationId: invitation.organizationId,
- },
- };
+ // Server redirect to the organization's root
+ redirect(`/${invitation.organizationId}/`);
}
if (!invitation.role) {
@@ -148,13 +144,8 @@ export const completeInvitation = authActionClientWithoutOrg
revalidatePath(`/${invitation.organization.id}/settings/users`);
revalidateTag(`user_${user.id}`);
- return {
- success: true,
- data: {
- accepted: true,
- organizationId: invitation.organizationId,
- },
- };
+ // Server redirect to the organization's root
+ redirect(`/${invitation.organizationId}/`);
} catch (error) {
console.error('Error accepting invitation:', error);
throw new Error(error as string);
diff --git a/apps/app/src/actions/policies/update-policy-action.ts b/apps/app/src/actions/policies/update-policy-action.ts
index 940885d85..41deb66a0 100644
--- a/apps/app/src/actions/policies/update-policy-action.ts
+++ b/apps/app/src/actions/policies/update-policy-action.ts
@@ -1,7 +1,7 @@
'use server';
import { db } from '@db';
-import { logger } from '@trigger.dev/sdk/v3';
+import { logger } from '@trigger.dev/sdk';
import { revalidatePath, revalidateTag } from 'next/cache';
import { authActionClient } from '../safe-action';
import { updatePolicySchema } from '../schema';
diff --git a/apps/app/src/actions/research-vendor.ts b/apps/app/src/actions/research-vendor.ts
index b9896df11..8ac04c87c 100644
--- a/apps/app/src/actions/research-vendor.ts
+++ b/apps/app/src/actions/research-vendor.ts
@@ -1,7 +1,7 @@
'use server';
import { researchVendor } from '@/jobs/tasks/scrape/research';
-import { tasks } from '@trigger.dev/sdk/v3';
+import { tasks } from '@trigger.dev/sdk';
import { z } from 'zod';
import { authActionClient } from './safe-action';
diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts
index 8a95aa782..d92cad218 100644
--- a/apps/app/src/actions/schema.ts
+++ b/apps/app/src/actions/schema.ts
@@ -65,7 +65,7 @@ export const organizationWebsiteSchema = z.object({
export const createRiskSchema = z.object({
title: z
.string({
- required_error: 'Risk name is required',
+ error: 'Risk name is required',
})
.min(1, {
message: 'Risk name should be at least 1 character',
@@ -75,7 +75,7 @@ export const createRiskSchema = z.object({
}),
description: z
.string({
- required_error: 'Risk description is required',
+ error: 'Risk description is required',
})
.min(1, {
message: 'Risk description should be at least 1 character',
@@ -84,10 +84,10 @@ export const createRiskSchema = z.object({
message: 'Risk description should be at most 255 characters',
}),
category: z.nativeEnum(RiskCategory, {
- required_error: 'Risk category is required',
+ error: 'Risk category is required',
}),
department: z.nativeEnum(Departments, {
- required_error: 'Risk department is required',
+ error: 'Risk department is required',
}),
assigneeId: z.string().optional().nullable(),
});
@@ -103,14 +103,14 @@ export const updateRiskSchema = z.object({
message: 'Risk description is required',
}),
category: z.nativeEnum(RiskCategory, {
- required_error: 'Risk category is required',
+ error: 'Risk category is required',
}),
department: z.nativeEnum(Departments, {
- required_error: 'Risk department is required',
+ error: 'Risk department is required',
}),
assigneeId: z.string().optional().nullable(),
status: z.nativeEnum(RiskStatus, {
- required_error: 'Risk status is required',
+ error: 'Risk status is required',
}),
});
@@ -162,7 +162,7 @@ export const updateTaskSchema = z.object({
description: z.string().optional(),
dueDate: z.date().optional(),
status: z.nativeEnum(TaskStatus, {
- required_error: 'Task status is required',
+ error: 'Task status is required',
}),
assigneeId: z.string().optional().nullable(),
});
@@ -251,10 +251,8 @@ export const updateResidualRiskEnumSchema = z.object({
// Policies
export const createPolicySchema = z.object({
- title: z.string({ required_error: 'Title is required' }).min(1, 'Title is required'),
- description: z
- .string({ required_error: 'Description is required' })
- .min(1, 'Description is required'),
+ title: z.string({ error: 'Title is required' }).min(1, 'Title is required'),
+ description: z.string({ error: 'Description is required' }).min(1, 'Description is required'),
frameworkIds: z.array(z.string()).optional(),
controlIds: z.array(z.string()).optional(),
entityId: z.string().optional(),
@@ -281,7 +279,7 @@ export const createEmployeeSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
department: z.nativeEnum(Departments, {
- required_error: 'Department is required',
+ error: 'Department is required',
}),
externalEmployeeId: z.string().optional(),
isActive: z.boolean().default(true),
diff --git a/apps/app/src/actions/trigger/heal-access-token.ts b/apps/app/src/actions/trigger/heal-access-token.ts
index e552d4597..08dfd70bf 100644
--- a/apps/app/src/actions/trigger/heal-access-token.ts
+++ b/apps/app/src/actions/trigger/heal-access-token.ts
@@ -1,6 +1,6 @@
'use server';
-import { auth } from '@trigger.dev/sdk/v3';
+import { auth } from '@trigger.dev/sdk';
import { cookies } from 'next/headers';
// Server action that can set cookies (called from client components or forms)
diff --git a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
index 122310065..753625398 100644
--- a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
+++ b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
@@ -115,11 +115,10 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
const friendlyStatus = getFriendlyStatusName(run.status);
switch (run.status) {
- case 'WAITING_FOR_DEPLOY':
+ case 'WAITING':
case 'QUEUED':
case 'EXECUTING':
- case 'REATTEMPTING':
- case 'FROZEN':
+ case 'PENDING_VERSION':
case 'DELAYED':
return (
@@ -156,8 +155,8 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
case 'FAILED':
case 'CANCELED':
case 'CRASHED':
- case 'INTERRUPTED':
case 'SYSTEM_FAILURE':
+ case 'DEQUEUED':
case 'EXPIRED':
case 'TIMED_OUT': {
const errorMessage = run.error?.message || 'An unexpected issue occurred.';
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx
index 546836e31..080275097 100644
--- a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx
@@ -25,6 +25,15 @@ export default async function DashboardPage() {
redirect('/');
}
+ // If onboarding for this org is not completed yet, redirect to onboarding first
+ const org = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { onboardingCompleted: true },
+ });
+ if (org && org.onboardingCompleted === false) {
+ redirect(`/onboarding/${organizationId}`);
+ }
+
const tasks = await getControlTasks();
const frameworksWithControls = await getAllFrameworkInstancesWithControls({
organizationId,
diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx
index 8ba4f54a3..2f4879ced 100644
--- a/apps/app/src/app/(app)/[orgId]/layout.tsx
+++ b/apps/app/src/app/(app)/[orgId]/layout.tsx
@@ -36,14 +36,13 @@ export default async function Layout({
});
if (!session) {
- return redirect('/auth/login');
+ console.log('no session');
+ return redirect('/auth');
}
- // First check if the organization exists
+ // First check if the organization exists and load access flags
const organization = await db.organization.findUnique({
- where: {
- id: requestedOrgId,
- },
+ where: { id: requestedOrgId },
});
if (!organization) {
@@ -58,11 +57,23 @@ export default async function Layout({
},
});
+ console.log('member', member);
+
if (!member) {
// User doesn't have access to this organization
return redirect('/auth/unauthorized');
}
+ // If this org is not accessible on current plan, redirect to upgrade
+ if (!organization.hasAccess) {
+ return redirect(`/upgrade/${organization.id}`);
+ }
+
+ // If onboarding is required and user isn't already on onboarding, redirect
+ if (!organization.onboardingCompleted) {
+ return redirect(`/onboarding/${organization.id}`);
+ }
+
const onboarding = await db.onboarding.findFirst({
where: {
organizationId: requestedOrgId,
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx
index 56eef4e14..76bcce063 100644
--- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx
+++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx
@@ -1,7 +1,8 @@
'use client';
import { Card } from '@comp/ui/card';
-import type { Member, Task, User } from '@db';
+import type { Control, Member, Task, User } from '@db';
+import { useParams } from 'next/navigation';
import { useMemo, useState } from 'react';
import { updateTask } from '../../actions/updateTask';
import { TaskDeleteDialog } from './TaskDeleteDialog';
@@ -9,12 +10,14 @@ import { TaskMainContent } from './TaskMainContent';
import { TaskPropertiesSidebar } from './TaskPropertiesSidebar';
interface SingleTaskProps {
- task: Task & { fileUrls?: string[] };
+ task: Task & { fileUrls?: string[]; controls?: Control[] };
members?: (Member & { user: User })[];
}
export function SingleTask({ task, members }: SingleTaskProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const params = useParams<{ orgId: string }>();
+ const orgIdFromParams = params.orgId;
const assignedMember = useMemo(() => {
if (!task.assigneeId || !members) return null;
@@ -54,6 +57,7 @@ export function SingleTask({ task, members }: SingleTaskProps) {
assignedMember={assignedMember}
handleUpdateTask={handleUpdateTask}
onDeleteClick={() => setDeleteDialogOpen(true)}
+ orgId={orgIdFromParams}
/>
{/* Delete Dialog */}
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx
index 93bbb6f47..a46e38181 100644
--- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx
+++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx
@@ -7,21 +7,23 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@comp/ui/dropdown-menu';
-import type { Departments, Member, Task, TaskFrequency, TaskStatus, User } from '@db';
+import type { Control, Departments, Member, Task, TaskFrequency, TaskStatus, User } from '@db';
import { MoreVertical, Trash2 } from 'lucide-react';
+import Link from 'next/link';
import { useState } from 'react';
import { TaskStatusIndicator } from '../../components/TaskStatusIndicator';
import { PropertySelector } from './PropertySelector';
import { DEPARTMENT_COLORS, taskDepartments, taskFrequencies, taskStatuses } from './constants';
interface TaskPropertiesSidebarProps {
- task: Task;
+ task: Task & { controls?: Control[] };
members?: (Member & { user: User })[];
assignedMember: (Member & { user: User }) | null | undefined; // Allow undefined
handleUpdateTask: (
data: Partial
>,
) => void;
onDeleteClick?: () => void;
+ orgId: string;
}
export function TaskPropertiesSidebar({
@@ -30,6 +32,7 @@ export function TaskPropertiesSidebar({
assignedMember,
handleUpdateTask,
onDeleteClick,
+ orgId,
}: TaskPropertiesSidebarProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
return (
@@ -247,6 +250,26 @@ export function TaskPropertiesSidebar({
contentWidth="w-48"
/>
+
+ {/* Control */}
+ {task.controls && (
+
+
Control
+
+ {task.controls.map((control) => (
+
+
+ {control.name}
+
+
+ ))}
+
+
+ )}
);
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx
index d91eaa2e6..b2387fec8 100644
--- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx
@@ -48,6 +48,9 @@ const getTask = async (taskId: string, session: Session) => {
id: taskId,
organizationId: activeOrgId,
},
+ include: {
+ controls: true,
+ },
});
console.log('[getTask] Database query successful');
@@ -58,8 +61,6 @@ const getTask = async (taskId: string, session: Session) => {
}
};
-
-
const getMembers = async (orgId: string, session: Session) => {
const activeOrgId = orgId ?? session?.session.activeOrganizationId;
if (!activeOrgId) {
diff --git a/apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts b/apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts
index 40f6768ba..fa8de72cc 100644
--- a/apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts
+++ b/apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts
@@ -3,7 +3,7 @@
import { sendIntegrationResults } from '@/jobs/tasks/integration/integration-results';
import { auth } from '@/utils/auth';
import { db } from '@db';
-import { runs, tasks } from '@trigger.dev/sdk/v3';
+import { tasks } from '@trigger.dev/sdk';
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
@@ -56,7 +56,7 @@ export const runTests = async () => {
};
}
- const batchHandle = await tasks.batchTrigger(
+ const batchHandle = await tasks.batchTriggerAndWait(
'send-integration-results',
integrations.map((integration) => ({
payload: {
@@ -72,20 +72,6 @@ export const runTests = async () => {
})),
);
- let existingRuns = await runs.list({
- status: 'EXECUTING',
- batch: batchHandle.batchId,
- });
-
- while (existingRuns.data.length > 0) {
- console.log(`Waiting for existing runs to complete: ${existingRuns.data.length}`);
- await new Promise((resolve) => setTimeout(resolve, 500));
- existingRuns = await runs.list({
- status: 'EXECUTING',
- batch: batchHandle.batchId,
- });
- }
-
revalidatePath(`/${orgId}/tests/dashboard`);
return {
success: true,
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts
index fa93708db..6cb96fa10 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts
+++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts
@@ -24,7 +24,7 @@ export const createVendorTaskSchema = z.object({
message: 'Description is required',
}),
dueDate: z.date({
- required_error: 'Due date is required',
+ error: 'Due date is required',
}),
assigneeId: z.string().nullable(),
});
@@ -79,7 +79,7 @@ export const updateVendorTaskSchema = z.object({
}),
dueDate: z.date().optional(),
status: z.nativeEnum(TaskStatus, {
- required_error: 'Task status is required',
+ error: 'Task status is required',
}),
assigneeId: z.string().nullable(),
});
diff --git a/apps/app/src/app/(app)/invite/[code]/components/InviteNotMatchCard.tsx b/apps/app/src/app/(app)/invite/[code]/components/InviteNotMatchCard.tsx
new file mode 100644
index 000000000..fa9856641
--- /dev/null
+++ b/apps/app/src/app/(app)/invite/[code]/components/InviteNotMatchCard.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { SignOut } from '@/components/sign-out';
+import { InviteStatusCard } from './InviteStatusCard';
+
+export function InviteNotMatchCard({
+ currentEmail,
+ invitedEmail,
+}: {
+ currentEmail: string;
+ invitedEmail: string;
+}) {
+ return (
+
+
+
+
+ You are signed in as
+
+ {currentEmail}
+
+
+
+ This invite is for
+
+ {invitedEmail}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/invite/[code]/components/InviteStatusCard.tsx b/apps/app/src/app/(app)/invite/[code]/components/InviteStatusCard.tsx
new file mode 100644
index 000000000..c11d799de
--- /dev/null
+++ b/apps/app/src/app/(app)/invite/[code]/components/InviteStatusCard.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { Button } from '@comp/ui/button';
+import { Icons } from '@comp/ui/icons';
+import Link from 'next/link';
+
+export function InviteStatusCard({
+ title,
+ description,
+ primaryHref,
+ primaryLabel,
+ children,
+}: {
+ title: string;
+ description: string;
+ primaryHref?: string;
+ primaryLabel?: string;
+ children?: React.ReactNode;
+}) {
+ return (
+
+
+
+
{title}
+
+ {description}
+
+ {children}
+ {primaryHref && primaryLabel && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/invite/[code]/page.tsx b/apps/app/src/app/(app)/invite/[code]/page.tsx
index ad2b78e88..7ad36bd76 100644
--- a/apps/app/src/app/(app)/invite/[code]/page.tsx
+++ b/apps/app/src/app/(app)/invite/[code]/page.tsx
@@ -1,10 +1,12 @@
-import { getOrganizations } from '@/data/getOrganizations';
+import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
import { auth } from '@/utils/auth';
-import type { Organization } from '@db';
import { db } from '@db';
import { headers } from 'next/headers';
-import { notFound, redirect } from 'next/navigation';
+import { redirect } from 'next/navigation';
import { AcceptInvite } from '../../setup/components/accept-invite';
+import { InviteNotMatchCard } from './components/InviteNotMatchCard';
+import { InviteStatusCard } from './components/InviteStatusCard';
+import { maskEmail } from './utils';
interface InvitePageProps {
params: Promise<{ code: string }>;
@@ -17,26 +19,12 @@ export default async function InvitePage({ params }: InvitePageProps) {
});
if (!session) {
- // Redirect to auth with the invite code
return redirect(`/auth?inviteCode=${code}`);
}
- // Fetch existing organizations
- let organizations: Organization[] = [];
- try {
- const result = await getOrganizations();
- organizations = result.organizations;
- } catch (error) {
- // If user has no organizations, continue with empty array
- console.error('Failed to fetch organizations:', error);
- }
-
- // Check if this invitation exists and is valid for this user
const invitation = await db.invitation.findFirst({
where: {
id: code,
- email: session.user.email,
- status: 'pending',
},
include: {
organization: {
@@ -48,16 +36,60 @@ export default async function InvitePage({ params }: InvitePageProps) {
});
if (!invitation) {
- // Either invitation doesn't exist, already accepted, or not for this user
- notFound();
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (invitation.status !== 'pending') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (invitation.email !== session.user.email) {
+ return (
+
+
+
+
+
+ );
}
return (
-
+
+
+
);
}
diff --git a/apps/app/src/app/(app)/invite/[code]/utils.ts b/apps/app/src/app/(app)/invite/[code]/utils.ts
new file mode 100644
index 000000000..a58f8e93b
--- /dev/null
+++ b/apps/app/src/app/(app)/invite/[code]/utils.ts
@@ -0,0 +1,14 @@
+export function maskEmail(email: string): string {
+ const [local, domain] = email.split('@');
+ if (!domain) return email;
+ const maskedLocal =
+ local.length <= 2 ? `${local[0] ?? ''}***` : `${local[0]}***${local.slice(-1)}`;
+
+ const domainParts = domain.split('.');
+ if (domainParts.length === 0) return `${maskedLocal}@***`;
+ const tld = domainParts[domainParts.length - 1];
+ const secondLevel = domainParts.length >= 2 ? domainParts[domainParts.length - 2] : '';
+ const maskedSecondLevel = secondLevel ? `${secondLevel[0]}***` : '***';
+
+ return `${maskedLocal}@${maskedSecondLevel}.${tld}`;
+}
diff --git a/apps/app/src/app/(app)/layout.tsx b/apps/app/src/app/(app)/layout.tsx
new file mode 100644
index 000000000..8fd5117a1
--- /dev/null
+++ b/apps/app/src/app/(app)/layout.tsx
@@ -0,0 +1,34 @@
+import { auth } from '@/utils/auth';
+import { db } from '@db';
+import { headers } from 'next/headers';
+import { redirect } from 'next/navigation';
+
+export default async function Layout({ children }: { children: React.ReactNode }) {
+ const hdrs = await headers();
+ const session = await auth.api.getSession({
+ headers: hdrs,
+ });
+
+ if (!session) {
+ return redirect('/auth');
+ }
+
+ const pendingInvite = await db.invitation.findFirst({
+ where: {
+ email: session.user.email,
+ status: 'pending',
+ },
+ });
+
+ if (pendingInvite) {
+ let path = hdrs.get('x-pathname') || hdrs.get('referer') || '';
+ // normalize potential locale prefix
+ path = path.replace(/\/([a-z]{2})\//, '/');
+ const target = `/invite/${pendingInvite.id}`;
+ if (!path.startsWith(target)) {
+ return redirect(target);
+ }
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts
index 6d19b4d30..501aeb934 100644
--- a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts
+++ b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts
@@ -5,7 +5,7 @@ import { steps } from '@/app/(app)/setup/lib/constants';
import { createFleetLabelForOrg } from '@/jobs/tasks/device/create-fleet-label-for-org';
import { onboardOrganization as onboardOrganizationTask } from '@/jobs/tasks/onboarding/onboard-organization';
import { db } from '@db';
-import { tasks } from '@trigger.dev/sdk/v3';
+import { tasks } from '@trigger.dev/sdk';
import { revalidatePath } from 'next/cache';
import { cookies, headers } from 'next/headers';
import { z } from 'zod';
@@ -80,19 +80,9 @@ export const completeOnboarding = authActionClient
});
// Now trigger the jobs that were skipped during minimal creation
- const handle = await tasks.trigger(
- 'onboard-organization',
- {
- organizationId: parsedInput.organizationId,
- },
- {
- queue: {
- name: 'onboard-organization',
- concurrencyLimit: 5,
- },
- concurrencyKey: parsedInput.organizationId,
- },
- );
+ const handle = await tasks.trigger('onboard-organization', {
+ organizationId: parsedInput.organizationId,
+ });
// Update onboarding record with job ID
await db.onboarding.update({
diff --git a/apps/app/src/app/(app)/setup/actions/create-organization.ts b/apps/app/src/app/(app)/setup/actions/create-organization.ts
index 4b5b7fa65..182ccf21e 100644
--- a/apps/app/src/app/(app)/setup/actions/create-organization.ts
+++ b/apps/app/src/app/(app)/setup/actions/create-organization.ts
@@ -10,7 +10,7 @@ import { createFleetLabelForOrg } from '@/jobs/tasks/device/create-fleet-label-f
import { onboardOrganization as onboardOrganizationTask } from '@/jobs/tasks/onboarding/onboard-organization';
import { auth } from '@/utils/auth';
import { db } from '@db';
-import { tasks } from '@trigger.dev/sdk/v3';
+import { tasks } from '@trigger.dev/sdk';
import { revalidatePath } from 'next/cache';
import { cookies, headers } from 'next/headers';
import { companyDetailsSchema, steps } from '../lib/constants';
@@ -173,19 +173,9 @@ export const createOrganization = authActionClientWithoutOrg
revalidatePath(`/${org.organizationId}`);
}
- const handle = await tasks.trigger(
- 'onboard-organization',
- {
- organizationId: orgId,
- },
- {
- queue: {
- name: 'onboard-organization',
- concurrencyLimit: 5,
- },
- concurrencyKey: orgId,
- },
- );
+ const handle = await tasks.trigger('onboard-organization', {
+ organizationId: orgId,
+ });
// Set triggerJobId to signal that the job is running.
await db.onboarding.update({
diff --git a/apps/app/src/app/(app)/setup/components/accept-invite.tsx b/apps/app/src/app/(app)/setup/components/accept-invite.tsx
index a201ae40e..7a4fef68f 100644
--- a/apps/app/src/app/(app)/setup/components/accept-invite.tsx
+++ b/apps/app/src/app/(app)/setup/components/accept-invite.tsx
@@ -7,7 +7,7 @@ import { Icons } from '@comp/ui/icons';
import { Loader2 } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import Link from 'next/link';
-import { useRouter } from 'next/navigation';
+import { redirect } from 'next/navigation';
import { toast } from 'sonner';
export function AcceptInvite({
@@ -17,7 +17,7 @@ export function AcceptInvite({
inviteCode: string;
organizationName: string;
}) {
- const router = useRouter();
+ // Using next/navigation redirect to avoid showing invite page after accept
const { execute, isPending } = useAction(completeInvitation, {
onSuccess: async (result) => {
@@ -26,8 +26,6 @@ export function AcceptInvite({
await authClient.organization.setActive({
organizationId: result.data.data.organizationId,
});
- // Redirect to the organization's root path
- router.push(`/${result.data.data.organizationId}/`);
}
},
onError: (error) => {
@@ -35,27 +33,32 @@ export function AcceptInvite({
},
});
- const handleAccept = () => {
- execute({ inviteCode });
+ const handleAccept = async () => {
+ await execute({ inviteCode });
+ redirect(`/`);
};
return (
-
+
-
-
You have been invited to join
-
{organizationName || 'an organization'}
+
+
+ You have been invited to join
+
+
+ {organizationName || 'an organization'}
+
Please accept the invitation to join the organization.
-