Compare commits
52 commits
main
...
fix/32-dep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d5cd00da | ||
|
|
144b8de540 | ||
|
|
66231de404 | ||
|
|
11563a794e | ||
|
|
6f0f97ab7b | ||
|
|
d2a3c71bcd | ||
|
|
9139c8e8de | ||
|
|
7745071e63 | ||
|
|
57ddcd64ea | ||
|
|
8cee269811 | ||
|
|
b80faa9055 | ||
|
|
c1feaa4cb1 | ||
|
|
5b89bfce7c | ||
|
|
75c7f3ff76 | ||
|
|
20ef676cf1 | ||
|
|
46e1116bc5 | ||
|
|
a55e939592 | ||
|
|
7be5cb1478 | ||
|
|
f20cd5a53c | ||
|
|
c8ccee7ada | ||
|
|
a83907b4be | ||
|
|
42eb0d896c | ||
|
|
3659a4ef38 | ||
|
|
58e713c98b | ||
|
|
a35d18b5a9 | ||
|
|
13a5a03860 | ||
|
|
494404c4c1 | ||
|
|
05a0261355 | ||
|
|
6e8d3e171e | ||
|
|
5ff5eb4213 | ||
|
|
5a1be0de94 | ||
|
|
21cc160f8f | ||
|
|
dd0265f3f5 | ||
|
|
810a333429 | ||
|
|
def602c05e | ||
|
|
9e5ea2fc41 | ||
|
|
07ee819520 | ||
|
|
ba0f1adcc4 | ||
|
|
5af67ef6ee | ||
|
|
25a5f0bc68 | ||
|
|
ab67245b91 | ||
|
|
857d877efa | ||
|
|
414502a33d | ||
|
|
0525f58324 | ||
|
|
9fb3665ced | ||
|
|
f552f8024f | ||
|
|
589010493d | ||
|
|
a8dd53507d | ||
|
|
130554d86b | ||
|
|
84731c9e9a | ||
|
|
7ad25e8a95 | ||
|
|
48aa89f7cf |
172 changed files with 8790 additions and 6711 deletions
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
||||||
v20.2.0
|
v22.18.0
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,7 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
|
||||||
dumb-init
|
dumb-init
|
||||||
RUN mkdir -p ${APP_DIR}
|
RUN mkdir -p ${APP_DIR}
|
||||||
WORKDIR ${APP_DIR}
|
WORKDIR ${APP_DIR}
|
||||||
COPY --from=installer ${APP_DIR}/node_modules/ ./node_modules/
|
COPY --from=installer ${APP_DIR} ./
|
||||||
COPY --from=installer ${APP_DIR}/apps/bridge-frontend/ ./apps/bridge-frontend/
|
|
||||||
COPY --from=installer ${APP_DIR}/apps/bridge-migrations/ ./apps/bridge-migrations/
|
|
||||||
COPY --from=installer ${APP_DIR}/package.json ./package.json
|
|
||||||
RUN chown -R node:node ${APP_DIR}/
|
RUN chown -R node:node ${APP_DIR}/
|
||||||
WORKDIR ${APP_DIR}/apps/bridge-frontend/
|
WORKDIR ${APP_DIR}/apps/bridge-frontend/
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,133 @@
|
||||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
# Bridge Frontend
|
||||||
|
|
||||||
## Getting Started
|
Frontend application for managing communication bridges between various messaging platforms and the CDR Link system.
|
||||||
|
|
||||||
First, run the development server:
|
## 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
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
npm run migrate:latest
|
||||||
|
|
||||||
|
# Run development server
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
# Build for production
|
||||||
# or
|
npm run build
|
||||||
pnpm dev
|
|
||||||
# or
|
# Start production server
|
||||||
bun dev
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
### Environment Variables
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
Required environment variables:
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
- `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
|
||||||
|
|
||||||
## Learn More
|
### Available Scripts
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
- `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
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
## Architecture
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
### Database Schema
|
||||||
|
|
||||||
## Deploy on Vercel
|
The application manages the following main entities:
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
- **Bots**: Communication channel bot configurations
|
||||||
|
- **Webhooks**: Webhook endpoints for external integrations
|
||||||
|
- **Settings**: Channel-specific configuration settings
|
||||||
|
- **Users**: User accounts with role-based permissions
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
### 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
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Create } from "@link-stack/bridge-ui";
|
import { Create } from "@link-stack/bridge-ui";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: { segment: string[] };
|
params: Promise<{ segment: string[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page({ params: { segment } }: PageProps) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { segment } = await params;
|
||||||
const service = segment[0];
|
const service = segment[0];
|
||||||
|
|
||||||
return <Create service={service} />;
|
return <Create service={service} />;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { db } from "@link-stack/bridge-common";
|
import { db } from "@link-stack/bridge-common";
|
||||||
import { serviceConfig, Detail } from "@link-stack/bridge-ui";
|
import { serviceConfig, Detail } from "@link-stack/bridge-ui";
|
||||||
|
|
||||||
type Props = {
|
type PageProps = {
|
||||||
params: { segment: string[] };
|
params: Promise<{ segment: string[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params: { segment } }: Props) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { segment } = await params;
|
||||||
const service = segment[0];
|
const service = segment[0];
|
||||||
const id = segment?.[1];
|
const id = segment?.[1];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import { db } from "@link-stack/bridge-common";
|
||||||
import { serviceConfig, Edit } from "@link-stack/bridge-ui";
|
import { serviceConfig, Edit } from "@link-stack/bridge-ui";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: { segment: string[] };
|
params: Promise<{ segment: string[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params: { segment } }: PageProps) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { segment } = await params;
|
||||||
const service = segment[0];
|
const service = segment[0];
|
||||||
const id = segment?.[1];
|
const id = segment?.[1];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ import { db } from "@link-stack/bridge-common";
|
||||||
import { serviceConfig, List } from "@link-stack/bridge-ui";
|
import { serviceConfig, List } from "@link-stack/bridge-ui";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
segment: string[];
|
segment: string[];
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params: { segment } }: PageProps) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { segment } = await params;
|
||||||
const service = segment[0];
|
const service = segment[0];
|
||||||
|
|
||||||
if (!service) return null;
|
if (!service) return null;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||||
import { LicenseInfo } from "@mui/x-license";
|
import { LicenseInfo } from "@mui/x-license";
|
||||||
|
|
||||||
LicenseInfo.setLicenseKey(
|
LicenseInfo.setLicenseKey(
|
||||||
"c787ac6613c5f2aa0494c4285fe3e9f2Tz04OTY1NyxFPTE3NDYzNDE0ODkwMDAsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
|
"2a7dd73ee59e3e028b96b0d2adee1ad8Tz0xMTMwOTUsRT0xNzc5MDYyMzk5MDAwLFM9cHJvLExNPXN1YnNjcmlwdGlvbixQVj1pbml0aWFsLEtWPTI=",
|
||||||
);
|
);
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-frontend",
|
"name": "@link-stack/bridge-frontend",
|
||||||
"version": "2.2.0",
|
"version": "3.2.0b5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|
@ -13,28 +13,30 @@
|
||||||
"migrate:down:one": "tsx database/migrate.ts down:one"
|
"migrate:down:one": "tsx database/migrate.ts down:one"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/kysely-adapter": "^1.5.2",
|
"@auth/kysely-adapter": "^1.10.0",
|
||||||
|
"@mui/icons-material": "^6",
|
||||||
|
"@mui/material": "^6",
|
||||||
|
"@mui/material-nextjs": "^6",
|
||||||
|
"@mui/x-license": "^7",
|
||||||
"@link-stack/bridge-common": "*",
|
"@link-stack/bridge-common": "*",
|
||||||
"@link-stack/bridge-ui": "*",
|
"@link-stack/bridge-ui": "*",
|
||||||
"@link-stack/ui": "*",
|
"next": "15.5.9",
|
||||||
"@mui/icons-material": "^5",
|
"next-auth": "^4.24.11",
|
||||||
"@mui/material": "^5",
|
"react": "19.2.0",
|
||||||
"@mui/material-nextjs": "^5",
|
"react-dom": "19.2.0",
|
||||||
"@mui/x-license": "^7.18.0",
|
"sharp": "^0.34.4",
|
||||||
"next": "^14.2.23",
|
"tsx": "^4.20.6",
|
||||||
"next-auth": "^4.24.8",
|
"@link-stack/ui": "*"
|
||||||
"react": "18.3.1",
|
|
||||||
"react-dom": "18.3.1",
|
|
||||||
"sharp": "^0.33.5",
|
|
||||||
"tsx": "^4.19.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@link-stack/eslint-config": "*",
|
"@link-stack/eslint-config": "*",
|
||||||
"@link-stack/typescript-config": "*",
|
"@link-stack/typescript-config": "*",
|
||||||
"@types/node": "^22",
|
"@types/node": "^24",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react": "^18",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^19",
|
||||||
|
"@link-stack/eslint-config": "*",
|
||||||
|
"@link-stack/typescript-config": "*",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
apps/bridge-frontend/public/robots.txt
Normal file
2
apps/bridge-frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
@ -14,14 +18,24 @@
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"target": "ES2017"
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
158
apps/bridge-migrations/README.md
Normal file
158
apps/bridge-migrations/README.md
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
# Bridge Migrations
|
||||||
|
|
||||||
|
Database migration management for the CDR Link bridge system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bridge Migrations handles database schema versioning and migrations for all bridge-related tables using Kysely migration framework. It manages the database structure for authentication, messaging channels, webhooks, and settings.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Schema Versioning**: Track and apply database schema changes
|
||||||
|
- **Up/Down Migrations**: Support for rolling forward and backward
|
||||||
|
- **Type-Safe Migrations**: TypeScript-based migration files
|
||||||
|
- **Migration History**: Track applied migrations in the database
|
||||||
|
- **Multiple Migration Strategies**: Run all, run one, or rollback migrations
|
||||||
|
|
||||||
|
## Migration Files
|
||||||
|
|
||||||
|
Current migrations in order:
|
||||||
|
|
||||||
|
1. **0001-add-next-auth.ts** - NextAuth.js authentication tables
|
||||||
|
2. **0002-add-signal.ts** - Signal messenger integration
|
||||||
|
3. **0003-add-whatsapp.ts** - WhatsApp integration
|
||||||
|
4. **0004-add-voice.ts** - Voice/Twilio integration
|
||||||
|
5. **0005-add-facebook.ts** - Facebook Messenger integration
|
||||||
|
6. **0006-add-webhooks.ts** - Webhook configuration
|
||||||
|
7. **0007-add-settings.ts** - Application settings
|
||||||
|
8. **0008-add-user-role.ts** - User role management
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- npm >= 10
|
||||||
|
- PostgreSQL database
|
||||||
|
- Database connection credentials
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run all pending migrations
|
||||||
|
npm run migrate:latest
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
npm run migrate:list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
- `npm run migrate:latest` - Run all pending migrations
|
||||||
|
- `npm run migrate:up` - Run next pending migration
|
||||||
|
- `npm run migrate:down` - Rollback last migration
|
||||||
|
- `npm run migrate:up:all` - Run all migrations (alias)
|
||||||
|
- `npm run migrate:up:one` - Run one migration
|
||||||
|
- `npm run migrate:down:all` - Rollback all migrations
|
||||||
|
- `npm run migrate:down:one` - Rollback one migration
|
||||||
|
- `npm run migrate:list` - List migration status
|
||||||
|
- `npm run migrate:make <name>` - Create new migration file
|
||||||
|
|
||||||
|
## Creating New Migrations
|
||||||
|
|
||||||
|
To create a new migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate:make add-new-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a new timestamped migration file in the `migrations/` directory.
|
||||||
|
|
||||||
|
Example migration structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Kysely } from 'kysely'
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('new_table')
|
||||||
|
.addColumn('id', 'serial', (col) => col.primaryKey())
|
||||||
|
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||||
|
.addColumn('created_at', 'timestamp', (col) =>
|
||||||
|
col.defaultTo('now()').notNull()
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('new_table').execute()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
- **users** - User accounts with roles
|
||||||
|
- **accounts** - OAuth account connections
|
||||||
|
- **sessions** - User sessions
|
||||||
|
- **verification_tokens** - Email verification
|
||||||
|
|
||||||
|
### Communication Tables
|
||||||
|
|
||||||
|
- **bots** - Bot configurations for each service
|
||||||
|
- **signal_messages** - Signal message history
|
||||||
|
- **whatsapp_messages** - WhatsApp message history
|
||||||
|
- **voice_messages** - Voice/call records
|
||||||
|
- **facebook_messages** - Facebook message history
|
||||||
|
|
||||||
|
### Configuration Tables
|
||||||
|
|
||||||
|
- **webhooks** - External webhook endpoints
|
||||||
|
- **settings** - Application settings
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Test Migrations**: Always test migrations in development first
|
||||||
|
2. **Backup Database**: Create backups before running migrations in production
|
||||||
|
3. **Review Changes**: Review migration files before applying
|
||||||
|
4. **Atomic Operations**: Keep migrations focused and atomic
|
||||||
|
5. **Rollback Plan**: Ensure down() methods properly reverse changes
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Migration Failed**: Check error logs and database permissions
|
||||||
|
2. **Locked Migrations**: Check for concurrent migration processes
|
||||||
|
3. **Missing Tables**: Ensure all previous migrations have run
|
||||||
|
4. **Connection Issues**: Verify DATABASE_URL and network access
|
||||||
|
|
||||||
|
### Recovery
|
||||||
|
|
||||||
|
If migrations fail:
|
||||||
|
|
||||||
|
1. Check migration history table
|
||||||
|
2. Manually verify database state
|
||||||
|
3. Run specific migrations as needed
|
||||||
|
4. Use rollback if necessary
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
Migrations are used by:
|
||||||
|
- **bridge-frontend** - Requires migrated schema
|
||||||
|
- **bridge-worker** - Depends on message tables
|
||||||
|
- **bridge-whatsapp** - Uses bot configuration tables
|
||||||
|
|
@ -10,6 +10,9 @@ import {
|
||||||
CamelCasePlugin,
|
CamelCasePlugin,
|
||||||
} from "kysely";
|
} from "kysely";
|
||||||
import pkg from "pg";
|
import pkg from "pg";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-migrations-migrate');
|
||||||
const { Pool } = pkg;
|
const { Pool } = pkg;
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
|
@ -72,17 +75,17 @@ export const migrate = async (arg: string) => {
|
||||||
|
|
||||||
results?.forEach((it) => {
|
results?.forEach((it) => {
|
||||||
if (it.status === "Success") {
|
if (it.status === "Success") {
|
||||||
console.log(
|
logger.info(
|
||||||
`Migration "${it.migrationName} ${it.direction.toLowerCase()}" was executed successfully`,
|
`Migration "${it.migrationName} ${it.direction.toLowerCase()}" was executed successfully`,
|
||||||
);
|
);
|
||||||
} else if (it.status === "Error") {
|
} else if (it.status === "Error") {
|
||||||
console.error(`Failed to execute migration "${it.migrationName}"`);
|
logger.error(`Failed to execute migration "${it.migrationName}"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Failed to migrate");
|
logger.error("Failed to migrate");
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-migrations",
|
"name": "@link-stack/bridge-migrations",
|
||||||
"version": "2.2.0",
|
"version": "3.2.0b5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"migrate:up:all": "tsx migrate.ts up:all",
|
"migrate:up:all": "tsx migrate.ts up:all",
|
||||||
|
|
@ -9,14 +9,15 @@
|
||||||
"migrate:down:one": "tsx migrate.ts down:one"
|
"migrate:down:one": "tsx migrate.ts down:one"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.5",
|
"@link-stack/logger": "*",
|
||||||
"kysely": "0.26.1",
|
"dotenv": "^17.2.3",
|
||||||
"pg": "^8.13.0",
|
"kysely": "0.27.5",
|
||||||
"tsx": "^4.19.1"
|
"pg": "^8.16.3",
|
||||||
|
"tsx": "^4.20.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22",
|
"@types/node": "^24",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.15.5",
|
||||||
"@link-stack/eslint-config": "*",
|
"@link-stack/eslint-config": "*",
|
||||||
"@link-stack/typescript-config": "*",
|
"@link-stack/typescript-config": "*",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|
|
||||||
172
apps/bridge-whatsapp/README.md
Normal file
172
apps/bridge-whatsapp/README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# Bridge WhatsApp
|
||||||
|
|
||||||
|
WhatsApp integration service for the CDR Link communication bridge system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bridge WhatsApp provides a REST API for sending and receiving WhatsApp messages using the Baileys library (WhatsApp Web API). It handles bot session management, message routing, and media processing for WhatsApp communication channels.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Bot Management**: Register and manage multiple WhatsApp bot sessions
|
||||||
|
- **Message Handling**: Send and receive text messages with formatting
|
||||||
|
- **Media Support**: Handle images, documents, audio, and video files
|
||||||
|
- **QR Code Authentication**: Web-based WhatsApp authentication
|
||||||
|
- **REST API**: Simple HTTP endpoints for integration
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- npm >= 10
|
||||||
|
- PostgreSQL database (for bot configuration)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `PORT` - Server port (default: 5000)
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection string (optional)
|
||||||
|
- Additional WhatsApp-specific configuration as needed
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
- `npm run build` - Compile TypeScript
|
||||||
|
- `npm run dev` - Development mode with auto-reload
|
||||||
|
- `npm run start` - Start production server
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Bot Management
|
||||||
|
|
||||||
|
- `POST /api/bots/:token` - Register/initialize a bot
|
||||||
|
- `GET /api/bots/:token` - Get bot status and QR code
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
|
||||||
|
- `POST /api/bots/:token/send` - Send a message
|
||||||
|
- `POST /api/bots/:token/receive` - Webhook for incoming messages
|
||||||
|
|
||||||
|
### Request/Response Format
|
||||||
|
|
||||||
|
#### Send Message
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"to": "1234567890@s.whatsapp.net",
|
||||||
|
"message": "Hello World",
|
||||||
|
"media": {
|
||||||
|
"url": "https://example.com/image.jpg",
|
||||||
|
"type": "image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Receive Message Webhook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"from": "1234567890@s.whatsapp.net",
|
||||||
|
"message": "Hello",
|
||||||
|
"timestamp": "2024-01-01T00:00:00Z",
|
||||||
|
"media": {
|
||||||
|
"url": "https://...",
|
||||||
|
"type": "image",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Server Framework
|
||||||
|
|
||||||
|
Built with Hapi.js for:
|
||||||
|
|
||||||
|
- Route validation
|
||||||
|
- Plugin architecture
|
||||||
|
- Error handling
|
||||||
|
- Request lifecycle
|
||||||
|
|
||||||
|
### WhatsApp Integration
|
||||||
|
|
||||||
|
Uses @whiskeysockets/baileys:
|
||||||
|
|
||||||
|
- WhatsApp Web protocol
|
||||||
|
- Multi-device support
|
||||||
|
- Message encryption
|
||||||
|
- Media handling
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
|
||||||
|
- File-based session storage
|
||||||
|
- Automatic reconnection
|
||||||
|
- QR code regeneration
|
||||||
|
- Session cleanup
|
||||||
|
|
||||||
|
## Media Handling
|
||||||
|
|
||||||
|
Supported media types:
|
||||||
|
|
||||||
|
- **Images**: JPEG, PNG, GIF
|
||||||
|
- **Documents**: PDF, DOC, DOCX
|
||||||
|
- **Audio**: MP3, OGG, WAV
|
||||||
|
- **Video**: MP4, AVI
|
||||||
|
|
||||||
|
Media is processed and uploaded before sending.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Connection errors trigger reconnection
|
||||||
|
- Invalid sessions regenerate QR codes
|
||||||
|
- API errors return appropriate HTTP status codes
|
||||||
|
- Comprehensive logging for debugging
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Token-based bot authentication
|
||||||
|
- Message validation
|
||||||
|
- Rate limiting (configurable)
|
||||||
|
- Secure session storage
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
Designed to work with:
|
||||||
|
|
||||||
|
- **bridge-worker**: Processes WhatsApp message jobs
|
||||||
|
- **bridge-frontend**: Manages bot configuration
|
||||||
|
- External webhooks for message routing
|
||||||
|
|
||||||
|
## Docker Support
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build -t link-stack/bridge-whatsapp .
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
docker run -p 5000:5000 link-stack/bridge-whatsapp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
While test configuration exists (jest.config.json), tests should be implemented for:
|
||||||
|
|
||||||
|
- API endpoint validation
|
||||||
|
- Message processing logic
|
||||||
|
- Session management
|
||||||
|
- Error scenarios
|
||||||
|
|
@ -1,26 +1,27 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-whatsapp",
|
"name": "@link-stack/bridge-whatsapp",
|
||||||
"version": "2.2.0",
|
"version": "3.2.0b5",
|
||||||
"main": "build/main/index.js",
|
"main": "build/main/index.js",
|
||||||
"author": "Darren Clarke <darren@redaranj.com>",
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adiwajshing/keyed-db": "0.2.4",
|
"@adiwajshing/keyed-db": "0.2.4",
|
||||||
"@hapi/hapi": "^21.3.10",
|
"@hapi/hapi": "^21.4.3",
|
||||||
"@hapipal/schmervice": "^3.0.0",
|
"@hapipal/schmervice": "^3.0.0",
|
||||||
"@hapipal/toys": "^4.0.0",
|
"@hapipal/toys": "^4.0.0",
|
||||||
"@whiskeysockets/baileys": "^6.7.8",
|
"@link-stack/logger": "*",
|
||||||
"hapi-pino": "^12.1.0",
|
"@whiskeysockets/baileys": "6.7.21",
|
||||||
"link-preview-js": "^3.0.5"
|
"hapi-pino": "^13.0.0",
|
||||||
|
"link-preview-js": "^3.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@link-stack/eslint-config": "*",
|
"@link-stack/eslint-config": "*",
|
||||||
"@link-stack/jest-config": "*",
|
"@link-stack/jest-config": "*",
|
||||||
"@link-stack/typescript-config": "*",
|
"@link-stack/typescript-config": "*",
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^10.0.0",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import {
|
||||||
SendMessageRoute,
|
SendMessageRoute,
|
||||||
ReceiveMessageRoute,
|
ReceiveMessageRoute,
|
||||||
} from "./routes.js";
|
} from "./routes.js";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-whatsapp-index');
|
||||||
|
|
||||||
const server = Hapi.server({ port: 5000 });
|
const server = Hapi.server({ port: 5000 });
|
||||||
|
|
||||||
|
|
@ -34,6 +37,6 @@ const main = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error(err);
|
logger.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const getService = (request: Hapi.Request): WhatsappService => {
|
||||||
interface MessageRequest {
|
interface MessageRequest {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SendMessageRoute = withDefaults({
|
export const SendMessageRoute = withDefaults({
|
||||||
|
|
@ -26,11 +27,23 @@ export const SendMessageRoute = withDefaults({
|
||||||
description: "Send a message",
|
description: "Send a message",
|
||||||
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
console.log({ payload: request.payload });
|
const { phoneNumber, message, attachments } =
|
||||||
const { phoneNumber, message } = request.payload as MessageRequest;
|
request.payload as MessageRequest;
|
||||||
const whatsappService = getService(request);
|
const whatsappService = getService(request);
|
||||||
await whatsappService.send(id, phoneNumber, message as string);
|
await whatsappService.send(
|
||||||
request.logger.info({ id }, "Sent a message at %s", new Date());
|
id,
|
||||||
|
phoneNumber,
|
||||||
|
message as string,
|
||||||
|
attachments,
|
||||||
|
);
|
||||||
|
request.logger.info(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
attachmentCount: attachments?.length || 0,
|
||||||
|
},
|
||||||
|
"Sent a message at %s",
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
|
||||||
return _h
|
return _h
|
||||||
.response({
|
.response({
|
||||||
|
|
@ -56,7 +69,7 @@ export const ReceiveMessageRoute = withDefaults({
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const twoDaysAgo = new Date(date.getTime());
|
const twoDaysAgo = new Date(date.getTime());
|
||||||
twoDaysAgo.setDate(date.getDate() - 2);
|
twoDaysAgo.setDate(date.getDate() - 2);
|
||||||
request.logger.info({ id }, "Received messages at %s", new Date());
|
request.logger.info({ id }, "Received messages at %s", new Date().toISOString());
|
||||||
|
|
||||||
return whatsappService.receive(id, twoDaysAgo);
|
return whatsappService.receive(id, twoDaysAgo);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import makeWASocket, {
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
} from "@whiskeysockets/baileys";
|
} from "@whiskeysockets/baileys";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("bridge-whatsapp-service");
|
||||||
|
|
||||||
export type AuthCompleteCallback = (error?: string) => void;
|
export type AuthCompleteCallback = (error?: string) => void;
|
||||||
|
|
||||||
|
|
@ -57,7 +60,7 @@ export default class WhatsappService extends Service {
|
||||||
try {
|
try {
|
||||||
connection.end(null);
|
connection.end(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
logger.error({ error }, "Connection reset error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.connections = {};
|
this.connections = {};
|
||||||
|
|
@ -92,27 +95,27 @@ export default class WhatsappService extends Service {
|
||||||
isNewLogin,
|
isNewLogin,
|
||||||
} = update;
|
} = update;
|
||||||
if (qr) {
|
if (qr) {
|
||||||
console.log("got qr code");
|
logger.info("got qr code");
|
||||||
const botDirectory = this.getBotDirectory(botID);
|
const botDirectory = this.getBotDirectory(botID);
|
||||||
const qrPath = `${botDirectory}/qr.txt`;
|
const qrPath = `${botDirectory}/qr.txt`;
|
||||||
fs.writeFileSync(qrPath, qr, "utf8");
|
fs.writeFileSync(qrPath, qr, "utf8");
|
||||||
} else if (isNewLogin) {
|
} else if (isNewLogin) {
|
||||||
console.log("got new login");
|
logger.info("got new login");
|
||||||
const botDirectory = this.getBotDirectory(botID);
|
const botDirectory = this.getBotDirectory(botID);
|
||||||
const verifiedFile = `${botDirectory}/verified`;
|
const verifiedFile = `${botDirectory}/verified`;
|
||||||
fs.writeFileSync(verifiedFile, "");
|
fs.writeFileSync(verifiedFile, "");
|
||||||
} else if (connectionState === "open") {
|
} else if (connectionState === "open") {
|
||||||
console.log("opened connection");
|
logger.info("opened connection");
|
||||||
} else if (connectionState === "close") {
|
} else if (connectionState === "close") {
|
||||||
console.log("connection closed due to ", lastDisconnect?.error);
|
logger.info({ lastDisconnect }, "connection closed");
|
||||||
const disconnectStatusCode = (lastDisconnect?.error as any)?.output
|
const disconnectStatusCode = (lastDisconnect?.error as any)?.output
|
||||||
?.statusCode;
|
?.statusCode;
|
||||||
if (disconnectStatusCode === DisconnectReason.restartRequired) {
|
if (disconnectStatusCode === DisconnectReason.restartRequired) {
|
||||||
console.log("reconnecting after got new login");
|
logger.info("reconnecting after got new login");
|
||||||
await this.createConnection(botID, server, options);
|
await this.createConnection(botID, server, options);
|
||||||
authCompleteCallback?.();
|
authCompleteCallback?.();
|
||||||
} else if (disconnectStatusCode !== DisconnectReason.loggedOut) {
|
} else if (disconnectStatusCode !== DisconnectReason.loggedOut) {
|
||||||
console.log("reconnecting");
|
logger.info("reconnecting");
|
||||||
await this.sleep(pause);
|
await this.sleep(pause);
|
||||||
pause *= 2;
|
pause *= 2;
|
||||||
this.createConnection(botID, server, options);
|
this.createConnection(botID, server, options);
|
||||||
|
|
@ -121,12 +124,12 @@ export default class WhatsappService extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (events["creds.update"]) {
|
if (events["creds.update"]) {
|
||||||
console.log("creds update");
|
logger.info("creds update");
|
||||||
await saveCreds();
|
await saveCreds();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (events["messages.upsert"]) {
|
if (events["messages.upsert"]) {
|
||||||
console.log("messages upsert");
|
logger.info("messages upsert");
|
||||||
const upsert = events["messages.upsert"];
|
const upsert = events["messages.upsert"];
|
||||||
const { messages } = upsert;
|
const { messages } = upsert;
|
||||||
if (messages) {
|
if (messages) {
|
||||||
|
|
@ -143,13 +146,16 @@ export default class WhatsappService extends Service {
|
||||||
|
|
||||||
const baseDirectory = this.getBaseDirectory();
|
const baseDirectory = this.getBaseDirectory();
|
||||||
const botIDs = fs.readdirSync(baseDirectory);
|
const botIDs = fs.readdirSync(baseDirectory);
|
||||||
console.log({ botIDs });
|
|
||||||
for await (const botID of botIDs) {
|
for await (const botID of botIDs) {
|
||||||
const directory = this.getBotDirectory(botID);
|
const directory = this.getBotDirectory(botID);
|
||||||
const verifiedFile = `${directory}/verified`;
|
const verifiedFile = `${directory}/verified`;
|
||||||
if (fs.existsSync(verifiedFile)) {
|
if (fs.existsSync(verifiedFile)) {
|
||||||
const { version, isLatest } = await fetchLatestBaileysVersion();
|
const { version, isLatest } = await fetchLatestBaileysVersion();
|
||||||
console.log(`using WA v${version.join(".")}, isLatest: ${isLatest}`);
|
logger.info(
|
||||||
|
{ version: version.join("."), isLatest },
|
||||||
|
"using WA version",
|
||||||
|
);
|
||||||
|
|
||||||
await this.createConnection(botID, this.server, {
|
await this.createConnection(botID, this.server, {
|
||||||
browser: WhatsappService.browserDescription,
|
browser: WhatsappService.browserDescription,
|
||||||
|
|
@ -169,7 +175,13 @@ export default class WhatsappService extends Service {
|
||||||
message,
|
message,
|
||||||
messageTimestamp,
|
messageTimestamp,
|
||||||
} = webMessageInfo;
|
} = webMessageInfo;
|
||||||
console.log(webMessageInfo);
|
logger.info("Message type debug");
|
||||||
|
for (const key in message) {
|
||||||
|
logger.info(
|
||||||
|
{ key, exists: !!message[key as keyof proto.IMessage] },
|
||||||
|
"Message field",
|
||||||
|
);
|
||||||
|
}
|
||||||
const isValidMessage =
|
const isValidMessage =
|
||||||
message && remoteJid !== "status@broadcast" && !fromMe;
|
message && remoteJid !== "status@broadcast" && !fromMe;
|
||||||
if (isValidMessage) {
|
if (isValidMessage) {
|
||||||
|
|
@ -186,19 +198,22 @@ export default class WhatsappService extends Service {
|
||||||
if (isMediaMessage) {
|
if (isMediaMessage) {
|
||||||
if (audioMessage) {
|
if (audioMessage) {
|
||||||
messageType = "audio";
|
messageType = "audio";
|
||||||
filename = id + "." + audioMessage.mimetype?.split("/").pop();
|
const extension = audioMessage.mimetype?.split("/").pop() || "audio";
|
||||||
|
filename = `${id}.${extension}`;
|
||||||
mimeType = audioMessage.mimetype;
|
mimeType = audioMessage.mimetype;
|
||||||
} else if (documentMessage) {
|
} else if (documentMessage) {
|
||||||
messageType = "document";
|
messageType = "document";
|
||||||
filename = documentMessage.fileName;
|
filename = documentMessage.fileName || `${id}.bin`;
|
||||||
mimeType = documentMessage.mimetype;
|
mimeType = documentMessage.mimetype;
|
||||||
} else if (imageMessage) {
|
} else if (imageMessage) {
|
||||||
messageType = "image";
|
messageType = "image";
|
||||||
filename = id + "." + imageMessage.mimetype?.split("/").pop();
|
const extension = imageMessage.mimetype?.split("/").pop() || "jpg";
|
||||||
|
filename = `${id}.${extension}`;
|
||||||
mimeType = imageMessage.mimetype;
|
mimeType = imageMessage.mimetype;
|
||||||
} else if (videoMessage) {
|
} else if (videoMessage) {
|
||||||
messageType = "video";
|
messageType = "video";
|
||||||
filename = id + "." + videoMessage.mimetype?.split("/").pop();
|
const extension = videoMessage.mimetype?.split("/").pop() || "mp4";
|
||||||
|
filename = `${id}.${extension}`;
|
||||||
mimeType = videoMessage.mimetype;
|
mimeType = videoMessage.mimetype;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,12 +308,47 @@ export default class WhatsappService extends Service {
|
||||||
botID: string,
|
botID: string,
|
||||||
phoneNumber: string,
|
phoneNumber: string,
|
||||||
message: string,
|
message: string,
|
||||||
|
attachments?: Array<{ data: string; filename: string; mime_type: string }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const connection = this.connections[botID]?.socket;
|
const connection = this.connections[botID]?.socket;
|
||||||
const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`;
|
const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`;
|
||||||
|
|
||||||
|
// Send text message if provided
|
||||||
|
if (message) {
|
||||||
await connection.sendMessage(recipient, { text: message });
|
await connection.sendMessage(recipient, { text: message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send attachments if provided
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const buffer = Buffer.from(attachment.data, "base64");
|
||||||
|
|
||||||
|
if (attachment.mime_type.startsWith("image/")) {
|
||||||
|
await connection.sendMessage(recipient, {
|
||||||
|
image: buffer,
|
||||||
|
caption: attachment.filename,
|
||||||
|
});
|
||||||
|
} else if (attachment.mime_type.startsWith("video/")) {
|
||||||
|
await connection.sendMessage(recipient, {
|
||||||
|
video: buffer,
|
||||||
|
caption: attachment.filename,
|
||||||
|
});
|
||||||
|
} else if (attachment.mime_type.startsWith("audio/")) {
|
||||||
|
await connection.sendMessage(recipient, {
|
||||||
|
audio: buffer,
|
||||||
|
mimetype: attachment.mime_type,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await connection.sendMessage(recipient, {
|
||||||
|
document: buffer,
|
||||||
|
fileName: attachment.filename,
|
||||||
|
mimetype: attachment.mime_type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async receive(
|
async receive(
|
||||||
botID: string,
|
botID: string,
|
||||||
_lastReceivedDate: Date,
|
_lastReceivedDate: Date,
|
||||||
|
|
|
||||||
147
apps/bridge-worker/README.md
Normal file
147
apps/bridge-worker/README.md
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# Bridge Worker
|
||||||
|
|
||||||
|
Background job processor for handling asynchronous tasks in the CDR Link communication bridge system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bridge Worker uses Graphile Worker to process queued jobs for message handling, media conversion, webhook notifications, and scheduled tasks. It manages the flow of messages between various communication channels (Signal, WhatsApp, Facebook, Voice) and the Zammad ticketing system.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Message Processing**: Handle incoming/outgoing messages for all supported channels
|
||||||
|
- **Media Conversion**: Convert audio/video files between formats
|
||||||
|
- **Webhook Notifications**: Notify external systems of events
|
||||||
|
- **Scheduled Tasks**: Cron-based job scheduling
|
||||||
|
- **Job Queue Management**: Reliable job processing with retries
|
||||||
|
- **Multi-Channel Support**: Signal, WhatsApp, Facebook, Voice (Twilio)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- npm >= 10
|
||||||
|
- PostgreSQL database
|
||||||
|
- Redis (for caching)
|
||||||
|
- FFmpeg (for media conversion)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run development server with auto-reload
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Start production worker
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
|
- `GRAPHILE_WORKER_CONCURRENCY` - Number of concurrent jobs (default: 10)
|
||||||
|
- `GRAPHILE_WORKER_POLL_INTERVAL` - Job poll interval in ms (default: 1000)
|
||||||
|
- `ZAMMAD_URL` - Zammad instance URL
|
||||||
|
- `ZAMMAD_API_TOKEN` - Zammad API token
|
||||||
|
- `TWILIO_ACCOUNT_SID` - Twilio account SID
|
||||||
|
- `TWILIO_AUTH_TOKEN` - Twilio auth token
|
||||||
|
- `SIGNAL_CLI_URL` - Signal CLI REST API URL
|
||||||
|
- `WHATSAPP_SERVICE_URL` - WhatsApp bridge service URL
|
||||||
|
- `FACEBOOK_APP_SECRET` - Facebook app secret
|
||||||
|
- `FACEBOOK_PAGE_ACCESS_TOKEN` - Facebook page token
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
- `npm run build` - Compile TypeScript
|
||||||
|
- `npm run dev` - Development mode with watch
|
||||||
|
- `npm run start` - Start production worker
|
||||||
|
|
||||||
|
## Task Types
|
||||||
|
|
||||||
|
### Signal Tasks
|
||||||
|
- `receive-signal-message` - Process incoming Signal messages
|
||||||
|
- `send-signal-message` - Send outgoing Signal messages
|
||||||
|
- `fetch-signal-messages` - Fetch messages from Signal CLI
|
||||||
|
|
||||||
|
### WhatsApp Tasks
|
||||||
|
- `receive-whatsapp-message` - Process incoming WhatsApp messages
|
||||||
|
- `send-whatsapp-message` - Send outgoing WhatsApp messages
|
||||||
|
|
||||||
|
### Facebook Tasks
|
||||||
|
- `receive-facebook-message` - Process incoming Facebook messages
|
||||||
|
- `send-facebook-message` - Send outgoing Facebook messages
|
||||||
|
|
||||||
|
### Voice Tasks
|
||||||
|
- `receive-voice-message` - Process incoming voice calls/messages
|
||||||
|
- `send-voice-message` - Send voice messages via Twilio
|
||||||
|
- `twilio-recording` - Handle Twilio call recordings
|
||||||
|
- `voice-line-audio-update` - Update voice line audio
|
||||||
|
- `voice-line-delete` - Delete voice line
|
||||||
|
- `voice-line-provider-update` - Update voice provider settings
|
||||||
|
|
||||||
|
### Common Tasks
|
||||||
|
- `notify-webhooks` - Send webhook notifications
|
||||||
|
|
||||||
|
### Leafcutter Tasks
|
||||||
|
- `import-leafcutter` - Import data to Leafcutter
|
||||||
|
- `import-label-studio` - Import Label Studio annotations
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Job Processing
|
||||||
|
|
||||||
|
Jobs are queued in PostgreSQL using Graphile Worker:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await addJob('send-signal-message', {
|
||||||
|
to: '+1234567890',
|
||||||
|
message: 'Hello world'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Schedule
|
||||||
|
|
||||||
|
Scheduled tasks are configured in `crontab`:
|
||||||
|
- Periodic message fetching
|
||||||
|
- Cleanup tasks
|
||||||
|
- Health checks
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Automatic retries with exponential backoff
|
||||||
|
- Dead letter queue for failed jobs
|
||||||
|
- Comprehensive logging with winston
|
||||||
|
|
||||||
|
## Media Handling
|
||||||
|
|
||||||
|
Supports conversion between formats:
|
||||||
|
- Audio: MP3, OGG, WAV, M4A
|
||||||
|
- Uses fluent-ffmpeg for processing
|
||||||
|
- Automatic format detection
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
- **Zammad**: Creates/updates tickets via API
|
||||||
|
- **Signal CLI**: REST API for Signal messaging
|
||||||
|
- **WhatsApp Bridge**: HTTP API for WhatsApp
|
||||||
|
- **Twilio**: Voice and SMS capabilities
|
||||||
|
- **Facebook**: Graph API for Messenger
|
||||||
|
|
||||||
|
## Docker Support
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build -t link-stack/bridge-worker .
|
||||||
|
|
||||||
|
# Run with docker-compose
|
||||||
|
docker-compose -f docker/compose/bridge.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
The worker includes cron support via built-in crontab.
|
||||||
|
|
@ -4,8 +4,12 @@ import type {} from "graphile-worker";
|
||||||
const preset: GraphileConfig.Preset = {
|
const preset: GraphileConfig.Preset = {
|
||||||
worker: {
|
worker: {
|
||||||
connectionString: process.env.DATABASE_URL,
|
connectionString: process.env.DATABASE_URL,
|
||||||
maxPoolSize: 10,
|
maxPoolSize: process.env.BRIDGE_WORKER_POOL_SIZE
|
||||||
pollInterval: 2000,
|
? parseInt(process.env.BRIDGE_WORKER_POOL_SIZE, 10)
|
||||||
|
: 10,
|
||||||
|
pollInterval: process.env.BRIDGE_WORKER_POLL_INTERVAL
|
||||||
|
? parseInt(process.env.BRIDGE_WORKER_POLL_INTERVAL, 10)
|
||||||
|
: 2000,
|
||||||
fileExtensions: [".ts"],
|
fileExtensions: [".ts"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
import { run } from "graphile-worker";
|
import { run } from "graphile-worker";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-worker');
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const startWorker = async () => {
|
const startWorker = async () => {
|
||||||
console.log("Starting worker...");
|
logger.info("Starting worker...");
|
||||||
console.log(process.env);
|
|
||||||
await run({
|
await run({
|
||||||
connectionString: process.env.DATABASE_URL,
|
connectionString: process.env.DATABASE_URL,
|
||||||
concurrency: 10,
|
|
||||||
noHandleSignals: false,
|
noHandleSignals: false,
|
||||||
pollInterval: 1000,
|
concurrency: process.env.BRIDGE_WORKER_CONCURRENCY
|
||||||
|
? parseInt(process.env.BRIDGE_WORKER_CONCURRENCY, 10)
|
||||||
|
: 10,
|
||||||
|
maxPoolSize: process.env.BRIDGE_WORKER_POOL_SIZE
|
||||||
|
? parseInt(process.env.BRIDGE_WORKER_POOL_SIZE, 10)
|
||||||
|
: 10,
|
||||||
|
pollInterval: process.env.BRIDGE_WORKER_POLL_INTERVAL
|
||||||
|
? parseInt(process.env.BRIDGE_WORKER_POLL_INTERVAL, 10)
|
||||||
|
: 1000,
|
||||||
taskDirectory: `${__dirname}/tasks`,
|
taskDirectory: `${__dirname}/tasks`,
|
||||||
crontabFile: `${__dirname}/crontab`,
|
crontabFile: `${__dirname}/crontab`,
|
||||||
});
|
});
|
||||||
|
|
@ -23,6 +32,6 @@ const main = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error(err);
|
logger.error({ error: err }, 'Worker failed to start');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import Twilio from "twilio";
|
import Twilio from "twilio";
|
||||||
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||||
import { Zammad, getOrCreateUser } from "./zammad.js";
|
import { Zammad, getOrCreateUser } from "./zammad.js";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-worker-common');
|
||||||
|
|
||||||
type SavedVoiceProvider = any;
|
type SavedVoiceProvider = any;
|
||||||
|
|
||||||
|
|
@ -62,9 +65,8 @@ export const createZammadTicket = async (
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(Object.keys(error));
|
|
||||||
if (error.isBoom) {
|
if (error.isBoom) {
|
||||||
console.log(error.output);
|
logger.error({ output: error.output }, 'Zammad ticket creation failed');
|
||||||
throw new Error("Failed to create zamamd ticket");
|
throw new Error("Failed to create zamamd ticket");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
//import { defState } from "@digiresilience/montar";
|
|
||||||
//import { configureLogger } from "@digiresilience/bridge-common";
|
|
||||||
// import config from "@digiresilience/bridge-config";
|
|
||||||
|
|
||||||
//export const logger = defState("workerLogger", {
|
|
||||||
// start: async () => configureLogger(config),
|
|
||||||
//});
|
|
||||||
//export default logger;
|
|
||||||
|
|
||||||
export const logger = {};
|
|
||||||
export default logger;
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import ffmpeg from "fluent-ffmpeg";
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
import * as R from "remeda";
|
import * as R from "remeda";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-worker-media-convert');
|
||||||
|
|
||||||
const requiredCodecs = ["mp3", "webm", "wav"];
|
const requiredCodecs = ["mp3", "webm", "wav"];
|
||||||
|
|
||||||
|
|
@ -25,7 +28,7 @@ const defaultAudioConvertOpts = {
|
||||||
**/
|
**/
|
||||||
export const convert = (
|
export const convert = (
|
||||||
input: Buffer,
|
input: Buffer,
|
||||||
opts?: AudioConvertOpts
|
opts?: AudioConvertOpts,
|
||||||
): Promise<Buffer> => {
|
): Promise<Buffer> => {
|
||||||
const settings = { ...defaultAudioConvertOpts, ...opts };
|
const settings = { ...defaultAudioConvertOpts, ...opts };
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -35,12 +38,8 @@ export const convert = (
|
||||||
.audioCodec(settings.audioCodec)
|
.audioCodec(settings.audioCodec)
|
||||||
.audioBitrate(settings.bitrate)
|
.audioBitrate(settings.bitrate)
|
||||||
.toFormat(settings.format)
|
.toFormat(settings.format)
|
||||||
.on("error", (err, stdout, stderr) => {
|
.on("error", (err, _stdout, _stderr) => {
|
||||||
console.error(err.message);
|
logger.error({ error: err }, 'FFmpeg conversion error');
|
||||||
console.log("FFMPEG OUTPUT");
|
|
||||||
console.log(stdout);
|
|
||||||
console.log("FFMPEG ERROR");
|
|
||||||
console.log(stderr);
|
|
||||||
reject(err);
|
reject(err);
|
||||||
})
|
})
|
||||||
.on("end", () => {
|
.on("end", () => {
|
||||||
|
|
@ -62,12 +61,16 @@ export const selfCheck = (): Promise<boolean> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
ffmpeg.getAvailableFormats((err, codecs) => {
|
ffmpeg.getAvailableFormats((err, codecs) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("FFMPEG error:", err);
|
logger.error({ error: err }, 'FFMPEG error');
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const preds = R.map(requiredCodecs, (codec) => (available: any) =>
|
const preds = R.map(
|
||||||
available[codec] && available[codec].canDemux && available[codec].canMux
|
requiredCodecs,
|
||||||
|
(codec) => (available: any) =>
|
||||||
|
available[codec] &&
|
||||||
|
available[codec].canDemux &&
|
||||||
|
available[codec].canMux,
|
||||||
);
|
);
|
||||||
|
|
||||||
resolve(R.allPass(codecs, preds));
|
resolve(R.allPass(codecs, preds));
|
||||||
|
|
@ -79,6 +82,6 @@ export const assertFfmpegAvailable = async (): Promise<void> => {
|
||||||
const r = await selfCheck();
|
const r = await selfCheck();
|
||||||
if (!r)
|
if (!r)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`ffmpeg is not installed, could not be located, or does not support the required codecs: ${requiredCodecs}`
|
`ffmpeg is not installed, could not be located, or does not support the required codecs: ${requiredCodecs}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-worker",
|
"name": "@link-stack/bridge-worker",
|
||||||
"version": "2.2.0",
|
"version": "3.2.0b5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "build/main/index.js",
|
"main": "build/main/index.js",
|
||||||
"author": "Darren Clarke <darren@redaranj.com>",
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
|
|
@ -13,17 +13,18 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hapi/wreck": "^18.1.0",
|
"@hapi/wreck": "^18.1.0",
|
||||||
"@link-stack/bridge-common": "*",
|
"@link-stack/bridge-common": "*",
|
||||||
|
"@link-stack/logger": "*",
|
||||||
"@link-stack/signal-api": "*",
|
"@link-stack/signal-api": "*",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"graphile-worker": "^0.16.6",
|
"graphile-worker": "^0.16.6",
|
||||||
"remeda": "^2.14.0",
|
"remeda": "^2.32.0",
|
||||||
"twilio": "^5.3.2"
|
"twilio": "^5.10.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fluent-ffmpeg": "^2.1.26",
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^10.0.0",
|
||||||
"@link-stack/eslint-config": "*",
|
"@link-stack/eslint-config": "*",
|
||||||
"@link-stack/typescript-config": "*",
|
"@link-stack/typescript-config": "*",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { db } from "@link-stack/bridge-common";
|
import { db } from "@link-stack/bridge-common";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('notify-webhooks');
|
||||||
|
|
||||||
export interface NotifyWebhooksOptions {
|
export interface NotifyWebhooksOptions {
|
||||||
backendId: string;
|
backendId: string;
|
||||||
|
|
@ -10,22 +13,59 @@ const notifyWebhooksTask = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const { backendId, payload } = options;
|
const { backendId, payload } = options;
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
backendId,
|
||||||
|
payloadKeys: Object.keys(payload),
|
||||||
|
}, 'Processing webhook notification');
|
||||||
|
|
||||||
const webhooks = await db
|
const webhooks = await db
|
||||||
.selectFrom("Webhook")
|
.selectFrom("Webhook")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("backendId", "=", backendId)
|
.where("backendId", "=", backendId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
logger.debug({ count: webhooks.length, backendId }, 'Found webhooks');
|
||||||
|
|
||||||
for (const webhook of webhooks) {
|
for (const webhook of webhooks) {
|
||||||
const { endpointUrl, httpMethod, headers } = webhook;
|
const { endpointUrl, httpMethod, headers } = webhook;
|
||||||
const finalHeaders = { "Content-Type": "application/json", ...headers };
|
const finalHeaders = { "Content-Type": "application/json", ...headers };
|
||||||
console.log({ endpointUrl, httpMethod, headers, finalHeaders });
|
const body = JSON.stringify(payload);
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
url: endpointUrl,
|
||||||
|
method: httpMethod,
|
||||||
|
bodyLength: body.length,
|
||||||
|
headerKeys: Object.keys(finalHeaders),
|
||||||
|
}, 'Sending webhook');
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await fetch(endpointUrl, {
|
const result = await fetch(endpointUrl, {
|
||||||
method: httpMethod,
|
method: httpMethod,
|
||||||
headers: finalHeaders,
|
headers: finalHeaders,
|
||||||
body: JSON.stringify(payload),
|
body,
|
||||||
});
|
});
|
||||||
console.log(result);
|
|
||||||
|
logger.debug({
|
||||||
|
url: endpointUrl,
|
||||||
|
status: result.status,
|
||||||
|
statusText: result.statusText,
|
||||||
|
ok: result.ok,
|
||||||
|
}, 'Webhook response');
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const responseText = await result.text();
|
||||||
|
logger.error({
|
||||||
|
url: endpointUrl,
|
||||||
|
status: result.status,
|
||||||
|
responseSample: responseText.substring(0, 500),
|
||||||
|
}, 'Webhook error response');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({
|
||||||
|
url: endpointUrl,
|
||||||
|
error: error instanceof Error ? error.message : error,
|
||||||
|
}, 'Webhook request failed');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { db } from "@link-stack/bridge-common";
|
import { db } from "@link-stack/bridge-common";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-worker-send-facebook-message');
|
||||||
|
|
||||||
interface SendFacebookMessageTaskOptions {
|
interface SendFacebookMessageTaskOptions {
|
||||||
token: string;
|
token: string;
|
||||||
|
|
@ -31,9 +34,8 @@ const sendFacebookMessageTask = async (
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(outgoingMessage),
|
body: JSON.stringify(outgoingMessage),
|
||||||
});
|
});
|
||||||
console.log({ response });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error({ error });
|
logger.error({ error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
import * as signalApi from "@link-stack/signal-api";
|
import * as signalApi from "@link-stack/signal-api";
|
||||||
|
|
||||||
|
const logger = createLogger('fetch-signal-messages');
|
||||||
|
|
||||||
const { Configuration, MessagesApi, AttachmentsApi } = signalApi;
|
const { Configuration, MessagesApi, AttachmentsApi } = signalApi;
|
||||||
const config = new Configuration({
|
const config = new Configuration({
|
||||||
basePath: process.env.BRIDGE_SIGNAL_URL,
|
basePath: process.env.BRIDGE_SIGNAL_URL,
|
||||||
|
|
@ -21,8 +24,23 @@ const fetchAttachments = async (attachments: any[] | undefined) => {
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
const base64Attachment = Buffer.from(arrayBuffer).toString("base64");
|
const base64Attachment = Buffer.from(arrayBuffer).toString("base64");
|
||||||
|
|
||||||
|
// Generate default filename if not provided by Signal API
|
||||||
|
let defaultFilename = name;
|
||||||
|
if (!defaultFilename) {
|
||||||
|
// Check if id already has an extension
|
||||||
|
const hasExtension = id.includes('.');
|
||||||
|
if (hasExtension) {
|
||||||
|
// ID already includes extension
|
||||||
|
defaultFilename = id;
|
||||||
|
} else {
|
||||||
|
// Add extension based on content type
|
||||||
|
const extension = contentType?.split('/')[1] || 'bin';
|
||||||
|
defaultFilename = `${id}.${extension}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formattedAttachment = {
|
const formattedAttachment = {
|
||||||
filename: name,
|
filename: defaultFilename,
|
||||||
mimeType: contentType,
|
mimeType: contentType,
|
||||||
attachment: base64Attachment,
|
attachment: base64Attachment,
|
||||||
};
|
};
|
||||||
|
|
@ -46,21 +64,48 @@ const processMessage = async ({
|
||||||
message: msg,
|
message: msg,
|
||||||
}: ProcessMessageArgs): Promise<Record<string, any>[]> => {
|
}: ProcessMessageArgs): Promise<Record<string, any>[]> => {
|
||||||
const { envelope } = msg;
|
const { envelope } = msg;
|
||||||
console.log(envelope);
|
|
||||||
const { source, sourceUuid, dataMessage } = envelope;
|
const { source, sourceUuid, dataMessage } = envelope;
|
||||||
|
|
||||||
|
const isGroup = !!(
|
||||||
|
dataMessage?.groupV2 ||
|
||||||
|
dataMessage?.groupContext ||
|
||||||
|
dataMessage?.groupInfo
|
||||||
|
);
|
||||||
|
|
||||||
if (!dataMessage) return [];
|
if (!dataMessage) return [];
|
||||||
|
|
||||||
const { attachments } = dataMessage;
|
const { attachments } = dataMessage;
|
||||||
const rawTimestamp = dataMessage?.timestamp;
|
const rawTimestamp = dataMessage?.timestamp;
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
sourceUuid,
|
||||||
|
source,
|
||||||
|
rawTimestamp,
|
||||||
|
hasGroupV2: !!dataMessage?.groupV2,
|
||||||
|
hasGroupContext: !!dataMessage?.groupContext,
|
||||||
|
hasGroupInfo: !!dataMessage?.groupInfo,
|
||||||
|
isGroup,
|
||||||
|
groupV2Id: dataMessage?.groupV2?.id,
|
||||||
|
groupContextType: dataMessage?.groupContext?.type,
|
||||||
|
groupInfoType: dataMessage?.groupInfo?.type,
|
||||||
|
}, 'Processing message');
|
||||||
const timestamp = new Date(rawTimestamp);
|
const timestamp = new Date(rawTimestamp);
|
||||||
|
|
||||||
const formattedAttachments = await fetchAttachments(attachments);
|
const formattedAttachments = await fetchAttachments(attachments);
|
||||||
const primaryAttachment = formattedAttachments[0] ?? {};
|
const primaryAttachment = formattedAttachments[0] ?? {};
|
||||||
const additionalAttachments = formattedAttachments.slice(1);
|
const additionalAttachments = formattedAttachments.slice(1);
|
||||||
|
|
||||||
|
const groupId =
|
||||||
|
dataMessage?.groupV2?.id ||
|
||||||
|
dataMessage?.groupContext?.id ||
|
||||||
|
dataMessage?.groupInfo?.groupId;
|
||||||
|
const toRecipient = groupId
|
||||||
|
? `group.${Buffer.from(groupId).toString("base64")}`
|
||||||
|
: phoneNumber;
|
||||||
|
|
||||||
const primaryMessage = {
|
const primaryMessage = {
|
||||||
token: id,
|
token: id,
|
||||||
to: phoneNumber,
|
to: toRecipient,
|
||||||
from: source,
|
from: source,
|
||||||
messageId: `${sourceUuid}-${rawTimestamp}`,
|
messageId: `${sourceUuid}-${rawTimestamp}`,
|
||||||
message: dataMessage?.message,
|
message: dataMessage?.message,
|
||||||
|
|
@ -68,6 +113,7 @@ const processMessage = async ({
|
||||||
attachment: primaryAttachment.attachment,
|
attachment: primaryAttachment.attachment,
|
||||||
filename: primaryAttachment.filename,
|
filename: primaryAttachment.filename,
|
||||||
mimeType: primaryAttachment.mimeType,
|
mimeType: primaryAttachment.mimeType,
|
||||||
|
isGroup,
|
||||||
};
|
};
|
||||||
const formattedMessages = [primaryMessage];
|
const formattedMessages = [primaryMessage];
|
||||||
|
|
||||||
|
|
@ -119,15 +165,25 @@ const fetchSignalMessagesTask = async ({
|
||||||
number: phoneNumber,
|
number: phoneNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.debug({ botId: id, phoneNumber }, 'Fetching messages for bot');
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const formattedMessages = await processMessage({
|
const formattedMessages = await processMessage({
|
||||||
id,
|
id,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
console.log({ formattedMessages });
|
|
||||||
for (const formattedMessage of formattedMessages) {
|
for (const formattedMessage of formattedMessages) {
|
||||||
if (formattedMessage.to !== formattedMessage.from) {
|
if (formattedMessage.to !== formattedMessage.from) {
|
||||||
|
logger.debug({
|
||||||
|
messageId: formattedMessage.messageId,
|
||||||
|
from: formattedMessage.from,
|
||||||
|
to: formattedMessage.to,
|
||||||
|
isGroup: formattedMessage.isGroup,
|
||||||
|
hasMessage: !!formattedMessage.message,
|
||||||
|
hasAttachment: !!formattedMessage.attachment,
|
||||||
|
}, 'Creating job for message');
|
||||||
|
|
||||||
await worker.addJob(
|
await worker.addJob(
|
||||||
"signal/receive-signal-message",
|
"signal/receive-signal-message",
|
||||||
formattedMessage,
|
formattedMessage,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ const getZammadTickets = async (
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
const tickets: any = await rawTickets.json();
|
const tickets: any = await rawTickets.json();
|
||||||
console.log({ tickets });
|
|
||||||
if (!tickets || tickets.length === 0) {
|
if (!tickets || tickets.length === 0) {
|
||||||
return [shouldContinue, docs];
|
return [shouldContinue, docs];
|
||||||
}
|
}
|
||||||
|
|
@ -49,22 +48,8 @@ const getZammadTickets = async (
|
||||||
shouldContinue = true;
|
shouldContinue = true;
|
||||||
|
|
||||||
if (source_closed_at <= minUpdatedTimestamp) {
|
if (source_closed_at <= minUpdatedTimestamp) {
|
||||||
console.log(`Skipping ticket`, {
|
|
||||||
source_id,
|
|
||||||
source_updated_at,
|
|
||||||
source_closed_at,
|
|
||||||
minUpdatedTimestamp,
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Processing ticket`, {
|
|
||||||
source_id,
|
|
||||||
source_updated_at,
|
|
||||||
source_closed_at,
|
|
||||||
minUpdatedTimestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawArticles = await fetch(
|
const rawArticles = await fetch(
|
||||||
`${zammadApiUrl}/ticket_articles/by_ticket/${source_id}`,
|
`${zammadApiUrl}/ticket_articles/by_ticket/${source_id}`,
|
||||||
{ headers },
|
{ headers },
|
||||||
|
|
@ -178,8 +163,6 @@ const sendToLabelStudio = async (tickets: FormattedZammadTicket[]) => {
|
||||||
body: JSON.stringify([ticket]),
|
body: JSON.stringify([ticket]),
|
||||||
});
|
});
|
||||||
const importResult = await res.json();
|
const importResult = await res.json();
|
||||||
|
|
||||||
console.log(JSON.stringify(importResult, undefined, 2));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
*/
|
*/
|
||||||
|
|
@ -201,7 +184,6 @@ const importLabelStudioTask = async (): Promise<void> => {
|
||||||
await sendToLabelStudio(tickets);
|
await sendToLabelStudio(tickets);
|
||||||
const lastTicket = tickets.pop();
|
const lastTicket = tickets.pop();
|
||||||
const newLastTimestamp = lastTicket.data.source_closed_at;
|
const newLastTimestamp = lastTicket.data.source_closed_at;
|
||||||
console.log({ newLastTimestamp });
|
|
||||||
await db.settings.upsert(settingName, {
|
await db.settings.upsert(settingName, {
|
||||||
minUpdatedTimestamp: newLastTimestamp,
|
minUpdatedTimestamp: newLastTimestamp,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
/*
|
/*
|
||||||
import { URLSearchParams } from "url";
|
import { URLSearchParams } from "url";
|
||||||
import { withDb, AppDatabase } from "../../lib/db.js";
|
import { withDb, AppDatabase } from "../../lib/db.js";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-worker-import-leafcutter');
|
||||||
// import { loadConfig } from "@digiresilience/bridge-config";
|
// import { loadConfig } from "@digiresilience/bridge-config";
|
||||||
|
|
||||||
const config: any = {};
|
const config: any = {};
|
||||||
|
|
@ -43,14 +46,11 @@ const getLabelStudioTickets = async (
|
||||||
page_size: "50",
|
page_size: "50",
|
||||||
page: `${page}`,
|
page: `${page}`,
|
||||||
});
|
});
|
||||||
console.log({ url: `${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}` });
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}`,
|
`${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}`,
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
console.log({ res });
|
|
||||||
const tasksResult: any = await res.json();
|
const tasksResult: any = await res.json();
|
||||||
console.log({ tasksResult });
|
|
||||||
|
|
||||||
return tasksResult;
|
return tasksResult;
|
||||||
};
|
};
|
||||||
|
|
@ -63,14 +63,11 @@ const fetchFromLabelStudio = async (
|
||||||
|
|
||||||
for await (const page of pages) {
|
for await (const page of pages) {
|
||||||
const docs = await getLabelStudioTickets(page + 1);
|
const docs = await getLabelStudioTickets(page + 1);
|
||||||
console.log({ page, docs });
|
|
||||||
|
|
||||||
if (docs && docs.length > 0) {
|
if (docs && docs.length > 0) {
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
const updatedAt = new Date(doc.updated_at);
|
const updatedAt = new Date(doc.updated_at);
|
||||||
console.log({ updatedAt, minUpdatedTimestamp });
|
|
||||||
if (updatedAt > minUpdatedTimestamp) {
|
if (updatedAt > minUpdatedTimestamp) {
|
||||||
console.log(`Adding doc`, { doc });
|
|
||||||
allDocs.push(doc);
|
allDocs.push(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +76,6 @@ const fetchFromLabelStudio = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log({ allDocs });
|
|
||||||
return allDocs;
|
return allDocs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -93,9 +89,7 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
|
||||||
},
|
},
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
console.log({ tickets });
|
|
||||||
const filteredTickets = tickets.filter((ticket) => ticket.is_labeled);
|
const filteredTickets = tickets.filter((ticket) => ticket.is_labeled);
|
||||||
console.log({ filteredTickets });
|
|
||||||
const finalTickets: LeafcutterTicket[] = filteredTickets.map((ticket) => {
|
const finalTickets: LeafcutterTicket[] = filteredTickets.map((ticket) => {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
|
@ -131,8 +125,7 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Sending to Leafcutter");
|
logger.info("Sending to Leafcutter");
|
||||||
console.log({ finalTickets });
|
|
||||||
|
|
||||||
const result = await fetch(opensearchApiUrl, {
|
const result = await fetch(opensearchApiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -157,15 +150,7 @@ const importLeafcutterTask = async (): Promise<void> => {
|
||||||
? new Date(res.value.minUpdatedTimestamp as string)
|
? new Date(res.value.minUpdatedTimestamp as string)
|
||||||
: new Date("2023-03-01");
|
: new Date("2023-03-01");
|
||||||
const newLastTimestamp = new Date();
|
const newLastTimestamp = new Date();
|
||||||
console.log({
|
|
||||||
contributorName,
|
|
||||||
settingName,
|
|
||||||
res,
|
|
||||||
startTimestamp,
|
|
||||||
newLastTimestamp,
|
|
||||||
});
|
|
||||||
const tickets = await fetchFromLabelStudio(startTimestamp);
|
const tickets = await fetchFromLabelStudio(startTimestamp);
|
||||||
console.log({ tickets });
|
|
||||||
await sendToLeafcutter(tickets);
|
await sendToLeafcutter(tickets);
|
||||||
await db.settings.upsert(settingName, {
|
await db.settings.upsert(settingName, {
|
||||||
minUpdatedTimestamp: newLastTimestamp,
|
minUpdatedTimestamp: newLastTimestamp,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
import * as signalApi from "@link-stack/signal-api";
|
||||||
|
const { Configuration, GroupsApi } = signalApi;
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-worker-receive-signal-message');
|
||||||
|
|
||||||
interface ReceiveSignalMessageTaskOptions {
|
interface ReceiveSignalMessageTaskOptions {
|
||||||
token: string;
|
token: string;
|
||||||
|
|
@ -10,6 +15,7 @@ interface ReceiveSignalMessageTaskOptions {
|
||||||
attachment?: string;
|
attachment?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
isGroup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const receiveSignalMessageTask = async ({
|
const receiveSignalMessageTask = async ({
|
||||||
|
|
@ -22,8 +28,17 @@ const receiveSignalMessageTask = async ({
|
||||||
attachment,
|
attachment,
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
isGroup,
|
||||||
}: ReceiveSignalMessageTaskOptions): Promise<void> => {
|
}: ReceiveSignalMessageTaskOptions): Promise<void> => {
|
||||||
console.log({ token, to, from });
|
logger.debug({
|
||||||
|
messageId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
isGroup,
|
||||||
|
hasMessage: !!message,
|
||||||
|
hasAttachment: !!attachment,
|
||||||
|
token,
|
||||||
|
}, 'Processing incoming message');
|
||||||
const worker = await getWorkerUtils();
|
const worker = await getWorkerUtils();
|
||||||
const row = await db
|
const row = await db
|
||||||
.selectFrom("SignalBot")
|
.selectFrom("SignalBot")
|
||||||
|
|
@ -32,8 +47,170 @@ const receiveSignalMessageTask = async ({
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
const backendId = row.id;
|
const backendId = row.id;
|
||||||
const payload = {
|
let finalTo = to;
|
||||||
|
let createdInternalId: string | undefined;
|
||||||
|
|
||||||
|
// Check if auto-group creation is enabled and this is NOT already a group message
|
||||||
|
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
enableAutoGroups,
|
||||||
|
isGroup,
|
||||||
|
shouldCreateGroup: enableAutoGroups && !isGroup && from && to,
|
||||||
|
}, 'Auto-groups config');
|
||||||
|
|
||||||
|
// If this is already a group message and auto-groups is enabled,
|
||||||
|
// use group provided in 'to'
|
||||||
|
if (enableAutoGroups && isGroup && to) {
|
||||||
|
// Signal sends the internal ID (base64) in group messages
|
||||||
|
// We should NOT add "group." prefix - that's for sending messages, not receiving
|
||||||
|
logger.debug('Message is from existing group with internal ID');
|
||||||
|
|
||||||
|
finalTo = to;
|
||||||
|
} else if (enableAutoGroups && !isGroup && from && to) {
|
||||||
|
try {
|
||||||
|
const config = new Configuration({
|
||||||
|
basePath: process.env.BRIDGE_SIGNAL_URL,
|
||||||
|
});
|
||||||
|
const groupsClient = new GroupsApi(config);
|
||||||
|
|
||||||
|
// Always create a new group for direct messages to the helpdesk
|
||||||
|
// This ensures each conversation gets its own group/ticket
|
||||||
|
logger.info({ from }, 'Creating new group for user');
|
||||||
|
|
||||||
|
// Include timestamp to make each group unique
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[:.]/g, "-")
|
||||||
|
.substring(0, 19);
|
||||||
|
const groupName = `Support: ${from} (${timestamp})`;
|
||||||
|
|
||||||
|
// Create new group for this conversation
|
||||||
|
const createGroupResponse = await groupsClient.v1GroupsNumberPost({
|
||||||
|
number: row.phoneNumber,
|
||||||
|
data: {
|
||||||
|
name: groupName,
|
||||||
|
members: [from],
|
||||||
|
description: "Private support conversation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug({ createGroupResponse }, 'Group creation response from Signal API');
|
||||||
|
|
||||||
|
if (createGroupResponse.id) {
|
||||||
|
// The createGroupResponse.id already contains the full group identifier (group.BASE64)
|
||||||
|
finalTo = createGroupResponse.id;
|
||||||
|
|
||||||
|
// Fetch the group details to get the actual internalId
|
||||||
|
// The base64 part of the ID is NOT the same as the internalId!
|
||||||
|
try {
|
||||||
|
logger.debug('Fetching group details to get internalId');
|
||||||
|
const groups = await groupsClient.v1GroupsNumberGet({
|
||||||
|
number: row.phoneNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug({ groupsSample: groups.slice(0, 3) }, 'Groups for bot');
|
||||||
|
|
||||||
|
const createdGroup = groups.find((g) => g.id === finalTo);
|
||||||
|
if (createdGroup) {
|
||||||
|
logger.debug({ createdGroup }, 'Found created group details');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdGroup && createdGroup.internalId) {
|
||||||
|
createdInternalId = createdGroup.internalId;
|
||||||
|
logger.debug({ createdInternalId }, 'Got actual internalId');
|
||||||
|
} else {
|
||||||
|
// Fallback: extract base64 part from ID
|
||||||
|
if (finalTo.startsWith("group.")) {
|
||||||
|
createdInternalId = finalTo.substring(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
logger.debug('Could not fetch group details, using ID base64 part');
|
||||||
|
// Fallback: extract base64 part from ID
|
||||||
|
if (finalTo.startsWith("group.")) {
|
||||||
|
createdInternalId = finalTo.substring(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
fullGroupId: finalTo,
|
||||||
|
internalId: createdInternalId,
|
||||||
|
}, 'Group created successfully');
|
||||||
|
logger.debug({
|
||||||
|
groupId: finalTo,
|
||||||
|
internalId: createdInternalId,
|
||||||
|
groupName,
|
||||||
|
forPhoneNumber: from,
|
||||||
|
botNumber: row.phoneNumber,
|
||||||
|
response: createGroupResponse,
|
||||||
|
}, 'Created new Signal group');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now handle notifications and message forwarding for both new and existing groups
|
||||||
|
if (finalTo && finalTo.startsWith("group.")) {
|
||||||
|
// Forward the user's initial message to the group using quote feature
|
||||||
|
try {
|
||||||
|
logger.debug('Forwarding initial message to group using quote feature');
|
||||||
|
|
||||||
|
const attributionMessage = `Message from ${from}:\n"${message}"\n\n---\nSupport team: Your request has been received. An agent will respond shortly.`;
|
||||||
|
|
||||||
|
await worker.addJob("signal/send-signal-message", {
|
||||||
|
token: row.token,
|
||||||
|
to: finalTo,
|
||||||
|
message: attributionMessage,
|
||||||
|
conversationId: null,
|
||||||
|
quoteMessage: message,
|
||||||
|
quoteAuthor: from,
|
||||||
|
quoteTimestamp: Date.parse(sentAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug({ finalTo }, 'Successfully forwarded initial message to group');
|
||||||
|
} catch (forwardError) {
|
||||||
|
logger.error({ error: forwardError }, 'Error forwarding message to group');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a response to the original DM informing about the group
|
||||||
|
try {
|
||||||
|
logger.debug('Sending group notification to original DM');
|
||||||
|
|
||||||
|
const dmNotification = `Hello! A private support group has been created for your conversation.\n\nGroup name: ${groupName}\n\nPlease look for the new group in your Signal app to continue the conversation. Our support team will respond there shortly.\n\nThank you for contacting support!`;
|
||||||
|
|
||||||
|
await worker.addJob("signal/send-signal-message", {
|
||||||
|
token: row.token,
|
||||||
|
to: from,
|
||||||
|
message: dmNotification,
|
||||||
|
conversationId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Successfully sent group notification to user DM');
|
||||||
|
} catch (dmError) {
|
||||||
|
logger.error({ error: dmError }, 'Error sending DM notification');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check if error is because group already exists
|
||||||
|
const errorMessage =
|
||||||
|
error?.response?.data?.error || error?.message || error;
|
||||||
|
const isAlreadyExists =
|
||||||
|
errorMessage?.toString().toLowerCase().includes("already") ||
|
||||||
|
errorMessage?.toString().toLowerCase().includes("exists");
|
||||||
|
|
||||||
|
if (isAlreadyExists) {
|
||||||
|
logger.debug({ from }, 'Group might already exist, continuing with original recipient');
|
||||||
|
} else {
|
||||||
|
logger.error({
|
||||||
|
error: errorMessage,
|
||||||
|
from,
|
||||||
to,
|
to,
|
||||||
|
botNumber: row.phoneNumber,
|
||||||
|
}, 'Error creating Signal group');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
to: finalTo,
|
||||||
from,
|
from,
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
sent_at: sentAt,
|
sent_at: sentAt,
|
||||||
|
|
@ -41,6 +218,7 @@ const receiveSignalMessageTask = async ({
|
||||||
attachment,
|
attachment,
|
||||||
filename,
|
filename,
|
||||||
mime_type: mimeType,
|
mime_type: mimeType,
|
||||||
|
is_group: finalTo.startsWith("group"),
|
||||||
};
|
};
|
||||||
|
|
||||||
await worker.addJob("common/notify-webhooks", { backendId, payload });
|
await worker.addJob("common/notify-webhooks", { backendId, payload });
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,44 @@
|
||||||
import { db } from "@link-stack/bridge-common";
|
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
import * as signalApi from "@link-stack/signal-api";
|
import * as signalApi from "@link-stack/signal-api";
|
||||||
const { Configuration, MessagesApi } = signalApi;
|
const { Configuration, MessagesApi, GroupsApi } = signalApi;
|
||||||
|
|
||||||
|
const logger = createLogger("bridge-worker-send-signal-message");
|
||||||
|
|
||||||
interface SendSignalMessageTaskOptions {
|
interface SendSignalMessageTaskOptions {
|
||||||
token: string;
|
token: string;
|
||||||
to: string;
|
to: string;
|
||||||
message: any;
|
message: any;
|
||||||
|
conversationId?: string; // Zammad ticket/conversation ID for callback
|
||||||
|
quoteMessage?: string; // Optional: message text to quote
|
||||||
|
quoteAuthor?: string; // Optional: author of quoted message (phone number)
|
||||||
|
quoteTimestamp?: number; // Optional: timestamp of quoted message in milliseconds
|
||||||
|
attachments?: Array<{
|
||||||
|
data: string; // base64
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendSignalMessageTask = async ({
|
const sendSignalMessageTask = async ({
|
||||||
token,
|
token,
|
||||||
to,
|
to,
|
||||||
message,
|
message,
|
||||||
|
conversationId,
|
||||||
|
quoteMessage,
|
||||||
|
quoteAuthor,
|
||||||
|
quoteTimestamp,
|
||||||
|
attachments,
|
||||||
}: SendSignalMessageTaskOptions): Promise<void> => {
|
}: SendSignalMessageTaskOptions): Promise<void> => {
|
||||||
console.log({ token, to });
|
logger.debug(
|
||||||
|
{
|
||||||
|
token,
|
||||||
|
to,
|
||||||
|
conversationId,
|
||||||
|
messageLength: message?.length,
|
||||||
|
},
|
||||||
|
"Processing outgoing message",
|
||||||
|
);
|
||||||
const bot = await db
|
const bot = await db
|
||||||
.selectFrom("SignalBot")
|
.selectFrom("SignalBot")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
|
|
@ -25,18 +50,208 @@ const sendSignalMessageTask = async ({
|
||||||
basePath: process.env.BRIDGE_SIGNAL_URL,
|
basePath: process.env.BRIDGE_SIGNAL_URL,
|
||||||
});
|
});
|
||||||
const messagesClient = new MessagesApi(config);
|
const messagesClient = new MessagesApi(config);
|
||||||
|
const groupsClient = new GroupsApi(config);
|
||||||
|
const worker = await getWorkerUtils();
|
||||||
|
|
||||||
|
let finalTo = to;
|
||||||
|
let groupCreated = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await messagesClient.v2SendPost({
|
// Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number
|
||||||
|
const isUUID =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
const isGroupPrefix = to.startsWith("group.");
|
||||||
|
const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(to) && to.length > 20; // Base64 internal_id
|
||||||
|
const isGroupId = isUUID || isGroupPrefix || isBase64;
|
||||||
|
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
isGroupId,
|
||||||
|
enableAutoGroups,
|
||||||
|
shouldCreateGroup:
|
||||||
|
enableAutoGroups && !isGroupId && to && conversationId,
|
||||||
|
},
|
||||||
|
"Recipient analysis",
|
||||||
|
);
|
||||||
|
|
||||||
|
// If sending to a phone number and auto-groups is enabled, create a group first
|
||||||
|
if (enableAutoGroups && !isGroupId && to && conversationId) {
|
||||||
|
try {
|
||||||
|
const groupName = `DPN Support Request: ${conversationId}`;
|
||||||
|
const createGroupResponse = await groupsClient.v1GroupsNumberPost({
|
||||||
|
number: bot.phoneNumber,
|
||||||
data: {
|
data: {
|
||||||
number,
|
name: groupName,
|
||||||
recipients: [to],
|
members: [to],
|
||||||
message,
|
description: "Private support conversation",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log({ response });
|
|
||||||
} catch (error) {
|
if (createGroupResponse.id) {
|
||||||
console.error({ error });
|
// The createGroupResponse.id already contains the full group identifier (group.BASE64)
|
||||||
|
finalTo = createGroupResponse.id;
|
||||||
|
groupCreated = true;
|
||||||
|
|
||||||
|
// Fetch the group details to get the actual internalId
|
||||||
|
let internalId: string | undefined;
|
||||||
|
try {
|
||||||
|
const groups = await groupsClient.v1GroupsNumberGet({
|
||||||
|
number: bot.phoneNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdGroup = groups.find((g) => g.id === finalTo);
|
||||||
|
if (createdGroup && createdGroup.internalId) {
|
||||||
|
internalId = createdGroup.internalId;
|
||||||
|
logger.debug({ internalId }, "Got actual internalId");
|
||||||
|
} else {
|
||||||
|
// Fallback: extract base64 part from ID
|
||||||
|
if (finalTo.startsWith("group.")) {
|
||||||
|
internalId = finalTo.substring(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
logger.debug("Could not fetch group details, using ID base64 part");
|
||||||
|
// Fallback: extract base64 part from ID
|
||||||
|
if (finalTo.startsWith("group.")) {
|
||||||
|
internalId = finalTo.substring(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
groupId: finalTo,
|
||||||
|
internalId,
|
||||||
|
groupName,
|
||||||
|
conversationId,
|
||||||
|
originalRecipient: to,
|
||||||
|
botNumber: bot.phoneNumber,
|
||||||
|
},
|
||||||
|
"Created new Signal group",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify Zammad about the new group ID via webhook
|
||||||
|
await worker.addJob("common/notify-webhooks", {
|
||||||
|
backendId: bot.id,
|
||||||
|
payload: {
|
||||||
|
event: "group_created",
|
||||||
|
conversation_id: conversationId,
|
||||||
|
original_recipient: to,
|
||||||
|
group_id: finalTo,
|
||||||
|
internal_group_id: internalId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
groupError instanceof Error ? groupError.message : groupError,
|
||||||
|
to,
|
||||||
|
conversationId,
|
||||||
|
},
|
||||||
|
"Error creating Signal group",
|
||||||
|
);
|
||||||
|
// Continue with original recipient if group creation fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
fromNumber: number,
|
||||||
|
toRecipient: finalTo,
|
||||||
|
originalTo: to,
|
||||||
|
recipientChanged: to !== finalTo,
|
||||||
|
groupCreated,
|
||||||
|
isGroupRecipient: finalTo.startsWith("group."),
|
||||||
|
},
|
||||||
|
"Sending message via API",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the message data with optional quote parameters
|
||||||
|
const messageData: signalApi.ApiSendMessageV2 = {
|
||||||
|
number,
|
||||||
|
recipients: [finalTo],
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
number,
|
||||||
|
recipients: [finalTo],
|
||||||
|
message: message.substring(0, 50) + "...",
|
||||||
|
hasQuoteParams: !!(quoteMessage && quoteAuthor && quoteTimestamp),
|
||||||
|
},
|
||||||
|
"Message data being sent",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add quote parameters if all are provided
|
||||||
|
if (quoteMessage && quoteAuthor && quoteTimestamp) {
|
||||||
|
messageData.quoteTimestamp = quoteTimestamp;
|
||||||
|
messageData.quoteAuthor = quoteAuthor;
|
||||||
|
messageData.quoteMessage = quoteMessage;
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
quoteAuthor,
|
||||||
|
quoteMessage: quoteMessage.substring(0, 50) + "...",
|
||||||
|
quoteTimestamp,
|
||||||
|
},
|
||||||
|
"Including quote in message",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add attachments if provided
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
messageData.base64Attachments = attachments.map((att) => att.data);
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
attachmentCount: attachments.length,
|
||||||
|
attachmentNames: attachments.map((att) => att.filename),
|
||||||
|
},
|
||||||
|
"Including attachments in message",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await messagesClient.v2SendPost({
|
||||||
|
data: messageData,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
to: finalTo,
|
||||||
|
groupCreated,
|
||||||
|
response: response?.timestamp || "no timestamp",
|
||||||
|
},
|
||||||
|
"Message sent successfully",
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Try to get the actual error message from the response
|
||||||
|
if (error.response) {
|
||||||
|
try {
|
||||||
|
const errorBody = await error.response.text();
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
status: error.response.status,
|
||||||
|
statusText: error.response.statusText,
|
||||||
|
body: errorBody,
|
||||||
|
sentTo: finalTo,
|
||||||
|
messageDetails: {
|
||||||
|
fromNumber: number,
|
||||||
|
toRecipients: [finalTo],
|
||||||
|
hasQuote: !!quoteMessage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Signal API error",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Could not parse error response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.error({ error }, "Full error details");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { withDb, AppDatabase } from "../../lib/db.js";
|
||||||
import { twilioClientFor } from "../../lib/common.js";
|
import { twilioClientFor } from "../../lib/common.js";
|
||||||
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||||
import workerUtils from "../../lib/utils.js";
|
import workerUtils from "../../lib/utils.js";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('bridge-worker-twilio-recording');
|
||||||
|
|
||||||
interface WebhookPayload {
|
interface WebhookPayload {
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
|
@ -20,7 +23,7 @@ const getTwilioRecording = async (url: string) => {
|
||||||
const { payload } = await Wreck.get(url);
|
const { payload } = await Wreck.get(url);
|
||||||
return { recording: payload as Buffer };
|
return { recording: payload as Buffer };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error.output);
|
logger.error(error.output);
|
||||||
return { error: error.output };
|
return { error: error.output };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ const receiveWhatsappMessageTask = async ({
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
}: ReceiveWhatsappMessageTaskOptions): Promise<void> => {
|
}: ReceiveWhatsappMessageTaskOptions): Promise<void> => {
|
||||||
console.log({ token, to, from });
|
|
||||||
|
|
||||||
const worker = await getWorkerUtils();
|
const worker = await getWorkerUtils();
|
||||||
const row = await db
|
const row = await db
|
||||||
.selectFrom("WhatsappBot")
|
.selectFrom("WhatsappBot")
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
import { db } from "@link-stack/bridge-common";
|
import { db } from "@link-stack/bridge-common";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("bridge-worker-send-whatsapp-message");
|
||||||
|
|
||||||
interface SendWhatsappMessageTaskOptions {
|
interface SendWhatsappMessageTaskOptions {
|
||||||
token: string;
|
token: string;
|
||||||
to: string;
|
to: string;
|
||||||
message: any;
|
message: any;
|
||||||
|
attachments?: Array<{
|
||||||
|
data: string;
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendWhatsappMessageTask = async ({
|
const sendWhatsappMessageTask = async ({
|
||||||
message,
|
message,
|
||||||
to,
|
to,
|
||||||
token,
|
token,
|
||||||
|
attachments,
|
||||||
}: SendWhatsappMessageTaskOptions): Promise<void> => {
|
}: SendWhatsappMessageTaskOptions): Promise<void> => {
|
||||||
const bot = await db
|
const bot = await db
|
||||||
.selectFrom("WhatsappBot")
|
.selectFrom("WhatsappBot")
|
||||||
|
|
@ -18,16 +27,40 @@ const sendWhatsappMessageTask = async ({
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
const url = `${process.env.BRIDGE_WHATSAPP_URL}/api/bots/${bot.id}/send`;
|
const url = `${process.env.BRIDGE_WHATSAPP_URL}/api/bots/${bot.id}/send`;
|
||||||
const params = { message, phoneNumber: to };
|
const params: any = { message, phoneNumber: to };
|
||||||
|
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
params.attachments = attachments;
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
attachmentCount: attachments.length,
|
||||||
|
attachmentNames: attachments.map((att) => att.filename),
|
||||||
|
},
|
||||||
|
"Sending WhatsApp message with attachments",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetch(url, {
|
const result = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
});
|
});
|
||||||
console.log({ result });
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const errorText = await result.text();
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
status: result.status,
|
||||||
|
errorText,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
"WhatsApp send failed",
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to send message: ${result.status}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error({ error });
|
logger.error({ error });
|
||||||
throw new Error("Failed to send message");
|
throw new Error("Failed to send message");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,7 @@ RUN chown -R node ${APP_DIR}/
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
WORKDIR ${APP_DIR}
|
WORKDIR ${APP_DIR}
|
||||||
COPY --from=installer ${APP_DIR}/node_modules/ ./node_modules/
|
COPY --from=installer ${APP_DIR} ./
|
||||||
COPY --from=installer ${APP_DIR}/apps/leafcutter/ ./apps/leafcutter/
|
|
||||||
COPY --from=installer ${APP_DIR}/package.json ./package.json
|
|
||||||
USER root
|
USER root
|
||||||
WORKDIR ${APP_DIR}/apps/leafcutter/
|
WORKDIR ${APP_DIR}/apps/leafcutter/
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,195 @@
|
||||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
# Leafcutter
|
||||||
|
|
||||||
## Getting Started
|
Data visualization and analytics platform for the CDR Link ecosystem.
|
||||||
|
|
||||||
First, run the development server:
|
## Overview
|
||||||
|
|
||||||
|
Leafcutter provides powerful data visualization capabilities with multiple chart types, trend analysis, and OpenSearch integration. It enables users to create, save, and share custom visualizations of their data with support for multiple languages.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multiple Visualization Types**:
|
||||||
|
- Vertical/Horizontal Bar Charts (including stacked)
|
||||||
|
- Line Charts (including stacked)
|
||||||
|
- Pie/Donut Charts
|
||||||
|
- Data Tables
|
||||||
|
- Metrics Display
|
||||||
|
- Tag Clouds
|
||||||
|
|
||||||
|
- **Data Management**:
|
||||||
|
- Create and save custom searches
|
||||||
|
- User-specific visualizations
|
||||||
|
- Trend analysis and insights
|
||||||
|
- OpenSearch integration for data queries
|
||||||
|
|
||||||
|
- **User Experience**:
|
||||||
|
- Internationalization (English, French)
|
||||||
|
- Responsive design
|
||||||
|
- Export capabilities
|
||||||
|
- Preview mode for sharing
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- npm >= 10
|
||||||
|
- OpenSearch instance
|
||||||
|
- PostgreSQL database (for user data)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run development server (port 3001)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
### Environment Variables
|
||||||
|
|
||||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
Required environment variables:
|
||||||
|
|
||||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
- `OPENSEARCH_URL` - OpenSearch endpoint
|
||||||
|
- `OPENSEARCH_USERNAME` - OpenSearch username
|
||||||
|
- `OPENSEARCH_PASSWORD` - OpenSearch password
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection
|
||||||
|
- `NEXTAUTH_URL` - Application URL
|
||||||
|
- `NEXTAUTH_SECRET` - NextAuth.js secret
|
||||||
|
- `GOOGLE_CLIENT_ID` - Google OAuth client ID
|
||||||
|
- `GOOGLE_CLIENT_SECRET` - Google OAuth client secret
|
||||||
|
|
||||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
### Available Scripts
|
||||||
|
|
||||||
## Learn More
|
- `npm run dev` - Development server on port 3001
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm run start` - Start production server
|
||||||
|
- `npm run lint` - Run ESLint
|
||||||
|
- `npm run export` - Export static site
|
||||||
|
- `npm run aws:*` - AWS deployment utilities
|
||||||
|
- `npm run kubectl:*` - Kubernetes utilities
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
## Architecture
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
### Page Structure
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
- `/` - Home dashboard
|
||||||
|
- `/create` - Create new visualizations
|
||||||
|
- `/visualizations/[id]` - View/edit visualizations
|
||||||
|
- `/preview/[id]` - Public preview mode
|
||||||
|
- `/trends` - Trend analysis
|
||||||
|
- `/about` - About page
|
||||||
|
- `/faq` - Frequently asked questions
|
||||||
|
- `/setup` - Initial setup wizard
|
||||||
|
|
||||||
## Deploy on Vercel
|
### API Routes
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
- `/api/searches/*` - Search management
|
||||||
|
- `/api/visualizations/*` - Visualization CRUD
|
||||||
|
- `/api/trends/*` - Trend data
|
||||||
|
- `/api/upload` - File upload handling
|
||||||
|
- `/api/link/auth` - Link authentication
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
### Visualization Configuration
|
||||||
|
|
||||||
|
Each visualization type has a JSON configuration in `_config/visualizations/`:
|
||||||
|
- Chart options
|
||||||
|
- Data mapping
|
||||||
|
- Styling preferences
|
||||||
|
- Aggregation settings
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. User creates search query
|
||||||
|
2. Query sent to OpenSearch
|
||||||
|
3. Results processed and aggregated
|
||||||
|
4. Data rendered in chosen visualization
|
||||||
|
5. Visualization saved to PostgreSQL
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
Supported languages:
|
||||||
|
- English (`_locales/en.json`)
|
||||||
|
- French (`_locales/fr.json`)
|
||||||
|
|
||||||
|
Language selection available in the UI with automatic persistence.
|
||||||
|
|
||||||
|
## OpenSearch Integration
|
||||||
|
|
||||||
|
### Query Structure
|
||||||
|
|
||||||
|
Leafcutter translates user inputs into OpenSearch queries:
|
||||||
|
- Full-text search
|
||||||
|
- Field filtering
|
||||||
|
- Date ranges
|
||||||
|
- Aggregations
|
||||||
|
|
||||||
|
### Index Management
|
||||||
|
|
||||||
|
Works with OpenSearch indices for:
|
||||||
|
- Document storage
|
||||||
|
- Real-time analytics
|
||||||
|
- Historical data
|
||||||
|
|
||||||
|
## Visualization Types
|
||||||
|
|
||||||
|
### Bar Charts
|
||||||
|
- Vertical and horizontal orientations
|
||||||
|
- Stacked variants for multi-series data
|
||||||
|
- Customizable colors and labels
|
||||||
|
|
||||||
|
### Line Charts
|
||||||
|
- Time series visualization
|
||||||
|
- Multiple series support
|
||||||
|
- Trend analysis
|
||||||
|
|
||||||
|
### Pie/Donut Charts
|
||||||
|
- Proportional data display
|
||||||
|
- Interactive legends
|
||||||
|
- Percentage calculations
|
||||||
|
|
||||||
|
### Data Tables
|
||||||
|
- Sortable columns
|
||||||
|
- Pagination
|
||||||
|
- Export functionality
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
- Single value display
|
||||||
|
- Comparison indicators
|
||||||
|
- Real-time updates
|
||||||
|
|
||||||
|
### Tag Clouds
|
||||||
|
- Word frequency visualization
|
||||||
|
- Interactive filtering
|
||||||
|
- Size-based importance
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Authentication via NextAuth.js
|
||||||
|
- User-scoped data access
|
||||||
|
- Secure OpenSearch queries
|
||||||
|
- Input validation
|
||||||
|
|
||||||
|
## Docker Support
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build -t link-stack/leafcutter .
|
||||||
|
|
||||||
|
# Run with docker-compose
|
||||||
|
docker-compose -f docker/compose/leafcutter.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Includes utilities for:
|
||||||
|
- AWS deployment (S3, CloudFront)
|
||||||
|
- Kubernetes deployment
|
||||||
|
- Static site export
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
|
import { Suspense } from "react";
|
||||||
import { About } from "@link-stack/leafcutter-ui";
|
import { About } from "@link-stack/leafcutter-ui";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <About />;
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<About />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
|
import { Suspense } from "react";
|
||||||
import { FAQ } from "@link-stack/leafcutter-ui";
|
import { FAQ } from "@link-stack/leafcutter-ui";
|
||||||
|
|
||||||
export default function Page() {
|
export const dynamic = "force-dynamic";
|
||||||
return <FAQ />;
|
|
||||||
|
export default async function Page() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<FAQ />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { ReactNode } from "react";
|
||||||
import "app/_styles/global.css";
|
import "app/_styles/global.css";
|
||||||
import { InternalLayout } from "../_components/InternalLayout";
|
import { InternalLayout } from "../_components/InternalLayout";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type LayoutProps = {
|
type LayoutProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -69,19 +69,17 @@ export const getServerSideProps: GetServerSideProps = async (
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log({ query });
|
|
||||||
const dataResponse = await client.search({
|
const dataResponse = await client.search({
|
||||||
index: "demo_data",
|
index: "demo_data",
|
||||||
size: 200,
|
size: 200,
|
||||||
body: { query },
|
body: { query },
|
||||||
});
|
});
|
||||||
console.log({ dataResponse });
|
|
||||||
res.props.data = dataResponse.body.hits.hits.map((hit) => ({
|
res.props.data = dataResponse.body.hits.hits.map((hit) => ({
|
||||||
id: hit._id,
|
id: hit._id,
|
||||||
...hit._source,
|
...hit._source,
|
||||||
}));
|
}));
|
||||||
console.log({ data: res.props.data });
|
|
||||||
console.log(res.props.data[0]);
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const Setup: FC = () => {
|
||||||
} = useLeafcutterContext();
|
} = useLeafcutterContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setTimeout(() => router.push("/"), 4000);
|
setTimeout(() => router.push("/"), 2000);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
|
import { Suspense } from "react";
|
||||||
import { Setup } from "./_components/Setup";
|
import { Setup } from "./_components/Setup";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <Setup />;
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<Setup />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ const getVisualization = async (visualizationID: string) => {
|
||||||
);
|
);
|
||||||
const hit = hits[0];
|
const hit = hits[0];
|
||||||
const visualization = {
|
const visualization = {
|
||||||
id: hit._id.split(":")[1],
|
id: hit?._id.split(":")[1],
|
||||||
title: hit._source.visualization.title,
|
title: hit?._source?.visualization.title,
|
||||||
description: hit._source.visualization.description,
|
description: hit?._source?.visualization.description,
|
||||||
url: `/app/visualize?security_tenant=global#/edit/${
|
url: `/app/visualize?security_tenant=global#/edit/${
|
||||||
hit._id.split(":")[1]
|
hit._id.split(":")[1]
|
||||||
}?embed=true`,
|
}?embed=true`,
|
||||||
|
|
@ -34,12 +34,13 @@ const getVisualization = async (visualizationID: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
visualizationID: string;
|
visualizationID: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params: { visualizationID } }: PageProps) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { visualizationID } = await params;
|
||||||
const visualization = await getVisualization(visualizationID);
|
const visualization = await getVisualization(visualizationID);
|
||||||
|
|
||||||
return <VisualizationDetail {...visualization} editing={false} />;
|
return <VisualizationDetail {...visualization} editing={false} />;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fr from "@link-stack/leafcutter-ui/locales/fr.json";
|
||||||
import { LicenseInfo } from "@mui/x-license";
|
import { LicenseInfo } from "@mui/x-license";
|
||||||
|
|
||||||
LicenseInfo.setLicenseKey(
|
LicenseInfo.setLicenseKey(
|
||||||
"c787ac6613c5f2aa0494c4285fe3e9f2Tz04OTY1NyxFPTE3NDYzNDE0ODkwMDAsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
|
"2a7dd73ee59e3e028b96b0d2adee1ad8Tz0xMTMwOTUsRT0xNzc5MDYyMzk5MDAwLFM9cHJvLExNPXN1YnNjcmlwdGlvbixQVj1pbml0aWFsLEtWPTI=",
|
||||||
);
|
);
|
||||||
|
|
||||||
const messages: any = { en, fr };
|
const messages: any = { en, fr };
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ const MenuItem = ({
|
||||||
return (
|
return (
|
||||||
<Link href={href} passHref>
|
<Link href={href} passHref>
|
||||||
<ListItem
|
<ListItem
|
||||||
button
|
|
||||||
sx={{
|
sx={{
|
||||||
paddingLeft: "62px",
|
paddingLeft: "62px",
|
||||||
backgroundColor: selected ? leafcutterLightBlue : "transparent",
|
backgroundColor: selected ? leafcutterLightBlue : "transparent",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import Google from "next-auth/providers/google";
|
||||||
import Apple from "next-auth/providers/apple";
|
import Apple from "next-auth/providers/apple";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
import { checkAuth } from "./opensearch";
|
import { checkAuth } from "./opensearch";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('leafcutter-auth');
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
pages: {
|
pages: {
|
||||||
|
|
@ -22,20 +25,17 @@ export const authOptions: NextAuthOptions = {
|
||||||
Credentials({
|
Credentials({
|
||||||
name: "Link",
|
name: "Link",
|
||||||
credentials: {
|
credentials: {
|
||||||
authToken: { label: "AuthToken", type: "text", },
|
authToken: { label: "AuthToken", type: "text" },
|
||||||
},
|
},
|
||||||
async authorize(credentials, req) {
|
async authorize(credentials, req) {
|
||||||
const { headers } = req;
|
const { headers } = req;
|
||||||
console.log({ headers });
|
|
||||||
const leafcutterUser = headers?.["x-leafcutter-user"];
|
const leafcutterUser = headers?.["x-leafcutter-user"];
|
||||||
const authToken = credentials?.authToken;
|
const authToken = credentials?.authToken;
|
||||||
|
|
||||||
if (!leafcutterUser || leafcutterUser.trim() === "") {
|
if (!leafcutterUser || leafcutterUser.trim() === "") {
|
||||||
console.log("no leafcutter user");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log({ authToken });
|
|
||||||
return null;
|
return null;
|
||||||
/*
|
/*
|
||||||
try {
|
try {
|
||||||
|
|
@ -48,14 +48,13 @@ export const authOptions: NextAuthOptions = {
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log({ e });
|
logger.error({ e });
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
*/
|
*/
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
|
|
||||||
],
|
],
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
/*
|
/*
|
||||||
|
|
@ -77,4 +76,3 @@ export const authOptions: NextAuthOptions = {
|
||||||
}
|
}
|
||||||
},*/
|
},*/
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
/* eslint-disable no-underscore-dangle */
|
/* eslint-disable no-underscore-dangle */
|
||||||
import { Client } from "@opensearch-project/opensearch";
|
import { Client } from "@opensearch-project/opensearch";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('leafcutter-opensearch');
|
||||||
|
|
||||||
/* Common */
|
/* Common */
|
||||||
|
|
||||||
|
|
@ -10,7 +13,8 @@ const userMetadataIndexName = "user_metadata";
|
||||||
|
|
||||||
const baseURL = `https://${process.env.OPENSEARCH_USERNAME}:${process.env.OPENSEARCH_PASSWORD}@${process.env.OPENSEARCH_URL}`;
|
const baseURL = `https://${process.env.OPENSEARCH_USERNAME}:${process.env.OPENSEARCH_PASSWORD}@${process.env.OPENSEARCH_URL}`;
|
||||||
|
|
||||||
const createClient = () => new Client({
|
const createClient = () =>
|
||||||
|
new Client({
|
||||||
node: baseURL,
|
node: baseURL,
|
||||||
auth: {
|
auth: {
|
||||||
username: process.env.OPENSEARCH_USERNAME!,
|
username: process.env.OPENSEARCH_USERNAME!,
|
||||||
|
|
@ -21,7 +25,8 @@ const createClient = () => new Client({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createUserClient = (username: string, password: string) => new Client({
|
const createUserClient = (username: string, password: string) =>
|
||||||
|
new Client({
|
||||||
node: baseURL,
|
node: baseURL,
|
||||||
auth: {
|
auth: {
|
||||||
username,
|
username,
|
||||||
|
|
@ -115,7 +120,7 @@ export const getUserMetadata = async (username: string) => {
|
||||||
await client.create({
|
await client.create({
|
||||||
id: username,
|
id: username,
|
||||||
index: userMetadataIndexName,
|
index: userMetadataIndexName,
|
||||||
body: { username, savedSearches: [] }
|
body: { username, savedSearches: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
res = await client.get({
|
res = await client.get({
|
||||||
|
|
@ -132,7 +137,7 @@ export const saveUserMetadata = async (username: string, metadata: any) => {
|
||||||
await client.update({
|
await client.update({
|
||||||
id: username,
|
id: username,
|
||||||
index: userMetadataIndexName,
|
index: userMetadataIndexName,
|
||||||
body: { doc: { username, ...metadata } }
|
body: { doc: { username, ...metadata } },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -181,7 +186,7 @@ const getIndexPattern: any = async (index: string) => {
|
||||||
sort: ["updated_at:desc"],
|
sort: ["updated_at:desc"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.body.hits.total.value === 0) {
|
if (res?.body?.hits?.total?.valueOf() === 0) {
|
||||||
// eslint-disable-next-line no-use-before-define
|
// eslint-disable-next-line no-use-before-define
|
||||||
return createCurrentUserIndexPattern(index);
|
return createCurrentUserIndexPattern(index);
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +231,7 @@ interface createUserVisualizationProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createUserVisualization = async (
|
export const createUserVisualization = async (
|
||||||
props: createUserVisualizationProps
|
props: createUserVisualizationProps,
|
||||||
) => {
|
) => {
|
||||||
const { email, query, visualizationID, title, description } = props;
|
const { email, query, visualizationID, title, description } = props;
|
||||||
const userIndex = await getCurrentUserIndex(email);
|
const userIndex = await getCurrentUserIndex(email);
|
||||||
|
|
@ -279,7 +284,7 @@ interface updateVisualizationProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateUserVisualization = async (
|
export const updateUserVisualization = async (
|
||||||
props: updateVisualizationProps
|
props: updateVisualizationProps,
|
||||||
) => {
|
) => {
|
||||||
const { email, id, query, title, description } = props;
|
const { email, id, query, title, description } = props;
|
||||||
const userIndex = await getCurrentUserIndex(email);
|
const userIndex = await getCurrentUserIndex(email);
|
||||||
|
|
@ -300,8 +305,7 @@ export const updateUserVisualization = async (
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
logger.error({ e });
|
||||||
console.log({ e });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
|
|
@ -469,10 +473,18 @@ export const performQuery = async (searchQuery: any, limit: number) => {
|
||||||
const results = hits.map((hit: any) => ({
|
const results = hits.map((hit: any) => ({
|
||||||
...hit._source,
|
...hit._source,
|
||||||
id: hit._id,
|
id: hit._id,
|
||||||
incident: Array.isArray(hit._source.incident) ? hit._source.incident.join(", ") : hit._source.incident,
|
incident: Array.isArray(hit._source.incident)
|
||||||
technology: Array.isArray(hit._source.technology) ? hit._source.technology.join(", ") : hit._source.technology,
|
? hit._source.incident.join(", ")
|
||||||
targeted_group: Array.isArray(hit._source.targeted_group) ? hit._source.targeted_group.join(", ") : hit._source.targeted_group,
|
: hit._source.incident,
|
||||||
country: Array.isArray(hit._source.country) ? hit._source.country.join(", ") : hit._source.country,
|
technology: Array.isArray(hit._source.technology)
|
||||||
|
? hit._source.technology.join(", ")
|
||||||
|
: hit._source.technology,
|
||||||
|
targeted_group: Array.isArray(hit._source.targeted_group)
|
||||||
|
? hit._source.targeted_group.join(", ")
|
||||||
|
: hit._source.targeted_group,
|
||||||
|
country: Array.isArray(hit._source.country)
|
||||||
|
? hit._source.country.join(", ")
|
||||||
|
: hit._source.country,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -570,7 +582,6 @@ export const getTemplates = async (limit: number) => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const rawResponse = await client.search({
|
const rawResponse = await client.search({
|
||||||
index: globalIndex,
|
index: globalIndex,
|
||||||
size: limit,
|
size: limit,
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export const GET = async (req: NextRequest) => {
|
export const GET = async (req: NextRequest) => {
|
||||||
const validDomains = "localhost";
|
const validDomains = "localhost";
|
||||||
console.log({ req });
|
|
||||||
|
|
||||||
return NextResponse.json({ response: "ok" });
|
return NextResponse.json({ response: "ok" });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST = async (req: NextRequest) => {
|
export const POST = async (req: NextRequest) => {
|
||||||
const validDomains = "localhost";
|
const validDomains = "localhost";
|
||||||
console.log({ req });
|
|
||||||
|
|
||||||
return NextResponse.json({ response: "ok" });
|
return NextResponse.json({ response: "ok" });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ import { getTrends } from "app/_lib/opensearch";
|
||||||
|
|
||||||
export const GET = async () => {
|
export const GET = async () => {
|
||||||
const results = await getTrends(5);
|
const results = await getTrends(5);
|
||||||
console.log({ results });
|
|
||||||
|
|
||||||
NextResponse.json(results);
|
NextResponse.json(results);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = "force-dynamic";
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import { Client } from "@opensearch-project/opensearch";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import taxonomy from "app/_config/taxonomy.json";
|
import taxonomy from "app/_config/taxonomy.json";
|
||||||
import unRegions from "app/_config/unRegions.json";
|
import unRegions from "app/_config/unRegions.json";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('leafcutter-index');
|
||||||
|
|
||||||
export const POST = async (req: NextRequest) => {
|
export const POST = async (req: NextRequest) => {
|
||||||
const { tickets } = await req.json();
|
const { tickets } = await req.json();
|
||||||
|
|
@ -15,8 +18,8 @@ export const POST = async (req: NextRequest) => {
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
authorization
|
authorization,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const succeeded = [];
|
const succeeded = [];
|
||||||
|
|
@ -28,11 +31,15 @@ export const POST = async (req: NextRequest) => {
|
||||||
const country = ticket.country[0] ?? "none";
|
const country = ticket.country[0] ?? "none";
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const translatedCountry = taxonomy.country[country]?.display ?? "none";
|
const translatedCountry = taxonomy.country[country]?.display ?? "none";
|
||||||
const countryDetails: any = unRegions.find((c) => c.name === translatedCountry);
|
const countryDetails: any = unRegions.find(
|
||||||
|
(c) => c.name === translatedCountry,
|
||||||
|
);
|
||||||
const augmentedTicket = {
|
const augmentedTicket = {
|
||||||
...ticket,
|
...ticket,
|
||||||
region: countryDetails['sub-region']?.toLowerCase().replace(" ", "-") ?? null,
|
region:
|
||||||
continent: countryDetails.region?.toLowerCase().replace(" ", "-") ?? null,
|
countryDetails["sub-region"]?.toLowerCase().replace(" ", "-") ?? null,
|
||||||
|
continent:
|
||||||
|
countryDetails.region?.toLowerCase().replace(" ", "-") ?? null,
|
||||||
};
|
};
|
||||||
const out = await client.create({
|
const out = await client.create({
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
|
@ -40,10 +47,9 @@ export const POST = async (req: NextRequest) => {
|
||||||
refresh: true,
|
refresh: true,
|
||||||
body: augmentedTicket,
|
body: augmentedTicket,
|
||||||
});
|
});
|
||||||
console.log(out);
|
|
||||||
succeeded.push(id);
|
succeeded.push(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
logger.error(e);
|
||||||
failed.push(id);
|
failed.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,4 +58,3 @@ export const POST = async (req: NextRequest) => {
|
||||||
|
|
||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "app/_lib/auth";
|
import { authOptions } from "app/_lib/auth";
|
||||||
import { deleteUserVisualization } from "app/_lib/opensearch";
|
import { deleteUserVisualization } from "app/_lib/opensearch";
|
||||||
|
|
||||||
export const POST = async (req: NextRequest, res: NextResponse) => {
|
export const POST = async (req: NextRequest) => {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
const { user: { email } }: any = session;
|
const {
|
||||||
|
user: { email },
|
||||||
|
}: any = session;
|
||||||
const { id } = await req.json();
|
const { id } = await req.json();
|
||||||
await deleteUserVisualization(email as string, id);
|
await deleteUserVisualization(email as string, id);
|
||||||
|
|
||||||
return NextResponse.json({ id });
|
return NextResponse.json({ id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
3
apps/leafcutter/next-env.d.ts
vendored
3
apps/leafcutter/next-env.d.ts
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference types="next/navigation-types/compat/navigation" />
|
/// <reference types="next/navigation-types/compat/navigation" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
transpilePackages: ["@link-stack/leafcutter-ui", "@link-stack/opensearch-common"],
|
transpilePackages: ["@link-stack/leafcutter-ui", "@link-stack/opensearch-common"],
|
||||||
experimental: {
|
|
||||||
missingSuspenseWithCSRBailout: false,
|
|
||||||
},
|
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
rewrites: async () => ({
|
rewrites: async () => ({
|
||||||
fallback: [
|
fallback: [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/leafcutter",
|
"name": "@link-stack/leafcutter",
|
||||||
"version": "2.2.0",
|
"version": "3.2.0b5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3001",
|
"dev": "next dev -p 3001",
|
||||||
"login": "aws sso login --sso-session cdr",
|
"login": "aws sso login --sso-session cdr",
|
||||||
|
|
@ -13,37 +13,38 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/cache": "^11.13.1",
|
"@emotion/cache": "^11.14.0",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/server": "^11.11.0",
|
"@emotion/server": "^11.11.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@link-stack/leafcutter-ui": "*",
|
"@link-stack/leafcutter-ui": "*",
|
||||||
|
"@link-stack/logger": "*",
|
||||||
"@link-stack/opensearch-common": "*",
|
"@link-stack/opensearch-common": "*",
|
||||||
"@mui/icons-material": "^5",
|
"@mui/icons-material": "^6",
|
||||||
"@mui/material": "^5",
|
"@mui/material": "^6",
|
||||||
"@mui/material-nextjs": "^5",
|
"@mui/material-nextjs": "^6",
|
||||||
"@mui/x-date-pickers-pro": "^7.18.0",
|
"@mui/x-date-pickers-pro": "^7",
|
||||||
"@opensearch-project/opensearch": "^2.12.0",
|
"@opensearch-project/opensearch": "^3.5.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"http-proxy-middleware": "^3.0.2",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"material-ui-popup-state": "^5.3.1",
|
"material-ui-popup-state": "^5.3.6",
|
||||||
"next": "^14.2.23",
|
"next": "15.5.9",
|
||||||
"next-auth": "^4.24.8",
|
"next-auth": "^4.24.11",
|
||||||
"react": "18.3.1",
|
"react": "19.2.0",
|
||||||
"react-cookie": "^7.2.0",
|
"react-cookie": "^8.0.1",
|
||||||
"react-cookie-consent": "^9.0.0",
|
"react-cookie-consent": "^9.0.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.2.0",
|
||||||
"react-iframe": "^1.8.5",
|
"react-iframe": "^1.8.5",
|
||||||
"react-polyglot": "^0.7.2",
|
"react-polyglot": "^0.7.2",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.34.4",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.7.0",
|
||||||
|
"@types/react": "19.2.2",
|
||||||
|
"@types/uuid": "^11.0.0",
|
||||||
"@link-stack/eslint-config": "*",
|
"@link-stack/eslint-config": "*",
|
||||||
"@link-stack/typescript-config": "*",
|
"@link-stack/typescript-config": "*",
|
||||||
"@types/node": "^22.7.3",
|
"typescript": "5.9.3"
|
||||||
"@types/react": "18.3.9",
|
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"typescript": "5.6.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('leafcutter-[[...path]]');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
|
|
@ -24,17 +27,17 @@ const withAuthInfo =
|
||||||
const requestSignature = req.query.signature;
|
const requestSignature = req.query.signature;
|
||||||
const url = new URL(req.headers.referer as string);
|
const url = new URL(req.headers.referer as string);
|
||||||
const referrerSignature = url.searchParams.get("signature");
|
const referrerSignature = url.searchParams.get("signature");
|
||||||
|
|
||||||
console.log({ requestSignature, referrerSignature });
|
|
||||||
const isAppPath = !!req.url?.startsWith("/app");
|
const isAppPath = !!req.url?.startsWith("/app");
|
||||||
const isResourcePath = !!req.url?.match(/\/(api|app|bootstrap|3961|ui|translations|internal|login|node_modules)/);
|
const isResourcePath = !!req.url?.match(
|
||||||
|
/\/(api|app|bootstrap|3961|ui|translations|internal|login|node_modules)/,
|
||||||
|
);
|
||||||
|
|
||||||
if (requestSignature && isAppPath) {
|
if (requestSignature && isAppPath) {
|
||||||
console.log("Has Signature");
|
logger.info("Has Signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (referrerSignature && isResourcePath) {
|
if (referrerSignature && isResourcePath) {
|
||||||
console.log("Has Signature");
|
logger.info("Has Signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
|
|
||||||
2
apps/leafcutter/public/robots.txt
Normal file
2
apps/leafcutter/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
@ -38,10 +38,7 @@ RUN chown -R node ${APP_DIR}/
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
WORKDIR ${APP_DIR}
|
WORKDIR ${APP_DIR}
|
||||||
COPY --from=installer ${APP_DIR}/node_modules/ ./node_modules/
|
COPY --from=installer ${APP_DIR} ./
|
||||||
COPY --from=installer ${APP_DIR}/apps/link/ ./apps/link/
|
|
||||||
COPY --from=installer ${APP_DIR}/apps/bridge-migrations/ ./apps/bridge-migrations/
|
|
||||||
COPY --from=installer ${APP_DIR}/package.json ./package.json
|
|
||||||
USER root
|
USER root
|
||||||
WORKDIR ${APP_DIR}/apps/link/
|
WORKDIR ${APP_DIR}/apps/link/
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,109 @@
|
||||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
# CDR Link
|
||||||
|
|
||||||
## Getting Started
|
The main CDR (Center for Digital Resilience) Link application - a streamlined helpdesk interface built on top of Zammad with integrated communication channels and data visualization.
|
||||||
|
|
||||||
First, run the development server:
|
## Overview
|
||||||
|
|
||||||
|
CDR Link provides a unified dashboard for managing support tickets, communication channels, and data analytics. It integrates multiple services including Zammad (ticketing), Bridge (multi-channel messaging), Leafcutter (data visualization), and OpenSearch.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Simplified Helpdesk Interface**: Streamlined UI for Zammad ticket management
|
||||||
|
- **Multi-Channel Communication**: Integration with Signal, WhatsApp, Facebook, and Voice channels
|
||||||
|
- **Data Visualization**: Embedded Leafcutter analytics and reporting
|
||||||
|
- **User Management**: Role-based access control with Google OAuth
|
||||||
|
- **Search**: Integrated OpenSearch for advanced queries
|
||||||
|
- **Label Studio Integration**: For data annotation workflows
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- npm >= 10
|
||||||
|
- Running instances of Zammad, PostgreSQL, and Redis
|
||||||
|
- Configured authentication providers
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run development server
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
### Environment Variables
|
||||||
|
|
||||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
Key environment variables required:
|
||||||
|
|
||||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
- `ZAMMAD_URL` - Zammad instance URL
|
||||||
|
- `ZAMMAD_API_TOKEN` - Zammad API authentication token
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
|
- `REDIS_URL` - Redis connection URL
|
||||||
|
- `NEXTAUTH_URL` - Application URL for authentication
|
||||||
|
- `NEXTAUTH_SECRET` - Secret for NextAuth.js
|
||||||
|
- `GOOGLE_CLIENT_ID` - Google OAuth client ID
|
||||||
|
- `GOOGLE_CLIENT_SECRET` - Google OAuth client secret
|
||||||
|
|
||||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
### Available Scripts
|
||||||
|
|
||||||
## Learn More
|
- `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 export` - Export static site
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
## Architecture
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
### Pages Structure
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
- `/` - Main dashboard
|
||||||
|
- `/overview/[overview]` - Ticket overview pages
|
||||||
|
- `/tickets/[id]` - Individual ticket view/edit
|
||||||
|
- `/admin/bridge` - Bridge configuration management
|
||||||
|
- `/leafcutter` - Data visualization dashboard
|
||||||
|
- `/opensearch` - Search dashboard
|
||||||
|
- `/zammad` - Direct Zammad access
|
||||||
|
- `/profile` - User profile management
|
||||||
|
|
||||||
## Deploy on Vercel
|
### API Routes
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
- `/api/auth` - NextAuth.js authentication
|
||||||
|
- `/api/v2/users` - User management API
|
||||||
|
- `/api/[service]/bots` - Bot management for communication channels
|
||||||
|
- `/api/[service]/webhooks` - Webhook endpoints
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
### Key Components
|
||||||
|
|
||||||
|
- `ZammadWrapper` - Embeds Zammad UI with authentication
|
||||||
|
- `SearchBox` - Global search functionality
|
||||||
|
- `TicketList` / `TicketDetail` - Ticket management components
|
||||||
|
- `Sidebar` - Navigation and service switching
|
||||||
|
|
||||||
|
## Docker Support
|
||||||
|
|
||||||
|
Build and run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build -t link-stack/link .
|
||||||
|
|
||||||
|
# Run with docker-compose
|
||||||
|
docker-compose -f docker/compose/link.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
- **Zammad**: GraphQL queries for ticket data
|
||||||
|
- **Bridge Services**: REST APIs for channel management
|
||||||
|
- **Leafcutter**: Embedded iframe integration
|
||||||
|
- **OpenSearch**: Direct dashboard embedding
|
||||||
|
- **Redis**: Session and cache storage
|
||||||
|
|
@ -23,13 +23,15 @@ import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
type LoginProps = {
|
type LoginProps = {
|
||||||
session: any;
|
session: any;
|
||||||
|
baseURL: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Login: FC<LoginProps> = ({ session }) => {
|
export const Login: FC<LoginProps> = ({ session, baseURL }) => {
|
||||||
const origin =
|
let origin = null;
|
||||||
typeof window !== "undefined" && window.location.origin
|
if (typeof window !== "undefined") {
|
||||||
? window.location.origin
|
origin = window.location.origin;
|
||||||
: "";
|
}
|
||||||
|
const callbackUrl = `${origin}/link`;
|
||||||
const [provider, setProvider] = useState(undefined);
|
const [provider, setProvider] = useState(undefined);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
@ -158,7 +160,7 @@ export const Login: FC<LoginProps> = ({ session }) => {
|
||||||
sx={buttonStyles}
|
sx={buttonStyles}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
signIn("google", {
|
signIn("google", {
|
||||||
callbackUrl: `${origin}`,
|
callbackUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -174,7 +176,7 @@ export const Login: FC<LoginProps> = ({ session }) => {
|
||||||
sx={buttonStyles}
|
sx={buttonStyles}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
signIn("apple", {
|
signIn("apple", {
|
||||||
callbackUrl: `${window.location.origin}`,
|
callbackUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -189,7 +191,7 @@ export const Login: FC<LoginProps> = ({ session }) => {
|
||||||
sx={buttonStyles}
|
sx={buttonStyles}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
signIn("azure-ad", {
|
signIn("azure-ad", {
|
||||||
callbackUrl: `${origin}`,
|
callbackUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -226,13 +228,13 @@ export const Login: FC<LoginProps> = ({ session }) => {
|
||||||
<Grid item sx={{ width: "100%" }}>
|
<Grid item sx={{ width: "100%" }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
sx={buttonStyles}
|
sx={buttonStyles}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
signIn("credentials", {
|
signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackUrl: `${origin}/setup`,
|
callbackUrl,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<KeyIcon sx={{ mr: 1 }} />
|
<KeyIcon sx={{ mr: 1 }} />
|
||||||
Sign in with Zammad credentials
|
Sign in with Zammad credentials
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ export const metadata: Metadata = {
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
const baseURL = process.env.LINK_URL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<Login session={session} />
|
<Login session={session} baseURL={baseURL} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
type ClientOnlyProps = { children: JSX.Element };
|
type ClientOnlyProps = { children: ReactNode };
|
||||||
const ClientOnly = (props: ClientOnlyProps) => {
|
const ClientOnly = (props: ClientOnlyProps) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { OpenSearchWrapper } from "@link-stack/leafcutter-ui";
|
import { OpenSearchWrapper } from "@link-stack/leafcutter-ui";
|
||||||
|
|
||||||
export const Home: FC = () => (
|
type HomeProps = {
|
||||||
<OpenSearchWrapper
|
url: string;
|
||||||
url="/app/visualize#/edit/237b8f00-e6a0-11ee-94b3-d7b7409294e7?embed=true"
|
};
|
||||||
marginTop="0"
|
|
||||||
/>
|
export const Home: FC<HomeProps> = ({ url }) => (
|
||||||
|
<OpenSearchWrapper url={url} margin={0} />
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -207,10 +207,6 @@ export const Sidebar: FC<SidebarProps> = ({
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
signOut({ callbackUrl: "/login" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
sx={{ width: open ? openWidth : closedWidth, flexShrink: 0 }}
|
sx={{ width: open ? openWidth : closedWidth, flexShrink: 0 }}
|
||||||
|
|
@ -485,7 +481,17 @@ export const Sidebar: FC<SidebarProps> = ({
|
||||||
selected={pathname.endsWith("/docs")}
|
selected={pathname.endsWith("/docs")}
|
||||||
open={open}
|
open={open}
|
||||||
/>
|
/>
|
||||||
{leafcutterEnabled && (
|
{roles.includes("admin") && leafcutterEnabled && (
|
||||||
|
<MenuItem
|
||||||
|
name="Opensearch"
|
||||||
|
href="/opensearch"
|
||||||
|
Icon={InsightsIcon}
|
||||||
|
iconSize={20}
|
||||||
|
selected={pathname.startsWith("/opensearch")}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{false && leafcutterEnabled && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
name="Leafcutter"
|
name="Leafcutter"
|
||||||
href="/leafcutter"
|
href="/leafcutter"
|
||||||
|
|
@ -617,7 +623,6 @@ export const Sidebar: FC<SidebarProps> = ({
|
||||||
Icon={LogoutIcon}
|
Icon={LogoutIcon}
|
||||||
iconSize={20}
|
iconSize={20}
|
||||||
open={open}
|
open={open}
|
||||||
onClick={logout}
|
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
console.log({ res });
|
|
||||||
if (res.type === "opaqueredirect") {
|
if (res.type === "opaqueredirect") {
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -69,7 +68,6 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
if (!session || !authenticated) {
|
if (!session || !authenticated) {
|
||||||
console.log("Not authenticated");
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: "100%" }}>
|
<Box sx={{ width: "100%" }}>
|
||||||
<Grid
|
<Grid
|
||||||
|
|
@ -89,7 +87,6 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session && authenticated) {
|
if (session && authenticated) {
|
||||||
console.log("Session and authenticated");
|
|
||||||
return (
|
return (
|
||||||
<Iframe
|
<Iframe
|
||||||
id={id}
|
id={id}
|
||||||
|
|
@ -102,10 +99,6 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
|
||||||
const linkElement = document.querySelector(
|
const linkElement = document.querySelector(
|
||||||
`#${id}`,
|
`#${id}`,
|
||||||
) as HTMLIFrameElement;
|
) as HTMLIFrameElement;
|
||||||
|
|
||||||
console.log({ path });
|
|
||||||
console.log({ id });
|
|
||||||
console.log({ linkElement });
|
|
||||||
if (
|
if (
|
||||||
linkElement.contentDocument &&
|
linkElement.contentDocument &&
|
||||||
linkElement.contentDocument?.querySelector &&
|
linkElement.contentDocument?.querySelector &&
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Create } from "@link-stack/bridge-ui";
|
import { Create } from "@link-stack/bridge-ui";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: { segment: string[] };
|
params: Promise<{ segment: string[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page({ params: { segment } }: PageProps) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { segment } = await params;
|
||||||
const service = segment[0];
|
const service = segment[0];
|
||||||
|
|
||||||
return <Create service={service} />;
|
return <Create service={service} />;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { db } from "@link-stack/bridge-common";
|
import { db } from "@link-stack/bridge-common";
|
||||||
import { serviceConfig, Detail } from "@link-stack/bridge-ui";
|
import { serviceConfig, Detail } from "@link-stack/bridge-ui";
|
||||||
|
|
||||||
type Props = {
|
type PageProps = {
|
||||||
params: { segment: string[] };
|
params: Promise<{ segment: string[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params: { segment } }: Props) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { segment } = await params;
|
||||||
const service = segment[0];
|
const service = segment[0];
|
||||||
const id = segment?.[1];
|
const id = segment?.[1];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import { db } from "@link-stack/bridge-common";
|
||||||
import { serviceConfig, Edit } from "@link-stack/bridge-ui";
|
import { serviceConfig, Edit } from "@link-stack/bridge-ui";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: { segment: string[] };
|
params: Promise<{ segment: string[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params: { segment } }: PageProps) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { segment } = await params;
|
||||||
const service = segment[0];
|
const service = segment[0];
|
||||||
const id = segment?.[1];
|
const id = segment?.[1];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ import { db } from "@link-stack/bridge-common";
|
||||||
import { serviceConfig, List } from "@link-stack/bridge-ui";
|
import { serviceConfig, List } from "@link-stack/bridge-ui";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
segment: string[];
|
segment: string[];
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params: { segment } }: PageProps) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { segment } = await params;
|
||||||
const service = segment[0];
|
const service = segment[0];
|
||||||
|
|
||||||
if (!service) return null;
|
if (!service) return null;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Metadata } from "next";
|
|
||||||
import { ZammadWrapper } from "../../(main)/_components/ZammadWrapper";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Knowledge Base",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <ZammadWrapper path="/#knowledge_base/1/locale/en-us" />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
signOut({ callbackUrl: "/login" });
|
useEffect(() => {
|
||||||
|
const multistepSignOut = async () => {
|
||||||
|
const response = await fetch("/api/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
signOut({ callbackUrl: "/link/login" });
|
||||||
|
};
|
||||||
|
multistepSignOut();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return <div />;
|
return <div />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
apps/link/app/(main)/opensearch/page.tsx
Normal file
5
apps/link/app/(main)/opensearch/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { OpenSearchWrapper } from "@link-stack/leafcutter-ui";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <OpenSearchWrapper url="/app/visualize#/" margin={50} />;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FC, useState, useEffect } from "react";
|
import { FC, useState, useEffect, useActionState } from "react";
|
||||||
import { useFormState } from "react-dom";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Grid } from "@mui/material";
|
import { Grid } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
|
|
@ -44,7 +43,7 @@ export const TicketCreateDialog: FC<TicketCreateDialogProps> = ({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const [formState, formAction] = useFormState(
|
const [formState, formAction] = useActionState(
|
||||||
createTicketAction,
|
createTicketAction,
|
||||||
initialState,
|
initialState,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,6 @@ export const TicketList: FC<TicketListProps> = ({ title, tickets }) => {
|
||||||
columns={gridColumns}
|
columns={gridColumns}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
getRowID={(row: any) => {
|
getRowID={(row: any) => {
|
||||||
console.log({ row });
|
|
||||||
return row.internalId;
|
return row.internalId;
|
||||||
}}
|
}}
|
||||||
buttons={
|
buttons={
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getOverviewTicketsAction } from "app/_actions/overviews";
|
import { getOverviewTicketsAction } from "app/_actions/overviews";
|
||||||
|
|
||||||
import { TicketList } from "./TicketList";
|
import { TicketList } from "./TicketList";
|
||||||
|
|
@ -12,6 +13,19 @@ type ZammadOverviewProps = {
|
||||||
export const ZammadOverview: FC<ZammadOverviewProps> = ({ name }) => {
|
export const ZammadOverview: FC<ZammadOverviewProps> = ({ name }) => {
|
||||||
const [tickets, setTickets] = useState([]);
|
const [tickets, setTickets] = useState([]);
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window?.location?.hash;
|
||||||
|
|
||||||
|
if (hash) {
|
||||||
|
const ticketID = hash.replace("#ticket/zoom/", "");
|
||||||
|
if (ticketID && !isNaN(parseInt(ticketID, 10))) {
|
||||||
|
redirect(`/tickets/${ticketID}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [window?.location?.hash]);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTickets = async () => {
|
const fetchTickets = async () => {
|
||||||
const { tickets } = await getOverviewTicketsAction(name);
|
const { tickets } = await getOverviewTicketsAction(name);
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,15 @@ const getSection = (overview: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
type MetadataProps = {
|
type MetadataProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
overview: string;
|
overview: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params: { overview },
|
params,
|
||||||
}: MetadataProps): Promise<Metadata> {
|
}: MetadataProps): Promise<Metadata> {
|
||||||
|
const { overview } = await params;
|
||||||
const section = getSection(overview);
|
const section = getSection(overview);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -22,12 +23,13 @@ export async function generateMetadata({
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
overview: string;
|
overview: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page({ params: { overview } }: PageProps) {
|
export default async function Page({ params }: PageProps) {
|
||||||
|
const { overview } = await params;
|
||||||
const section = getSection(overview);
|
const section = getSection(overview);
|
||||||
|
|
||||||
return <ZammadOverview name={section} />;
|
return <ZammadOverview name={section} />;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getServerSession } from "app/_lib/authentication";
|
import { Home } from "./_components/Home";
|
||||||
import { Home } from "@link-stack/leafcutter-ui";
|
import { createLogger } from "@link-stack/logger";
|
||||||
import { getUserVisualizations } from "@link-stack/opensearch-common";
|
|
||||||
import { LeafcutterWrapper } from "@link-stack/leafcutter-ui";
|
const logger = createLogger('link-page');
|
||||||
|
// import { getServerSession } from "app/_lib/authentication";
|
||||||
|
// import { Home } from "@link-stack/leafcutter-ui";
|
||||||
|
// import { getUserVisualizations } from "@link-stack/opensearch-common";
|
||||||
|
// import { LeafcutterWrapper } from "@link-stack/leafcutter-ui";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "CDR Link - Home",
|
title: "CDR Link - Home",
|
||||||
|
|
@ -11,21 +15,31 @@ export const metadata: Metadata = {
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const leafcutterEnabled = process.env.LEAFCUTTER_ENABLED === "true";
|
const leafcutterEnabled = process.env.LEAFCUTTER_ENABLED === "true";
|
||||||
|
const dashboardURL = process.env.LEAFCUTTER_DEFAULT_DASHBOARD_URL;
|
||||||
|
|
||||||
if (!leafcutterEnabled) {
|
if (!leafcutterEnabled) {
|
||||||
redirect("/overview/recent");
|
redirect("/overview/recent");
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
const {
|
const {
|
||||||
user: { email },
|
user: { email },
|
||||||
}: any = session;
|
}: any = session;
|
||||||
|
*/
|
||||||
const visualizations = await getUserVisualizations(email ?? "none", 20);
|
let visualizations = [];
|
||||||
|
/*
|
||||||
|
try {
|
||||||
|
visualizations = await getUserVisualizations(email ?? "none", 20);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ meta: e.meta }, "Error metadata");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LeafcutterWrapper>
|
<LeafcutterWrapper>
|
||||||
<Home visualizations={visualizations} showWelcome={false} />
|
<Home visualizations={visualizations} showWelcome={false} />
|
||||||
</LeafcutterWrapper>
|
</LeafcutterWrapper>
|
||||||
);
|
);
|
||||||
|
*/
|
||||||
|
|
||||||
|
return <Home url={dashboardURL} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ import { ZammadWrapper } from "app/(main)/_components/ZammadWrapper";
|
||||||
export const Setup: FC = () => {
|
export const Setup: FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
const fingerprint = localStorage.getItem("fingerprint");
|
||||||
|
if (!fingerprint || fingerprint === "") {
|
||||||
|
const newFingerprint = `${Math.floor(
|
||||||
|
Math.random() * 100000000,
|
||||||
|
)}`.padStart(8, "0");
|
||||||
|
localStorage.setItem("fingerprint", newFingerprint);
|
||||||
|
}
|
||||||
setTimeout(() => router.push("/"), 4000);
|
setTimeout(() => router.push("/"), 4000);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { FC, useState } from "react";
|
|
||||||
import {
|
|
||||||
Grid,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
TextField,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { createTicketArticleAction } from "app/_actions/tickets";
|
|
||||||
|
|
||||||
interface ArticleCreateDialogProps {
|
|
||||||
ticketID: string;
|
|
||||||
open: boolean;
|
|
||||||
closeDialog: () => void;
|
|
||||||
kind: string;
|
|
||||||
recipient?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ArticleCreateDialog: FC<ArticleCreateDialogProps> = ({
|
|
||||||
ticketID,
|
|
||||||
open,
|
|
||||||
closeDialog,
|
|
||||||
kind,
|
|
||||||
recipient,
|
|
||||||
}) => {
|
|
||||||
const [body, setBody] = useState("");
|
|
||||||
const backgroundColor = kind === "note" ? "#FFB620" : "#1982FC";
|
|
||||||
const color = kind === "note" ? "black" : "white";
|
|
||||||
const article = {
|
|
||||||
body,
|
|
||||||
type: kind,
|
|
||||||
internal: kind === "note",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (kind === "email") {
|
|
||||||
article["to"] = recipient;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createArticle = async () => {
|
|
||||||
await createTicketArticleAction(ticketID, article);
|
|
||||||
closeDialog();
|
|
||||||
setBody("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} maxWidth="sm" fullWidth>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
label={kind === "note" ? "Write internal note" : "Write reply"}
|
|
||||||
multiline
|
|
||||||
rows={10}
|
|
||||||
fullWidth
|
|
||||||
value={body}
|
|
||||||
onChange={(e: any) => setBody(e.target.value)}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions sx={{ px: 3, pt: 0, pb: 3 }}>
|
|
||||||
<Grid container justifyContent="space-between">
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
sx={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
color: "#666",
|
|
||||||
fontFamily: "Poppins, sans-serif",
|
|
||||||
fontWeight: 700,
|
|
||||||
borderRadius: 2,
|
|
||||||
textTransform: "none",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
setBody("");
|
|
||||||
closeDialog();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
sx={{
|
|
||||||
backgroundColor,
|
|
||||||
color,
|
|
||||||
fontFamily: "Poppins, sans-serif",
|
|
||||||
fontWeight: 700,
|
|
||||||
borderRadius: 2,
|
|
||||||
textTransform: "none",
|
|
||||||
px: 3,
|
|
||||||
}}
|
|
||||||
onClick={createArticle}
|
|
||||||
>
|
|
||||||
{kind === "note" ? "Save Note" : "Send Reply"}
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { FC, useState, useEffect } from "react";
|
|
||||||
import { getTicketAction, getTicketArticlesAction } from "app/_actions/tickets";
|
|
||||||
import { Grid, Box, Typography } from "@mui/material";
|
|
||||||
import { Button, fonts, colors } from "@link-stack/ui";
|
|
||||||
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
|
|
||||||
import {
|
|
||||||
MainContainer,
|
|
||||||
ChatContainer,
|
|
||||||
MessageList,
|
|
||||||
Message,
|
|
||||||
ConversationHeader,
|
|
||||||
} from "@chatscope/chat-ui-kit-react";
|
|
||||||
import { ArticleCreateDialog } from "./ArticleCreateDialog";
|
|
||||||
|
|
||||||
interface TicketDetailProps {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
|
|
||||||
const [ticket, setTicket] = useState<any>(null);
|
|
||||||
const [ticketArticles, setTicketArticles] = useState<any>(null);
|
|
||||||
const { poppins, roboto } = fonts;
|
|
||||||
const { veryLightGray, lightGray } = colors;
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [articleKind, setArticleKind] = useState("note");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTicket = async () => {
|
|
||||||
const result = await getTicketAction(id);
|
|
||||||
setTicket(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTicket();
|
|
||||||
|
|
||||||
const interval = setInterval(fetchTicket, 20000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTicketArticles = async () => {
|
|
||||||
const result = await getTicketArticlesAction(id);
|
|
||||||
setTicketArticles(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTicketArticles();
|
|
||||||
|
|
||||||
const interval = setInterval(fetchTicketArticles, 5000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const closeDialog = () => setDialogOpen(false);
|
|
||||||
|
|
||||||
const firstArticle = ticketArticles?.edges[0]?.node;
|
|
||||||
const firstArticleKind = firstArticle?.type?.name ?? "phone";
|
|
||||||
const firstEmailSender = firstArticle?.from?.parsed?.[0]?.emailAddress ?? "";
|
|
||||||
const recipient = firstEmailSender;
|
|
||||||
const shouldRender = !!ticket && !!ticketArticles;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ height: "100%", width: "100%", background: veryLightGray }}>
|
|
||||||
{shouldRender && (
|
|
||||||
<>
|
|
||||||
<MainContainer>
|
|
||||||
<ChatContainer>
|
|
||||||
<ConversationHeader>
|
|
||||||
<ConversationHeader.Content>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
textAlign: "center",
|
|
||||||
fontWeight: "bold",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="h5"
|
|
||||||
sx={{
|
|
||||||
fontFamily: poppins.style.fontFamily,
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ticket.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{
|
|
||||||
fontFamily: roboto.style.fontFamily,
|
|
||||||
fontWeight: 400,
|
|
||||||
}}
|
|
||||||
>{`Ticket #${ticket.number} (created ${new Date(
|
|
||||||
ticket.createdAt,
|
|
||||||
).toLocaleDateString()})`}</Typography>
|
|
||||||
</Box>
|
|
||||||
</ConversationHeader.Content>
|
|
||||||
</ConversationHeader>
|
|
||||||
<MessageList style={{ marginBottom: 80 }}>
|
|
||||||
{ticketArticles.edges.map(({ node: article }: any) => (
|
|
||||||
<Message
|
|
||||||
key={article.id}
|
|
||||||
className={
|
|
||||||
article.internal
|
|
||||||
? "internal-note"
|
|
||||||
: article?.sender?.name === "Agent"
|
|
||||||
? "outgoing-message"
|
|
||||||
: "incoming-message"
|
|
||||||
}
|
|
||||||
model={{
|
|
||||||
message: article.bodyWithUrls,
|
|
||||||
type:
|
|
||||||
article.contentType === "text/html" ? "html" : "text",
|
|
||||||
sentTime: article.updated_at,
|
|
||||||
sender: article.from,
|
|
||||||
direction:
|
|
||||||
article.sender === "Agent" ? "outgoing" : "incoming",
|
|
||||||
position: "single",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</MessageList>
|
|
||||||
</ChatContainer>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 80,
|
|
||||||
background: veryLightGray,
|
|
||||||
borderTop: `1px solid ${lightGray}`,
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
width: "100%",
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
spacing={6}
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
alignContent="center"
|
|
||||||
sx={{ height: "100%", pt: 6 }}
|
|
||||||
>
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
text="Write note to agent"
|
|
||||||
color="#FFB620"
|
|
||||||
onClick={() => {
|
|
||||||
setArticleKind("note");
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
text="Reply to ticket"
|
|
||||||
kind="primary"
|
|
||||||
onClick={() => {
|
|
||||||
setArticleKind(firstArticleKind);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
</MainContainer>
|
|
||||||
<ArticleCreateDialog
|
|
||||||
ticketID={ticket.internalId}
|
|
||||||
open={dialogOpen}
|
|
||||||
closeDialog={closeDialog}
|
|
||||||
kind={articleKind}
|
|
||||||
recipient={recipient}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { TicketDetail } from "./_components/TicketDetail";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Page({ params: { id } }: PageProps) {
|
|
||||||
return <TicketDetail id={id} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { FC, useEffect, useState } from "react";
|
|
||||||
import { Grid, Box } from "@mui/material";
|
|
||||||
import { Select, Button } from "@link-stack/ui";
|
|
||||||
import { MuiChipsInput } from "mui-chips-input";
|
|
||||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
|
||||||
import {
|
|
||||||
updateTicketAction,
|
|
||||||
getTicketAction,
|
|
||||||
getTicketStatesAction,
|
|
||||||
getTicketPrioritiesAction,
|
|
||||||
} from "app/_actions/tickets";
|
|
||||||
import { getAgentsAction } from "app/_actions/users";
|
|
||||||
import { getGroupsAction } from "app/_actions/groups";
|
|
||||||
|
|
||||||
interface TicketEditProps {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TicketEdit: FC<TicketEditProps> = ({ id }) => {
|
|
||||||
const [ticket, setTicket] = useState<any>();
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
const [formState, setFormState] = useState({
|
|
||||||
values: {
|
|
||||||
group: null,
|
|
||||||
owner: null,
|
|
||||||
priority: null,
|
|
||||||
pendingTime: null,
|
|
||||||
state: null,
|
|
||||||
tags: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [ticketStates, setTicketStates] = useState<any>();
|
|
||||||
const [ticketPriorities, setTicketPriorities] = useState<any>();
|
|
||||||
const [groups, setGroups] = useState<any>();
|
|
||||||
const [agents, setAgents] = useState<any>();
|
|
||||||
const [pendingVisible, setPendingVisible] = useState(false);
|
|
||||||
|
|
||||||
const filteredStates =
|
|
||||||
ticketStates?.filter(
|
|
||||||
(state: any) => !["new", "merged", "removed"].includes(state.label),
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAgents = async () => {
|
|
||||||
const result = await getAgentsAction();
|
|
||||||
setAgents(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchGroups = async () => {
|
|
||||||
const result = await getGroupsAction();
|
|
||||||
setGroups(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchTicketStates = async () => {
|
|
||||||
const result = await getTicketStatesAction();
|
|
||||||
setTicketStates(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchTicketPriorities = async () => {
|
|
||||||
const result = await getTicketPrioritiesAction();
|
|
||||||
setTicketPriorities(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTicketStates();
|
|
||||||
fetchTicketPriorities();
|
|
||||||
fetchAgents();
|
|
||||||
fetchGroups();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTicket = async () => {
|
|
||||||
const result = await getTicketAction(id);
|
|
||||||
setTicket(result);
|
|
||||||
setFormState({
|
|
||||||
values: {
|
|
||||||
...formState.values,
|
|
||||||
group: result?.group?.id,
|
|
||||||
owner: result?.owner?.id,
|
|
||||||
priority: result?.priority?.id,
|
|
||||||
state: result?.state?.id,
|
|
||||||
tags: result?.tags,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTicket();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateFormState = (name: string, value: any) => {
|
|
||||||
setFormState({
|
|
||||||
values: {
|
|
||||||
...formState.values,
|
|
||||||
[name]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const stateName = filteredStates?.find(
|
|
||||||
(state: any) => state.id === formState.values.state,
|
|
||||||
)?.name;
|
|
||||||
setPendingVisible(stateName?.includes("pending") ?? false);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTicket = async () => {
|
|
||||||
await updateTicketAction(id, formState.values);
|
|
||||||
setHasChanges(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldRender = !!ticket;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ height: "100vh", background: "#ddd", p: 2 }}>
|
|
||||||
{shouldRender && (
|
|
||||||
<Grid container direction="column" spacing={3}>
|
|
||||||
<Grid item>
|
|
||||||
<Box sx={{ m: 1 }}>Group</Box>
|
|
||||||
<Select
|
|
||||||
name="group"
|
|
||||||
label="Group"
|
|
||||||
formState={formState}
|
|
||||||
updateFormState={updateFormState}
|
|
||||||
getOptions={() => groups}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<Box sx={{ m: 1, mt: 0 }}>Owner</Box>
|
|
||||||
<Select
|
|
||||||
name="owner"
|
|
||||||
label="Owner"
|
|
||||||
formState={formState}
|
|
||||||
updateFormState={updateFormState}
|
|
||||||
getOptions={() => agents}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Box sx={{ m: 1, mt: 0 }}>State</Box>
|
|
||||||
<Select
|
|
||||||
name="state"
|
|
||||||
label="State"
|
|
||||||
formState={formState}
|
|
||||||
updateFormState={updateFormState}
|
|
||||||
getOptions={() => filteredStates}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid
|
|
||||||
item
|
|
||||||
xs={12}
|
|
||||||
sx={{ display: pendingVisible ? "inherit" : "none" }}
|
|
||||||
>
|
|
||||||
<DatePicker
|
|
||||||
label="Pending Date"
|
|
||||||
value={new Date(formState.values.pendingTime)}
|
|
||||||
onChange={(newValue: any) => {
|
|
||||||
updateFormState("pendingDate", newValue.toISOString());
|
|
||||||
}}
|
|
||||||
slotProps={{ textField: { size: "small" } }}
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<Box sx={{ m: 1, mt: 0 }}>Priority</Box>
|
|
||||||
<Select
|
|
||||||
name="priority"
|
|
||||||
label="Priority"
|
|
||||||
formState={formState}
|
|
||||||
updateFormState={updateFormState}
|
|
||||||
getOptions={() => ticketPriorities}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<Box sx={{ mb: 1 }}>Tags</Box>
|
|
||||||
<MuiChipsInput
|
|
||||||
sx={{ backgroundColor: "white", width: "100%" }}
|
|
||||||
value={formState.values.tags}
|
|
||||||
hideClearAll
|
|
||||||
onChange={(tags: any) => {
|
|
||||||
updateFormState("tags", tags);
|
|
||||||
}}
|
|
||||||
onDeleteChip={(tag: any) => {
|
|
||||||
const tags = formState.values.tags.filter(
|
|
||||||
(t: any) => t !== tag,
|
|
||||||
);
|
|
||||||
updateFormState("tags", tags);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item container direction="row-reverse">
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
text="Save"
|
|
||||||
kind="primary"
|
|
||||||
onClick={updateTicket}
|
|
||||||
disabled={!hasChanges}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { TicketEdit } from "./_components/TicketEdit";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Page({ params: { id } }: PageProps) {
|
|
||||||
return <TicketEdit id={id} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { DisplayError } from "app/_components/DisplayError";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
error: Error;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Page({ error }: PageProps) {
|
|
||||||
return <DisplayError error={error} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Grid } from "@mui/material";
|
|
||||||
|
|
||||||
type LayoutProps = {
|
|
||||||
detail: any;
|
|
||||||
edit: any;
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Layout({ detail, edit, params: { id } }: LayoutProps) {
|
|
||||||
return (
|
|
||||||
<Grid container spacing={0} sx={{ height: "100vh" }} direction="row">
|
|
||||||
<Grid item sx={{ height: "100vh" }} xs={9}>
|
|
||||||
{detail}
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={3} sx={{ height: "100vh" }}>
|
|
||||||
{edit}
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Not Found</h2>
|
|
||||||
<p>Could not find requested resource</p>
|
|
||||||
<p>
|
|
||||||
View <Link href="/blog">all posts</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { executeREST } from "app/_lib/zammad";
|
import { executeREST } from "app/_lib/zammad";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('link-groups');
|
||||||
|
|
||||||
export const getGroupsAction = async () => {
|
export const getGroupsAction = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -15,7 +18,7 @@ export const getGroupsAction = async () => {
|
||||||
|
|
||||||
return formattedGroups;
|
return formattedGroups;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import { executeGraphQL, executeREST } from "app/_lib/zammad";
|
import { executeGraphQL, executeREST } from "app/_lib/zammad";
|
||||||
import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery";
|
import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery";
|
||||||
import { getTicketsByOverviewQuery } from "app/_graphql/getTicketsByOverviewQuery";
|
import { getTicketsByOverviewQuery } from "app/_graphql/getTicketsByOverviewQuery";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('link-overviews');
|
||||||
|
|
||||||
const overviewLookup = {
|
const overviewLookup = {
|
||||||
Assigned: "My Assigned Tickets",
|
Assigned: "My Assigned Tickets",
|
||||||
|
|
@ -36,7 +39,7 @@ export const getOverviewTicketCountsAction = async () => {
|
||||||
|
|
||||||
return counts;
|
return counts;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -47,10 +50,10 @@ export const getOverviewTicketsAction = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
if (name === "Recent") {
|
if (name === "Recent") {
|
||||||
const recent = await executeREST({ path: "/api/v1/recent_view" });
|
const recent = await executeREST({ path: "/api/v1/recent_view" });
|
||||||
|
const uniqueIDs = new Set(recent.map((rec: any) => rec.o_id));
|
||||||
for (const rec of recent) {
|
for (const id of uniqueIDs) {
|
||||||
const tkt = await executeREST({
|
const tkt = await executeREST({
|
||||||
path: `/api/v1/tickets/${rec.o_id}`,
|
path: `/api/v1/tickets/${id}`,
|
||||||
});
|
});
|
||||||
tickets.push({
|
tickets.push({
|
||||||
...tkt,
|
...tkt,
|
||||||
|
|
@ -91,7 +94,7 @@ export const getOverviewTicketsAction = async (name: string) => {
|
||||||
|
|
||||||
return { tickets: sortedTickets };
|
return { tickets: sortedTickets };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return { tickets, message: e.message ?? "" };
|
return { tickets, message: e.message ?? "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
"use server";
|
"use server";
|
||||||
import { executeGraphQL } from "app/_lib/zammad";
|
import { executeGraphQL } from "app/_lib/zammad";
|
||||||
import { searchQuery } from "@/app/_graphql/searchQuery";
|
import { searchQuery } from "@/app/_graphql/searchQuery";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('link-search');
|
||||||
|
|
||||||
export const searchAllAction = async (query: string, limit: number) => {
|
export const searchAllAction = async (query: string, limit: number) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -11,7 +14,7 @@ export const searchAllAction = async (query: string, limit: number) => {
|
||||||
|
|
||||||
return result?.search;
|
return result?.search;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { getTicketQuery } from "app/_graphql/getTicketQuery";
|
import { getTicketQuery } from "app/_graphql/getTicketQuery";
|
||||||
import { getTicketArticlesQuery } from "app/_graphql/getTicketArticlesQuery";
|
import { getTicketArticlesQuery } from "app/_graphql/getTicketArticlesQuery";
|
||||||
import { createTicketMutation } from "app/_graphql/createTicketMutation";
|
import { createTicketMutation } from "app/_graphql/createTicketMutation";
|
||||||
import { updateTicketMutation } from "app/_graphql/updateTicketMutation";
|
import { updateTicketMutation } from "app/_graphql/updateTicketMutation";
|
||||||
import { updateTagsMutation } from "app/_graphql/updateTagsMutation";
|
import { updateTagsMutation } from "app/_graphql/updateTagsMutation";
|
||||||
import { executeGraphQL, executeREST } from "app/_lib/zammad";
|
import { executeGraphQL, executeREST } from "app/_lib/zammad";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('link-tickets');
|
||||||
|
|
||||||
export const createTicketAction = async (
|
export const createTicketAction = async (
|
||||||
currentState: any,
|
currentState: any,
|
||||||
|
|
@ -36,7 +38,7 @@ export const createTicketAction = async (
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
values: {},
|
values: {},
|
||||||
|
|
@ -63,7 +65,7 @@ export const createTicketArticleAction = async (
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: e?.message ?? "Unknown error",
|
message: e?.message ?? "Unknown error",
|
||||||
|
|
@ -75,7 +77,6 @@ export const updateTicketAction = async (
|
||||||
ticketID: string,
|
ticketID: string,
|
||||||
ticketInfo: Record<string, any>,
|
ticketInfo: Record<string, any>,
|
||||||
) => {
|
) => {
|
||||||
console.log({ ticketID, ticketInfo });
|
|
||||||
try {
|
try {
|
||||||
const input = {};
|
const input = {};
|
||||||
if (ticketInfo.state) {
|
if (ticketInfo.state) {
|
||||||
|
|
@ -117,7 +118,7 @@ export const updateTicketAction = async (
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: e?.message ?? "Unknown error",
|
message: e?.message ?? "Unknown error",
|
||||||
|
|
@ -134,7 +135,7 @@ export const getTicketAction = async (id: string) => {
|
||||||
|
|
||||||
return ticketData?.ticket;
|
return ticketData?.ticket;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -148,7 +149,7 @@ export const getTicketArticlesAction = async (id: string) => {
|
||||||
|
|
||||||
return ticketData?.ticketArticles;
|
return ticketData?.ticketArticles;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -158,16 +159,15 @@ export const getTicketStatesAction = async () => {
|
||||||
const states = await executeREST({
|
const states = await executeREST({
|
||||||
path: "/api/v1/ticket_states",
|
path: "/api/v1/ticket_states",
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedStates =
|
const formattedStates =
|
||||||
states?.map((state: any) => ({
|
states?.map((state: any) => ({
|
||||||
value: `gid://zammad/Ticket::State/${state.id}`,
|
value: `gid://zammad/Ticket::State/${state.id}`,
|
||||||
label: state.name,
|
label: state.name,
|
||||||
|
disabled: ["new", "merged", "removed"].includes(state.name),
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
return formattedStates;
|
return formattedStates;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -180,7 +180,7 @@ export const getTagsAction = async () => {
|
||||||
|
|
||||||
return tags;
|
return tags;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -199,7 +199,7 @@ export const getTicketPrioritiesAction = async () => {
|
||||||
|
|
||||||
return formattedPriorities;
|
return formattedPriorities;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { executeREST } from "app/_lib/zammad";
|
import { executeREST } from "app/_lib/zammad";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
export const getAgentsAction = async () => {
|
const logger = createLogger('link-users');
|
||||||
|
|
||||||
|
export const getAgentsAction = async (groupID: number) => {
|
||||||
try {
|
try {
|
||||||
const users = await executeREST({
|
const group = await executeREST({
|
||||||
path: "/api/v1/users",
|
path: `/api/v1/groups/${groupID}`,
|
||||||
});
|
});
|
||||||
|
const { user_ids: groupUserIDs } = group;
|
||||||
|
const path = `/api/v1/users/search?query=role_ids:2&limit=1000`;
|
||||||
|
const users = await executeREST({ path });
|
||||||
const agents =
|
const agents =
|
||||||
users?.filter((user: any) => user.role_ids.includes(2)) ?? [];
|
users?.filter((user: any) => groupUserIDs.includes(user.id)) ?? [];
|
||||||
const formattedAgents = agents
|
const formattedAgents = agents
|
||||||
.map((agent: any) => ({
|
.map((agent: any) => ({
|
||||||
label: `${agent.firstname} ${agent.lastname}`,
|
label: `${agent.firstname} ${agent.lastname}`,
|
||||||
|
|
@ -18,7 +24,7 @@ export const getAgentsAction = async () => {
|
||||||
|
|
||||||
return formattedAgents;
|
return formattedAgents;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -39,7 +45,7 @@ export const getCustomersAction = async () => {
|
||||||
|
|
||||||
return formattedCustomers;
|
return formattedCustomers;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -49,7 +55,6 @@ export const getUsersAction = async () => {
|
||||||
const users = await executeREST({
|
const users = await executeREST({
|
||||||
path: "/api/v1/users",
|
path: "/api/v1/users",
|
||||||
});
|
});
|
||||||
console.log({ users });
|
|
||||||
const formattedUsers = users
|
const formattedUsers = users
|
||||||
.map((customer: any) => ({
|
.map((customer: any) => ({
|
||||||
label: customer.login,
|
label: customer.login,
|
||||||
|
|
@ -59,7 +64,7 @@ export const getUsersAction = async () => {
|
||||||
|
|
||||||
return formattedUsers;
|
return formattedUsers;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
logger.error({ error: e }, "Error occurred");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { locales, LeafcutterProvider } from "@link-stack/leafcutter-ui";
|
||||||
import { ZammadLoginProvider } from "./ZammadLoginProvider";
|
import { ZammadLoginProvider } from "./ZammadLoginProvider";
|
||||||
|
|
||||||
LicenseInfo.setLicenseKey(
|
LicenseInfo.setLicenseKey(
|
||||||
"c787ac6613c5f2aa0494c4285fe3e9f2Tz04OTY1NyxFPTE3NDYzNDE0ODkwMDAsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
|
"2a7dd73ee59e3e028b96b0d2adee1ad8Tz0xMTMwOTUsRT0xNzc5MDYyMzk5MDAwLFM9cHJvLExNPXN1YnNjcmlwdGlvbixQVj1pbml0aWFsLEtWPTI=",
|
||||||
);
|
);
|
||||||
|
|
||||||
export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
|
export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,12 @@ export const ZammadLoginProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
const response = await fetch("/api/v1/users/me", {
|
const response = await fetch("/api/v1/users/me", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Browser-Fingerprint": `${session.expires}`,
|
"X-Browser-Fingerprint": localStorage.getItem("fingerprint") || "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
window.location.href = "/zammad/auth/sso";
|
window.location.href = "/link/login";
|
||||||
} else {
|
} else {
|
||||||
const token = response.headers.get("CSRF-Token");
|
const token = response.headers.get("CSRF-Token");
|
||||||
update({ zammadCsrfToken: token });
|
update({ zammadCsrfToken: token });
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ import {
|
||||||
import Google from "next-auth/providers/google";
|
import Google from "next-auth/providers/google";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
import Apple from "next-auth/providers/apple";
|
import Apple from "next-auth/providers/apple";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
import AzureADProvider from "next-auth/providers/azure-ad";
|
import AzureADProvider from "next-auth/providers/azure-ad";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('link-authentication');
|
||||||
|
|
||||||
const headers = { Authorization: `Token ${process.env.ZAMMAD_API_TOKEN}` };
|
const headers = { Authorization: `Token ${process.env.ZAMMAD_API_TOKEN}` };
|
||||||
|
|
||||||
|
|
@ -37,6 +41,9 @@ const fetchUser = async (email: string) => {
|
||||||
const getUserRoles = async (email: string) => {
|
const getUserRoles = async (email: string) => {
|
||||||
try {
|
try {
|
||||||
const user = await fetchUser(email);
|
const user = await fetchUser(email);
|
||||||
|
if (!user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const allRoles = await fetchRoles();
|
const allRoles = await fetchRoles();
|
||||||
const roles = user.role_ids.map((roleID: number) => {
|
const roles = user.role_ids.map((roleID: number) => {
|
||||||
const role = allRoles[roleID];
|
const role = allRoles[roleID];
|
||||||
|
|
@ -44,7 +51,7 @@ const getUserRoles = async (email: string) => {
|
||||||
});
|
});
|
||||||
return roles.filter((role: string) => role !== null);
|
return roles.filter((role: string) => role !== null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log({ e });
|
logger.error({ error: e, email }, 'Failed to get user roles');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -117,11 +124,14 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/login",
|
signIn: "/link/login",
|
||||||
error: "/login",
|
error: "/link/login",
|
||||||
signOut: "/logout",
|
signOut: "/link/logout",
|
||||||
},
|
},
|
||||||
providers,
|
providers,
|
||||||
|
session: {
|
||||||
|
maxAge: 3 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
signIn: async ({ user }) => {
|
signIn: async ({ user }) => {
|
||||||
|
|
@ -129,6 +139,11 @@ export const authOptions: NextAuthOptions = {
|
||||||
return roles.includes("admin") || roles.includes("agent");
|
return roles.includes("admin") || roles.includes("agent");
|
||||||
},
|
},
|
||||||
session: async ({ session, token }) => {
|
session: async ({ session, token }) => {
|
||||||
|
// const redis = new Redis(process.env.REDIS_URL);
|
||||||
|
// const isInvalidated = await redis.get(`invalidated:${token.sub}`);
|
||||||
|
// if (isInvalidated) {
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
session.user.roles = token.roles ?? [];
|
session.user.roles = token.roles ?? [];
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('link-utils');
|
||||||
|
|
||||||
export const fetchLeafcutter = async (url: string, options: any) => {
|
export const fetchLeafcutter = async (url: string, options: any) => {
|
||||||
/*
|
/*
|
||||||
|
|
||||||
|
|
@ -13,21 +17,18 @@ export const fetchLeafcutter = async (url: string, options: any) => {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
return json;
|
return json;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log({ error });
|
logger.error({ error }, "Error occurred");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await fetchData(url, options);
|
const data = await fetchData(url, options);
|
||||||
console.log({ data });
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
const csrfURL = `${process.env.NEXT_PUBLIC_LEAFCUTTER_URL}/api/auth/csrf`;
|
const csrfURL = `${process.env.NEXT_PUBLIC_LEAFCUTTER_URL}/api/auth/csrf`;
|
||||||
const csrfData = await fetchData(csrfURL, {});
|
const csrfData = await fetchData(csrfURL, {});
|
||||||
console.log({ csrfData });
|
|
||||||
const authURL = `${process.env.NEXT_PUBLIC_LEAFCUTTER_URL}/api/auth/callback/credentials`;
|
const authURL = `${process.env.NEXT_PUBLIC_LEAFCUTTER_URL}/api/auth/callback/credentials`;
|
||||||
const authData = await fetchData(authURL, { method: "POST" });
|
const authData = await fetchData(authURL, { method: "POST" });
|
||||||
console.log({ authData });
|
|
||||||
if (!authData) {
|
if (!authData) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -37,5 +38,3 @@ export const fetchLeafcutter = async (url: string, options: any) => {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@ import { getServerSession } from "app/_lib/authentication";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const getHeaders = async () => {
|
const getHeaders = async () => {
|
||||||
const allCookies = cookies().getAll();
|
const allCookies = (await cookies()).getAll();
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
const headers = {
|
const finalHeaders = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"X-Browser-Fingerprint": `${session.expires}`,
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
"X-CSRF-Token": session.user.zammadCsrfToken,
|
"X-CSRF-Token": session.user.zammadCsrfToken,
|
||||||
Cookie: allCookies
|
Cookie: allCookies
|
||||||
|
|
@ -15,7 +14,7 @@ const getHeaders = async () => {
|
||||||
.join("; "),
|
.join("; "),
|
||||||
};
|
};
|
||||||
|
|
||||||
return headers;
|
return finalHeaders;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ExecuteGraphQLOptions {
|
interface ExecuteGraphQLOptions {
|
||||||
|
|
|
||||||
38
apps/link/app/api/logout/route.ts
Normal file
38
apps/link/app/api/logout/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const token = await getToken({
|
||||||
|
req: request,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
});
|
||||||
|
const allCookies = request.cookies.getAll();
|
||||||
|
const zammadURL = process.env.ZAMMAD_URL ?? "http://zammad-nginx:8080";
|
||||||
|
const signOutURL = `${zammadURL}/api/v1/signout`;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
Cookie: allCookies
|
||||||
|
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
||||||
|
.join("; "),
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch(signOutURL, { headers });
|
||||||
|
|
||||||
|
const cookiePrefixesToRemove = ["_zammad"];
|
||||||
|
const response = NextResponse.json({ message: "ok" });
|
||||||
|
|
||||||
|
for (const cookie of allCookies) {
|
||||||
|
if (
|
||||||
|
cookiePrefixesToRemove.some((prefix) => cookie.name.startsWith(prefix))
|
||||||
|
) {
|
||||||
|
response.cookies.set(cookie.name, "", { path: "/", maxAge: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redis = new Redis(process.env.REDIS_URL);
|
||||||
|
await redis.setex(`invalidated:${token.sub}`, 24 * 60 * 60, "1");
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,30 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withAuth, NextRequestWithAuth } from "next-auth/middleware";
|
import { withAuth, NextRequestWithAuth } from "next-auth/middleware";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger('link-middleware');
|
||||||
|
|
||||||
/*
|
|
||||||
const rewriteURL = (
|
const rewriteURL = (
|
||||||
request: NextRequestWithAuth,
|
request: NextRequestWithAuth,
|
||||||
originBaseURL: string,
|
originBaseURL: string,
|
||||||
destinationBaseURL: string,
|
destinationBaseURL: string,
|
||||||
headers: any = {},
|
headers: any = {},
|
||||||
) => {
|
) => {
|
||||||
|
logger.debug({
|
||||||
|
originBaseURL,
|
||||||
|
destinationBaseURL,
|
||||||
|
headerKeys: Object.keys(headers)
|
||||||
|
}, "Rewriting URL");
|
||||||
let path = request.url.replace(originBaseURL, "");
|
let path = request.url.replace(originBaseURL, "");
|
||||||
if (path.startsWith("/")) {
|
if (path.startsWith("/")) {
|
||||||
path = path.slice(1);
|
path = path.slice(1);
|
||||||
}
|
}
|
||||||
const destinationURL = `${destinationBaseURL}/${path}`;
|
const destinationURL = `${destinationBaseURL}/${path}`;
|
||||||
console.log(`Rewriting ${request.url} to ${destinationURL}`);
|
logger.debug({ from: request.url, to: destinationURL }, "URL rewrite");
|
||||||
const requestHeaders = new Headers(request.headers);
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
|
||||||
requestHeaders.delete("x-forwarded-user");
|
requestHeaders.delete("x-forwarded-user");
|
||||||
|
requestHeaders.delete("x-forwarded-roles");
|
||||||
requestHeaders.delete("connection");
|
requestHeaders.delete("connection");
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
|
@ -26,17 +35,36 @@ const rewriteURL = (
|
||||||
request: { headers: requestHeaders },
|
request: { headers: requestHeaders },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
*/
|
|
||||||
const checkRewrites = async (request: NextRequestWithAuth) => {
|
const checkRewrites = async (request: NextRequestWithAuth) => {
|
||||||
// const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000";
|
const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000";
|
||||||
|
logger.debug({ linkBaseURL }, "Link base URL");
|
||||||
|
const opensearchBaseURL =
|
||||||
|
process.env.OPENSEARCH_DASHBOARDS_URL ??
|
||||||
|
"http://opensearch-dashboards:5601";
|
||||||
|
|
||||||
const { token } = request.nextauth;
|
const { token } = request.nextauth;
|
||||||
const email = token?.email?.toLowerCase() ?? "unknown";
|
const email = token?.email?.toLowerCase() ?? "unknown";
|
||||||
// let headers = { "x-forwarded-user": email };
|
const roles = (token?.roles as string[]) ?? [];
|
||||||
|
let headers = {
|
||||||
|
"x-forwarded-user": email,
|
||||||
|
"x-forwarded-roles": roles.join(","),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.nextUrl.pathname.startsWith("/dashboards")) {
|
||||||
|
return rewriteURL(
|
||||||
|
request,
|
||||||
|
`${linkBaseURL}/dashboards`,
|
||||||
|
opensearchBaseURL,
|
||||||
|
headers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||||
const cspHeader = `
|
const cspHeader = `
|
||||||
default-src 'self';
|
default-src 'self';
|
||||||
|
frame-src 'self' https://digiresilience.org;
|
||||||
connect-src 'self';
|
connect-src 'self';
|
||||||
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""};
|
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""};
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
|
|
@ -45,7 +73,7 @@ const checkRewrites = async (request: NextRequestWithAuth) => {
|
||||||
object-src 'none';
|
object-src 'none';
|
||||||
base-uri 'self';
|
base-uri 'self';
|
||||||
form-action 'self';
|
form-action 'self';
|
||||||
frame-ancestors 'none';
|
frame-ancestors 'self';
|
||||||
upgrade-insecure-requests;
|
upgrade-insecure-requests;
|
||||||
`;
|
`;
|
||||||
const contentSecurityPolicyHeaderValue = cspHeader
|
const contentSecurityPolicyHeaderValue = cspHeader
|
||||||
|
|
@ -71,7 +99,7 @@ const checkRewrites = async (request: NextRequestWithAuth) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default withAuth(checkRewrites, {
|
export default withAuth(checkRewrites, {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
|
|
@ -101,5 +129,7 @@ export default withAuth(checkRewrites, {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!ws|wss|api|_next/static|_next/image|favicon.ico).*)"],
|
matcher: [
|
||||||
};
|
"/((?!ws|wss|api/signal|api/whatsapp|_next/static|_next/image|favicon.ico).*)",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
|
||||||
3
apps/link/next-env.d.ts
vendored
3
apps/link/next-env.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue