diff --git a/.claude/skills/zammad-compat/SKILL.md b/.claude/skills/zammad-compat/SKILL.md new file mode 100644 index 0000000..bfde60d --- /dev/null +++ b/.claude/skills/zammad-compat/SKILL.md @@ -0,0 +1,197 @@ +--- +name: zammad-compat +description: Check upstream Zammad for breaking changes before upgrading the addon +disable-model-invocation: true +argument-hint: "[target-version]" +allowed-tools: Bash(git clone *), Bash(git -C /tmp/zammad-upstream *) +--- + +# Zammad Upstream Compatibility Check + +Check the upstream zammad/zammad repository for changes that could break or require updates to our Zammad addon (`packages/zammad-addon-link`). + +## Arguments + +- `$ARGUMENTS` - Optional: target Zammad version/tag/branch to compare against (e.g. `6.6.0`, `stable`). If not provided, ask the user what version to compare against. The current version is in `docker/zammad/Dockerfile` as the `ZAMMAD_VERSION` ARG. + +## Setup + +1. Read the current Zammad version from `docker/zammad/Dockerfile` (the `ARG ZAMMAD_VERSION=` line). +2. Clone or update the upstream Zammad repository: + - If `/tmp/zammad-upstream` does not exist, clone it: `git clone --bare https://github.com/zammad/zammad.git /tmp/zammad-upstream` + - If it exists, update it: `git -C /tmp/zammad-upstream fetch --all --tags` +3. Determine the version range. The current version is the `ZAMMAD_VERSION` from step 1. The target version is the argument or user-provided version. Both versions should be used as git refs (tags are typically in the format `X.Y.Z`). + +## Checks to Perform + +Run ALL of these checks and compile results into a single report. + +### 1. Replaced Stock Files + +These are stock Zammad files that our addon REPLACES with modified copies. Changes upstream mean we need to port those changes into our modified versions. + +For each file below, diff the upstream version between the current and target version. Report any changes found. + +**Vue/TypeScript (Desktop UI):** +- `app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleReply.vue` +- `app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingNotifications.vue` +- `app/frontend/apps/desktop/components/Form/fields/FieldNotifications/FieldNotificationsInput.vue` +- `app/frontend/apps/desktop/components/Form/fields/FieldNotifications/types.ts` + +**CoffeeScript (Legacy UI):** +- `app/assets/javascripts/app/controllers/_profile/notification.coffee` +- `app/assets/javascripts/app/controllers/_ui_element/notification_matrix.coffee` +- `app/assets/javascripts/app/lib/mixins/ticket_notification_matrix.coffee` +- `app/assets/javascripts/app/views/generic/notification_matrix.jst.eco` +- `app/assets/javascripts/app/views/profile/notification.jst.eco` + +Command pattern for each file: +```bash +git -C /tmp/zammad-upstream diff -- +``` + +If a file does not exist at either version, note that (it may have been added, removed, or renamed). + +### 2. Monkey-Patched Files + +These are files our addon patches at runtime via Ruby `prepend`, `include`, or `after_initialize` hooks. Changes to these files could break our patches. + +**Search Backend (OpenSearch compatibility patch):** +- `lib/search_index_backend.rb` - We prepend `SearchIndexBackendOpenSearchPatch` to override `_mapping_item_type_es`. Check if this method signature or the `'flattened'` string usage has changed. + +**Core Models (callback injection targets):** +- `app/models/ticket/article.rb` - We inject `after_create` callbacks via `include` for Signal and WhatsApp message delivery. Check for changes to the callback chain, model structure, or the `Sender`/`Type` lookup patterns. +- `app/models/link.rb` - We inject an `after_create` callback for Signal group setup on ticket split. Check for structural changes. + +**Transaction System:** +- `app/models/transaction/` directory - We register `Transaction::SignalNotification` as backend `0105_signal_notification`. Check if the transaction backend system has been refactored. + +**Icons:** +- `public/assets/images/icons.svg` - Our initializers append SVG icons at boot time. Check if the SVG structure or the icon injection mechanism has changed. + +Command pattern: +```bash +git -C /tmp/zammad-upstream diff -- +``` + +For the search backend specifically, also check if `_mapping_item_type_es` still exists and still returns `'flattened'`: +```bash +git -C /tmp/zammad-upstream show :lib/search_index_backend.rb | grep -n -A5 '_mapping_item_type_es\|flattened' +``` + +### 3. API Surface Dependencies + +These are Zammad APIs/interfaces/mixins our addon relies on. Changes could cause runtime failures. + +**Channel Driver Interface:** +- `app/models/channel/driver/` - Check if the driver base class or interface expectations have changed (methods: `fetchable?`, `disconnect`, `deliver`, `streamable?`). + +**Controller Concerns:** +- `app/controllers/concerns/creates_ticket_articles.rb` - Used by our webhook controllers. Check for interface changes. + +**Ticket Article Types & Senders:** +- `app/models/ticket/article/type.rb` and `app/models/ticket/article/sender.rb` - We look up types by name (`'signal message'`, `'whatsapp message'`). Check for changes in how types are registered or looked up. + +**Authentication/Authorization:** +- `app/policies/` directory structure - We create policies matching `controllers/` names. Check if the policy naming convention or base class has changed. + +**Package System:** +- `lib/package.rb` or the package install/uninstall API - We use `Package.install(file:)` and `Package.uninstall(name:, version:)` in setup.rb. + +**Scheduler/Job System:** +- `app/jobs/` base class patterns - Our jobs inherit from ApplicationJob. Check for changes. + +Command pattern: +```bash +git -C /tmp/zammad-upstream diff --stat -- +git -C /tmp/zammad-upstream diff -- +``` + +### 4. Path Collision Detection + +Check if the target Zammad version has added any NEW files at paths that collide with our addon files. Our addon installs files at these paths: + +**Controllers:** `app/controllers/channels_cdr_signal_controller.rb`, `channels_cdr_voice_controller.rb`, `channels_cdr_whatsapp_controller.rb`, `cdr_signal_channels_controller.rb`, `cdr_ticket_article_types_controller.rb`, `formstack_controller.rb`, `opensearch_controller.rb` + +**Models:** `app/models/channel/driver/cdr_signal.rb`, `cdr_whatsapp.rb`, `app/models/ticket/article/enqueue_communicate_cdr_signal_job.rb`, `enqueue_communicate_cdr_whatsapp_job.rb`, `app/models/link/setup_split_signal_group.rb`, `app/models/transaction/signal_notification.rb` + +**Jobs:** `app/jobs/communicate_cdr_signal_job.rb`, `communicate_cdr_whatsapp_job.rb`, `signal_notification_job.rb`, `create_ticket_from_form_job.rb` + +**Libraries:** `lib/cdr_signal.rb`, `cdr_signal_api.rb`, `cdr_signal_poller.rb`, `cdr_whatsapp.rb`, `cdr_whatsapp_api.rb`, `signal_notification_sender.rb` + +**Routes:** `config/routes/cdr_signal_channels.rb`, `channel_cdr_signal.rb`, `channel_cdr_voice.rb`, `channel_cdr_whatsapp.rb`, `cdr_ticket_article_types.rb`, `formstack.rb`, `opensearch.rb` + +**Frontend Plugins:** `app/frontend/shared/entities/ticket-article/action/plugins/cdr_signal.ts`, `cdr_whatsapp.ts`, `app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/article-type/plugins/signalMessage.ts`, `cdrWhatsappMessage.ts` + +Check if any of these paths exist in the target version: +```bash +for path in ; do + git -C /tmp/zammad-upstream show :$path 2>/dev/null && echo "COLLISION: $path exists upstream" +done +``` + +### 5. Dockerfile Patch Targets + +Check files that are patched at Docker build time via `sed`: + +- `lib/search_index_backend.rb` - `sed` replaces `'flattened'` with `'flat_object'`. Verify the string still exists in the target version. +- `contrib/nginx/zammad.conf` - Structure modified for embedded mode. Check for format changes. +- `docker-entrypoint.sh` - We inject addon install commands after the `# es config` comment. Verify this comment/anchor still exists. + +Check the upstream Docker entrypoint: +```bash +git -C /tmp/zammad-upstream show :contrib/docker/docker-entrypoint.sh 2>/dev/null | grep -n 'es config' || echo "Anchor comment not found - check entrypoint structure" +``` + +Also check the Zammad Docker Compose repo if relevant (the base image may come from `zammad/zammad-docker-compose`). + +### 6. Database Schema Conflicts + +Check if the target Zammad version adds any columns or tables that could conflict with our migrations: +- Column names: `whatsapp_uid`, `signal_uid`, `signal_username` on the users table +- Setting names containing: `signal_notification`, `cdr_link`, `formstack`, `opensearch_dashboard` + +```bash +git -C /tmp/zammad-upstream diff -- db/migrate/ | grep -i 'signal\|whatsapp\|formstack\|opensearch' +``` + +### 7. Frontend Build System + +Check if the Vite/asset pipeline configuration has changed significantly, since our addon relies on being compiled into the Zammad frontend: + +```bash +git -C /tmp/zammad-upstream diff --stat -- vite.config.ts app/frontend/vite.config.ts config/initializers/assets.rb Gemfile +``` + +Also check if CoffeeScript/Sprockets support has been removed (would break our legacy UI files): +```bash +git -C /tmp/zammad-upstream show :Gemfile 2>/dev/null | grep -i 'coffee\|sprockets' +``` + +## Report Format + +Compile all findings into a structured report: + +``` +## Zammad Compatibility Report: -> + +### CRITICAL (Action Required Before Upgrade) +- [List files that changed upstream AND are replaced by our addon - these need manual merging] +- [List any broken monkey-patch targets] +- [List any path collisions] + +### WARNING (Review Needed) +- [List API surface changes that could affect our code] +- [List Dockerfile patch targets that changed] +- [List build system changes] + +### INFO (No Action Needed) +- [List files checked with no changes] +- [List confirmed-safe paths] + +### Recommended Actions +- For each CRITICAL item, describe what needs to be done +- Note any files that should be re-copied from upstream and re-patched +``` + +For each changed file in CRITICAL, show the upstream diff so the user can see what changed and decide how to integrate it. diff --git a/.dockerignore b/.dockerignore index 923dc15..006c746 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,5 @@ node_modules out signald docker-compose.yml -README.md \ No newline at end of file +README.md +.git diff --git a/.gitignore b/.gitignore index 4011990..7a242f8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ build/** .next/** docker/zammad/addons/** !docker/zammad/addons/.gitkeep +docker/zammad/gems/** +!docker/zammad/gems/.gitkeep .npmrc coverage/ build/ @@ -28,6 +30,15 @@ baileys-state signald-state project.org **/.openapi-generator/ -apps/bridge-worker/scripts/* ENVIRONMENT_VARIABLES_MIGRATION.md local-scripts/* +docs/ +packages/zammad-addon-link/test/ + +# Allow Claude Code project config (overrides global gitignore) +!CLAUDE.md +!.claude/ +.claude/** +!.claude/skills/ +!.claude/skills/** +.claude/settings.local.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8fcd82b..b571488 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -69,39 +69,6 @@ buildx-docker-release: variables: DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/buildx -link-docker-build: - extends: .docker-build - variables: - DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/link - DOCKERFILE_PATH: ./apps/link/Dockerfile - -link-docker-release: - extends: .docker-release - variables: - DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/link - -bridge-frontend-docker-build: - extends: .docker-build - variables: - DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-frontend - DOCKERFILE_PATH: ./apps/bridge-frontend/Dockerfile - -bridge-frontend-docker-release: - extends: .docker-release - variables: - DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-frontend - -bridge-worker-docker-build: - extends: .docker-build - variables: - DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-worker - DOCKERFILE_PATH: ./apps/bridge-worker/Dockerfile - -bridge-worker-docker-release: - extends: .docker-release - variables: - DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-worker - bridge-whatsapp-docker-build: extends: .docker-build variables: @@ -205,33 +172,10 @@ zammad-docker-build: - 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} ${BUILD_CONTEXT} + - DOCKER_BUILDKIT=1 docker build --pull --no-cache -t ${DOCKER_NS}:${DOCKER_TAG} -f ${DOCKERFILE_PATH} ${BUILD_CONTEXT} - docker push ${DOCKER_NS}:${DOCKER_TAG} zammad-docker-release: extends: .docker-release variables: DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad - -zammad-standalone-docker-build: - extends: .docker-build - variables: - DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad-standalone - DOCKERFILE_PATH: ./docker/zammad/Dockerfile - BUILD_CONTEXT: ./docker/zammad - PNPM_HOME: "/pnpm" - before_script: - - export PATH="$PNPM_HOME:$PATH" - - corepack enable && corepack prepare pnpm@9.15.4 --activate - script: - - 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} ${BUILD_CONTEXT} - - docker push ${DOCKER_NS}:${DOCKER_TAG} - -zammad-standalone-docker-release: - extends: .docker-release - variables: - DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad-standalone diff --git a/.nvmrc b/.nvmrc index 32cfab6..54c6511 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v22.18.0 +v24 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a29fba6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +This is a monorepo for CDR Link - a Zammad addon and supporting services built by the Center for Digital Resilience. It adds Signal, WhatsApp, and voice channel support to Zammad via a custom `.zpm` addon package, along with a standalone WhatsApp bridge service. It uses pnpm workspaces and Turborepo for orchestration. + +**Tech Stack:** +- Zammad 6.5.x as the core helpdesk platform +- Ruby (Rails initializers, controllers, models, jobs) for the Zammad addon +- TypeScript/Node.js for build tooling and the WhatsApp bridge +- CoffeeScript for Zammad legacy UI extensions +- Vue 3 for Zammad desktop UI extensions +- Docker for containerization +- PostgreSQL, Redis, Memcached as backing services + +## Project Structure + +``` +apps/ + bridge-whatsapp/ # Standalone WhatsApp bridge (Hapi.js + Baileys) +packages/ + zammad-addon-link/ # Zammad addon source (Ruby, CoffeeScript, Vue, TS) + src/ # Addon source files (installed into /opt/zammad/) + scripts/build.ts # Builds .zpm package from src/ + scripts/migrate.ts # Generates new migration stubs +docker/ + zammad/ # Custom Zammad Docker image + Dockerfile # Extends zammad/zammad-docker-compose base image + install.rb # Extracts addon files from .zpm at build time + setup.rb # Registers addon packages at container startup + addons/ # Built .zpm files (gitignored, generated by turbo build) + compose/ # Docker Compose service definitions +``` + +## Common Development Commands + +```bash +pnpm install # Install all dependencies +turbo build # Build all packages (generates .zpm files) +npm run docker:zammad:build # Build custom Zammad Docker image +npm run docker:all:up # Start all Docker services +npm run docker:all:down # Stop all Docker services +npm run docker:zammad:restart # Restart railsserver + scheduler (after Ruby changes) +npm run update-version # Update version across all packages +npm run clean # Remove all build artifacts and dependencies +``` + +## Zammad Addon Architecture + +### Addon Build & Deploy Pipeline + +1. `turbo build` runs `tsx scripts/build.ts` in `packages/zammad-addon-link/` +2. Build script base64-encodes all files under `src/`, produces `docker/zammad/addons/zammad-addon-link-v{version}.zpm` +3. `docker/zammad/Dockerfile` builds a custom image: + - Copies `.zpm` files and runs `install.rb` to extract addon files into the Zammad directory tree + - Rebuilds Vite frontend (`bundle exec vite build`) to include addon Vue components + - Precompiles assets (`rake assets:precompile`) to include addon CoffeeScript + - Applies `sed` patches (OpenSearch compatibility, entrypoint injection) +4. At container startup, `setup.rb` registers the addon via `Package.install()` and runs migrations + +### How the Addon Extends Zammad + +**New files (no upstream conflict risk):** Controllers, channel drivers, jobs, routes, policies, library classes, views, CSS, SVG icons, frontend plugins. These add Signal/WhatsApp/voice channel support. + +**Replaced stock files (HIGH conflict risk - must be manually merged on Zammad upgrades):** +- `app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleReply.vue` - Adds channel whitelist filtering via `cdr_link_allowed_channels` setting +- `app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingNotifications.vue` - Adds Signal notification recipient field +- `app/frontend/apps/desktop/components/Form/fields/FieldNotifications/FieldNotificationsInput.vue` - Adds Signal column to notification matrix +- `app/frontend/apps/desktop/components/Form/fields/FieldNotifications/types.ts` - Extended notification types +- `app/assets/javascripts/app/controllers/_profile/notification.coffee` - Signal notification prefs (legacy UI) +- `app/assets/javascripts/app/controllers/_ui_element/notification_matrix.coffee` - Signal column (legacy UI) +- `app/assets/javascripts/app/lib/mixins/ticket_notification_matrix.coffee` - Notification matrix mixin +- `app/assets/javascripts/app/views/generic/notification_matrix.jst.eco` - Notification matrix template +- `app/assets/javascripts/app/views/profile/notification.jst.eco` - Notification profile template + +**Runtime monkey-patches (HIGH conflict risk):** +- `config/initializers/opensearch_compatibility.rb` - Prepends to `SearchIndexBackend._mapping_item_type_es()` to replace `'flattened'` with `'flat_object'` for OpenSearch +- `config/initializers/cdr_signal.rb` - Injects `after_create` callbacks into `Ticket::Article` and `Link` models +- `config/initializers/cdr_whatsapp.rb` - Injects `after_create` callback into `Ticket::Article` + +**Dockerfile-level patches:** +- `lib/search_index_backend.rb` - `sed` replaces `'flattened'` with `'flat_object'` +- `/docker-entrypoint.sh` - `sed` injects addon install commands after `# es config` anchor +- `contrib/nginx/zammad.conf` - Adds `/link` proxy location in embedded mode + +### Key Zammad API Dependencies + +The addon depends on these Zammad interfaces remaining stable: +- `Channel::Driver` interface (`fetchable?`, `disconnect`, `deliver`, `streamable?`) +- `Ticket::Article` model callbacks and `Sender`/`Type` lookup by name +- `Link` model and `Link::Type`/`Link::Object` +- `SearchIndexBackend._mapping_item_type_es` method +- `Transaction` backend registration system +- `Package.install(file:)` / `Package.uninstall(name:, version:)` API +- `CreatesTicketArticles` controller concern +- Policy naming convention (`controllers/_controller_policy.rb`) + +## Zammad Development Notes + +- After changing any Ruby files, restart railsserver and scheduler: `npm run docker:zammad:restart` +- The addon must be rebuilt (`turbo build`) and the Docker image rebuilt (`npm run docker:zammad:build`) for changes to take effect in Docker +- Use `/zammad-compat ` to check upstream Zammad for breaking changes before upgrading +- The current Zammad base version is set in `docker/zammad/Dockerfile` as `ARG ZAMMAD_VERSION` + +## Docker Services + +Defined in `docker/compose/`: +- **zammad.yml**: zammad-init, zammad-railsserver, zammad-nginx, zammad-scheduler, zammad-websocket, zammad-memcached, zammad-redis +- **bridge-whatsapp.yml**: bridge-whatsapp +- **postgresql.yml**: postgresql +- **signal-cli-rest-api.yml**: signal-cli-rest-api +- **opensearch.yml**: opensearch + dashboards diff --git a/apps/bridge-worker/Dockerfile b/apps/bridge-deltachat/Dockerfile similarity index 71% rename from apps/bridge-worker/Dockerfile rename to apps/bridge-deltachat/Dockerfile index 48de099..0847e99 100644 --- a/apps/bridge-worker/Dockerfile +++ b/apps/bridge-deltachat/Dockerfile @@ -1,7 +1,7 @@ FROM node:22-bookworm-slim AS base FROM base AS builder -ARG APP_DIR=/opt/bridge-worker +ARG APP_DIR=/opt/bridge-deltachat ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN mkdir -p ${APP_DIR}/ @@ -9,10 +9,10 @@ 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 +RUN turbo prune --scope=@link-stack/bridge-deltachat --docker FROM base AS installer -ARG APP_DIR=/opt/bridge-worker +ARG APP_DIR=/opt/bridge-deltachat ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" WORKDIR ${APP_DIR} @@ -22,24 +22,28 @@ COPY --from=builder ${APP_DIR}/out/full/ . 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 +RUN turbo run build --filter=@link-stack/bridge-deltachat FROM base as runner ARG BUILD_DATE ARG VERSION -ARG APP_DIR=/opt/bridge-worker +ARG APP_DIR=/opt/bridge-deltachat 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 \ dumb-init +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate WORKDIR ${APP_DIR} COPY --from=installer ${APP_DIR} ./ RUN chown -R node:node ${APP_DIR} -WORKDIR ${APP_DIR}/apps/bridge-worker/ +WORKDIR ${APP_DIR}/apps/bridge-deltachat/ RUN chmod +x docker-entrypoint.sh USER node +RUN mkdir /home/node/deltachat-data +EXPOSE 5001 +ENV PORT 5001 ENV NODE_ENV production -ENTRYPOINT ["/opt/bridge-worker/apps/bridge-worker/docker-entrypoint.sh"] +ENV COREPACK_ENABLE_NETWORK=0 +ENTRYPOINT ["/opt/bridge-deltachat/apps/bridge-deltachat/docker-entrypoint.sh"] diff --git a/apps/bridge-deltachat/docker-entrypoint.sh b/apps/bridge-deltachat/docker-entrypoint.sh new file mode 100755 index 0000000..13f5bd0 --- /dev/null +++ b/apps/bridge-deltachat/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e +echo "starting bridge-deltachat" +exec dumb-init pnpm run start diff --git a/apps/bridge-deltachat/eslint.config.mjs b/apps/bridge-deltachat/eslint.config.mjs new file mode 100644 index 0000000..2997d1b --- /dev/null +++ b/apps/bridge-deltachat/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from "@link-stack/eslint-config/node"; + +export default config; diff --git a/apps/bridge-deltachat/package.json b/apps/bridge-deltachat/package.json new file mode 100644 index 0000000..9ce251c --- /dev/null +++ b/apps/bridge-deltachat/package.json @@ -0,0 +1,34 @@ +{ + "name": "@link-stack/bridge-deltachat", + "version": "3.5.0-beta.1", + "main": "build/main/index.js", + "author": "Darren Clarke ", + "license": "AGPL-3.0-or-later", + "prettier": "@link-stack/prettier-config", + "dependencies": { + "@deltachat/jsonrpc-client": "^1.151.1", + "@deltachat/stdio-rpc-server": "^1.151.1", + "@hono/node-server": "^1.13.8", + "hono": "^4.7.4", + "@link-stack/logger": "workspace:*" + }, + "devDependencies": { + "@link-stack/eslint-config": "workspace:*", + "@link-stack/prettier-config": "workspace:*", + "@link-stack/typescript-config": "workspace:*", + "@types/node": "*", + "dotenv-cli": "^10.0.0", + "eslint": "^9.23.0", + "prettier": "^3.5.3", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "dotenv -- tsx src/index.ts", + "start": "node build/main/index.js", + "lint": "eslint src/", + "format": "prettier --write src/", + "format:check": "prettier --check src/" + } +} diff --git a/apps/bridge-deltachat/src/attachments.ts b/apps/bridge-deltachat/src/attachments.ts new file mode 100644 index 0000000..850b782 --- /dev/null +++ b/apps/bridge-deltachat/src/attachments.ts @@ -0,0 +1,35 @@ +/** + * Attachment size configuration for messaging channels + * + * Environment variables: + * - BRIDGE_MAX_ATTACHMENT_SIZE_MB: Maximum size for a single attachment in MB (default: 50) + */ + +/** + * Get the maximum attachment size in bytes from environment variable + * Defaults to 50MB if not set + */ +export function getMaxAttachmentSize(): number { + const envValue = process.env.BRIDGE_MAX_ATTACHMENT_SIZE_MB; + const sizeInMB = envValue ? Number.parseInt(envValue, 10) : 50; + + if (Number.isNaN(sizeInMB) || sizeInMB <= 0) { + console.warn(`Invalid BRIDGE_MAX_ATTACHMENT_SIZE_MB value: ${envValue}, using default 50MB`); + return 50 * 1024 * 1024; + } + + return sizeInMB * 1024 * 1024; +} + +/** + * Get the maximum total size for all attachments in a message + * This is 4x the single attachment size + */ +export function getMaxTotalAttachmentSize(): number { + return getMaxAttachmentSize() * 4; +} + +/** + * Maximum number of attachments per message + */ +export const MAX_ATTACHMENTS = 10; diff --git a/apps/bridge-deltachat/src/index.ts b/apps/bridge-deltachat/src/index.ts new file mode 100644 index 0000000..729b8b0 --- /dev/null +++ b/apps/bridge-deltachat/src/index.ts @@ -0,0 +1,33 @@ +import { serve } from "@hono/node-server"; +import { createLogger } from "@link-stack/logger"; + +import { createRoutes } from "./routes.ts"; +import DeltaChatService from "./service.ts"; + +const logger = createLogger("bridge-deltachat-index"); + +const main = async () => { + const service = new DeltaChatService(); + await service.initialize(); + + const app = createRoutes(service); + const port = Number.parseInt(process.env.PORT || "5001", 10); + + serve({ fetch: app.fetch, port }, (info) => { + logger.info({ port: info.port }, "bridge-deltachat listening"); + }); + + const shutdown = async () => { + logger.info("Shutting down..."); + await service.teardown(); + process.exit(0); + }; + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +}; + +main().catch((error) => { + logger.error(error); + process.exit(1); +}); diff --git a/apps/bridge-deltachat/src/routes.ts b/apps/bridge-deltachat/src/routes.ts new file mode 100644 index 0000000..a4d6d6d --- /dev/null +++ b/apps/bridge-deltachat/src/routes.ts @@ -0,0 +1,62 @@ +import { createLogger } from "@link-stack/logger"; +import { Hono } from "hono"; + +import type DeltaChatService from "./service.ts"; + +const logger = createLogger("bridge-deltachat-routes"); + +const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export function createRoutes(service: DeltaChatService): Hono { + const app = new Hono(); + + app.post("/api/bots/:id/configure", async (c) => { + const id = c.req.param("id"); + const { email, password } = await c.req.json<{ email: string; password: string }>(); + + try { + const result = await service.configure(id, email, password); + logger.info({ id, email }, "Bot configured"); + return c.json(result); + } catch (error) { + logger.error({ id, error: errorMessage(error) }, "Failed to configure bot"); + return c.json({ error: errorMessage(error) }, 500); + } + }); + + app.get("/api/bots/:id", async (c) => { + const id = c.req.param("id"); + return c.json(await service.getBot(id)); + }); + + app.post("/api/bots/:id/send", async (c) => { + const id = c.req.param("id"); + const { email, message, attachments } = await c.req.json<{ + email: string; + message: string; + attachments?: Array<{ data: string; filename: string; mime_type: string }>; + }>(); + + try { + const result = await service.send(id, email, message, attachments); + logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message"); + return c.json({ result }); + } catch (error) { + logger.error({ id, error: errorMessage(error) }, "Failed to send message"); + return c.json({ error: errorMessage(error) }, 500); + } + }); + + app.post("/api/bots/:id/unconfigure", async (c) => { + const id = c.req.param("id"); + await service.unconfigure(id); + logger.info({ id }, "Bot unconfigured"); + return c.body(null, 200); + }); + + app.get("/api/health", (c) => { + return c.json({ status: "ok" }); + }); + + return app; +} diff --git a/apps/bridge-deltachat/src/service.ts b/apps/bridge-deltachat/src/service.ts new file mode 100644 index 0000000..17b37e0 --- /dev/null +++ b/apps/bridge-deltachat/src/service.ts @@ -0,0 +1,365 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { startDeltaChat, type DeltaChatOverJsonRpcServer } from "@deltachat/stdio-rpc-server"; +import { createLogger } from "@link-stack/logger"; + +import { getMaxAttachmentSize, getMaxTotalAttachmentSize, MAX_ATTACHMENTS } from "./attachments"; + +const logger = createLogger("bridge-deltachat-service"); + +interface BotMapping { + [botId: string]: number; +} + +export default class DeltaChatService { + private dc: DeltaChatOverJsonRpcServer | null = null; + private botMapping: BotMapping = {}; + private dataDir: string; + private mappingFile: string; + + constructor() { + this.dataDir = process.env.DELTACHAT_DATA_DIR || "/home/node/deltachat-data"; + this.mappingFile = path.join(this.dataDir, "bot-mapping.json"); + } + + async initialize(): Promise { + if (!fs.existsSync(this.dataDir)) { + fs.mkdirSync(this.dataDir, { recursive: true }); + } + + logger.info({ dataDir: this.dataDir }, "Starting deltachat-rpc-server"); + this.dc = await startDeltaChat(this.dataDir); + logger.info("deltachat-rpc-server started"); + + this.loadBotMapping(); + + for (const [botId, accountId] of Object.entries(this.botMapping)) { + try { + const isConfigured = await this.dc.rpc.isConfigured(accountId); + if (isConfigured) { + await this.dc.rpc.startIo(accountId); + logger.info({ botId, accountId }, "Resumed IO for existing bot"); + } else { + logger.warn({ botId, accountId }, "Account not configured, removing from mapping"); + delete this.botMapping[botId]; + } + } catch (error) { + logger.error({ botId, accountId, err: error }, "Failed to resume bot, removing from mapping"); + delete this.botMapping[botId]; + } + } + + this.saveBotMapping(); + this.registerEventListeners(); + } + + async teardown(): Promise { + if (this.dc) { + for (const [botId, accountId] of Object.entries(this.botMapping)) { + try { + await this.dc.rpc.stopIo(accountId); + logger.info({ botId, accountId }, "Stopped IO for bot"); + } catch (error) { + logger.error({ botId, accountId, err: error }, "Error stopping IO"); + } + } + this.dc.close(); + this.dc = null; + } + } + + private loadBotMapping(): void { + if (fs.existsSync(this.mappingFile)) { + try { + const data = fs.readFileSync(this.mappingFile, "utf8"); + this.botMapping = JSON.parse(data); + logger.info({ botCount: Object.keys(this.botMapping).length }, "Loaded bot mapping"); + } catch (error) { + logger.error({ err: error }, "Failed to load bot mapping, starting fresh"); + this.botMapping = {}; + } + } + } + + private saveBotMapping(): void { + fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2), "utf8"); + } + + private validateBotId(id: string): void { + if (!/^[a-zA-Z0-9_-]+$/.test(id)) { + throw new Error(`Invalid bot ID format: ${id}`); + } + } + + private getBotIdForAccount(accountId: number): string | undefined { + return Object.entries(this.botMapping).find(([, aid]) => aid === accountId)?.[0]; + } + + private registerEventListeners(): void { + if (!this.dc) return; + + this.dc.on("IncomingMsg", (accountId, event) => { + this.handleIncomingMessage(accountId, event.chatId, event.msgId).catch((error) => { + logger.error({ err: error, accountId }, "Error handling incoming message"); + }); + }); + } + + private async handleIncomingMessage(accountId: number, chatId: number, msgId: number): Promise { + if (!this.dc) return; + + const botId = this.getBotIdForAccount(accountId); + if (!botId) { + logger.warn({ accountId }, "Received message for unknown account"); + return; + } + + const msg = await this.dc.rpc.getMessage(accountId, msgId); + + // Incoming states: 10=fresh, 13=noticed, 16=seen + const isIncoming = msg.state === 10 || msg.state === 13 || msg.state === 16; + if (msg.isBot || !isIncoming) { + logger.debug({ msgId, isBot: msg.isBot, state: msg.state }, "Skipping message"); + return; + } + + const contact = await this.dc.rpc.getContact(accountId, msg.fromId); + const senderEmail = contact.address; + const botConfig = await this.dc.rpc.getConfig(accountId, "configured_addr"); + const botEmail = botConfig || ""; + + logger.info({ botId, senderEmail, msgId }, "Processing incoming message"); + + let attachment: string | undefined; + let filename: string | undefined; + let mimeType: string | undefined; + + if (msg.file) { + try { + const fileData = fs.readFileSync(msg.file); + attachment = fileData.toString("base64"); + filename = msg.fileName || path.basename(msg.file); + mimeType = msg.fileMime || "application/octet-stream"; + logger.info({ filename, mimeType, size: fileData.length }, "Attachment found"); + } catch (error) { + logger.error({ err: error, file: msg.file }, "Failed to read attachment file"); + } + } + + const payload: Record = { + from: senderEmail, + to: botEmail, + message: msg.text || "", + message_id: String(msgId), + sent_at: new Date(msg.timestamp * 1000).toISOString(), + }; + + if (attachment) { + payload.attachment = attachment; + payload.filename = filename; + payload.mime_type = mimeType; + } + + const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080"; + try { + const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_deltachat_bot_webhook/${botId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + logger.info({ botId, msgId }, "Message forwarded to Zammad"); + } else { + const errorText = await response.text(); + logger.error({ status: response.status, error: errorText, botId }, "Failed to send message to Zammad"); + } + } catch (error) { + logger.error({ err: error, botId }, "Failed to POST to Zammad webhook"); + } + + try { + await this.dc.rpc.markseenMsgs(accountId, [msgId]); + } catch (error) { + logger.error({ err: error, msgId }, "Failed to mark message as seen"); + } + } + + async configure(botId: string, email: string, password: string): Promise<{ accountId: number; email: string }> { + this.validateBotId(botId); + if (!this.dc) throw new Error("DeltaChat not initialized"); + + if (this.botMapping[botId] !== undefined) { + throw new Error(`Bot ${botId} is already configured`); + } + + const accountId = await this.dc.rpc.addAccount(); + logger.info({ botId, accountId, email }, "Created new account"); + + try { + await this.dc.rpc.batchSetConfig(accountId, { + addr: email, + mail_pw: password, + bot: "1", + e2ee_enabled: "1", + }); + + logger.info({ botId, accountId }, "Configuring account (verifying credentials)..."); + await this.dc.rpc.configure(accountId); + logger.info({ botId, accountId }, "Account configured successfully"); + + await this.dc.rpc.startIo(accountId); + logger.info({ botId, accountId }, "IO started"); + + this.botMapping[botId] = accountId; + this.saveBotMapping(); + + return { accountId, email }; + } catch (error) { + logger.error({ botId, accountId, err: error }, "Configuration failed, removing account"); + try { + await this.dc.rpc.removeAccount(accountId); + } catch (error_) { + logger.error({ removeErr: error_ }, "Failed to clean up account after configuration failure"); + } + throw error; + } + } + + async getBot(botId: string): Promise<{ configured: boolean; email: string | null }> { + this.validateBotId(botId); + + const accountId = this.botMapping[botId]; + if (accountId === undefined || !this.dc) { + return { configured: false, email: null }; + } + + try { + const isConfigured = await this.dc.rpc.isConfigured(accountId); + const email = await this.dc.rpc.getConfig(accountId, "configured_addr"); + return { configured: isConfigured, email: email || null }; + } catch { + return { configured: false, email: null }; + } + } + + async unconfigure(botId: string): Promise { + this.validateBotId(botId); + if (!this.dc) throw new Error("DeltaChat not initialized"); + + const accountId = this.botMapping[botId]; + if (accountId === undefined) { + logger.warn({ botId }, "Bot not found for unconfigure"); + return; + } + + try { + await this.dc.rpc.stopIo(accountId); + } catch (error) { + logger.warn({ botId, accountId, err: error }, "Error stopping IO during unconfigure"); + } + + try { + await this.dc.rpc.removeAccount(accountId); + } catch (error) { + logger.warn({ botId, accountId, err: error }, "Error removing account during unconfigure"); + } + + delete this.botMapping[botId]; + this.saveBotMapping(); + logger.info({ botId, accountId }, "Bot unconfigured and removed"); + } + + async send( + botId: string, + email: string, + message: string, + attachments?: Array<{ data: string; filename: string; mime_type: string }> + ): Promise<{ recipient: string; timestamp: string; source: string }> { + this.validateBotId(botId); + if (!this.dc) throw new Error("DeltaChat not initialized"); + + const accountId = this.botMapping[botId]; + if (accountId === undefined) { + throw new Error(`Bot ${botId} is not configured`); + } + + const contactId = await this.dc.rpc.createContact(accountId, email, ""); + const chatId = await this.dc.rpc.createChatByContactId(accountId, contactId); + + 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 att of attachments) { + const estimatedSize = (att.data.length * 3) / 4; + + if (estimatedSize > MAX_ATTACHMENT_SIZE) { + logger.warn( + { filename: att.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(att.data, "base64"); + const tmpFile = path.join(os.tmpdir(), `dc-${Date.now()}-${att.filename}`); + fs.writeFileSync(tmpFile, buffer); + + try { + await this.dc.rpc.sendMsg(accountId, chatId, { + text: message, + html: null, + viewtype: null, + file: tmpFile, + filename: att.filename, + location: null, + overrideSenderName: null, + quotedMessageId: null, + quotedText: null, + }); + // Only include text with the first attachment; clear for subsequent + message = ""; + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + // ignore cleanup errors + } + } + } + + // If we had message text but all attachments were skipped, send text only + if (message) { + await this.dc.rpc.miscSendTextMessage(accountId, chatId, message); + } + } else { + await this.dc.rpc.miscSendTextMessage(accountId, chatId, message); + } + + const botEmail = (await this.dc.rpc.getConfig(accountId, "configured_addr")) || botId; + + return { + recipient: email, + timestamp: new Date().toISOString(), + source: botEmail, + }; + } +} diff --git a/apps/bridge-deltachat/tsconfig.json b/apps/bridge-deltachat/tsconfig.json new file mode 100644 index 0000000..0c55ac9 --- /dev/null +++ b/apps/bridge-deltachat/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@link-stack/typescript-config/tsconfig.node.json", + "compilerOptions": { + "outDir": "build/main", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/.*.ts"], + "exclude": ["node_modules/**"] +} diff --git a/apps/bridge-frontend/.eslintrc.json b/apps/bridge-frontend/.eslintrc.json deleted file mode 100644 index bffb357..0000000 --- a/apps/bridge-frontend/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/apps/bridge-frontend/.gitignore b/apps/bridge-frontend/.gitignore deleted file mode 100644 index fd3dbb5..0000000 --- a/apps/bridge-frontend/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/apps/bridge-frontend/README.md b/apps/bridge-frontend/README.md deleted file mode 100644 index ebec248..0000000 --- a/apps/bridge-frontend/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# Bridge Frontend - -Frontend application for managing communication bridges between various messaging platforms and the CDR Link system. - -## Overview - -Bridge Frontend provides a web interface for configuring and managing communication channels including Signal, WhatsApp, Facebook, and Voice integrations. It handles bot registration, webhook configuration, and channel settings. - -## Features - -- **Channel Management**: Configure Signal, WhatsApp, Facebook, and Voice channels -- **Bot Registration**: Register and manage bots for each communication platform -- **Webhook Configuration**: Set up webhooks for message routing -- **Settings Management**: Configure channel-specific settings and behaviors -- **User Authentication**: Secure access with NextAuth.js - -## Development - -### Prerequisites - -- Node.js >= 20 -- npm >= 10 -- PostgreSQL database -- Running bridge-worker service - -### Setup - -```bash -# Install dependencies -npm install - -# Run database migrations -npm run migrate:latest - -# Run development server -npm run dev - -# Build for production -npm run build - -# Start production server -npm run start -``` - -### Environment Variables - -Required environment variables: - -- `DATABASE_URL` - PostgreSQL connection string -- `DATABASE_HOST` - Database host -- `DATABASE_NAME` - Database name -- `DATABASE_USER` - Database username -- `DATABASE_PASSWORD` - Database password -- `NEXTAUTH_URL` - Application URL -- `NEXTAUTH_SECRET` - NextAuth.js secret -- `GOOGLE_CLIENT_ID` - Google OAuth client ID -- `GOOGLE_CLIENT_SECRET` - Google OAuth client secret - -### Available Scripts - -- `npm run dev` - Start development server -- `npm run build` - Build for production -- `npm run start` - Start production server -- `npm run lint` - Run ESLint -- `npm run migrate:latest` - Run all pending migrations -- `npm run migrate:down` - Rollback last migration -- `npm run migrate:up` - Run next migration -- `npm run migrate:make` - Create new migration - -## Architecture - -### Database Schema - -The application manages the following main entities: - -- **Bots**: Communication channel bot configurations -- **Webhooks**: Webhook endpoints for external integrations -- **Settings**: Channel-specific configuration settings -- **Users**: User accounts with role-based permissions - -### API Routes - -- `/api/auth` - Authentication endpoints -- `/api/[service]/bots` - Bot management for each service -- `/api/[service]/webhooks` - Webhook configuration - -### Page Structure - -- `/` - Dashboard/home page -- `/login` - Authentication page -- `/[...segment]` - Dynamic routing for CRUD operations - - `@create` - Create new entities - - `@detail` - View entity details - - `@edit` - Edit existing entities - -## Integration - -### Database Access - -Uses Kysely ORM for type-safe database queries: - -```typescript -import { db } from '@link-stack/database' - -const bots = await db - .selectFrom('bots') - .selectAll() - .execute() -``` - -### Authentication - -Integrated with NextAuth.js using database adapter: - -```typescript -import { authOptions } from '@link-stack/auth' -``` - -## Docker Support - -```bash -# Build image -docker build -t link-stack/bridge-frontend . - -# Run with docker-compose -docker-compose -f docker/compose/bridge.yml up -``` - -## Related Services - -- **bridge-worker**: Processes messages from configured channels -- **bridge-whatsapp**: WhatsApp-specific integration service -- **bridge-migrations**: Database schema management \ No newline at end of file diff --git a/apps/bridge-frontend/app/(login)/login/page.tsx b/apps/bridge-frontend/app/(login)/login/page.tsx deleted file mode 100644 index 8f79c27..0000000 --- a/apps/bridge-frontend/app/(login)/login/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Metadata } from "next"; -import { getSession } from "next-auth/react"; -import { Login } from "@/app/_components/Login"; - -export const dynamic = "force-dynamic"; - -export const metadata: Metadata = { - title: "Login", -}; - -export default async function Page() { - const session = await getSession(); - return ; -} diff --git a/apps/bridge-frontend/app/(main)/[...segment]/@create/page.tsx b/apps/bridge-frontend/app/(main)/[...segment]/@create/page.tsx deleted file mode 100644 index bc5b6b8..0000000 --- a/apps/bridge-frontend/app/(main)/[...segment]/@create/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Create } from "@link-stack/bridge-ui"; - -type PageProps = { - params: Promise<{ segment: string[] }>; -}; - -export default async function Page({ params }: PageProps) { - const { segment } = await params; - const service = segment[0]; - - return ; -} diff --git a/apps/bridge-frontend/app/(main)/[...segment]/@detail/page.tsx b/apps/bridge-frontend/app/(main)/[...segment]/@detail/page.tsx deleted file mode 100644 index e857b7d..0000000 --- a/apps/bridge-frontend/app/(main)/[...segment]/@detail/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { db } from "@link-stack/bridge-common"; -import { serviceConfig, Detail } from "@link-stack/bridge-ui"; - -type PageProps = { - params: Promise<{ segment: string[] }>; -}; - -export default async function Page({ params }: PageProps) { - const { segment } = await params; - const service = segment[0]; - const id = segment?.[1]; - - if (!id) return null; - - const { - [service]: { table }, - } = serviceConfig; - - const row = await db - .selectFrom(table) - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - - if (!row) return null; - - return ; -} diff --git a/apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx b/apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx deleted file mode 100644 index 82c8052..0000000 --- a/apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { db } from "@link-stack/bridge-common"; -import { serviceConfig, Edit } from "@link-stack/bridge-ui"; - -type PageProps = { - params: Promise<{ segment: string[] }>; -}; - -export default async function Page({ params }: PageProps) { - const { segment } = await params; - const service = segment[0]; - const id = segment?.[1]; - - if (!id) return null; - - const { - [service]: { table }, - } = serviceConfig; - - const row = await db - .selectFrom(table) - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - - if (!row) return null; - - return ; -} diff --git a/apps/bridge-frontend/app/(main)/[...segment]/layout.tsx b/apps/bridge-frontend/app/(main)/[...segment]/layout.tsx deleted file mode 100644 index c360a57..0000000 --- a/apps/bridge-frontend/app/(main)/[...segment]/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { ServiceLayout } from "@link-stack/bridge-ui"; - -export default ServiceLayout; diff --git a/apps/bridge-frontend/app/(main)/[...segment]/page.tsx b/apps/bridge-frontend/app/(main)/[...segment]/page.tsx deleted file mode 100644 index 7b4fc03..0000000 --- a/apps/bridge-frontend/app/(main)/[...segment]/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { db } from "@link-stack/bridge-common"; -import { serviceConfig, List } from "@link-stack/bridge-ui"; - -type PageProps = { - params: Promise<{ - segment: string[]; - }>; -}; - -export default async function Page({ params }: PageProps) { - const { segment } = await params; - const service = segment[0]; - - if (!service) return null; - - const config = serviceConfig[service]; - - if (!config) return null; - - const rows = await db.selectFrom(config.table).selectAll().execute(); - - return ; -} diff --git a/apps/bridge-frontend/app/(main)/layout.tsx b/apps/bridge-frontend/app/(main)/layout.tsx deleted file mode 100644 index 32203a2..0000000 --- a/apps/bridge-frontend/app/(main)/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { InternalLayout } from "@/app/_components/InternalLayout"; - -export default function Layout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return {children}; -} diff --git a/apps/bridge-frontend/app/(main)/page.tsx b/apps/bridge-frontend/app/(main)/page.tsx deleted file mode 100644 index e01be47..0000000 --- a/apps/bridge-frontend/app/(main)/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Home } from "@link-stack/bridge-ui"; - -export default function Page() { - return ; -} diff --git a/apps/bridge-frontend/app/_components/InternalLayout.tsx b/apps/bridge-frontend/app/_components/InternalLayout.tsx deleted file mode 100644 index 72985ad..0000000 --- a/apps/bridge-frontend/app/_components/InternalLayout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import { FC, PropsWithChildren, useState } from "react"; -import { Grid } from "@mui/material"; -import { CssBaseline } from "@mui/material"; -import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter"; -import { SessionProvider } from "next-auth/react"; -import { Sidebar } from "./Sidebar"; - -export const InternalLayout: FC = ({ children }) => { - const [open, setOpen] = useState(true); - - return ( - - - - - - - {children as any} - - - - - ); -}; diff --git a/apps/bridge-frontend/app/_components/Login.tsx b/apps/bridge-frontend/app/_components/Login.tsx deleted file mode 100644 index 8e40129..0000000 --- a/apps/bridge-frontend/app/_components/Login.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client"; - -import { FC, useState } from "react"; -import { - Box, - Grid, - Container, - IconButton, - Typography, - TextField, -} from "@mui/material"; -import { - Apple as AppleIcon, - Google as GoogleIcon, - Key as KeyIcon, -} from "@mui/icons-material"; -import { signIn } from "next-auth/react"; -import Image from "next/image"; -import LinkLogo from "@/app/_images/link-logo-small.png"; -import { colors, fonts } from "@link-stack/ui"; -import { useSearchParams } from "next/navigation"; - -type LoginProps = { - session: any; -}; - -export const Login: FC = ({ session }) => { - const origin = - typeof window !== "undefined" && window.location.origin - ? window.location.origin - : ""; - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const params = useSearchParams(); - const error = params.get("error"); - const { darkGray, cdrLinkOrange, white } = colors; - const { poppins } = fonts; - const buttonStyles = { - borderRadius: 500, - width: "100%", - fontSize: "16px", - fontWeight: "bold", - backgroundColor: white, - "&:hover": { - color: white, - backgroundColor: cdrLinkOrange, - }, - }; - const fieldStyles = { - "& label.Mui-focused": { - color: cdrLinkOrange, - }, - "& .MuiInput-underline:after": { - borderBottomColor: cdrLinkOrange, - }, - "& .MuiFilledInput-underline:after": { - borderBottomColor: cdrLinkOrange, - }, - "& .MuiOutlinedInput-root": { - "&.Mui-focused fieldset": { - borderColor: cdrLinkOrange, - }, - }, - }; - - return ( - - - - - - - Link logo - - - - - CDR Bridge - - - - - - {!session ? ( - - - {error ? ( - - - - {`${error} error`} - - - - ) : null} - - - signIn("google", { - callbackUrl: `${origin}`, - }) - } - > - - Sign in with Google - - - - - signIn("apple", { - callbackUrl: `${window.location.origin}`, - }) - } - > - - Sign in with Apple - - - - - ) : null} - {session ? ( - - {` ${session.user.name ?? session.user.email}.`} - - ) : null} - - - - - ); -}; diff --git a/apps/bridge-frontend/app/_components/Sidebar.tsx b/apps/bridge-frontend/app/_components/Sidebar.tsx deleted file mode 100644 index 31aaaaa..0000000 --- a/apps/bridge-frontend/app/_components/Sidebar.tsx +++ /dev/null @@ -1,399 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { - Box, - Grid, - Typography, - List, - ListItemButton, - ListItemIcon, - ListItemText, - ListItemSecondaryAction, - Drawer, -} from "@mui/material"; -import { - ExpandCircleDown as ExpandCircleDownIcon, - AccountCircle as AccountCircleIcon, - Chat as ChatIcon, - PermPhoneMsg as PhoneIcon, - WhatsApp as WhatsAppIcon, - Facebook as FacebookIcon, - AirlineStops as AirlineStopsIcon, - Logout as LogoutIcon, -} from "@mui/icons-material"; -import { usePathname } from "next/navigation"; -import Link from "next/link"; -import Image from "next/image"; -import { typography, fonts, Button } from "@link-stack/ui"; -import LinkLogo from "@/app/_images/link-logo-small.png"; -import { useSession, signOut } from "next-auth/react"; - -const openWidth = 270; -const closedWidth = 70; - -const MenuItem = ({ - name, - href, - Icon, - iconSize, - inset = false, - selected = false, - open = true, - badge, - target = "_self", -}: any) => ( - - - {iconSize > 0 ? ( - - - - - - ) : ( - - - - - )} - {open && ( - - {name} - - } - /> - )} - {badge && badge > 0 ? ( - - - {badge} - - - ) : null} - - -); - -interface SidebarProps { - open: boolean; - setOpen: (open: boolean) => void; -} - -export const Sidebar: FC = ({ open, setOpen }) => { - const pathname = usePathname(); - const { poppins } = fonts; - const { bodyLarge } = typography; - const { data: session } = useSession(); - const user = session?.user; - - const logout = () => { - signOut({ callbackUrl: "/login" }); - }; - - return ( - - { - setOpen!(!open); - }} - > - - - - - - - Link logo - - . - - {open && ( - - - CDR Bridge - - - )} - - - - - - - - - - - - - - - - {user?.image && ( - - - Profile image - - - )} - - - - {user?.email} - - - -