diff --git a/Dockerfile b/Dockerfile index d7904cc76e..38a8ca8500 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,29 @@ 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/ +# Install Node.js (Bun's WASM engine has a known crash bug with Prisma 7's +# query compiler - see https://github.com/prisma/prisma/issues/28805 and +# https://github.com/oven-sh/bun/issues/17146). Run seed under Node instead. +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g tsx -# 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 +74,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 +115,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 +130,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 +165,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 f2b813f1c1..782d498bf2 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -73,3 +73,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 cb34ae6c38..dd3284ef5e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -115,6 +115,8 @@ "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", diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts index 5f3c1738d2..3c43ad10e5 100644 --- a/apps/api/prisma/client.ts +++ b/apps/api/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); @@ -61,7 +61,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/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 3301dda54b..e8f6495871 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -42,6 +42,9 @@ function getCookieDomain(): string | undefined { if (baseUrl.includes('trycomp.ai')) { return '.trycomp.ai'; } + if (baseUrl.includes('dctrl.ai')) { + return '.dctrl.ai'; + } return undefined; } diff --git a/apps/api/src/email/email-transport.ts b/apps/api/src/email/email-transport.ts new file mode 100644 index 0000000000..b36510c940 --- /dev/null +++ b/apps/api/src/email/email-transport.ts @@ -0,0 +1,243 @@ +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, + }); +} + +function normalizeAttachments( + attachments?: EmailAttachment[], +): Mail.Attachment[] | undefined { + return attachments?.map((att) => ({ + filename: att.filename, + content: 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 = process.env.RESEND_TO_TEST ?? 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 (isBunMailConfigured()) { + return sendViaBunMail({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + }); + } + + 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..f078e3528d 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 { generateUnsubscribeToken } from '@trycompai/email'; 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,34 @@ 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 }> { + 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)}`; + + return sendHtmlEmail({ + to: params.to, + subject: params.subject, + html: params.html, + channel: params.channel, + cc: params.cc, + scheduledAt: params.scheduledAt, + attachments: params.attachments, + headers: { + 'List-Unsubscribe': `<${oneClickUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + }); +} + export async function triggerEmail(params: { to: string; subject: string; @@ -30,16 +60,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 +92,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/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 37ce027843..06b9abf499 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -678,7 +678,7 @@ export class PeopleInviteService { private buildPortalUrl(organizationId: string): string { const portalUrl = - process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'; + process.env.PORTAL_URL ?? process.env.NEXT_PUBLIC_PORTAL_URL ?? process.env.TRUST_APP_URL ?? 'https://portal.trycomp.ai'; return `${portalUrl}/${organizationId}`; } diff --git a/apps/api/src/trigger/email/send-email.ts b/apps/api/src/trigger/email/send-email.ts index d01181cee2..51d27a960f 100644 --- a/apps/api/src/trigger/email/send-email.ts +++ b/apps/api/src/trigger/email/send-email.ts @@ -1,6 +1,6 @@ import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; -import { resend } from '../../email/resend'; +import { sendHtmlEmail } from '../../email/email-transport'; import { generateUnsubscribeToken } from '@trycompai/email'; const emailQueue = queue({ @@ -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,31 +41,7 @@ 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); @@ -97,14 +51,15 @@ export const sendEmailTask = schemaTask({ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', }; - 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 +67,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..6f6896a4f9 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); @@ -46,7 +46,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..9d4f233d10 100644 --- a/apps/app/src/app/(app)/no-access/page.tsx +++ b/apps/app/src/app/(app)/no-access/page.tsx @@ -36,8 +36,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 + + {process.env.NEXT_PUBLIC_PORTAL_URL?.replace(/^https?:\/\//, "") ?? "comp-portal.dctrl.ai"} .

diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index e00b91fae3..9074dfc496 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); @@ -37,7 +37,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/docker-compose.yml b/docker-compose.yml index 399879bafc..ae2003ad1f 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,48 @@ 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' + - '3001:3000' env_file: - apps/app/.env + environment: + PGSSLMODE: disable + DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable 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 + environment: + PGSSLMODE: disable + NODE_EXTRA_CA_CERTS: "" + DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable + PORTAL_URL: https://comp-portal.dctrl.ai + NEXT_PUBLIC_PORTAL_URL: https://comp-portal.dctrl.ai + TRUST_APP_URL: https://comp-portal.dctrl.ai + NEXT_PUBLIC_APP_URL: https://comp-app.dctrl.ai + 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 +80,65 @@ services: target: portal args: NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL_PORTAL} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL_PORTAL} ports: - '3002:3000' env_file: - apps/portal/.env + environment: + PGSSLMODE: disable + DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable + MOCK_REDIS: "true" + FLEET_AGENT_BUCKET_NAME: comp + DEVICE_AGENT_S3_ENV: production + APP_AWS_ACCESS_KEY_ID: minioadmin + APP_AWS_SECRET_ACCESS_KEY: minioadmin + APP_AWS_BUCKET_NAME: comp + APP_AWS_REGION: us-east-1 + APP_AWS_ENDPOINT: http://minio:9000 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:latest + container_name: comp-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - '9000:9000' + - '9001:9001' + volumes: + - minio_data:/data + restart: unless-stopped + logging: *default-logging + + minio-init: + image: minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + for i in 1 2 3 4 5 6 7 8 9 10; do + mc alias set local http://minio:9000 minioadmin minioadmin && break; + sleep 2; + done && + mc mb -p local/comp || true && + echo bucket ready + " + restart: 'no' + logging: *default-logging + +networks: + default: + name: comp_network + external: true + +volumes: + minio_data: 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..53508c6ba3 100644 --- a/packages/db/src/ssl-config.ts +++ b/packages/db/src/ssl-config.ts @@ -3,7 +3,7 @@ export type SslConfig = | { checkServerIdentity: () => undefined } | { rejectUnauthorized: false }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); function isLocalhostUrl(connectionString: string): boolean { try { 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..52d3c144ac 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, @@ -61,7 +62,7 @@ export const PolicyAcknowledgmentDigestEmail = ({ if (!firstOrg) return null; const portalBase = ( - process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai' + getPortalBaseUrl() ).replace(/\/+$/, ''); 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(/\/+$/, ''); +}