diff --git a/.claude/skills/zammad-compat/SKILL.md b/.claude/skills/zammad-compat/SKILL.md deleted file mode 100644 index bfde60d..0000000 --- a/.claude/skills/zammad-compat/SKILL.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -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 006c746..923dc15 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,4 @@ node_modules out signald docker-compose.yml -README.md -.git +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7a242f8..4011990 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ build/** .next/** docker/zammad/addons/** !docker/zammad/addons/.gitkeep -docker/zammad/gems/** -!docker/zammad/gems/.gitkeep .npmrc coverage/ build/ @@ -30,15 +28,6 @@ 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 b571488..8fcd82b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -69,6 +69,39 @@ 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: @@ -172,10 +205,33 @@ 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 --pull --no-cache -t ${DOCKER_NS}:${DOCKER_TAG} -f ${DOCKERFILE_PATH} ${BUILD_CONTEXT} + - DOCKER_BUILDKIT=1 docker build --build-arg EMBEDDED=true --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 54c6511..32cfab6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24 +v22.18.0 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a29fba6..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,114 +0,0 @@ -# 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-deltachat/docker-entrypoint.sh b/apps/bridge-deltachat/docker-entrypoint.sh deleted file mode 100755 index 13f5bd0..0000000 --- a/apps/bridge-deltachat/docker-entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/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 deleted file mode 100644 index 2997d1b..0000000 --- a/apps/bridge-deltachat/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 9ce251c..0000000 --- a/apps/bridge-deltachat/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index 850b782..0000000 --- a/apps/bridge-deltachat/src/attachments.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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 deleted file mode 100644 index 729b8b0..0000000 --- a/apps/bridge-deltachat/src/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index a4d6d6d..0000000 --- a/apps/bridge-deltachat/src/routes.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 17b37e0..0000000 --- a/apps/bridge-deltachat/src/service.ts +++ /dev/null @@ -1,365 +0,0 @@ -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 deleted file mode 100644 index 0c55ac9..0000000 --- a/apps/bridge-deltachat/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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 new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/apps/bridge-frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/bridge-frontend/.gitignore b/apps/bridge-frontend/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/apps/bridge-frontend/.gitignore @@ -0,0 +1,36 @@ +# 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-signal/Dockerfile b/apps/bridge-frontend/Dockerfile similarity index 51% rename from apps/bridge-signal/Dockerfile rename to apps/bridge-frontend/Dockerfile index 6830a0d..75a19ce 100644 --- a/apps/bridge-signal/Dockerfile +++ b/apps/bridge-frontend/Dockerfile @@ -1,7 +1,7 @@ FROM node:22-bookworm-slim AS base FROM base AS builder -ARG APP_DIR=/opt/bridge-signal +ARG APP_DIR=/opt/bridge-frontend ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN mkdir -p ${APP_DIR}/ @@ -9,47 +9,46 @@ 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-signal --docker +RUN turbo prune --scope=@link-stack/bridge-frontend --scope=@link-stack/bridge-migrations --docker FROM base AS installer -ARG APP_DIR=/opt/bridge-signal +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/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-signal -FROM base as runner +COPY --from=builder ${APP_DIR}/out/full/ . +RUN pnpm add -g turbo +RUN turbo run build --filter=@link-stack/bridge-frontend --filter=@link-stack/bridge-migrations + +FROM base AS runner +ARG APP_DIR=/opt/bridge-frontend +WORKDIR ${APP_DIR}/ ARG BUILD_DATE ARG VERSION -ARG APP_DIR=/opt/bridge-signal -ARG SIGNAL_CLI_VERSION=0.13.12 +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 mkdir -p ${APP_DIR}/ +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 curl ca-certificates && \ - curl -L "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ - | tar xz -C /usr/local/bin && \ - chmod +x /usr/local/bin/signal-cli && \ - apt-get remove -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* -RUN corepack enable && corepack prepare pnpm@9.15.4 --activate + dumb-init +RUN mkdir -p ${APP_DIR} WORKDIR ${APP_DIR} COPY --from=installer ${APP_DIR} ./ -RUN chown -R node:node ${APP_DIR} -WORKDIR ${APP_DIR}/apps/bridge-signal/ +RUN chown -R node:node ${APP_DIR}/ +WORKDIR ${APP_DIR}/apps/bridge-frontend/ RUN chmod +x docker-entrypoint.sh USER node -RUN mkdir /home/node/signal-data -EXPOSE 5002 -ENV PORT 5002 +EXPOSE 3000 +ENV PORT 3000 ENV NODE_ENV production -ENV SIGNAL_DATA_DIR /home/node/signal-data -ENV COREPACK_ENABLE_NETWORK=0 -ENTRYPOINT ["/opt/bridge-signal/apps/bridge-signal/docker-entrypoint.sh"] +ENTRYPOINT ["/opt/bridge-frontend/apps/bridge-frontend/docker-entrypoint.sh"] diff --git a/apps/bridge-frontend/README.md b/apps/bridge-frontend/README.md new file mode 100644 index 0000000..ebec248 --- /dev/null +++ b/apps/bridge-frontend/README.md @@ -0,0 +1,133 @@ +# 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 new file mode 100644 index 0000000..8f79c27 --- /dev/null +++ b/apps/bridge-frontend/app/(login)/login/page.tsx @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..bc5b6b8 --- /dev/null +++ b/apps/bridge-frontend/app/(main)/[...segment]/@create/page.tsx @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..e857b7d --- /dev/null +++ b/apps/bridge-frontend/app/(main)/[...segment]/@detail/page.tsx @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..82c8052 --- /dev/null +++ b/apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..c360a57 --- /dev/null +++ b/apps/bridge-frontend/app/(main)/[...segment]/layout.tsx @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..7b4fc03 --- /dev/null +++ b/apps/bridge-frontend/app/(main)/[...segment]/page.tsx @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..32203a2 --- /dev/null +++ b/apps/bridge-frontend/app/(main)/layout.tsx @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..e01be47 --- /dev/null +++ b/apps/bridge-frontend/app/(main)/page.tsx @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..72985ad --- /dev/null +++ b/apps/bridge-frontend/app/_components/InternalLayout.tsx @@ -0,0 +1,29 @@ +"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 new file mode 100644 index 0000000..8e40129 --- /dev/null +++ b/apps/bridge-frontend/app/_components/Login.tsx @@ -0,0 +1,185 @@ +"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 new file mode 100644 index 0000000..31aaaaa --- /dev/null +++ b/apps/bridge-frontend/app/_components/Sidebar.tsx @@ -0,0 +1,399 @@ +"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} + + + +