diff --git a/.gitignore b/.gitignore index 1c74a84..71f0421 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ signald-state project.org **/.openapi-generator/ apps/bridge-worker/scripts/* +ENVIRONMENT_VARIABLES_MIGRATION.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 24acab5..a7eb162 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,9 +12,9 @@ build-all: TURBO_TEAM: ${TURBO_TEAM} ZAMMAD_URL: ${ZAMMAD_URL} script: - - npm install npm@10 -g - - npm install -g turbo - - npm ci + - corepack enable && corepack prepare pnpm@9.15.4 --activate + - pnpm add -g turbo + - pnpm install --frozen-lockfile - turbo build .docker-build: @@ -191,11 +191,11 @@ zammad-docker-build: DOCKERFILE_PATH: ./docker/zammad/Dockerfile DOCKER_CONTEXT: ./docker/zammad before_script: - - apk --update add nodejs npm + - apk --update add nodejs script: - - npm install npm@10 -g - - npm install -g turbo - - npm ci + - corepack enable && corepack prepare pnpm@9.15.4 --activate + - pnpm add -g turbo + - pnpm install --frozen-lockfile - turbo build --force --filter @link-stack/zammad-addon-* - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - DOCKER_BUILDKIT=1 docker build --build-arg EMBEDDED=true --pull --no-cache -t ${DOCKER_NS}:${DOCKER_TAG} -f ${DOCKERFILE_PATH} ${DOCKER_CONTEXT} @@ -213,11 +213,11 @@ zammad-standalone-docker-build: DOCKERFILE_PATH: ./docker/zammad/Dockerfile DOCKER_CONTEXT: ./docker/zammad before_script: - - apk --update add nodejs npm + - apk --update add nodejs script: - - npm install npm@10 -g - - npm install -g turbo - - npm ci + - corepack enable && corepack prepare pnpm@9.15.4 --activate + - pnpm add -g turbo + - pnpm install --frozen-lockfile - turbo build --force --filter @link-stack/zammad-addon-* - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - DOCKER_BUILDKIT=1 docker build --pull --no-cache -t ${DOCKER_NS}:${DOCKER_TAG} -f ${DOCKERFILE_PATH} ${DOCKER_CONTEXT} diff --git a/apps/bridge-frontend/Dockerfile b/apps/bridge-frontend/Dockerfile index 9d9eb26..75a19ce 100644 --- a/apps/bridge-frontend/Dockerfile +++ b/apps/bridge-frontend/Dockerfile @@ -2,22 +2,28 @@ FROM node:22-bookworm-slim AS base FROM base AS builder ARG APP_DIR=/opt/bridge-frontend +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN mkdir -p ${APP_DIR}/ -RUN npm i -g turbo +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +RUN pnpm add -g turbo WORKDIR ${APP_DIR} COPY . . RUN turbo prune --scope=@link-stack/bridge-frontend --scope=@link-stack/bridge-migrations --docker FROM base AS installer ARG APP_DIR=/opt/bridge-frontend +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" WORKDIR ${APP_DIR} +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate COPY --from=builder ${APP_DIR}/.gitignore .gitignore COPY --from=builder ${APP_DIR}/out/json/ . -COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json -RUN npm ci +COPY --from=builder ${APP_DIR}/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN pnpm install --frozen-lockfile COPY --from=builder ${APP_DIR}/out/full/ . -RUN npm i -g turbo +RUN pnpm add -g turbo RUN turbo run build --filter=@link-stack/bridge-frontend --filter=@link-stack/bridge-migrations FROM base AS runner @@ -29,6 +35,9 @@ LABEL maintainer="Darren Clarke " LABEL org.label-schema.build-date=$BUILD_DATE LABEL org.label-schema.version=$VERSION ENV APP_DIR ${APP_DIR} +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ apt-get install -y --no-install-recommends \ dumb-init diff --git a/apps/bridge-frontend/app/_lib/authentication.ts b/apps/bridge-frontend/app/_lib/authentication.ts index 2ea1461..fb2b4b7 100644 --- a/apps/bridge-frontend/app/_lib/authentication.ts +++ b/apps/bridge-frontend/app/_lib/authentication.ts @@ -1,10 +1,6 @@ import GoogleProvider from "next-auth/providers/google"; -import { KyselyAdapter } from "@auth/kysely-adapter"; -import { db } from "@link-stack/bridge-common"; export const authOptions = { - // @ts-ignore - adapter: KyselyAdapter(db), providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, diff --git a/apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts b/apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts index e20b40e..67cf309 100644 --- a/apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts +++ b/apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,9 @@ import NextAuth from "next-auth"; import { authOptions } from "@/app/_lib/authentication"; +// Force this route to be dynamic (not statically generated at build time) +export const dynamic = 'force-dynamic'; + const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; diff --git a/apps/bridge-frontend/docker-entrypoint.sh b/apps/bridge-frontend/docker-entrypoint.sh index 92f4841..aea8c51 100644 --- a/apps/bridge-frontend/docker-entrypoint.sh +++ b/apps/bridge-frontend/docker-entrypoint.sh @@ -2,6 +2,6 @@ set -e echo "running migrations" -(cd ../bridge-migrations/ && npm run migrate:up:all) +(cd ../bridge-migrations/ && pnpm run migrate:up:all) echo "starting bridge-frontend" -exec dumb-init npm run start +exec dumb-init pnpm run start diff --git a/apps/bridge-frontend/middleware.ts b/apps/bridge-frontend/middleware.ts index a00c650..13517bb 100644 --- a/apps/bridge-frontend/middleware.ts +++ b/apps/bridge-frontend/middleware.ts @@ -1,23 +1,81 @@ import { withAuth } from "next-auth/middleware"; +import { NextResponse } from "next/server"; -export default withAuth({ - pages: { - signIn: `/login`, +export default withAuth( + function middleware(req) { + const isDev = process.env.NODE_ENV === "development"; + const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); + + // Allow digiresilience.org for embedding documentation + const frameSrcDirective = `frame-src 'self' https://digiresilience.org;`; + + const cspHeader = ` + default-src 'self'; + ${frameSrcDirective} + connect-src 'self'; + script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""}; + style-src 'self' 'unsafe-inline'; + img-src 'self' blob: data:; + font-src 'self'; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'self'; + upgrade-insecure-requests; + `; + const contentSecurityPolicyHeaderValue = cspHeader + .replace(/\s{2,}/g, " ") + .trim(); + + const requestHeaders = new Headers(req.headers); + requestHeaders.set("x-nonce", nonce); + requestHeaders.set( + "Content-Security-Policy", + contentSecurityPolicyHeaderValue, + ); + + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + + response.headers.set( + "Content-Security-Policy", + contentSecurityPolicyHeaderValue, + ); + + // Additional security headers + response.headers.set("X-Frame-Options", "SAMEORIGIN"); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + response.headers.set("X-XSS-Protection", "1; mode=block"); + response.headers.set( + "Permissions-Policy", + "camera=(), microphone=(), geolocation=()" + ); + + return response; }, - callbacks: { - authorized: ({ token }) => { - if (process.env.SETUP_MODE === "true") { - return true; - } - - if (token?.email) { - return true; - } - - return false; + { + pages: { + signIn: `/login`, }, - }, -}); + callbacks: { + authorized: ({ token }) => { + if (process.env.SETUP_MODE === "true") { + return true; + } + + if (token?.email) { + return true; + } + + return false; + }, + }, + } +); export const config = { matcher: ["/((?!ws|wss|api|_next/static|_next/image|favicon.ico).*)"], diff --git a/apps/bridge-frontend/package.json b/apps/bridge-frontend/package.json index 7595d4c..6c009f7 100644 --- a/apps/bridge-frontend/package.json +++ b/apps/bridge-frontend/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-frontend", - "version": "3.2.0b3", + "version": "3.3.0-beta.1", "type": "module", "scripts": { "dev": "next dev", @@ -18,25 +18,23 @@ "@mui/material": "^6", "@mui/material-nextjs": "^6", "@mui/x-license": "^7", - "@link-stack/bridge-common": "*", - "@link-stack/bridge-ui": "*", + "@link-stack/bridge-common": "workspace:*", + "@link-stack/bridge-ui": "workspace:*", "next": "15.5.4", "next-auth": "^4.24.11", "react": "19.2.0", "react-dom": "19.2.0", "sharp": "^0.34.4", "tsx": "^4.20.6", - "@link-stack/ui": "*" + "@link-stack/ui": "workspace:*" }, "devDependencies": { - "@link-stack/eslint-config": "*", - "@link-stack/typescript-config": "*", + "@link-stack/eslint-config": "workspace:*", + "@link-stack/typescript-config": "workspace:*", "@types/node": "^24", "@types/pg": "^8.15.5", "@types/react": "^19", "@types/react-dom": "^19", - "@link-stack/eslint-config": "*", - "@link-stack/typescript-config": "*", "typescript": "^5" } } diff --git a/apps/bridge-migrations/package.json b/apps/bridge-migrations/package.json index 7aba07a..b650ba4 100644 --- a/apps/bridge-migrations/package.json +++ b/apps/bridge-migrations/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-migrations", - "version": "3.2.0b3", + "version": "3.3.0-beta.1", "type": "module", "scripts": { "migrate:up:all": "tsx migrate.ts up:all", @@ -9,7 +9,7 @@ "migrate:down:one": "tsx migrate.ts down:one" }, "dependencies": { - "@link-stack/logger": "*", + "@link-stack/logger": "workspace:*", "dotenv": "^17.2.3", "kysely": "0.27.5", "pg": "^8.16.3", @@ -18,8 +18,8 @@ "devDependencies": { "@types/node": "^24", "@types/pg": "^8.15.5", - "@link-stack/eslint-config": "*", - "@link-stack/typescript-config": "*", + "@link-stack/eslint-config": "workspace:*", + "@link-stack/typescript-config": "workspace:*", "typescript": "^5" } } diff --git a/apps/bridge-whatsapp/Dockerfile b/apps/bridge-whatsapp/Dockerfile index c5483c4..631020d 100644 --- a/apps/bridge-whatsapp/Dockerfile +++ b/apps/bridge-whatsapp/Dockerfile @@ -2,20 +2,26 @@ FROM node:22-bookworm-slim AS base FROM base AS builder ARG APP_DIR=/opt/bridge-whatsapp +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN mkdir -p ${APP_DIR}/ -RUN npm i -g turbo +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +RUN pnpm add -g turbo WORKDIR ${APP_DIR} COPY . . RUN turbo prune --scope=@link-stack/bridge-whatsapp --docker FROM base AS installer ARG APP_DIR=/opt/bridge-whatsapp +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" WORKDIR ${APP_DIR} +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate COPY --from=builder ${APP_DIR}/out/json/ . COPY --from=builder ${APP_DIR}/out/full/ . -COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json -RUN npm ci -RUN npm i -g turbo +COPY --from=builder ${APP_DIR}/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN pnpm install --frozen-lockfile +RUN pnpm add -g turbo RUN turbo run build --filter=@link-stack/bridge-whatsapp FROM base as runner diff --git a/apps/bridge-whatsapp/docker-entrypoint.sh b/apps/bridge-whatsapp/docker-entrypoint.sh index 866302b..188e492 100644 --- a/apps/bridge-whatsapp/docker-entrypoint.sh +++ b/apps/bridge-whatsapp/docker-entrypoint.sh @@ -2,4 +2,4 @@ set -e echo "starting bridge-whatsapp" -exec dumb-init npm run start +exec dumb-init pnpm run start diff --git a/apps/bridge-whatsapp/package.json b/apps/bridge-whatsapp/package.json index 5c80ea6..25e16bf 100644 --- a/apps/bridge-whatsapp/package.json +++ b/apps/bridge-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-whatsapp", - "version": "3.2.0b3", + "version": "3.3.0-beta.1", "main": "build/main/index.js", "author": "Darren Clarke ", "license": "AGPL-3.0-or-later", @@ -9,15 +9,17 @@ "@hapi/hapi": "^21.4.3", "@hapipal/schmervice": "^3.0.0", "@hapipal/toys": "^4.0.0", - "@link-stack/logger": "*", + "@link-stack/bridge-common": "workspace:*", + "@link-stack/logger": "workspace:*", "@whiskeysockets/baileys": "^6.7.20", "hapi-pino": "^13.0.0", "link-preview-js": "^3.1.0" }, "devDependencies": { - "@link-stack/eslint-config": "*", - "@link-stack/jest-config": "*", - "@link-stack/typescript-config": "*", + "@link-stack/eslint-config": "workspace:*", + "@link-stack/jest-config": "workspace:*", + "@link-stack/typescript-config": "workspace:*", + "@types/long": "^5", "@types/node": "*", "dotenv-cli": "^10.0.0", "tsx": "^4.20.6", diff --git a/apps/bridge-whatsapp/src/service.ts b/apps/bridge-whatsapp/src/service.ts index 60ba170..352d85b 100644 --- a/apps/bridge-whatsapp/src/service.ts +++ b/apps/bridge-whatsapp/src/service.ts @@ -12,6 +12,11 @@ import makeWASocket, { } from "@whiskeysockets/baileys"; import fs from "fs"; import { createLogger } from "@link-stack/logger"; +import { + getMaxAttachmentSize, + getMaxTotalAttachmentSize, + MAX_ATTACHMENTS, +} from "@link-stack/bridge-common"; const logger = createLogger("bridge-whatsapp-service"); @@ -36,7 +41,24 @@ export default class WhatsappService extends Service { } getBotDirectory(id: string): string { - return `${this.getBaseDirectory()}/${id}`; + // Validate that ID contains only safe characters (alphanumeric, dash, underscore) + if (!/^[a-zA-Z0-9_-]+$/.test(id)) { + throw new Error(`Invalid bot ID format: ${id}`); + } + + // Prevent path traversal by checking for suspicious patterns + if (id.includes('..') || id.includes('/') || id.includes('\\')) { + throw new Error(`Path traversal detected in bot ID: ${id}`); + } + + const botPath = `${this.getBaseDirectory()}/${id}`; + + // Ensure the resolved path is still within the base directory + if (!botPath.startsWith(this.getBaseDirectory())) { + throw new Error(`Invalid bot path: ${botPath}`); + } + + return botPath; } getAuthDirectory(id: string): string { @@ -340,9 +362,39 @@ export default class WhatsappService extends Service { await connection.sendMessage(recipient, { text: message }); } - // Send attachments if provided + // Send attachments if provided with size validation if (attachments && attachments.length > 0) { + const MAX_ATTACHMENT_SIZE = getMaxAttachmentSize(); + const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize(); + + if (attachments.length > MAX_ATTACHMENTS) { + throw new Error(`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`); + } + + let totalSize = 0; + for (const attachment of attachments) { + // Calculate size before converting to buffer + const estimatedSize = (attachment.data.length * 3) / 4; + + if (estimatedSize > MAX_ATTACHMENT_SIZE) { + logger.warn({ + filename: attachment.filename, + size: estimatedSize, + maxSize: MAX_ATTACHMENT_SIZE + }, 'Attachment exceeds size limit, skipping'); + continue; + } + + totalSize += estimatedSize; + if (totalSize > MAX_TOTAL_SIZE) { + logger.warn({ + totalSize, + maxTotalSize: MAX_TOTAL_SIZE + }, 'Total attachment size exceeds limit, skipping remaining'); + break; + } + const buffer = Buffer.from(attachment.data, "base64"); if (attachment.mime_type.startsWith("image/")) { diff --git a/apps/bridge-whatsapp/tsconfig.json b/apps/bridge-whatsapp/tsconfig.json index 1af057c..4b714ce 100644 --- a/apps/bridge-whatsapp/tsconfig.json +++ b/apps/bridge-whatsapp/tsconfig.json @@ -8,7 +8,7 @@ "outDir": "build/main", "rootDir": "src", "skipLibCheck": true, - "types": ["node", "long"], + "types": ["node"], "lib": ["es2020", "DOM"], "composite": true }, diff --git a/apps/bridge-worker/Dockerfile b/apps/bridge-worker/Dockerfile index 96a68f3..48de099 100644 --- a/apps/bridge-worker/Dockerfile +++ b/apps/bridge-worker/Dockerfile @@ -2,26 +2,35 @@ FROM node:22-bookworm-slim AS base FROM base AS builder ARG APP_DIR=/opt/bridge-worker +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN mkdir -p ${APP_DIR}/ -RUN npm i -g turbo +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +RUN pnpm add -g turbo WORKDIR ${APP_DIR} COPY . . RUN turbo prune --scope=@link-stack/bridge-worker --docker FROM base AS installer ARG APP_DIR=/opt/bridge-worker +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" WORKDIR ${APP_DIR} +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate COPY --from=builder ${APP_DIR}/out/json/ . COPY --from=builder ${APP_DIR}/out/full/ . -COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json -RUN npm ci -RUN npm i -g turbo +COPY --from=builder ${APP_DIR}/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN pnpm install --frozen-lockfile +RUN pnpm add -g turbo RUN turbo run build --filter=@link-stack/bridge-worker FROM base as runner ARG BUILD_DATE ARG VERSION ARG APP_DIR=/opt/bridge-worker +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN mkdir -p ${APP_DIR}/ RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ apt-get install -y --no-install-recommends \ diff --git a/apps/bridge-worker/README.md b/apps/bridge-worker/README.md index 8e5638a..c858ee2 100644 --- a/apps/bridge-worker/README.md +++ b/apps/bridge-worker/README.md @@ -88,9 +88,6 @@ Required environment variables: ### Common Tasks - `notify-webhooks` - Send webhook notifications - -### Leafcutter Tasks -- `import-leafcutter` - Import data to Leafcutter - `import-label-studio` - Import Label Studio annotations ## Architecture diff --git a/apps/bridge-worker/docker-entrypoint.sh b/apps/bridge-worker/docker-entrypoint.sh index 5a72275..74e24d2 100644 --- a/apps/bridge-worker/docker-entrypoint.sh +++ b/apps/bridge-worker/docker-entrypoint.sh @@ -2,4 +2,4 @@ set -e echo "starting bridge-worker" -exec dumb-init npm run start +exec dumb-init pnpm run start diff --git a/apps/bridge-worker/graphile.config.ts b/apps/bridge-worker/graphile.config.ts index 9367a65..fa601d6 100644 --- a/apps/bridge-worker/graphile.config.ts +++ b/apps/bridge-worker/graphile.config.ts @@ -1,7 +1,6 @@ -import type {} from "graphile-config"; import type {} from "graphile-worker"; -const preset: GraphileConfig.Preset = { +const preset: any = { worker: { connectionString: process.env.DATABASE_URL, maxPoolSize: process.env.BRIDGE_WORKER_POOL_SIZE diff --git a/apps/bridge-worker/lib/common.ts b/apps/bridge-worker/lib/common.ts index b74b0b6..c6e8ffa 100644 --- a/apps/bridge-worker/lib/common.ts +++ b/apps/bridge-worker/lib/common.ts @@ -1,11 +1,6 @@ /* eslint-disable camelcase */ // import { SavedVoiceProvider } from "@digiresilience/bridge-db"; import Twilio from "twilio"; -import { CallInstance } from "twilio/lib/rest/api/v2010/account/call"; -import { Zammad, getOrCreateUser } from "./zammad.js"; -import { createLogger } from "@link-stack/logger"; - -const logger = createLogger('bridge-worker-common'); type SavedVoiceProvider = any; @@ -23,51 +18,3 @@ export const twilioClientFor = ( }); }; -export const createZammadTicket = async ( - call: CallInstance, - mp3: Buffer, -): Promise => { - const title = `Call from ${call.fromFormatted} at ${call.startTime}`; - const body = `
    -
  • Caller: ${call.fromFormatted}
  • -
  • Service Number: ${call.toFormatted}
  • -
  • Call Duration: ${call.duration} seconds
  • -
  • Start Time: ${call.startTime}
  • -
  • End Time: ${call.endTime}
  • -
-

See the attached recording.

`; - const filename = `${call.sid}-${call.startTime}.mp3`; - const zammad = Zammad( - { - token: "EviH_WL0p6YUlCoIER7noAZEAPsYA_fVU4FZCKdpq525Vmzzvl8d7dNuP_8d-Amb", - }, - "https://demo.digiresilience.org", - ); - try { - const customer = await getOrCreateUser(zammad, call.fromFormatted); - await zammad.ticket.create({ - title, - group: "Finances", - note: "This ticket was created automaticaly from a recorded phone call.", - customer_id: customer.id, - article: { - body, - subject: title, - content_type: "text/html", - type: "note", - attachments: [ - { - filename, - data: mp3.toString("base64"), - "mime-type": "audio/mpeg", - }, - ], - }, - }); - } catch (error: any) { - if (error.isBoom) { - logger.error({ output: error.output }, 'Zammad ticket creation failed'); - throw new Error("Failed to create zamamd ticket"); - } - } -}; diff --git a/apps/bridge-worker/lib/formstack-field-mapping.ts b/apps/bridge-worker/lib/formstack-field-mapping.ts new file mode 100644 index 0000000..8f32825 --- /dev/null +++ b/apps/bridge-worker/lib/formstack-field-mapping.ts @@ -0,0 +1,272 @@ +import { createLogger } from "@link-stack/logger"; + +const logger = createLogger('formstack-field-mapping'); + +/** + * Field mapping configuration for Formstack to Zammad integration + * + * This configuration is completely flexible - you define your own internal field names + * and map them to both Formstack source fields and Zammad custom fields. + */ +export interface FieldMappingConfig { + /** + * Map internal field keys to Formstack field names + * + * Required keys (system): + * - formId: The Formstack Form ID field + * - uniqueId: The Formstack submission unique ID field + * + * Optional keys with special behavior: + * - email: Used for user lookup/creation (if provided) + * - phone: Used for user lookup/creation (if provided) + * - signalAccount: Used for Signal-based user lookup (tried first before phone) + * - name: User's full name (can be nested object with first/last, used in user creation) + * - organization: Used in ticket title template placeholder {organization} + * - typeOfSupport: Used in ticket title template placeholder {typeOfSupport} + * - descriptionOfIssue: Used as article subject (defaults to "Support Request" if not provided) + * + * All other keys are completely arbitrary and defined by your form. + */ + sourceFields: Record; + + /** + * Map Zammad custom field names to internal field keys (from sourceFields) + * + * Example: + * { + * "us_state": "state", // Zammad field "us_state" gets value from sourceFields["state"] + * "zip_code": "zipCode", // Zammad field "zip_code" gets value from sourceFields["zipCode"] + * "custom_field": "myField" // Any custom field mapping + * } + * + * The values in this object must correspond to keys in sourceFields. + */ + zammadFields: Record; + + /** + * Configuration for ticket creation + */ + ticket: { + /** Zammad group name to assign tickets to */ + group: string; + + /** Article type name (e.g., "note", "cdr_signal", "email") */ + defaultArticleType: string; + + /** + * Template for ticket title + * Supports placeholders: {name}, {organization}, {typeOfSupport} + * Placeholders reference internal field keys from sourceFields + */ + titleTemplate?: string; + }; + + /** + * Configuration for extracting nested field values + */ + nestedFields?: { + /** + * How to extract first/last name from a nested Name field + * Example: { firstNamePath: "first", lastNamePath: "last" } + * for a field like { "Name": { "first": "John", "last": "Doe" } } + */ + name?: { + firstNamePath?: string; + lastNamePath?: string; + }; + }; +} + +let cachedMapping: FieldMappingConfig | null = null; + +/** + * Load field mapping configuration from environment variable (REQUIRED) + */ +export function loadFieldMapping(): FieldMappingConfig { + if (cachedMapping) { + return cachedMapping; + } + + const configJson = process.env.FORMSTACK_FIELD_MAPPING; + + if (!configJson) { + throw new Error( + 'FORMSTACK_FIELD_MAPPING environment variable is required. ' + + 'Please set it to a JSON string containing your field mapping configuration.' + ); + } + + logger.info('Loading Formstack field mapping from environment variable'); + + try { + const config = JSON.parse(configJson) as FieldMappingConfig; + + // Validate required sections exist + if (!config.sourceFields || typeof config.sourceFields !== 'object') { + throw new Error('Invalid field mapping configuration: sourceFields must be an object'); + } + + if (!config.zammadFields || typeof config.zammadFields !== 'object') { + throw new Error('Invalid field mapping configuration: zammadFields must be an object'); + } + + if (!config.ticket || typeof config.ticket !== 'object') { + throw new Error('Invalid field mapping configuration: ticket must be an object'); + } + + // Validate required ticket fields + if (!config.ticket.group) { + throw new Error('Invalid field mapping configuration: ticket.group is required'); + } + + if (!config.ticket.defaultArticleType) { + throw new Error('Invalid field mapping configuration: ticket.defaultArticleType is required'); + } + + // Validate required source fields + const systemRequiredFields = ['formId', 'uniqueId']; + for (const field of systemRequiredFields) { + if (!config.sourceFields[field]) { + throw new Error(`Invalid field mapping configuration: sourceFields.${field} is required (system field)`); + } + } + + // Validate zammadFields reference valid sourceFields + for (const [zammadField, sourceKey] of Object.entries(config.zammadFields)) { + if (!config.sourceFields[sourceKey]) { + logger.warn( + { zammadField, sourceKey }, + 'Zammad field maps to non-existent source field key' + ); + } + } + + logger.info('Successfully loaded Formstack field mapping configuration'); + cachedMapping = config; + return cachedMapping; + + } catch (error) { + logger.error({ + error: error instanceof Error ? error.message : error, + jsonLength: configJson.length + }, 'Failed to parse field mapping configuration'); + + throw new Error( + `Failed to parse Formstack field mapping JSON: ${error instanceof Error ? error.message : error}` + ); + } +} + +/** + * Get a field value from formData using the source field name mapping + */ +export function getFieldValue( + formData: any, + internalFieldKey: string, + mapping?: FieldMappingConfig +): any { + const config = mapping || loadFieldMapping(); + const sourceFieldName = config.sourceFields[internalFieldKey]; + if (!sourceFieldName) { + return undefined; + } + return formData[sourceFieldName]; +} + +/** + * Get a nested field value (e.g., Name.first) + */ +export function getNestedFieldValue( + fieldValue: any, + path: string | undefined +): any { + if (!path || !fieldValue) { + return undefined; + } + + const parts = path.split('.'); + let current = fieldValue; + + for (const part of parts) { + if (current && typeof current === 'object') { + current = current[part]; + } else { + return undefined; + } + } + + return current; +} + +/** + * Format field value (handle arrays, objects, etc.) + */ +export function formatFieldValue(value: any): string | undefined { + if (value === null || value === undefined || value === '') { + return undefined; + } + if (Array.isArray(value)) { + return value.join(', '); + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +} + +/** + * Build ticket title from template and data + * Replaces placeholders like {name}, {organization}, {typeOfSupport} with provided values + */ +export function buildTicketTitle( + mapping: FieldMappingConfig, + data: Record +): string { + const template = mapping.ticket.titleTemplate || '{name}'; + + let title = template; + + // Replace all placeholders in the template + for (const [key, value] of Object.entries(data)) { + const placeholder = `{${key}}`; + if (title.includes(placeholder)) { + if (value) { + title = title.replace(placeholder, value); + } else { + // Remove empty placeholder and surrounding separators + title = title.replace(` - ${placeholder}`, '').replace(`${placeholder} - `, '').replace(placeholder, ''); + } + } + } + + return title.trim(); +} + +/** + * Get all Zammad field values from form data using the mapping + * Returns an object with Zammad field names as keys and formatted values + */ +export function getZammadFieldValues( + formData: any, + mapping?: FieldMappingConfig +): Record { + const config = mapping || loadFieldMapping(); + const result: Record = {}; + + for (const [zammadFieldName, sourceKey] of Object.entries(config.zammadFields)) { + const value = getFieldValue(formData, sourceKey, config); + const formatted = formatFieldValue(value); + if (formatted !== undefined) { + result[zammadFieldName] = formatted; + } + } + + return result; +} + +/** + * Reset cached mapping (useful for testing) + */ +export function resetMappingCache(): void { + cachedMapping = null; +} diff --git a/apps/bridge-worker/lib/zammad.ts b/apps/bridge-worker/lib/zammad.ts index e7cfcb6..2f19774 100644 --- a/apps/bridge-worker/lib/zammad.ts +++ b/apps/bridge-worker/lib/zammad.ts @@ -41,7 +41,7 @@ const formatAuth = (credentials: any) => { return ( "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString( - "base64" + "base64", ) ); } @@ -56,7 +56,7 @@ const formatAuth = (credentials: any) => { export const Zammad = ( credentials: ZammadCredentials, host: string, - opts?: ZammadClientOpts + opts?: ZammadClientOpts, ): ZammadClient => { const extraHeaders = (opts && opts.headers) || {}; @@ -76,7 +76,9 @@ export const Zammad = ( return result as Ticket; }, update: async (id, payload) => { - const { payload: result } = await wreck.put(`tickets/${id}`, { payload }); + const { payload: result } = await wreck.put(`tickets/${id}`, { + payload, + }); return result as Ticket; }, }, @@ -99,18 +101,30 @@ export const Zammad = ( }; export const getUser = async (zammad: ZammadClient, phoneNumber: string) => { - const mungedNumber = phoneNumber.replace("+", ""); - const results = await zammad.user.search(`phone:${mungedNumber}`); + // Sanitize phone number: only allow digits and + symbol + const mungedNumber = phoneNumber.replace(/[^\d+]/g, ""); + + // Validate phone number format (10-15 digits, optional + prefix) + if (!/^\+?\d{10,15}$/.test(mungedNumber)) { + throw new Error(`Invalid phone number format: ${phoneNumber}`); + } + + // Remove + for search query + const searchNumber = mungedNumber.replace("+", ""); + const results = await zammad.user.search(`phone:${searchNumber}`); if (results.length > 0) return results[0]; return undefined; }; -export const getOrCreateUser = async (zammad: ZammadClient, phoneNumber: string) => { +export const getOrCreateUser = async ( + zammad: ZammadClient, + phoneNumber: string, +) => { const customer = await getUser(zammad, phoneNumber); if (customer) return customer; return zammad.user.create({ phone: phoneNumber, - note: "User created by Grabadora from incoming voice call", + note: "User created from incoming voice call", }); }; diff --git a/apps/bridge-worker/package.json b/apps/bridge-worker/package.json index 3699243..fb027d7 100644 --- a/apps/bridge-worker/package.json +++ b/apps/bridge-worker/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-worker", - "version": "3.2.0b3", + "version": "3.3.0-beta.1", "type": "module", "main": "build/main/index.js", "author": "Darren Clarke ", @@ -12,9 +12,9 @@ }, "dependencies": { "@hapi/wreck": "^18.1.0", - "@link-stack/bridge-common": "*", - "@link-stack/logger": "*", - "@link-stack/signal-api": "*", + "@link-stack/bridge-common": "workspace:*", + "@link-stack/logger": "workspace:*", + "@link-stack/signal-api": "workspace:*", "fluent-ffmpeg": "^2.1.3", "graphile-worker": "^0.16.6", "remeda": "^2.32.0", @@ -23,8 +23,8 @@ "devDependencies": { "@types/fluent-ffmpeg": "^2.1.27", "dotenv-cli": "^10.0.0", - "@link-stack/eslint-config": "*", - "@link-stack/typescript-config": "*", + "@link-stack/eslint-config": "workspace:*", + "@link-stack/typescript-config": "workspace:*", "typescript": "^5.9.3" } } diff --git a/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts b/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts index 8249f04..492e3db 100644 --- a/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts +++ b/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts @@ -1,5 +1,14 @@ import { createLogger } from "@link-stack/logger"; import { Zammad, getUser } from "../../lib/zammad.js"; +import { + loadFieldMapping, + getFieldValue, + getNestedFieldValue, + formatFieldValue, + buildTicketTitle, + getZammadFieldValues, + type FieldMappingConfig, +} from "../../lib/formstack-field-mapping.js"; const logger = createLogger('create-ticket-from-form'); @@ -13,63 +22,73 @@ const createTicketFromFormTask = async ( ): Promise => { const { formData, receivedAt } = options; + // Load field mapping configuration + const mapping = loadFieldMapping(); + + // Log only non-PII metadata using configured field names + const formId = getFieldValue(formData, 'formId', mapping); + const uniqueId = getFieldValue(formData, 'uniqueId', mapping); + logger.info({ - formData, + formId, + uniqueId, receivedAt, - formDataKeys: Object.keys(formData), + fieldCount: Object.keys(formData).length }, 'Processing Formstack form submission'); - // Extract data from Formstack payload - matching Python ngo-isac-uploader field names - const { - FormID, - UniqueID, - Name, - Email, - Phone, - 'Signal Account': signalAccount, - City, - State, - 'Zip Code': zipCode, - 'What organization are you affiliated with and/or employed by (if applicable)?': organization, - 'What type of support do you wish to receive (to the extent you know)?': typeOfSupport, - 'Is there a specific deadline associated with this request (e.g., a legal or legislative deadline)?': specificDeadline, - 'Please provide the deadline': deadline, - 'Do you have an insurance provider that provides coverage for the types of services you seek (e.g., public official, professional liability insurance, litigation insurance)?': hasInsuranceProvider, - 'Have you approached the insurance provider for assistance?': approachedProvider, - 'Are you seeking help on behalf of an individual or an organization?': typeOfUser, - 'What is the structure of the organization?': orgStructure, - 'Are you currently a candidate for elected office, a government officeholder, or a government employee?': governmentAffiliated, - 'Where did you hear about the Democracy Protection Network?': whereHeard, - 'Do you or the organization work on behalf of any of the following communities or issues? Please select all that apply.': relatedIssues, - 'Do you or the organization engage in any of the following types of work? Please select all that apply.': typeOfWork, - 'Why are you seeking support? Please briefly describe the circumstances that have brought you to the DPN, including, as applicable, dates, places, and the people or entities involved. We coordinate crisis-response services and some resilience-building services (e.g., assistance establishing good-governance or security practices). If you are seeking resilience-building services, please note that in the text box below.': descriptionOfIssue, - 'What is your preferred communication method?': preferredContactMethod, - } = formData; - - // Build full name - matching Python pattern - const firstName = Name?.first || ''; - const lastName = Name?.last || ''; + // Extract fields using dynamic mapping + const nameField = getFieldValue(formData, 'name', mapping); + const firstName = mapping.nestedFields?.name?.firstNamePath + ? getNestedFieldValue(nameField, mapping.nestedFields.name.firstNamePath) || '' + : ''; + const lastName = mapping.nestedFields?.name?.lastNamePath + ? getNestedFieldValue(nameField, mapping.nestedFields.name.lastNamePath) || '' + : ''; const fullName = (firstName && lastName) ? `${firstName} ${lastName}`.trim() : firstName || lastName || 'Unknown'; - // Build ticket title - exactly matching Python ngo-isac-uploader pattern - // Pattern: [Name] - [Organization] - [Type of support] - let title = fullName; - if (organization) { - title += ` - ${organization}`; - } - if (typeOfSupport) { - // Handle array format (Formstack sends arrays for multi-select) - const supportText = Array.isArray(typeOfSupport) ? typeOfSupport.join(', ') : typeOfSupport; - title += ` - ${supportText}`; + // Extract well-known fields used for special logic (all optional) + const email = getFieldValue(formData, 'email', mapping); + const phone = getFieldValue(formData, 'phone', mapping); + const signalAccount = getFieldValue(formData, 'signalAccount', mapping); + const organization = getFieldValue(formData, 'organization', mapping); + const typeOfSupport = getFieldValue(formData, 'typeOfSupport', mapping); + const descriptionOfIssue = getFieldValue(formData, 'descriptionOfIssue', mapping); + + // Validate that at least one contact method is provided + if (!email && !phone && !signalAccount) { + logger.error({ formId, uniqueId }, 'No contact information provided - at least one of email, phone, or signalAccount is required'); + throw new Error('At least one contact method (email, phone, or signalAccount) is required for ticket creation'); } - // Build article body - format all fields as HTML like Python does + // Build ticket title using configured template + // Pass all potentially used fields - the template determines which are actually used + const title = buildTicketTitle(mapping, { + name: fullName, + organization: formatFieldValue(organization), + typeOfSupport: formatFieldValue(typeOfSupport), + }); + + // Build article body - format all fields as HTML const formatAllFields = (data: any): string => { let html = ''; + + // Add formatted name field first if we have it + if (fullName && fullName !== 'Unknown') { + html += `Name:
${fullName}
`; + } + for (const [key, value] of Object.entries(data)) { - if (key === 'HandshakeKey' || key === 'FormID' || key === 'UniqueID') continue; + // Skip metadata fields and name field (we already formatted it above) + const skipFields = [ + mapping.sourceFields.formId, + mapping.sourceFields.uniqueId, + mapping.sourceFields.name, // Skip raw name field + 'HandshakeKey', + ].filter(Boolean); + + if (skipFields.includes(key)) continue; if (value === null || value === undefined || value === '') continue; const displayValue = Array.isArray(value) ? value.join(', ') : @@ -93,130 +112,143 @@ const createTicketFromFormTask = async ( const zammad = Zammad({ token: zammadToken }, zammadUrl); try { - // Look up the article type ID for cdr_signal - let cdrSignalTypeId: number | undefined; + // Look up the configured article type + let articleTypeId: number | undefined; try { const articleTypes = await zammad.get('ticket_article_types'); - const cdrSignalType = articleTypes.find((t: any) => t.name === 'cdr_signal'); - cdrSignalTypeId = cdrSignalType?.id; - if (cdrSignalTypeId) { - logger.info({ cdrSignalTypeId }, 'Found cdr_signal article type'); + const configuredType = articleTypes.find((t: any) => t.name === mapping.ticket.defaultArticleType); + articleTypeId = configuredType?.id; + if (articleTypeId) { + logger.info({ articleTypeId, typeName: mapping.ticket.defaultArticleType }, 'Found configured article type'); } else { - logger.warn('cdr_signal article type not found, ticket will use default type'); + logger.warn({ typeName: mapping.ticket.defaultArticleType }, 'Configured article type not found, ticket will use default type'); } } catch (error: any) { - logger.warn({ error: error.message }, 'Failed to look up cdr_signal article type'); + logger.warn({ error: error.message }, 'Failed to look up article type'); } - // Determine contact method and phone number - matching Python logic - // Priority: Signal > SMS/Phone > Email - const useSignal = preferredContactMethod?.includes('Signal') || preferredContactMethod?.includes('ignal'); - const useSMS = preferredContactMethod?.includes('SMS'); - const phoneNumber = useSignal ? signalAccount : (useSMS || Phone) ? Phone : ''; - - // Get or create user - matching Python pattern + // Get or create user + // Try to find existing user by: signalAccount -> phone -> email let customer; - if (phoneNumber) { - // Try to find by phone (Signal or regular) - customer = await getUser(zammad, phoneNumber); + // Try Signal account first if provided + if (signalAccount) { + customer = await getUser(zammad, signalAccount); + if (customer) { + logger.info({ customerId: customer.id, method: 'signal' }, 'Found existing user by Signal account'); + } + } + + // Fall back to phone if no customer found yet + if (!customer && phone) { + customer = await getUser(zammad, phone); if (customer) { logger.info({ customerId: customer.id, method: 'phone' }, 'Found existing user by phone'); } } - if (!customer && Email) { - // Search by email if phone search didn't work - const emailResults = await zammad.user.search(`email:${Email}`); - if (emailResults.length > 0) { - customer = emailResults[0]; - logger.info({ customerId: customer.id, method: 'email' }, 'Found existing user by email'); + // Fall back to email if no customer found yet + if (!customer && email) { + // Validate email format before using in search + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (emailRegex.test(email)) { + const emailResults = await zammad.user.search(`email:${email}`); + if (emailResults.length > 0) { + customer = emailResults[0]; + logger.info({ customerId: customer.id, method: 'email' }, 'Found existing user by email'); + } + } else { + logger.warn({ email }, 'Invalid email format provided, skipping email search'); } } if (!customer) { - // Create new user - matching Python user creation pattern + // Create new user logger.info('Creating new user from form submission'); - customer = await zammad.user.create({ + + // Build user data with whatever contact info we have + const userData: any = { firstname: firstName, lastname: lastName, - email: Email || `${UniqueID}@formstack.local`, - phone: phoneNumber || '', roles: ['Customer'], - }); + }; + + // Add contact info only if provided + if (email) { + userData.email = email; + } + + const userPhone = signalAccount || phone; + if (userPhone) { + userData.phone = userPhone; + } + + customer = await zammad.user.create(userData); } logger.info({ customerId: customer.id, - customerEmail: customer.email, - customerPhone: customer.phone, - }, 'Customer identified/created'); + email: customer.email, + }, 'Using customer for ticket'); - // Helper function to format field values (handle arrays and null values) - const formatFieldValue = (value: any): string | undefined => { - if (value === null || value === undefined || value === '') return undefined; - if (Array.isArray(value)) return value.join(', '); - if (typeof value === 'object') return JSON.stringify(value); - return String(value); + // Look up the configured group + const groups = await zammad.get('groups'); + const targetGroup = groups.find((g: any) => g.name === mapping.ticket.group); + + if (!targetGroup) { + logger.error({ groupName: mapping.ticket.group }, 'Configured group not found'); + throw new Error(`Zammad group "${mapping.ticket.group}" not found`); + } + + logger.info({ groupId: targetGroup.id, groupName: targetGroup.name }, 'Using configured group'); + + // Build custom fields using Zammad field mapping + // This dynamically maps all configured fields without hardcoding + const customFields = getZammadFieldValues(formData, mapping); + + // Create the ticket + const articleData: any = { + subject: descriptionOfIssue || 'Support Request', + body, + content_type: 'text/html', + internal: false, }; - // Create the ticket with custom fields - EXACTLY matching Python ngo-isac-uploader field names - const ticketData: any = { + if (articleTypeId) { + articleData.type_id = articleTypeId; + } + + const ticketData = { title, - group: "Imports", // Matching Python - uses "Imports" group + group_id: targetGroup.id, customer_id: customer.id, - - // Custom fields - matching Python field names EXACTLY - us_state: formatFieldValue(State), - zip_code: formatFieldValue(zipCode), - city: formatFieldValue(City), - type_of_support: formatFieldValue(typeOfSupport), - specific_deadline: formatFieldValue(specificDeadline), - deadline: formatFieldValue(deadline), - has_insurance_provider: formatFieldValue(hasInsuranceProvider), - approached_provider: formatFieldValue(approachedProvider), - type_of_user: formatFieldValue(typeOfUser), - org_structure: formatFieldValue(orgStructure), - government_affiliated: formatFieldValue(governmentAffiliated), - where_heard: formatFieldValue(whereHeard), - related_issues: formatFieldValue(relatedIssues), - type_of_work: formatFieldValue(typeOfWork), - - // Article with all formatted fields - article: { - body, - subject: title, - content_type: "text/html", - type: useSignal ? "cdr_signal" : "note", - from: phoneNumber || Email || 'unknown', - sender: "Customer", - }, + article: articleData, + ...customFields, }; + logger.info({ + title, + groupId: targetGroup.id, + customerId: customer.id, + hasArticleType: !!articleTypeId, + customFieldCount: Object.keys(customFields).length, + }, 'Creating ticket'); + const ticket = await zammad.ticket.create(ticketData); - // Update the ticket with the cdr_signal article type - // This must be done after creation as Zammad doesn't allow setting this field during creation - if (cdrSignalTypeId) { - await zammad.ticket.update(ticket.id, { create_article_type_id: cdrSignalTypeId }); - logger.info({ ticketId: ticket.id, cdrSignalTypeId }, 'Updated ticket with cdr_signal article type'); - } - logger.info({ ticketId: ticket.id, - customerId: customer.id, - formId: FormID, - submissionId: UniqueID, - }, 'Zammad ticket created successfully'); + ticketNumber: ticket.id, + title, + }, 'Successfully created ticket from Formstack submission'); } catch (error: any) { logger.error({ error: error.message, stack: error.stack, - output: error.output, - formId: FormID, - submissionId: UniqueID, - }, 'Failed to create Zammad ticket'); + formId, + uniqueId, + }, 'Failed to create ticket from Formstack submission'); throw error; } }; diff --git a/apps/bridge-worker/tasks/signal/send-signal-message.ts b/apps/bridge-worker/tasks/signal/send-signal-message.ts index d6dda1e..65fac2e 100644 --- a/apps/bridge-worker/tasks/signal/send-signal-message.ts +++ b/apps/bridge-worker/tasks/signal/send-signal-message.ts @@ -1,4 +1,11 @@ -import { db, getWorkerUtils } from "@link-stack/bridge-common"; +import { + db, + getWorkerUtils, + getMaxAttachmentSize, + getMaxTotalAttachmentSize, + MAX_ATTACHMENTS, + buildSignalGroupName, +} from "@link-stack/bridge-common"; import { createLogger } from "@link-stack/logger"; import * as signalApi from "@link-stack/signal-api"; const { Configuration, MessagesApi, GroupsApi } = signalApi; @@ -81,7 +88,7 @@ const sendSignalMessageTask = async ({ // If sending to a phone number and auto-groups is enabled, create a group first if (enableAutoGroups && !isGroupId && to && conversationId) { try { - const groupName = `DPN Support Request: ${conversationId}`; + const groupName = buildSignalGroupName(conversationId); const createGroupResponse = await groupsClient.v1GroupsNumberPost({ number: bot.phoneNumber, data: { @@ -204,16 +211,54 @@ const sendSignalMessageTask = async ({ ); } - // Add attachments if provided + // Add attachments if provided with size validation if (attachments && attachments.length > 0) { - messageData.base64Attachments = attachments.map((att) => att.data); - logger.debug( - { - attachmentCount: attachments.length, - attachmentNames: attachments.map((att) => att.filename), - }, - "Including attachments in message", - ); + const MAX_ATTACHMENT_SIZE = getMaxAttachmentSize(); + const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize(); + + if (attachments.length > MAX_ATTACHMENTS) { + throw new Error(`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`); + } + + let totalSize = 0; + const validatedAttachments = []; + + for (const attachment of attachments) { + // Calculate size from base64 string (rough estimate: length * 3/4) + const estimatedSize = (attachment.data.length * 3) / 4; + + if (estimatedSize > MAX_ATTACHMENT_SIZE) { + logger.warn({ + filename: attachment.filename, + size: estimatedSize, + maxSize: MAX_ATTACHMENT_SIZE + }, 'Attachment exceeds size limit, skipping'); + continue; + } + + totalSize += estimatedSize; + if (totalSize > MAX_TOTAL_SIZE) { + logger.warn({ + totalSize, + maxTotalSize: MAX_TOTAL_SIZE + }, 'Total attachment size exceeds limit, skipping remaining'); + break; + } + + validatedAttachments.push(attachment.data); + } + + if (validatedAttachments.length > 0) { + messageData.base64Attachments = validatedAttachments; + logger.debug( + { + attachmentCount: validatedAttachments.length, + attachmentNames: attachments.slice(0, validatedAttachments.length).map((att) => att.filename), + totalSizeBytes: totalSize + }, + "Including attachments in message", + ); + } } const response = await messagesClient.v2SendPost({ diff --git a/apps/link/Dockerfile b/apps/link/Dockerfile index 833a82a..689fffe 100644 --- a/apps/link/Dockerfile +++ b/apps/link/Dockerfile @@ -2,22 +2,28 @@ FROM node:22-bookworm-slim AS base FROM base AS builder ARG APP_DIR=/opt/link +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN mkdir -p ${APP_DIR}/ -RUN npm i -g turbo +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +RUN pnpm add -g turbo WORKDIR ${APP_DIR} COPY . . RUN turbo prune --scope=@link-stack/link --scope=@link-stack/bridge-migrations --docker FROM base AS installer ARG APP_DIR=/opt/link +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" WORKDIR ${APP_DIR} +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate COPY --from=builder ${APP_DIR}/.gitignore .gitignore COPY --from=builder ${APP_DIR}/out/json/ . -COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json -RUN npm ci +COPY --from=builder ${APP_DIR}/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN pnpm install --frozen-lockfile COPY --from=builder ${APP_DIR}/out/full/ . -RUN npm i -g turbo +RUN pnpm add -g turbo ENV ZAMMAD_URL http://zammad-nginx:8080 RUN turbo run build --filter=@link-stack/link --filter=@link-stack/bridge-migrations @@ -30,6 +36,9 @@ LABEL maintainer="Darren Clarke " LABEL org.label-schema.build-date=$BUILD_DATE LABEL org.label-schema.version=$VERSION ENV APP_DIR ${APP_DIR} +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ apt-get install -y --no-install-recommends \ dumb-init diff --git a/apps/link/README.md b/apps/link/README.md index f080763..9d277ae 100644 --- a/apps/link/README.md +++ b/apps/link/README.md @@ -4,13 +4,12 @@ The main CDR (Center for Digital Resilience) Link application - a streamlined he ## Overview -CDR Link provides a unified dashboard for managing support tickets, communication channels, and data analytics. It integrates multiple services including Zammad (ticketing), Bridge (multi-channel messaging), Leafcutter (data visualization), and OpenSearch. +CDR Link provides a unified dashboard for managing support tickets, communication channels, and data analytics. It integrates multiple services including Zammad (ticketing), Bridge (multi-channel messaging), and OpenSearch. ## Features - **Simplified Helpdesk Interface**: Streamlined UI for Zammad ticket management - **Multi-Channel Communication**: Integration with Signal, WhatsApp, Facebook, and Voice channels -- **Data Visualization**: Embedded Leafcutter analytics and reporting - **User Management**: Role-based access control with Google OAuth - **Search**: Integrated OpenSearch for advanced queries - **Label Studio Integration**: For data annotation workflows @@ -69,7 +68,6 @@ Key environment variables required: - `/overview/[overview]` - Ticket overview pages - `/tickets/[id]` - Individual ticket view/edit - `/admin/bridge` - Bridge configuration management -- `/leafcutter` - Data visualization dashboard - `/opensearch` - Search dashboard - `/zammad` - Direct Zammad access - `/profile` - User profile management @@ -104,6 +102,5 @@ docker-compose -f docker/compose/link.yml up - **Zammad**: GraphQL queries for ticket data - **Bridge Services**: REST APIs for channel management -- **Leafcutter**: Embedded iframe integration - **OpenSearch**: Direct dashboard embedding - **Redis**: Session and cache storage \ No newline at end of file diff --git a/apps/link/app/(main)/_components/InternalLayout.tsx b/apps/link/app/(main)/_components/InternalLayout.tsx index e80b52a..6273bb4 100644 --- a/apps/link/app/(main)/_components/InternalLayout.tsx +++ b/apps/link/app/(main)/_components/InternalLayout.tsx @@ -7,13 +7,11 @@ import { SetupModeWarning } from "./SetupModeWarning"; interface InternalLayoutProps extends PropsWithChildren { setupModeActive: boolean; - leafcutterEnabled: boolean; } export const InternalLayout: FC = ({ children, setupModeActive, - leafcutterEnabled, }) => { const [open, setOpen] = useState(true); @@ -24,7 +22,6 @@ export const InternalLayout: FC = ({ void; - leafcutterEnabled?: boolean; } export const Sidebar: FC = ({ open, setOpen, - leafcutterEnabled = false, }) => { const pathname = usePathname(); const { data: session } = useSession(); @@ -372,11 +370,11 @@ export const Sidebar: FC = ({ }} > ( ( >