Compare commits
24 commits
main
...
3.5.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59872f579a | ||
|
|
d93797172a | ||
|
|
38efae02d4 | ||
|
|
b9324cf11c | ||
|
|
72b52463a2 | ||
|
|
eea56dd50b | ||
|
|
e8f2cc4c50 | ||
|
|
ac42d7df78 | ||
|
|
87bb05fdd5 | ||
|
|
3d8f794cab | ||
|
|
2db6bc5047 | ||
|
|
57d7173485 | ||
|
|
8688efc5af | ||
|
|
d6dab5fb1f | ||
|
|
7a6e7d0748 | ||
|
|
57f3ccbaeb | ||
|
|
e202eeb9d2 | ||
|
|
e952973f7f | ||
|
|
3b91c98d5e | ||
|
|
a882c9ecff | ||
|
|
69394c813d | ||
|
|
0b2ea19ebc | ||
|
|
f059e75acd | ||
|
|
d4ce94ddf8 |
61 changed files with 3661 additions and 850 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -31,3 +31,5 @@ project.org
|
||||||
apps/bridge-worker/scripts/*
|
apps/bridge-worker/scripts/*
|
||||||
ENVIRONMENT_VARIABLES_MIGRATION.md
|
ENVIRONMENT_VARIABLES_MIGRATION.md
|
||||||
local-scripts/*
|
local-scripts/*
|
||||||
|
docs/
|
||||||
|
packages/zammad-addon-bridge/test/
|
||||||
|
|
|
||||||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
||||||
v22.18.0
|
v24
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-frontend",
|
"name": "@link-stack/bridge-frontend",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-migrations",
|
"name": "@link-stack/bridge-migrations",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"migrate:up:all": "tsx migrate.ts up:all",
|
"migrate:up:all": "tsx migrate.ts up:all",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-whatsapp",
|
"name": "@link-stack/bridge-whatsapp",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import * as Hapi from "@hapi/hapi";
|
import * as Hapi from "@hapi/hapi";
|
||||||
import hapiPino from "hapi-pino";
|
import hapiPino from "hapi-pino";
|
||||||
import Schmervice from "@hapipal/schmervice";
|
import Schmervice from "@hapipal/schmervice";
|
||||||
import WhatsappService from "./service.js";
|
import WhatsappService from "./service.ts";
|
||||||
import {
|
import {
|
||||||
RegisterBotRoute,
|
RegisterBotRoute,
|
||||||
UnverifyBotRoute,
|
UnverifyBotRoute,
|
||||||
GetBotRoute,
|
GetBotRoute,
|
||||||
SendMessageRoute,
|
SendMessageRoute,
|
||||||
ReceiveMessageRoute,
|
ReceiveMessageRoute,
|
||||||
} from "./routes.js";
|
} from "./routes.ts";
|
||||||
import { createLogger } from "@link-stack/logger";
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
const logger = createLogger('bridge-whatsapp-index');
|
const logger = createLogger("bridge-whatsapp-index");
|
||||||
|
|
||||||
const server = Hapi.server({ port: 5000 });
|
const server = Hapi.server({ port: 5000 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as Hapi from "@hapi/hapi";
|
import * as Hapi from "@hapi/hapi";
|
||||||
import Toys from "@hapipal/toys";
|
import Toys from "@hapipal/toys";
|
||||||
import WhatsappService from "./service";
|
import WhatsappService from "./service.ts";
|
||||||
|
|
||||||
const withDefaults = Toys.withRouteDefaults({
|
const withDefaults = Toys.withRouteDefaults({
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -27,15 +27,9 @@ 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;
|
||||||
const { phoneNumber, message, attachments } =
|
const { phoneNumber, message, attachments } = request.payload as MessageRequest;
|
||||||
request.payload as MessageRequest;
|
|
||||||
const whatsappService = getService(request);
|
const whatsappService = getService(request);
|
||||||
await whatsappService.send(
|
await whatsappService.send(id, phoneNumber, message as string, attachments);
|
||||||
id,
|
|
||||||
phoneNumber,
|
|
||||||
message as string,
|
|
||||||
attachments,
|
|
||||||
);
|
|
||||||
request.logger.info(
|
request.logger.info(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import makeWASocket, {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
proto,
|
proto,
|
||||||
downloadContentFromMessage,
|
downloadContentFromMessage,
|
||||||
MediaType,
|
|
||||||
fetchLatestBaileysVersion,
|
fetchLatestBaileysVersion,
|
||||||
isJidBroadcast,
|
isJidBroadcast,
|
||||||
isJidStatusBroadcast,
|
isJidStatusBroadcast,
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
} from "@whiskeysockets/baileys";
|
} from "@whiskeysockets/baileys";
|
||||||
|
|
||||||
|
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { createLogger } from "@link-stack/logger";
|
import { createLogger } from "@link-stack/logger";
|
||||||
import {
|
import {
|
||||||
|
|
@ -97,6 +98,7 @@ export default class WhatsappService extends Service {
|
||||||
...options,
|
...options,
|
||||||
auth: state,
|
auth: state,
|
||||||
generateHighQualityLinkPreview: false,
|
generateHighQualityLinkPreview: false,
|
||||||
|
syncFullHistory: true,
|
||||||
msgRetryCounterMap,
|
msgRetryCounterMap,
|
||||||
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
|
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
|
||||||
});
|
});
|
||||||
|
|
@ -147,6 +149,17 @@ export default class WhatsappService extends Service {
|
||||||
await this.queueUnreadMessages(botID, messages);
|
await this.queueUnreadMessages(botID, messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (events["messaging-history.set"]) {
|
||||||
|
const { messages, isLatest } = events["messaging-history.set"];
|
||||||
|
logger.info(
|
||||||
|
{ messageCount: messages.length, isLatest },
|
||||||
|
"received message history on connection",
|
||||||
|
);
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await this.queueUnreadMessages(botID, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connections[botID] = { socket, msgRetryCounterMap };
|
this.connections[botID] = { socket, msgRetryCounterMap };
|
||||||
|
|
@ -167,7 +180,6 @@ export default class WhatsappService extends Service {
|
||||||
|
|
||||||
await this.createConnection(botID, this.server, {
|
await this.createConnection(botID, this.server, {
|
||||||
browser: WhatsappService.browserDescription,
|
browser: WhatsappService.browserDescription,
|
||||||
printQRInTerminal: true,
|
|
||||||
version,
|
version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -175,18 +187,20 @@ export default class WhatsappService extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) {
|
private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) {
|
||||||
const {
|
const { key, message, messageTimestamp } = webMessageInfo;
|
||||||
key: { id, fromMe, remoteJid },
|
if (!key) {
|
||||||
message,
|
logger.warn("Message missing key, skipping");
|
||||||
messageTimestamp,
|
return;
|
||||||
} = webMessageInfo;
|
|
||||||
logger.info("Message type debug");
|
|
||||||
for (const key in message) {
|
|
||||||
logger.info(
|
|
||||||
{ key, exists: !!message[key as keyof proto.IMessage] },
|
|
||||||
"Message field",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
const { id, fromMe, remoteJid } = key;
|
||||||
|
// Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases.
|
||||||
|
// senderPn contains the actual phone number when available.
|
||||||
|
const senderPn = (key as any).senderPn as string | undefined;
|
||||||
|
const participantPn = (key as any).participantPn as string | undefined;
|
||||||
|
logger.info(
|
||||||
|
{ remoteJid, senderPn, participantPn, fromMe },
|
||||||
|
"Processing incoming message",
|
||||||
|
);
|
||||||
const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe;
|
const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe;
|
||||||
if (isValidMessage) {
|
if (isValidMessage) {
|
||||||
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
|
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
|
||||||
|
|
@ -244,9 +258,27 @@ export default class WhatsappService extends Service {
|
||||||
videoMessage,
|
videoMessage,
|
||||||
].find((text) => text && text !== "");
|
].find((text) => text && text !== "");
|
||||||
|
|
||||||
|
// Extract phone number and user ID (LID) separately
|
||||||
|
// remoteJid may contain LIDs (Baileys 7+) which are not phone numbers
|
||||||
|
const jidValue = remoteJid?.split("@")[0];
|
||||||
|
const isLidJid = remoteJid?.endsWith("@lid");
|
||||||
|
|
||||||
|
// Phone number: prefer senderPn/participantPn, fall back to remoteJid only if it's not a LID
|
||||||
|
const senderPhone = senderPn?.split("@")[0] || participantPn?.split("@")[0] || (!isLidJid ? jidValue : undefined);
|
||||||
|
|
||||||
|
// User ID (LID): extract from remoteJid if it's a LID format
|
||||||
|
const senderUserId = isLidJid ? jidValue : undefined;
|
||||||
|
|
||||||
|
// Must have at least one identifier
|
||||||
|
if (!senderPhone && !senderUserId) {
|
||||||
|
logger.warn({ remoteJid, senderPn, participantPn }, "Could not determine sender identity, skipping message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
to: botID,
|
to: botID,
|
||||||
from: remoteJid?.split("@")[0],
|
from: senderPhone,
|
||||||
|
userId: senderUserId,
|
||||||
messageId: id,
|
messageId: id,
|
||||||
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
|
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
|
||||||
message: messageText,
|
message: messageText,
|
||||||
|
|
@ -410,12 +442,17 @@ export default class WhatsappService extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
async receive(
|
async receive(
|
||||||
botID: string,
|
_botID: string,
|
||||||
_lastReceivedDate: Date,
|
_lastReceivedDate: Date,
|
||||||
): Promise<proto.IWebMessageInfo[]> {
|
): Promise<proto.IWebMessageInfo[]> {
|
||||||
const connection = this.connections[botID]?.socket;
|
// loadAllUnreadMessages() was removed in Baileys 7.x
|
||||||
const messages = await connection.loadAllUnreadMessages();
|
// Messages are now delivered via events (messages.upsert, messaging-history.set)
|
||||||
|
// and forwarded to webhooks automatically.
|
||||||
return messages;
|
// See: https://baileys.wiki/docs/migration/to-v7.0.0/
|
||||||
|
throw new Error(
|
||||||
|
"Message polling is no longer supported in Baileys 7.x. " +
|
||||||
|
"Please configure a webhook to receive messages instead. " +
|
||||||
|
"Messages are automatically forwarded to BRIDGE_FRONTEND_URL/api/whatsapp/bots/{id}/receive"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type WhatsappService from "./service.js";
|
import type WhatsappService from "./service.ts";
|
||||||
|
|
||||||
declare module "@hapipal/schmervice" {
|
declare module "@hapipal/schmervice" {
|
||||||
interface SchmerviceDecorator {
|
interface SchmerviceDecorator {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
{
|
{
|
||||||
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "NodeNext",
|
||||||
"target": "es2018",
|
"target": "es2022",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "build/main",
|
"outDir": "build/main",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"lib": ["es2020", "DOM"],
|
"lib": ["es2022", "DOM"],
|
||||||
"composite": true
|
"composite": true,
|
||||||
|
"rewriteRelativeImportExtensions": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
||||||
"exclude": ["node_modules/**"]
|
"exclude": ["node_modules/**"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-worker",
|
"name": "@link-stack/bridge-worker",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"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>",
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ const processMessage = async ({
|
||||||
token: id,
|
token: id,
|
||||||
to: toRecipient,
|
to: toRecipient,
|
||||||
from: source,
|
from: source,
|
||||||
|
userId: sourceUuid, // Signal user UUID for user identification
|
||||||
messageId: `${sourceUuid}-${rawTimestamp}`,
|
messageId: `${sourceUuid}-${rawTimestamp}`,
|
||||||
message: dataMessage?.message,
|
message: dataMessage?.message,
|
||||||
sentAt: timestamp.toISOString(),
|
sentAt: timestamp.toISOString(),
|
||||||
|
|
@ -204,17 +205,16 @@ const fetchSignalMessagesTask = async ({
|
||||||
|
|
||||||
if (scheduleTasks === "true") {
|
if (scheduleTasks === "true") {
|
||||||
// because cron only has minimum 1 minute resolution
|
// because cron only has minimum 1 minute resolution
|
||||||
for (const offset of [15000, 30000, 45000]) {
|
// schedule one additional job at 30s to achieve 30-second polling
|
||||||
await worker.addJob(
|
await worker.addJob(
|
||||||
"fetch-signal-messages",
|
"fetch-signal-messages",
|
||||||
{ scheduleTasks: "false" },
|
{ scheduleTasks: "false" },
|
||||||
{
|
{
|
||||||
maxAttempts: 1,
|
maxAttempts: 1,
|
||||||
runAt: new Date(Date.now() + offset),
|
runAt: new Date(Date.now() + 30000),
|
||||||
jobKey: `fetchSignalMessages-${offset}`,
|
jobKey: "fetchSignalMessages-30000",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesClient = new MessagesApi(config);
|
const messagesClient = new MessagesApi(config);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface ReceiveSignalMessageTaskOptions {
|
||||||
token: string;
|
token: string;
|
||||||
to: string;
|
to: string;
|
||||||
from: string;
|
from: string;
|
||||||
|
userId?: string; // Signal user UUID for user identification
|
||||||
messageId: string;
|
messageId: string;
|
||||||
sentAt: string;
|
sentAt: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -22,6 +23,7 @@ const receiveSignalMessageTask = async ({
|
||||||
token,
|
token,
|
||||||
to,
|
to,
|
||||||
from,
|
from,
|
||||||
|
userId,
|
||||||
messageId,
|
messageId,
|
||||||
sentAt,
|
sentAt,
|
||||||
message,
|
message,
|
||||||
|
|
@ -212,6 +214,7 @@ const receiveSignalMessageTask = async ({
|
||||||
const payload = {
|
const payload = {
|
||||||
to: finalTo,
|
to: finalTo,
|
||||||
from,
|
from,
|
||||||
|
user_id: userId, // Signal user UUID for user identification
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
sent_at: sentAt,
|
sent_at: sentAt,
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -64,13 +64,14 @@ const sendSignalMessageTask = async ({
|
||||||
let groupCreated = false;
|
let groupCreated = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number
|
// Check if 'to' is a group ID (group.base64 format or base64 internal ID) vs individual recipient
|
||||||
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
// Signal group IDs are 32 bytes = 44 chars base64 (or 43 without padding)
|
||||||
to,
|
// Signal user UUIDs (ACIs) are 36 chars with hyphens: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
);
|
// Phone numbers start with +, usernames with u:, PNIs with PNI:
|
||||||
const isGroupPrefix = to.startsWith("group.");
|
const isGroupPrefix = to.startsWith("group.");
|
||||||
const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(to) && to.length > 20; // Base64 internal_id
|
const isBase64GroupId =
|
||||||
const isGroupId = isUUID || isGroupPrefix || isBase64;
|
/^[A-Za-z0-9+/]+=*$/.test(to) && to.length >= 43 && to.length <= 44;
|
||||||
|
const isGroupId = isGroupPrefix || isBase64GroupId;
|
||||||
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
|
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -282,6 +283,35 @@ const sendSignalMessageTask = async ({
|
||||||
},
|
},
|
||||||
"Message sent successfully",
|
"Message sent successfully",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update group name to use consistent template with ticket number
|
||||||
|
// This ensures groups created by receive-signal-message get renamed
|
||||||
|
// to match the template (e.g., "Support Request: 94085")
|
||||||
|
if (finalTo.startsWith("group.") && conversationId) {
|
||||||
|
try {
|
||||||
|
const expectedGroupName = buildSignalGroupName(conversationId);
|
||||||
|
await groupsClient.v1GroupsNumberGroupidPut({
|
||||||
|
number: bot.phoneNumber,
|
||||||
|
groupid: finalTo,
|
||||||
|
data: {
|
||||||
|
name: expectedGroupName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.debug(
|
||||||
|
{ groupId: finalTo, newName: expectedGroupName },
|
||||||
|
"Updated group name",
|
||||||
|
);
|
||||||
|
} catch (renameError) {
|
||||||
|
// Non-fatal - group name update is best-effort
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
error: renameError instanceof Error ? renameError.message : renameError,
|
||||||
|
groupId: finalTo,
|
||||||
|
},
|
||||||
|
"Could not update group name",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Try to get the actual error message from the response
|
// Try to get the actual error message from the response
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||||
interface ReceiveWhatsappMessageTaskOptions {
|
interface ReceiveWhatsappMessageTaskOptions {
|
||||||
token: string;
|
token: string;
|
||||||
to: string;
|
to: string;
|
||||||
from: string;
|
from?: string;
|
||||||
|
userId?: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
sentAt: string;
|
sentAt: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -16,6 +17,7 @@ const receiveWhatsappMessageTask = async ({
|
||||||
token,
|
token,
|
||||||
to,
|
to,
|
||||||
from,
|
from,
|
||||||
|
userId,
|
||||||
messageId,
|
messageId,
|
||||||
sentAt,
|
sentAt,
|
||||||
message,
|
message,
|
||||||
|
|
@ -33,6 +35,7 @@ const receiveWhatsappMessageTask = async ({
|
||||||
const payload = {
|
const payload = {
|
||||||
to,
|
to,
|
||||||
from,
|
from,
|
||||||
|
user_id: userId,
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
sent_at: sentAt,
|
sent_at: sentAt,
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,11 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
|
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
dumb-init
|
dumb-init
|
||||||
RUN mkdir -p ${APP_DIR}
|
RUN mkdir -p ${APP_DIR} /pnpm
|
||||||
RUN chown -R node ${APP_DIR}/
|
RUN chown -R node ${APP_DIR}/ /pnpm
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
RUN corepack prepare pnpm@9.15.4 --activate
|
||||||
WORKDIR ${APP_DIR}
|
WORKDIR ${APP_DIR}
|
||||||
COPY --from=installer ${APP_DIR} ./
|
COPY --from=installer ${APP_DIR} ./
|
||||||
USER root
|
USER root
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
Google as GoogleIcon,
|
Google as GoogleIcon,
|
||||||
Microsoft as MicrosoftIcon,
|
Microsoft as MicrosoftIcon,
|
||||||
Key as KeyIcon,
|
Key as KeyIcon,
|
||||||
|
VpnKey as KeycloakIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { signIn, getProviders } from "next-auth/react";
|
import { signIn, getProviders } from "next-auth/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
@ -200,6 +201,21 @@ export const Login: FC<LoginProps> = ({ session, baseURL }) => {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
{provider === "keycloak" && (
|
||||||
|
<Grid item sx={{ width: "100%" }}>
|
||||||
|
<IconButton
|
||||||
|
sx={buttonStyles}
|
||||||
|
onClick={() =>
|
||||||
|
signIn("keycloak", {
|
||||||
|
callbackUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<KeycloakIcon sx={{ mr: 1 }} />
|
||||||
|
Sign in with Keycloak
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
{provider === "credentials" && (
|
{provider === "credentials" && (
|
||||||
<Grid item container spacing={3}>
|
<Grid item container spacing={3}>
|
||||||
<Grid item sx={{ width: "100%" }}>
|
<Grid item sx={{ width: "100%" }}>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ 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 AzureADProvider from "next-auth/providers/azure-ad";
|
import AzureADProvider from "next-auth/providers/azure-ad";
|
||||||
|
import Keycloak from "next-auth/providers/keycloak";
|
||||||
import { createLogger } from "@link-stack/logger";
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
const logger = createLogger('link-authentication');
|
const logger = createLogger('link-authentication');
|
||||||
|
|
@ -101,6 +102,18 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||||
tenantId: process.env.AZURE_AD_TENANT_ID,
|
tenantId: process.env.AZURE_AD_TENANT_ID,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
process.env.KEYCLOAK_CLIENT_ID &&
|
||||||
|
process.env.KEYCLOAK_CLIENT_SECRET &&
|
||||||
|
process.env.KEYCLOAK_ISSUER
|
||||||
|
) {
|
||||||
|
providers.push(
|
||||||
|
Keycloak({
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||||
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
issuer: process.env.KEYCLOAK_ISSUER,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
providers.push(
|
providers.push(
|
||||||
Credentials({
|
Credentials({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/link",
|
"name": "@link-stack/link",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -H 0.0.0.0",
|
"dev": "next dev -H 0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ COPY --from=node /usr/local/lib /usr/local/lib
|
||||||
COPY --from=node /usr/local/include /usr/local/include
|
COPY --from=node /usr/local/include /usr/local/include
|
||||||
COPY --from=node /usr/local/bin /usr/local/bin
|
COPY --from=node /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
# Prepare pnpm (corepack is already enabled in node:22-alpine)
|
# Prepare pnpm (corepack symlinks already copied from node image)
|
||||||
RUN corepack prepare pnpm@9.15.4 --activate
|
RUN corepack prepare pnpm@9.15.4 --activate
|
||||||
|
|
||||||
# Set up pnpm home
|
# Set up pnpm home
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ const app = process.argv[2];
|
||||||
const command = process.argv[3];
|
const command = process.argv[3];
|
||||||
|
|
||||||
const files = {
|
const files = {
|
||||||
all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api"],
|
all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||||
linkDev: ["zammad", "postgresql", "opensearch"],
|
linkDev: ["zammad", "postgresql", "opensearch"],
|
||||||
link: ["zammad", "postgresql", "opensearch", "link"],
|
link: ["zammad", "postgresql", "opensearch", "link"],
|
||||||
linkOnly: ["link"],
|
linkOnly: ["link"],
|
||||||
opensearch: ["opensearch"],
|
opensearch: ["opensearch"],
|
||||||
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api"],
|
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||||
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api"],
|
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||||
zammad: ["zammad", "postgresql", "opensearch"],
|
zammad: ["zammad", "postgresql", "opensearch"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -21,11 +21,12 @@ const finalFiles = files[app]
|
||||||
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
|
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
|
||||||
|
|
||||||
// Add bridge-dev.yml for dev commands that include zammad
|
// Add bridge-dev.yml for dev commands that include zammad
|
||||||
const devAppsWithZammad = ['linkDev', 'bridgeDev', 'all'];
|
const devAppsWithZammad = ['linkDev', 'bridgeDev'];
|
||||||
if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
|
if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
|
||||||
finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
|
finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const finalCommand = command === "up" ? ["up", "-d", "--remove-orphans"] : [command];
|
const finalCommand = command === "up" ? ["up", "-d", "--remove-orphans"] : [command];
|
||||||
const dockerCompose = spawn('docker', ['compose', '--env-file', envFile, ...finalFiles, ...finalCommand]);
|
const dockerCompose = spawn('docker', ['compose', '--env-file', envFile, ...finalFiles, ...finalCommand]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ RUN if [ "$EMBEDDED" = "true" ] ; then \
|
||||||
sed -i '$ d' /opt/zammad/contrib/nginx/zammad.conf && \
|
sed -i '$ d' /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
echo "" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
echo "" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
echo " location /link {" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
echo " location /link {" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
echo " proxy_pass ${LINK_HOST};" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
echo " set \$link_url ${LINK_HOST}; proxy_pass \$link_url;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
echo " proxy_set_header Host \$host;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
echo " proxy_set_header Host \$host;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
echo " proxy_set_header X-Real-IP \$remote_addr;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
echo " proxy_set_header X-Real-IP \$remote_addr;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
echo " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
echo " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
|
|
|
||||||
506
docs/bridge-ticket-split-merge-investigation.md
Normal file
506
docs/bridge-ticket-split-merge-investigation.md
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
# Zammad Ticket Splits & Merges with Bridge Channels
|
||||||
|
|
||||||
|
## Investigation Summary
|
||||||
|
|
||||||
|
This document analyzes how Zammad handles ticket splits and merges, and the implications for our custom bridge channels (WhatsApp, Signal, Voice).
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### How Zammad Handles Split/Merge (Built-in)
|
||||||
|
|
||||||
|
#### Merge (`ticket.merge_to` in `app/models/ticket.rb:330`)
|
||||||
|
|
||||||
|
When ticket A is merged into ticket B:
|
||||||
|
|
||||||
|
1. All articles from A are moved to B
|
||||||
|
2. A "parent" link is created between A and B
|
||||||
|
3. Mentions and external links are migrated
|
||||||
|
4. Source ticket A's state is set to "merged"
|
||||||
|
5. Source ticket A's owner is reset to System (id: 1)
|
||||||
|
|
||||||
|
**Critical issue:** Ticket preferences are NOT copied or migrated. The target ticket B keeps its original preferences, and source ticket A's preferences become orphaned.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# From app/models/ticket.rb - merge_to method
|
||||||
|
# Articles are moved:
|
||||||
|
Ticket::Article.where(ticket_id: id).update_all(['ticket_id = ?', data[:ticket_id]])
|
||||||
|
|
||||||
|
# But preferences are never touched - they stay on the source ticket
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Split (`app/models/form_updater/concerns/applies_split_ticket_article.rb`)
|
||||||
|
|
||||||
|
When an article is split from ticket A to create new ticket C:
|
||||||
|
|
||||||
|
1. Basic ticket attributes are copied (group, customer, state, priority, title)
|
||||||
|
2. Attachments are cloned
|
||||||
|
3. A link is created to the original ticket
|
||||||
|
4. `owner_id` is explicitly deleted (not copied)
|
||||||
|
|
||||||
|
**Critical issue:** Preferences are NOT copied. The new ticket C has no channel metadata.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# From applies_split_ticket_article.rb
|
||||||
|
def attributes_to_apply
|
||||||
|
attrs = selected_ticket_article.ticket.attributes
|
||||||
|
attrs['title'] = selected_ticket_article.subject if selected_ticket_article.subject.present?
|
||||||
|
attrs['body'] = body_with_form_id_urls
|
||||||
|
attrs.delete 'owner_id' # Explicitly deleted
|
||||||
|
attrs
|
||||||
|
# Note: preferences are NOT included in .attributes
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Email Follow-up Handling (`app/models/channel/filter/follow_up_merged.rb`)
|
||||||
|
|
||||||
|
Zammad has a postmaster filter that handles incoming emails to merged tickets:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def self.run(_channel, mail, _transaction_params)
|
||||||
|
return if mail[:'x-zammad-ticket-id'].blank?
|
||||||
|
|
||||||
|
referenced_ticket = Ticket.find_by(id: mail[:'x-zammad-ticket-id'])
|
||||||
|
return if referenced_ticket.blank?
|
||||||
|
|
||||||
|
new_target_ticket = find_merge_follow_up_ticket(referenced_ticket)
|
||||||
|
return if new_target_ticket.blank?
|
||||||
|
|
||||||
|
mail[:'x-zammad-ticket-id'] = new_target_ticket.id
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
This follows the parent link to find the active target ticket. **This only works for email** - no equivalent exists for other channels like Telegram, WhatsApp, or Signal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bridge Channel Metadata Structure
|
||||||
|
|
||||||
|
Our bridge channels store critical routing metadata in `ticket.preferences`:
|
||||||
|
|
||||||
|
### WhatsApp
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
ticket.preferences = {
|
||||||
|
channel_id: 123,
|
||||||
|
cdr_whatsapp: {
|
||||||
|
bot_token: "abc123", # Identifies which bot/channel
|
||||||
|
chat_id: "+1234567890" # Customer's phone number - WHERE TO SEND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signal (Direct Message)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
ticket.preferences = {
|
||||||
|
channel_id: 456,
|
||||||
|
cdr_signal: {
|
||||||
|
bot_token: "xyz789",
|
||||||
|
chat_id: "+1234567890" # Customer's phone number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signal (Group)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
ticket.preferences = {
|
||||||
|
channel_id: 456,
|
||||||
|
cdr_signal: {
|
||||||
|
bot_token: "xyz789",
|
||||||
|
chat_id: "group.abc123...", # Signal group ID
|
||||||
|
group_joined: true, # Whether customer accepted invite
|
||||||
|
group_joined_at: "2024-01-01",
|
||||||
|
original_recipient: "+1234567890"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Bridge Channels Use This Metadata
|
||||||
|
|
||||||
|
### Outgoing Messages
|
||||||
|
|
||||||
|
The communication jobs (`CommunicateCdrWhatsappJob`, `CommunicateCdrSignalJob`) rely entirely on ticket preferences:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# From communicate_cdr_whatsapp_job.rb
|
||||||
|
def perform(article_id)
|
||||||
|
article = Ticket::Article.find(article_id)
|
||||||
|
ticket = Ticket.lookup(id: article.ticket_id)
|
||||||
|
|
||||||
|
# These MUST exist or the job fails:
|
||||||
|
unless ticket.preferences['cdr_whatsapp']['bot_token']
|
||||||
|
log_error(article, "Can't find ticket.preferences['cdr_whatsapp']['bot_token']")
|
||||||
|
end
|
||||||
|
unless ticket.preferences['cdr_whatsapp']['chat_id']
|
||||||
|
log_error(article, "Can't find ticket.preferences['cdr_whatsapp']['chat_id']")
|
||||||
|
end
|
||||||
|
|
||||||
|
channel = Channel.lookup(id: ticket.preferences['channel_id'])
|
||||||
|
result = channel.deliver(article) # Uses chat_id to know where to send
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Incoming Messages
|
||||||
|
|
||||||
|
The webhook controllers look up existing tickets:
|
||||||
|
|
||||||
|
**WhatsApp** (`channels_cdr_whatsapp_controller.rb`):
|
||||||
|
```ruby
|
||||||
|
# Find open ticket for this customer
|
||||||
|
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||||
|
ticket = Ticket.where(customer_id: customer.id)
|
||||||
|
.where.not(state_id: state_ids)
|
||||||
|
.order(:updated_at).first
|
||||||
|
```
|
||||||
|
|
||||||
|
**Signal Groups** (`channels_cdr_signal_controller.rb`):
|
||||||
|
```ruby
|
||||||
|
# Find ticket by group ID in preferences
|
||||||
|
ticket = Ticket.where.not(state_id: state_ids)
|
||||||
|
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
|
||||||
|
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
|
||||||
|
.order(updated_at: :desc)
|
||||||
|
.first
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Merge Bridge Ticket → Non-Bridge Ticket
|
||||||
|
|
||||||
|
**Setup:** Ticket A (has WhatsApp metadata) merged into Ticket B (no bridge metadata)
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- A's articles move to B
|
||||||
|
- A's preferences stay on A (now in merged state)
|
||||||
|
- B still has no bridge preferences
|
||||||
|
|
||||||
|
**Result:** Agent replies on ticket B fail - no `chat_id` to send to.
|
||||||
|
|
||||||
|
### Scenario 2: Merge Bridge Ticket → Different Bridge Ticket
|
||||||
|
|
||||||
|
**Setup:** Ticket A (WhatsApp to +111) merged into Ticket B (WhatsApp to +222)
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- A's articles move to B
|
||||||
|
- B keeps its preferences (`chat_id: +222`)
|
||||||
|
|
||||||
|
**Result:** Agent replies go to +222, not to +111. Customer +111 never receives responses.
|
||||||
|
|
||||||
|
### Scenario 3: Split Article from Bridge Ticket
|
||||||
|
|
||||||
|
**Setup:** Split an article from Ticket A (has WhatsApp metadata) to create Ticket C
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- New ticket C is created with no preferences
|
||||||
|
- C is linked to A
|
||||||
|
|
||||||
|
**Result:** Agent cannot reply via WhatsApp on ticket C at all - job fails immediately.
|
||||||
|
|
||||||
|
### Scenario 4: Incoming Message to Merged Ticket's Customer
|
||||||
|
|
||||||
|
**Setup:** Ticket A (customer +111) was merged into B. Customer +111 sends new message.
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- Webhook finds customer by phone number
|
||||||
|
- Looks for open ticket for customer
|
||||||
|
- A is excluded (merged state)
|
||||||
|
- Either finds B (if same customer) or creates new ticket
|
||||||
|
|
||||||
|
**Result:** May work if B has same customer, but conversation context is fragmented.
|
||||||
|
|
||||||
|
### Scenario 5: Signal Group Ticket Merged
|
||||||
|
|
||||||
|
**Setup:** Ticket A (Signal group X) merged into Ticket B (no Signal metadata)
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- All group messages went to A
|
||||||
|
- A is now merged, B has no group reference
|
||||||
|
- New messages from group X create a new ticket (can't find existing by group ID)
|
||||||
|
|
||||||
|
**Result:** Conversation splits into multiple tickets unexpectedly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Solutions
|
||||||
|
|
||||||
|
### Option 1: Preferences Migration on Merge (Recommended)
|
||||||
|
|
||||||
|
Create a concern that copies bridge channel metadata when tickets are merged:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# app/models/ticket/merge_bridge_channel_preferences.rb
|
||||||
|
module Ticket::MergeBridgeChannelPreferences
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
after_update :migrate_bridge_preferences_on_merge
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def migrate_bridge_preferences_on_merge
|
||||||
|
return unless saved_change_to_state_id?
|
||||||
|
return unless state.state_type.name == 'merged'
|
||||||
|
|
||||||
|
target_ticket = find_merge_target
|
||||||
|
return unless target_ticket
|
||||||
|
|
||||||
|
# Copy bridge preferences if target doesn't have them
|
||||||
|
%w[cdr_whatsapp cdr_signal cdr_voice].each do |channel_key|
|
||||||
|
next unless preferences[channel_key].present?
|
||||||
|
next if target_ticket.preferences[channel_key].present?
|
||||||
|
|
||||||
|
target_ticket.preferences[channel_key] = preferences[channel_key].deep_dup
|
||||||
|
target_ticket.preferences['channel_id'] ||= preferences['channel_id']
|
||||||
|
end
|
||||||
|
|
||||||
|
target_ticket.save! if target_ticket.changed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_merge_target
|
||||||
|
Link.list(link_object: 'Ticket', link_object_value: id)
|
||||||
|
.find { |l| l['link_type'] == 'parent' && l['link_object'] == 'Ticket' }
|
||||||
|
&.then { |l| Ticket.find_by(id: l['link_object_value']) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Handles the common case (merging bridge ticket into non-bridge ticket)
|
||||||
|
- Automatic, no agent action required
|
||||||
|
- Non-destructive (doesn't overwrite existing preferences)
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Doesn't handle case where both tickets have different bridge metadata
|
||||||
|
- May need additional logic for conflicting preferences
|
||||||
|
|
||||||
|
### Option 2: Follow-up Filter for Bridge Channels
|
||||||
|
|
||||||
|
Create filters similar to `FollowUpMerged` that redirect incoming bridge messages:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Modify webhook controllers to check for merged tickets
|
||||||
|
def find_active_ticket_for_customer(customer, state_ids)
|
||||||
|
ticket = Ticket.where(customer_id: customer.id)
|
||||||
|
.where.not(state_id: state_ids)
|
||||||
|
.order(:updated_at).first
|
||||||
|
|
||||||
|
# If ticket is merged, follow parent link
|
||||||
|
if ticket&.state&.state_type&.name == 'merged'
|
||||||
|
ticket = find_merge_target(ticket) || ticket
|
||||||
|
end
|
||||||
|
|
||||||
|
ticket
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_merge_target(ticket)
|
||||||
|
Link.list(link_object: 'Ticket', link_object_value: ticket.id)
|
||||||
|
.filter_map do |link|
|
||||||
|
next if link['link_type'] != 'parent'
|
||||||
|
next if link['link_object'] != 'Ticket'
|
||||||
|
|
||||||
|
Ticket.joins(state: :state_type)
|
||||||
|
.where.not(ticket_state_types: { name: 'merged' })
|
||||||
|
.find_by(id: link['link_object_value'])
|
||||||
|
end.first
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Handles incoming messages to merged tickets correctly
|
||||||
|
- Follows same pattern as Zammad's email handling
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Requires modifying webhook controllers
|
||||||
|
- Only handles incoming direction, not outgoing
|
||||||
|
|
||||||
|
### Option 3: Copy Preferences on Split
|
||||||
|
|
||||||
|
Modify the split form updater or add a callback to copy bridge preferences:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Add to ticket creation from split
|
||||||
|
module Ticket::SplitBridgeChannelPreferences
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
after_create :copy_bridge_preferences_from_source
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def copy_bridge_preferences_from_source
|
||||||
|
# Find source ticket via link
|
||||||
|
source_link = Link.list(link_object: 'Ticket', link_object_value: id)
|
||||||
|
.find { |l| l['link_type'] == 'child' }
|
||||||
|
return unless source_link
|
||||||
|
|
||||||
|
source_ticket = Ticket.find_by(id: source_link['link_object_value'])
|
||||||
|
return unless source_ticket
|
||||||
|
|
||||||
|
# Copy bridge preferences
|
||||||
|
%w[cdr_whatsapp cdr_signal cdr_voice channel_id].each do |key|
|
||||||
|
next unless source_ticket.preferences[key].present?
|
||||||
|
self.preferences[key] = source_ticket.preferences[key].deep_dup
|
||||||
|
end
|
||||||
|
|
||||||
|
save! if changed?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 4: UI Warning + Manual Handling
|
||||||
|
|
||||||
|
Add frontend validation to warn agents:
|
||||||
|
|
||||||
|
1. Check for bridge preferences before merge/split
|
||||||
|
2. Show warning dialog explaining implications
|
||||||
|
3. Optionally provide UI to manually transfer channel association
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In merge confirmation dialog
|
||||||
|
const hasBridgeChannel = ticket.preferences?.cdr_whatsapp ||
|
||||||
|
ticket.preferences?.cdr_signal;
|
||||||
|
if (hasBridgeChannel) {
|
||||||
|
showWarning(
|
||||||
|
"This ticket uses WhatsApp/Signal messaging. " +
|
||||||
|
"Merging may affect message routing. " +
|
||||||
|
"Replies will be sent to the target ticket's contact."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 5: Multi-Channel Preferences (Long-term)
|
||||||
|
|
||||||
|
Allow tickets to have multiple channel associations:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
ticket.preferences = {
|
||||||
|
bridge_channels: [
|
||||||
|
{ type: 'cdr_whatsapp', chat_id: '+111...', channel_id: 1, customer_id: 100 },
|
||||||
|
{ type: 'cdr_whatsapp', chat_id: '+222...', channel_id: 1, customer_id: 101 },
|
||||||
|
{ type: 'cdr_signal', chat_id: 'group.xxx', channel_id: 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This would require significant refactoring of communication jobs to handle multiple recipients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signal Groups - Special Considerations
|
||||||
|
|
||||||
|
Signal groups add complexity:
|
||||||
|
|
||||||
|
1. **Group ID is the routing key**, not phone number
|
||||||
|
2. **Multiple customers** might be in the same group
|
||||||
|
3. **`group_joined` flag** tracks invite acceptance - messages can't be sent until true
|
||||||
|
4. **Group membership changes** could affect ticket routing
|
||||||
|
|
||||||
|
### Merge Rules for Signal Groups
|
||||||
|
|
||||||
|
| Source Ticket | Target Ticket | Recommendation |
|
||||||
|
|---------------|---------------|----------------|
|
||||||
|
| Signal group A | No Signal | Copy preferences (Option 1) |
|
||||||
|
| Signal group A | Signal group A (same) | Safe to merge |
|
||||||
|
| Signal group A | Signal group B (different) | **Block or warn** - can't merge different group conversations |
|
||||||
|
| Signal group A | Signal DM | **Block or warn** - different communication modes |
|
||||||
|
|
||||||
|
Consider adding validation:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def validate_signal_group_merge(source, target)
|
||||||
|
source_group = source.preferences.dig('cdr_signal', 'chat_id')
|
||||||
|
target_group = target.preferences.dig('cdr_signal', 'chat_id')
|
||||||
|
|
||||||
|
return true if source_group.blank? || target_group.blank?
|
||||||
|
return true if source_group == target_group
|
||||||
|
|
||||||
|
# Different groups - this is problematic
|
||||||
|
raise Exceptions::UnprocessableEntity,
|
||||||
|
"Cannot merge tickets from different Signal groups"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Implementation Path
|
||||||
|
|
||||||
|
### Phase 1: Immediate (Low Risk)
|
||||||
|
|
||||||
|
1. **Add preferences migration on merge** (Option 1)
|
||||||
|
- Only copies if target doesn't have existing preferences
|
||||||
|
- Handles most common case safely
|
||||||
|
|
||||||
|
2. **Add preferences copy on split** (Option 3)
|
||||||
|
- New tickets get parent's channel metadata
|
||||||
|
- Enables replies on split tickets
|
||||||
|
|
||||||
|
### Phase 2: Short-term
|
||||||
|
|
||||||
|
3. **Add follow-up handling in webhooks** (Option 2)
|
||||||
|
- Modify webhook controllers to follow merge parent links
|
||||||
|
- Handles incoming messages to merged ticket's customer
|
||||||
|
|
||||||
|
4. **Add UI warnings** (Option 4)
|
||||||
|
- Warn agents about implications
|
||||||
|
- Especially for conflicting metadata scenarios
|
||||||
|
|
||||||
|
### Phase 3: Medium-term
|
||||||
|
|
||||||
|
5. **Add merge validation for Signal groups**
|
||||||
|
- Block merging tickets from different groups
|
||||||
|
- Or add clear warning about implications
|
||||||
|
|
||||||
|
6. **Add audit logging**
|
||||||
|
- Track when preferences are migrated
|
||||||
|
- Help agents understand what happened
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### Zammad Addon (zammad-addon-bridge)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/app/models/ticket/merge_bridge_channel_preferences.rb` | New - preferences migration |
|
||||||
|
| `src/app/models/ticket/split_bridge_channel_preferences.rb` | New - preferences copy on split |
|
||||||
|
| `src/app/controllers/channels_cdr_whatsapp_controller.rb` | Add merge follow-up handling |
|
||||||
|
| `src/app/controllers/channels_cdr_signal_controller.rb` | Add merge follow-up handling |
|
||||||
|
| `src/config/initializers/bridge.rb` | Include new concerns in Ticket model |
|
||||||
|
|
||||||
|
### Link Frontend (optional)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| Merge dialog component | Add warning for bridge tickets |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Scenarios
|
||||||
|
|
||||||
|
1. Merge WhatsApp ticket → empty ticket → verify agent can reply
|
||||||
|
2. Merge WhatsApp ticket → WhatsApp ticket (same number) → verify routing
|
||||||
|
3. Merge WhatsApp ticket → WhatsApp ticket (different number) → verify warning/behavior
|
||||||
|
4. Split article from WhatsApp ticket → verify new ticket has preferences
|
||||||
|
5. Customer sends message after their ticket was merged → verify routing
|
||||||
|
6. Merge Signal group ticket → verify group_joined flag is preserved
|
||||||
|
7. Merge two different Signal group tickets → verify validation/warning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Zammad merge implementation: `app/models/ticket.rb:330-450`
|
||||||
|
- Zammad split implementation: `app/models/form_updater/concerns/applies_split_ticket_article.rb`
|
||||||
|
- Zammad email follow-up filter: `app/models/channel/filter/follow_up_merged.rb`
|
||||||
|
- Bridge WhatsApp controller: `packages/zammad-addon-bridge/src/app/controllers/channels_cdr_whatsapp_controller.rb`
|
||||||
|
- Bridge Signal controller: `packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb`
|
||||||
|
- Bridge WhatsApp job: `packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_whatsapp_job.rb`
|
||||||
|
- Bridge Signal job: `packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb`
|
||||||
906
docs/ticket-field-propagation-design.md
Normal file
906
docs/ticket-field-propagation-design.md
Normal file
|
|
@ -0,0 +1,906 @@
|
||||||
|
# Ticket Field Propagation System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A configurable system for copying/syncing fields between related tickets (parent/child, merged, linked). This addresses the bridge channel preferences problem while providing a general-purpose solution for custom fields.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Zammad creates relationships between tickets through:
|
||||||
|
- **Split**: Creates child ticket from parent's article
|
||||||
|
- **Merge**: Source ticket becomes child of target (merged state)
|
||||||
|
- **Manual linking**: Agents can link tickets as parent/child or related
|
||||||
|
|
||||||
|
Currently, no field values are propagated across these relationships except basic attributes on split. This causes issues when:
|
||||||
|
- Bridge channel metadata needs to follow the conversation
|
||||||
|
- Custom fields (account ID, region, priority score) should be inherited
|
||||||
|
- Parent ticket context should flow to children (or vice versa)
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Use Case 1: Bridge Channel Inheritance (Immediate Need)
|
||||||
|
When a ticket is split or merged, the bridge channel metadata (`preferences.cdr_whatsapp`, `preferences.cdr_signal`) should be copied so agents can reply via the same channel.
|
||||||
|
|
||||||
|
### Use Case 2: Custom Field Inheritance
|
||||||
|
Organization uses custom fields like `account_tier`, `region`, `contract_id`. When splitting a ticket, the child should inherit these values.
|
||||||
|
|
||||||
|
### Use Case 3: Escalation Propagation
|
||||||
|
When a child ticket is escalated (custom `escalation_level` field), the parent should be updated to reflect this.
|
||||||
|
|
||||||
|
### Use Case 4: SLA Context
|
||||||
|
Parent ticket has SLA deadline. Child tickets should inherit or reference this deadline.
|
||||||
|
|
||||||
|
### Use Case 5: Bulk Operations
|
||||||
|
When updating a parent ticket's category, optionally cascade to all children.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Terminology
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
|------|------------|
|
||||||
|
| **Source** | The ticket providing the field value |
|
||||||
|
| **Target** | The ticket receiving the field value |
|
||||||
|
| **Direction** | Which way data flows (parent→child, child→parent, source→target on merge) |
|
||||||
|
| **Trigger** | The event that initiates propagation (split, merge, update, link_create) |
|
||||||
|
| **Condition** | When to apply the copy (always, if_empty, if_greater, custom) |
|
||||||
|
| **Field Path** | Dot-notation path to the field (`preferences.cdr_whatsapp.chat_id`) |
|
||||||
|
|
||||||
|
### Field Types
|
||||||
|
|
||||||
|
The system must handle different field storage mechanisms:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# 1. Standard ticket attributes
|
||||||
|
ticket.group_id
|
||||||
|
ticket.priority_id
|
||||||
|
ticket.organization_id
|
||||||
|
|
||||||
|
# 2. Preferences hash (nested)
|
||||||
|
ticket.preferences['channel_id']
|
||||||
|
ticket.preferences['cdr_whatsapp']['chat_id']
|
||||||
|
ticket.preferences['cdr_signal']['group_joined']
|
||||||
|
|
||||||
|
# 3. Custom object attributes (Zammad ObjectManager)
|
||||||
|
ticket.custom_account_id # Added via Admin → Objects → Ticket
|
||||||
|
ticket.custom_region
|
||||||
|
ticket.custom_escalation_level
|
||||||
|
|
||||||
|
# 4. Tags (special handling)
|
||||||
|
ticket.tag_list # Array of strings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Schema
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Stored in Setting or dedicated table
|
||||||
|
TicketFieldPropagation.configure do |config|
|
||||||
|
|
||||||
|
# Define field groups for convenience
|
||||||
|
config.field_group :bridge_channel, [
|
||||||
|
'preferences.channel_id',
|
||||||
|
'preferences.cdr_whatsapp', # Copies entire hash
|
||||||
|
'preferences.cdr_signal',
|
||||||
|
'preferences.cdr_voice'
|
||||||
|
]
|
||||||
|
|
||||||
|
config.field_group :customer_context, [
|
||||||
|
'organization_id',
|
||||||
|
'custom_account_id',
|
||||||
|
'custom_region',
|
||||||
|
'custom_contract_id'
|
||||||
|
]
|
||||||
|
|
||||||
|
config.field_group :sla_context, [
|
||||||
|
'custom_sla_deadline',
|
||||||
|
'custom_escalation_level'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Define propagation rules
|
||||||
|
|
||||||
|
# Bridge preferences: copy to child on split if child doesn't have them
|
||||||
|
config.rule :bridge_on_split do |r|
|
||||||
|
r.fields :bridge_channel
|
||||||
|
r.trigger :split
|
||||||
|
r.direction :parent_to_child
|
||||||
|
r.condition :if_target_empty
|
||||||
|
r.timing :immediate
|
||||||
|
end
|
||||||
|
|
||||||
|
# Bridge preferences: copy to target on merge if target doesn't have them
|
||||||
|
config.rule :bridge_on_merge do |r|
|
||||||
|
r.fields :bridge_channel
|
||||||
|
r.trigger :merge
|
||||||
|
r.direction :source_to_target
|
||||||
|
r.condition :if_target_empty
|
||||||
|
r.timing :immediate
|
||||||
|
end
|
||||||
|
|
||||||
|
# Customer context: always copy to child on split
|
||||||
|
config.rule :customer_context_on_split do |r|
|
||||||
|
r.fields :customer_context
|
||||||
|
r.trigger :split
|
||||||
|
r.direction :parent_to_child
|
||||||
|
r.condition :always
|
||||||
|
r.timing :immediate
|
||||||
|
end
|
||||||
|
|
||||||
|
# Escalation: propagate highest level to parent
|
||||||
|
config.rule :escalation_to_parent do |r|
|
||||||
|
r.fields ['custom_escalation_level']
|
||||||
|
r.trigger :update
|
||||||
|
r.direction :child_to_parent
|
||||||
|
r.condition :if_greater
|
||||||
|
r.timing :deferred # Use job queue
|
||||||
|
end
|
||||||
|
|
||||||
|
# Manual sync: allow agent to trigger full sync
|
||||||
|
config.rule :manual_sync do |r|
|
||||||
|
r.fields [:customer_context, :sla_context]
|
||||||
|
r.trigger :manual
|
||||||
|
r.direction :parent_to_children # All children
|
||||||
|
r.condition :always
|
||||||
|
r.timing :immediate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: JSON Configuration
|
||||||
|
|
||||||
|
For storage in Zammad's `Setting` table:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field_groups": {
|
||||||
|
"bridge_channel": [
|
||||||
|
"preferences.channel_id",
|
||||||
|
"preferences.cdr_whatsapp",
|
||||||
|
"preferences.cdr_signal"
|
||||||
|
],
|
||||||
|
"customer_context": [
|
||||||
|
"organization_id",
|
||||||
|
"custom_account_id",
|
||||||
|
"custom_region"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "bridge_on_split",
|
||||||
|
"fields": ["@bridge_channel"],
|
||||||
|
"trigger": "split",
|
||||||
|
"direction": "parent_to_child",
|
||||||
|
"condition": "if_target_empty",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bridge_on_merge",
|
||||||
|
"fields": ["@bridge_channel"],
|
||||||
|
"trigger": "merge",
|
||||||
|
"direction": "source_to_target",
|
||||||
|
"condition": "if_target_empty",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customer_context_inherit",
|
||||||
|
"fields": ["@customer_context"],
|
||||||
|
"trigger": "split",
|
||||||
|
"direction": "parent_to_child",
|
||||||
|
"condition": "always",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TicketFieldPropagation │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Configuration│ │ Engine │ │ FieldAccessor │ │
|
||||||
|
│ │ │───▶│ │───▶│ │ │
|
||||||
|
│ │ - field_groups │ - execute() │ │ - get(path) │ │
|
||||||
|
│ │ - rules │ │ - apply_rule │ │ - set(path, val) │ │
|
||||||
|
│ │ - load/save │ │ - find_related │ - deep_merge │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ RelationshipFinder │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ - find_parent(ticket) │ │
|
||||||
|
│ │ - find_children(ticket) │ │
|
||||||
|
│ │ - find_merge_target(ticket) │ │
|
||||||
|
│ │ - find_merge_source(ticket) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Triggers │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────┐ ┌────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Ticket Concern │ │ Transaction │ │ Manual API │ │
|
||||||
|
│ │ │ │ Observer │ │ Endpoint │ │
|
||||||
|
│ │ after_create │ │ │ │ │ │
|
||||||
|
│ │ after_update │ │ on merge event │ │ POST /tickets/ │ │
|
||||||
|
│ │ after_save │ │ on split event │ │ :id/propagate │ │
|
||||||
|
│ └────────────────┘ └────────────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Class Design
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# lib/ticket_field_propagation/configuration.rb
|
||||||
|
module TicketFieldPropagation
|
||||||
|
class Configuration
|
||||||
|
attr_accessor :field_groups, :rules
|
||||||
|
|
||||||
|
def self.load
|
||||||
|
# Load from Setting table or YAML
|
||||||
|
end
|
||||||
|
|
||||||
|
def field_group(name, fields)
|
||||||
|
@field_groups[name] = fields
|
||||||
|
end
|
||||||
|
|
||||||
|
def rule(name, &block)
|
||||||
|
rule = Rule.new(name)
|
||||||
|
block.call(rule)
|
||||||
|
@rules << rule
|
||||||
|
end
|
||||||
|
|
||||||
|
def expand_fields(field_refs)
|
||||||
|
# Expand @group_name references to actual field list
|
||||||
|
field_refs.flat_map do |ref|
|
||||||
|
if ref.start_with?('@')
|
||||||
|
@field_groups[ref[1..].to_sym] || []
|
||||||
|
else
|
||||||
|
[ref]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Rule
|
||||||
|
attr_accessor :name, :fields, :trigger, :direction, :condition, :timing
|
||||||
|
|
||||||
|
def initialize(name)
|
||||||
|
@name = name
|
||||||
|
@timing = :immediate
|
||||||
|
@condition = :always
|
||||||
|
end
|
||||||
|
|
||||||
|
def applies_to?(event_type)
|
||||||
|
@trigger == event_type || (@trigger.is_a?(Array) && @trigger.include?(event_type))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# lib/ticket_field_propagation/engine.rb
|
||||||
|
module TicketFieldPropagation
|
||||||
|
class Engine
|
||||||
|
def initialize(source_ticket, event_type, target_ticket: nil)
|
||||||
|
@source = source_ticket
|
||||||
|
@event = event_type
|
||||||
|
@explicit_target = target_ticket
|
||||||
|
@config = Configuration.load
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute
|
||||||
|
applicable_rules.each do |rule|
|
||||||
|
if rule.timing == :deferred
|
||||||
|
PropagationJob.perform_later(@source.id, rule.name)
|
||||||
|
else
|
||||||
|
apply_rule(rule)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def applicable_rules
|
||||||
|
@config.rules.select { |r| r.applies_to?(@event) && r.enabled }
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_rule(rule)
|
||||||
|
targets = find_targets(rule.direction)
|
||||||
|
fields = @config.expand_fields(rule.fields)
|
||||||
|
|
||||||
|
targets.each do |target|
|
||||||
|
source = determine_source(rule.direction, target)
|
||||||
|
PropagationResult.log(@source, target, rule)
|
||||||
|
|
||||||
|
fields.each do |field_path|
|
||||||
|
copy_field(source, target, field_path, rule.condition)
|
||||||
|
end
|
||||||
|
|
||||||
|
target.save! if target.changed?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_targets(direction)
|
||||||
|
case direction
|
||||||
|
when :parent_to_child, :parent_to_children
|
||||||
|
RelationshipFinder.find_children(@source)
|
||||||
|
when :child_to_parent
|
||||||
|
[RelationshipFinder.find_parent(@source)].compact
|
||||||
|
when :source_to_target
|
||||||
|
[@explicit_target || RelationshipFinder.find_merge_target(@source)].compact
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def determine_source(direction, target)
|
||||||
|
case direction
|
||||||
|
when :parent_to_child, :parent_to_children, :source_to_target
|
||||||
|
@source
|
||||||
|
when :child_to_parent
|
||||||
|
target # We're copying FROM child TO parent, so target is source here
|
||||||
|
# Wait, this is confusing. Let me reconsider...
|
||||||
|
@source # The ticket that triggered the event is the source
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def copy_field(source, target, field_path, condition)
|
||||||
|
source_value = FieldAccessor.get(source, field_path)
|
||||||
|
return if source_value.nil?
|
||||||
|
|
||||||
|
target_value = FieldAccessor.get(target, field_path)
|
||||||
|
|
||||||
|
case condition
|
||||||
|
when :if_target_empty
|
||||||
|
return if target_value.present?
|
||||||
|
when :if_greater
|
||||||
|
return if target_value.present? && target_value >= source_value
|
||||||
|
when :always
|
||||||
|
# proceed
|
||||||
|
end
|
||||||
|
|
||||||
|
FieldAccessor.set(target, field_path, source_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# lib/ticket_field_propagation/field_accessor.rb
|
||||||
|
module TicketFieldPropagation
|
||||||
|
class FieldAccessor
|
||||||
|
class << self
|
||||||
|
def get(ticket, field_path)
|
||||||
|
parts = field_path.split('.')
|
||||||
|
|
||||||
|
value = ticket
|
||||||
|
parts.each do |part|
|
||||||
|
value = access_part(value, part)
|
||||||
|
return nil if value.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deep dup hashes to prevent mutation
|
||||||
|
value.is_a?(Hash) ? value.deep_dup : value
|
||||||
|
end
|
||||||
|
|
||||||
|
def set(ticket, field_path, value)
|
||||||
|
parts = field_path.split('.')
|
||||||
|
|
||||||
|
if parts.length == 1
|
||||||
|
# Direct attribute
|
||||||
|
set_attribute(ticket, parts[0], value)
|
||||||
|
else
|
||||||
|
# Nested in preferences or similar
|
||||||
|
set_nested(ticket, parts, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def access_part(object, part)
|
||||||
|
if object.is_a?(Hash)
|
||||||
|
object[part] || object[part.to_sym]
|
||||||
|
elsif object.respond_to?(part)
|
||||||
|
object.send(part)
|
||||||
|
elsif object.respond_to?(:[])
|
||||||
|
object[part]
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_attribute(ticket, attr_name, value)
|
||||||
|
if ticket.respond_to?("#{attr_name}=")
|
||||||
|
ticket.send("#{attr_name}=", value)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Unknown attribute: #{attr_name}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_nested(ticket, parts, value)
|
||||||
|
# e.g., ['preferences', 'cdr_whatsapp']
|
||||||
|
root = parts[0]
|
||||||
|
|
||||||
|
if root == 'preferences'
|
||||||
|
ticket.preferences ||= {}
|
||||||
|
set_hash_path(ticket.preferences, parts[1..], value)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Unsupported nested path root: #{root}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_hash_path(hash, remaining_parts, value)
|
||||||
|
if remaining_parts.length == 1
|
||||||
|
key = remaining_parts[0]
|
||||||
|
if value.is_a?(Hash) && hash[key].is_a?(Hash)
|
||||||
|
# Deep merge for hash values
|
||||||
|
hash[key] = hash[key].deep_merge(value)
|
||||||
|
else
|
||||||
|
hash[key] = value
|
||||||
|
end
|
||||||
|
else
|
||||||
|
key = remaining_parts[0]
|
||||||
|
hash[key] ||= {}
|
||||||
|
set_hash_path(hash[key], remaining_parts[1..], value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# lib/ticket_field_propagation/relationship_finder.rb
|
||||||
|
module TicketFieldPropagation
|
||||||
|
class RelationshipFinder
|
||||||
|
class << self
|
||||||
|
def find_parent(ticket)
|
||||||
|
# In Zammad links: parent ticket has link_type 'child' pointing to it
|
||||||
|
# Wait, need to verify Zammad's link semantics...
|
||||||
|
#
|
||||||
|
# From merge: source ticket gets a 'parent' link pointing TO target
|
||||||
|
# Link.add(link_type: 'parent', source: target_id, target: source_id)
|
||||||
|
#
|
||||||
|
# So to find parent of a ticket, look for 'parent' links where
|
||||||
|
# this ticket is the target (link_object_target_value)
|
||||||
|
|
||||||
|
links = Link.list(
|
||||||
|
link_object: 'Ticket',
|
||||||
|
link_object_value: ticket.id
|
||||||
|
)
|
||||||
|
|
||||||
|
parent_link = links.find { |l| l['link_type'] == 'parent' }
|
||||||
|
return nil unless parent_link
|
||||||
|
|
||||||
|
Ticket.find_by(id: parent_link['link_object_value'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_children(ticket)
|
||||||
|
links = Link.list(
|
||||||
|
link_object: 'Ticket',
|
||||||
|
link_object_value: ticket.id
|
||||||
|
)
|
||||||
|
|
||||||
|
child_links = links.select { |l| l['link_type'] == 'child' }
|
||||||
|
child_ids = child_links.map { |l| l['link_object_value'] }
|
||||||
|
|
||||||
|
Ticket.where(id: child_ids).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_merge_target(ticket)
|
||||||
|
# Merged ticket has 'parent' link to target
|
||||||
|
return nil unless ticket.state.state_type.name == 'merged'
|
||||||
|
find_parent(ticket)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_merge_sources(ticket)
|
||||||
|
# Find tickets that were merged into this one
|
||||||
|
links = Link.list(
|
||||||
|
link_object: 'Ticket',
|
||||||
|
link_object_value: ticket.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look for child links where the child is in merged state
|
||||||
|
child_links = links.select { |l| l['link_type'] == 'child' }
|
||||||
|
|
||||||
|
child_links.filter_map do |link|
|
||||||
|
child = Ticket.find_by(id: link['link_object_value'])
|
||||||
|
child if child&.state&.state_type&.name == 'merged'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
#### 1. Ticket Concern (for create/update triggers)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# app/models/ticket/field_propagation.rb
|
||||||
|
module Ticket::FieldPropagation
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
after_create :trigger_propagation_on_create
|
||||||
|
after_update :trigger_propagation_on_update
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def trigger_propagation_on_create
|
||||||
|
# Check if this is a split (has parent link created simultaneously)
|
||||||
|
# This is tricky because link might be created after ticket...
|
||||||
|
# May need to hook into Link.add instead
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_propagation_on_update
|
||||||
|
return unless saved_change_to_attribute?(:state_id)
|
||||||
|
|
||||||
|
if state.state_type.name == 'merged'
|
||||||
|
TicketFieldPropagation::Engine.new(self, :merge).execute
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Transaction Observer (for merge/split events)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# app/models/transaction/ticket_field_propagation.rb
|
||||||
|
class Transaction::TicketFieldPropagation
|
||||||
|
def self.execute(object, type, _changes, user_id, _options)
|
||||||
|
return unless object.is_a?(Ticket)
|
||||||
|
|
||||||
|
case type
|
||||||
|
when 'update.merged_into'
|
||||||
|
# Source ticket was merged - propagate to target
|
||||||
|
target = TicketFieldPropagation::RelationshipFinder.find_merge_target(object)
|
||||||
|
TicketFieldPropagation::Engine.new(object, :merge, target_ticket: target).execute
|
||||||
|
|
||||||
|
when 'update.received_merge'
|
||||||
|
# Target ticket received a merge - could trigger reverse propagation if needed
|
||||||
|
|
||||||
|
when 'create'
|
||||||
|
# Check if this is from a split (check for immediate parent link)
|
||||||
|
parent = TicketFieldPropagation::RelationshipFinder.find_parent(object)
|
||||||
|
if parent.present?
|
||||||
|
TicketFieldPropagation::Engine.new(parent, :split, target_ticket: object).execute
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Manual API Endpoint
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# app/controllers/ticket_field_propagation_controller.rb
|
||||||
|
class TicketFieldPropagationController < ApplicationController
|
||||||
|
before_action :authenticate_and_authorize
|
||||||
|
|
||||||
|
# POST /api/v1/tickets/:id/propagate
|
||||||
|
def propagate
|
||||||
|
ticket = Ticket.find(params[:id])
|
||||||
|
direction = params[:direction] || 'to_children'
|
||||||
|
fields = params[:fields] || 'all'
|
||||||
|
|
||||||
|
case direction
|
||||||
|
when 'to_children'
|
||||||
|
engine = TicketFieldPropagation::Engine.new(ticket, :manual)
|
||||||
|
engine.execute_for_fields(fields, direction: :parent_to_children)
|
||||||
|
when 'from_parent'
|
||||||
|
parent = TicketFieldPropagation::RelationshipFinder.find_parent(ticket)
|
||||||
|
return render json: { error: 'No parent ticket' }, status: :not_found unless parent
|
||||||
|
|
||||||
|
engine = TicketFieldPropagation::Engine.new(parent, :manual, target_ticket: ticket)
|
||||||
|
engine.execute_for_fields(fields, direction: :parent_to_child)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { success: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /api/v1/tickets/:id/propagation_preview
|
||||||
|
def preview
|
||||||
|
# Show what would be copied without doing it
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handling Edge Cases
|
||||||
|
|
||||||
|
### 1. Circular Reference Prevention
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
class Engine
|
||||||
|
MAX_DEPTH = 5
|
||||||
|
|
||||||
|
def execute(depth: 0)
|
||||||
|
return if depth >= MAX_DEPTH
|
||||||
|
|
||||||
|
# Track processed tickets in this chain
|
||||||
|
Thread.current[:propagation_chain] ||= Set.new
|
||||||
|
return if Thread.current[:propagation_chain].include?(@source.id)
|
||||||
|
|
||||||
|
Thread.current[:propagation_chain].add(@source.id)
|
||||||
|
|
||||||
|
begin
|
||||||
|
# ... execute rules
|
||||||
|
ensure
|
||||||
|
Thread.current[:propagation_chain].delete(@source.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Conflicting Values on Merge
|
||||||
|
|
||||||
|
When both source and target have values, the default is "don't overwrite" (`if_target_empty`). But we could support strategies:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
config.rule :merge_preferences do |r|
|
||||||
|
r.fields ['preferences.cdr_whatsapp']
|
||||||
|
r.trigger :merge
|
||||||
|
r.direction :source_to_target
|
||||||
|
r.condition :merge_hash # Deep merge instead of replace
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with explicit conflict resolution:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
r.on_conflict do |source_val, target_val, field|
|
||||||
|
case field
|
||||||
|
when /escalation/
|
||||||
|
[source_val, target_val].max
|
||||||
|
when /preferences\.cdr_/
|
||||||
|
target_val.presence || source_val # Keep target if present
|
||||||
|
else
|
||||||
|
source_val # Default: source wins
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Multiple Bridge Channels
|
||||||
|
|
||||||
|
If source has WhatsApp and target has Signal, we might want both:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Current behavior with if_target_empty:
|
||||||
|
# - Source: {cdr_whatsapp: {...}}
|
||||||
|
# - Target: {cdr_signal: {...}}
|
||||||
|
# - Result: Target keeps cdr_signal, gains cdr_whatsapp (both present)
|
||||||
|
|
||||||
|
# This works because we check per-field, not per-category
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Signal Group Merge Validation
|
||||||
|
|
||||||
|
Special case: don't allow merging tickets from different Signal groups:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
config.rule :validate_signal_merge do |r|
|
||||||
|
r.trigger :merge
|
||||||
|
r.validator ->(source, target) {
|
||||||
|
source_group = source.preferences.dig('cdr_signal', 'chat_id')
|
||||||
|
target_group = target.preferences.dig('cdr_signal', 'chat_id')
|
||||||
|
|
||||||
|
# OK if either doesn't have signal, or same group
|
||||||
|
return true if source_group.blank? || target_group.blank?
|
||||||
|
return true if source_group == target_group
|
||||||
|
|
||||||
|
# Different groups - block the merge
|
||||||
|
raise Exceptions::UnprocessableEntity,
|
||||||
|
"Cannot merge tickets from different Signal groups"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Trail
|
||||||
|
|
||||||
|
Track what was propagated for debugging and transparency:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# app/models/ticket_field_propagation_log.rb
|
||||||
|
class TicketFieldPropagationLog < ApplicationRecord
|
||||||
|
belongs_to :source_ticket, class_name: 'Ticket'
|
||||||
|
belongs_to :target_ticket, class_name: 'Ticket'
|
||||||
|
|
||||||
|
# Columns:
|
||||||
|
# - source_ticket_id
|
||||||
|
# - target_ticket_id
|
||||||
|
# - rule_name
|
||||||
|
# - field_path
|
||||||
|
# - old_value (serialized)
|
||||||
|
# - new_value (serialized)
|
||||||
|
# - trigger_event
|
||||||
|
# - created_by_id
|
||||||
|
# - created_at
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Or simpler: add to ticket history:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
target_ticket.history_log(
|
||||||
|
'field_propagated',
|
||||||
|
UserInfo.current_user_id,
|
||||||
|
value_from: source_ticket.id,
|
||||||
|
value_to: { field: field_path, value: new_value.to_s.truncate(100) }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration UI (Future)
|
||||||
|
|
||||||
|
Admin interface at Settings → Ticket → Field Propagation:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Field Propagation Rules [+Add]│
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [✓] Bridge Channel on Split [Edit] │ │
|
||||||
|
│ │ Copy: preferences.cdr_whatsapp, preferences.cdr_signal │ │
|
||||||
|
│ │ When: Ticket is split │ │
|
||||||
|
│ │ Direction: Parent → Child │ │
|
||||||
|
│ │ Condition: Only if child field is empty │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [✓] Bridge Channel on Merge [Edit] │ │
|
||||||
|
│ │ Copy: preferences.cdr_whatsapp, preferences.cdr_signal │ │
|
||||||
|
│ │ When: Ticket is merged │ │
|
||||||
|
│ │ Direction: Source → Target │ │
|
||||||
|
│ │ Condition: Only if target field is empty │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [ ] Customer Context Inheritance [Edit] │ │
|
||||||
|
│ │ Copy: organization_id, custom_account_id, custom_region │ │
|
||||||
|
│ │ When: Ticket is split │ │
|
||||||
|
│ │ Direction: Parent → Child │ │
|
||||||
|
│ │ Condition: Always │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Default Configuration
|
||||||
|
|
||||||
|
Out-of-the-box settings that solve the bridge preferences problem:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field_groups": {
|
||||||
|
"bridge_channel": [
|
||||||
|
"preferences.channel_id",
|
||||||
|
"preferences.cdr_whatsapp",
|
||||||
|
"preferences.cdr_signal",
|
||||||
|
"preferences.cdr_voice"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "bridge_on_split",
|
||||||
|
"description": "Copy bridge channel info when splitting tickets",
|
||||||
|
"fields": ["@bridge_channel"],
|
||||||
|
"trigger": "split",
|
||||||
|
"direction": "parent_to_child",
|
||||||
|
"condition": "if_target_empty",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bridge_on_merge",
|
||||||
|
"description": "Copy bridge channel info when merging tickets",
|
||||||
|
"fields": ["@bridge_channel"],
|
||||||
|
"trigger": "merge",
|
||||||
|
"direction": "source_to_target",
|
||||||
|
"condition": "if_target_empty",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Engine (Solves Bridge Problem)
|
||||||
|
- FieldAccessor with dot-notation support
|
||||||
|
- RelationshipFinder for parent/child/merge relationships
|
||||||
|
- Engine with basic rule processing
|
||||||
|
- Hardcoded rules for bridge channel propagation
|
||||||
|
- Integration with Ticket merge (via concern or observer)
|
||||||
|
|
||||||
|
### Phase 2: Configuration System
|
||||||
|
- JSON configuration in Setting table
|
||||||
|
- Field groups support
|
||||||
|
- Multiple condition types (if_empty, always, if_greater)
|
||||||
|
- Deferred execution via jobs
|
||||||
|
|
||||||
|
### Phase 3: Split Integration
|
||||||
|
- Hook into ticket split workflow
|
||||||
|
- Detect parent relationship after split
|
||||||
|
- Apply split rules
|
||||||
|
|
||||||
|
### Phase 4: Manual Triggers
|
||||||
|
- API endpoint for manual propagation
|
||||||
|
- Preview endpoint
|
||||||
|
- Audit logging
|
||||||
|
|
||||||
|
### Phase 5: Admin UI
|
||||||
|
- Configuration interface in Zammad admin
|
||||||
|
- Visual rule builder
|
||||||
|
- Field picker for custom object attributes
|
||||||
|
|
||||||
|
### Phase 6: Advanced Features
|
||||||
|
- Bidirectional sync
|
||||||
|
- Conflict resolution strategies
|
||||||
|
- Cascading updates
|
||||||
|
- Validation rules (like Signal group merge prevention)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/zammad-addon-bridge/src/
|
||||||
|
├── lib/
|
||||||
|
│ └── ticket_field_propagation/
|
||||||
|
│ ├── configuration.rb
|
||||||
|
│ ├── engine.rb
|
||||||
|
│ ├── field_accessor.rb
|
||||||
|
│ ├── relationship_finder.rb
|
||||||
|
│ └── propagation_job.rb
|
||||||
|
├── app/
|
||||||
|
│ ├── models/
|
||||||
|
│ │ └── ticket/
|
||||||
|
│ │ └── field_propagation.rb # Concern
|
||||||
|
│ └── controllers/
|
||||||
|
│ └── ticket_field_propagation_controller.rb
|
||||||
|
├── config/
|
||||||
|
│ └── initializers/
|
||||||
|
│ └── ticket_field_propagation.rb # Default config & include concern
|
||||||
|
└── db/
|
||||||
|
└── seeds/
|
||||||
|
└── field_propagation_settings.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship to Bridge Preferences Problem
|
||||||
|
|
||||||
|
The bridge preferences problem from the previous investigation is solved by:
|
||||||
|
|
||||||
|
1. **Default rule `bridge_on_merge`**: Copies `preferences.cdr_whatsapp` and `preferences.cdr_signal` from source to target when tickets are merged, if target doesn't already have them.
|
||||||
|
|
||||||
|
2. **Default rule `bridge_on_split`**: Copies the same preferences from parent to child when tickets are split.
|
||||||
|
|
||||||
|
3. **Extensibility**: Additional custom fields can be added to propagation rules without code changes.
|
||||||
|
|
||||||
|
This makes the field propagation system a superset solution that handles the immediate bridge problem while providing a framework for future field synchronization needs.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack",
|
"name": "@link-stack",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "Link from the Center for Digital Resilience",
|
"description": "Link from the Center for Digital Resilience",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "dotenv -- turbo dev",
|
"dev": "dotenv -- turbo dev",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-common",
|
"name": "@link-stack/bridge-common",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"main": "build/main/index.js",
|
"main": "build/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Darren Clarke <darren@redaranj.com>",
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-ui",
|
"name": "@link-stack/bridge-ui",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json"
|
"build": "tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/eslint-config",
|
"name": "@link-stack/eslint-config",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "amigo's eslint config",
|
"description": "amigo's eslint config",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"author": "Abel Luck <abel@guardianproject.info>",
|
"author": "Abel Luck <abel@guardianproject.info>",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/jest-config",
|
"name": "@link-stack/jest-config",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"author": "Abel Luck <abel@guardianproject.info>",
|
"author": "Abel Luck <abel@guardianproject.info>",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/logger",
|
"name": "@link-stack/logger",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "Shared logging utility for Link Stack monorepo",
|
"description": "Shared logging utility for Link Stack monorepo",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/signal-api",
|
"name": "@link-stack/signal-api",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/typescript-config",
|
"name": "@link-stack/typescript-config",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "Shared TypeScript config",
|
"description": "Shared TypeScript config",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"author": "Abel Luck <abel@guardianproject.info>",
|
"author": "Abel Luck <abel@guardianproject.info>",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/ui",
|
"name": "@link-stack/ui",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json"
|
"build": "tsc -p tsconfig.json"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/zammad-addon-bridge",
|
"name": "@link-stack/zammad-addon-bridge",
|
||||||
"displayName": "Bridge",
|
"displayName": "Bridge",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "An addon that adds CDR Bridge channels to Zammad.",
|
"description": "An addon that adds CDR Bridge channels to Zammad.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
"build": "node '../zammad-addon-common/dist/build.js'",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ class ChannelCdrSignal extends App.ControllerSubContent
|
||||||
'click .js-disable': 'disable'
|
'click .js-disable': 'disable'
|
||||||
'click .js-enable': 'enable'
|
'click .js-enable': 'enable'
|
||||||
'click .js-rotate-token': 'rotateToken'
|
'click .js-rotate-token': 'rotateToken'
|
||||||
|
'click .js-set-notification': 'setNotification'
|
||||||
|
'click .js-unset-notification': 'unsetNotification'
|
||||||
|
|
||||||
constructor: ->
|
constructor: ->
|
||||||
super
|
super
|
||||||
|
|
@ -41,6 +43,8 @@ class ChannelCdrSignal extends App.ControllerSubContent
|
||||||
channels.push channel
|
channels.push channel
|
||||||
@html App.view('cdr_signal/index')(
|
@html App.view('cdr_signal/index')(
|
||||||
channels: channels
|
channels: channels
|
||||||
|
notificationEnabled: data.notification_enabled
|
||||||
|
notificationChannelId: data.notification_channel_id
|
||||||
)
|
)
|
||||||
|
|
||||||
new: (e) =>
|
new: (e) =>
|
||||||
|
|
@ -124,6 +128,31 @@ class ChannelCdrSignal extends App.ControllerSubContent
|
||||||
@load()
|
@load()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
setNotification: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
id = $(e.target).closest('.action').data('id')
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_signal_set_notification'
|
||||||
|
type: 'POST'
|
||||||
|
url: "#{@apiPath}/channels_cdr_signal_set_notification"
|
||||||
|
data: JSON.stringify(id: id)
|
||||||
|
processData: true
|
||||||
|
success: =>
|
||||||
|
@load()
|
||||||
|
)
|
||||||
|
|
||||||
|
unsetNotification: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_signal_unset_notification'
|
||||||
|
type: 'POST'
|
||||||
|
url: "#{@apiPath}/channels_cdr_signal_unset_notification"
|
||||||
|
data: JSON.stringify({})
|
||||||
|
processData: true
|
||||||
|
success: =>
|
||||||
|
@load()
|
||||||
|
)
|
||||||
|
|
||||||
class FormAdd extends App.ControllerModal
|
class FormAdd extends App.ControllerModal
|
||||||
head: 'Add Web Form'
|
head: 'Add Web Form'
|
||||||
shown: true
|
shown: true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
class ProfileNotification extends App.ControllerSubContent
|
||||||
|
@include App.TicketNotificationMatrix
|
||||||
|
|
||||||
|
@requiredPermission: 'user_preferences.notifications+ticket.agent'
|
||||||
|
header: __('Notifications')
|
||||||
|
events:
|
||||||
|
'submit form': 'update'
|
||||||
|
'click .js-reset' : 'reset'
|
||||||
|
'change .js-notificationSound': 'previewSound'
|
||||||
|
'change #profile-groups-limit': 'didSwitchGroupsLimit'
|
||||||
|
'change input[name=group_ids]': 'didChangeGroupIds'
|
||||||
|
'change input[name$=".channel.signal"]': 'didChangeSignalCheckbox'
|
||||||
|
|
||||||
|
elements:
|
||||||
|
'#profile-groups-limit': 'profileGroupsLimitInput'
|
||||||
|
'.profile-groups-limit-settings-inner': 'groupsLimitSettings'
|
||||||
|
'.profile-groups-all-unchecked': 'groupsAllUncheckedWarning'
|
||||||
|
|
||||||
|
sounds: [
|
||||||
|
{
|
||||||
|
name: 'Bell'
|
||||||
|
file: 'Bell.mp3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Kalimba'
|
||||||
|
file: 'Kalimba.mp3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Marimba'
|
||||||
|
file: 'Marimba.mp3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Peep'
|
||||||
|
file: 'Peep.mp3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Plop'
|
||||||
|
file: 'Plop.mp3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ring'
|
||||||
|
file: 'Ring.mp3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Space'
|
||||||
|
file: 'Space.mp3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wood'
|
||||||
|
file: 'Wood.mp3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Xylo'
|
||||||
|
file: 'Xylo.mp3'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
constructor: ->
|
||||||
|
super
|
||||||
|
App.User.full(App.Session.get().id, @render, true, true)
|
||||||
|
|
||||||
|
render: =>
|
||||||
|
|
||||||
|
matrix =
|
||||||
|
create:
|
||||||
|
name: __('New Ticket')
|
||||||
|
update:
|
||||||
|
name: __('Ticket update')
|
||||||
|
reminder_reached:
|
||||||
|
name: __('Ticket reminder reached')
|
||||||
|
escalation:
|
||||||
|
name: __('Ticket escalation')
|
||||||
|
|
||||||
|
config =
|
||||||
|
group_ids: []
|
||||||
|
matrix: {}
|
||||||
|
|
||||||
|
user_config = @Session.get('preferences').notification_config
|
||||||
|
if user_config
|
||||||
|
config = $.extend(true, {}, config, user_config)
|
||||||
|
|
||||||
|
# groups
|
||||||
|
user_group_config = true
|
||||||
|
if !user_config || !user_config['group_ids'] || _.isEmpty(user_config['group_ids']) || user_config['group_ids'][0] is '-'
|
||||||
|
user_group_config = false
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
group_ids = App.User.find(@Session.get('id')).allGroupIds()
|
||||||
|
if group_ids
|
||||||
|
for group_id in group_ids
|
||||||
|
group = App.Group.find(group_id)
|
||||||
|
groups.push group
|
||||||
|
if !user_group_config
|
||||||
|
if !config['group_ids']
|
||||||
|
config['group_ids'] = []
|
||||||
|
config['group_ids'].push group_id.toString()
|
||||||
|
|
||||||
|
groups = _.sortBy(groups, (item) -> return item.name)
|
||||||
|
|
||||||
|
for sound in @sounds
|
||||||
|
sound.selected = sound.file is App.OnlineNotification.soundFile() ? true : false
|
||||||
|
|
||||||
|
signal_notification_enabled = App.Config.get('signal_notification_enabled')
|
||||||
|
|
||||||
|
signal_uid = config.signal_uid || ''
|
||||||
|
|
||||||
|
# Check if any signal checkbox is currently checked in the matrix
|
||||||
|
signal_has_checked = false
|
||||||
|
if signal_notification_enabled
|
||||||
|
for key, val of config.matrix
|
||||||
|
if val?.channel?.signal
|
||||||
|
signal_has_checked = true
|
||||||
|
break
|
||||||
|
|
||||||
|
@html App.view('profile/notification')
|
||||||
|
matrixTableHTML: @renderNotificationMatrix(config.matrix)
|
||||||
|
groups: groups
|
||||||
|
config: config
|
||||||
|
sounds: @sounds
|
||||||
|
notificationSoundEnabled: App.OnlineNotification.soundEnabled()
|
||||||
|
user_group_config: user_group_config
|
||||||
|
signal_notification_enabled: signal_notification_enabled
|
||||||
|
signal_uid: signal_uid
|
||||||
|
signal_has_checked: signal_has_checked
|
||||||
|
|
||||||
|
update: (e) =>
|
||||||
|
|
||||||
|
#notification_config
|
||||||
|
e.preventDefault()
|
||||||
|
params = {}
|
||||||
|
params.notification_config = {}
|
||||||
|
|
||||||
|
formParams = @formParam(e.target)
|
||||||
|
|
||||||
|
params.notification_config.matrix = @updatedNotificationMatrixValues(formParams)
|
||||||
|
|
||||||
|
if formParams.signal_uid?
|
||||||
|
params.notification_config.signal_uid = formParams.signal_uid
|
||||||
|
|
||||||
|
if @profileGroupsLimitInput.is(':checked')
|
||||||
|
params.notification_config.group_ids = formParams['group_ids']
|
||||||
|
if typeof params.notification_config.group_ids isnt 'object'
|
||||||
|
params.notification_config.group_ids = [params.notification_config.group_ids]
|
||||||
|
|
||||||
|
if _.isEmpty(params.notification_config.group_ids)
|
||||||
|
delete params.notification_config.group_ids
|
||||||
|
|
||||||
|
@formDisable(e)
|
||||||
|
|
||||||
|
params.notification_sound = formParams.notification_sound
|
||||||
|
if !params.notification_sound.enabled
|
||||||
|
params.notification_sound.enabled = false
|
||||||
|
else
|
||||||
|
params.notification_sound.enabled = true
|
||||||
|
|
||||||
|
# get data
|
||||||
|
@ajax(
|
||||||
|
id: 'preferences'
|
||||||
|
type: 'PUT'
|
||||||
|
url: @apiPath + '/users/preferences'
|
||||||
|
data: JSON.stringify(params)
|
||||||
|
processData: true
|
||||||
|
success: @success
|
||||||
|
error: @error
|
||||||
|
)
|
||||||
|
|
||||||
|
reset: (e) =>
|
||||||
|
new App.ControllerConfirm(
|
||||||
|
message: __('Are you sure? Your notifications settings will be reset to default.')
|
||||||
|
buttonClass: 'btn--danger'
|
||||||
|
callback: =>
|
||||||
|
@ajax(
|
||||||
|
id: 'preferences_notifications_reset'
|
||||||
|
type: 'POST'
|
||||||
|
url: "#{@apiPath}/users/preferences_notifications_reset"
|
||||||
|
processData: true
|
||||||
|
success: @success
|
||||||
|
)
|
||||||
|
container: @el.closest('.content')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
success: (data, status, xhr) =>
|
||||||
|
App.User.full(
|
||||||
|
App.Session.get('id'),
|
||||||
|
=>
|
||||||
|
App.Event.trigger('ui:rerender')
|
||||||
|
@notify(
|
||||||
|
type: 'success'
|
||||||
|
msg: __('Update successful.')
|
||||||
|
)
|
||||||
|
,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
error: (xhr, status, error) =>
|
||||||
|
@render()
|
||||||
|
data = JSON.parse(xhr.responseText)
|
||||||
|
@notify(
|
||||||
|
type: 'error'
|
||||||
|
msg: data.message
|
||||||
|
)
|
||||||
|
|
||||||
|
previewSound: (e) =>
|
||||||
|
params = @formParam(e.target)
|
||||||
|
return if !params.notification_sound
|
||||||
|
return if !params.notification_sound.file
|
||||||
|
App.OnlineNotification.play(params.notification_sound.file)
|
||||||
|
|
||||||
|
didSwitchGroupsLimit: (e) =>
|
||||||
|
@groupsLimitSettings.collapse('toggle')
|
||||||
|
|
||||||
|
didChangeGroupIds: (e) =>
|
||||||
|
@groupsAllUncheckedWarning.toggleClass 'hide', @el.find('input[name=group_ids]:checked').length != 0
|
||||||
|
|
||||||
|
didChangeSignalCheckbox: (e) =>
|
||||||
|
hasChecked = @el.find('input[name$=".channel.signal"]:checked').length > 0
|
||||||
|
@el.find('.js-signal-phone-container').toggle(hasChecked)
|
||||||
|
|
||||||
|
App.Config.set('Notifications', { prio: 2600, name: __('Notifications'), parent: '#profile', target: '#profile/notifications', permission: ['user_preferences.notifications+ticket.agent'], controller: ProfileNotification }, 'NavBarProfile')
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# coffeelint: disable=camel_case_classes
|
||||||
|
class App.UiElement.notification_matrix
|
||||||
|
@render: (values, options = {}) ->
|
||||||
|
|
||||||
|
matrixYAxe =
|
||||||
|
create:
|
||||||
|
name: __('New Ticket')
|
||||||
|
update:
|
||||||
|
name: __('Ticket update')
|
||||||
|
reminder_reached:
|
||||||
|
name: __('Ticket reminder reached')
|
||||||
|
escalation:
|
||||||
|
name: __('Ticket escalation')
|
||||||
|
|
||||||
|
$( App.view('generic/notification_matrix')( matrixYAxe: matrixYAxe, values: values, signal_notification_enabled: options.signal_notification_enabled ) )
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Common handling for the notification matrix
|
||||||
|
App.TicketNotificationMatrix =
|
||||||
|
renderNotificationMatrix: (values) ->
|
||||||
|
App.UiElement.notification_matrix.render(values, signal_notification_enabled: App.Config.get('signal_notification_enabled'))[0].outerHTML
|
||||||
|
|
||||||
|
updatedNotificationMatrixValues: (formParams) ->
|
||||||
|
matrix = {}
|
||||||
|
|
||||||
|
for key, value of formParams
|
||||||
|
area = key.split('.')
|
||||||
|
|
||||||
|
continue if area[0] isnt 'matrix'
|
||||||
|
|
||||||
|
if !matrix[area[1]]
|
||||||
|
matrix[area[1]] = {}
|
||||||
|
|
||||||
|
switch area[2]
|
||||||
|
when 'criteria'
|
||||||
|
if !matrix[area[1]][area[2]]
|
||||||
|
matrix[area[1]][area[2]] = {}
|
||||||
|
|
||||||
|
matrix[area[1]][area[2]][area[3]] = value is 'true'
|
||||||
|
when 'channel'
|
||||||
|
if !matrix[area[1]][area[2]]
|
||||||
|
matrix[area[1]][area[2]] = { online: true }
|
||||||
|
|
||||||
|
matrix[area[1]][area[2]][area[3]] = value is 'true'
|
||||||
|
|
||||||
|
matrix
|
||||||
|
|
@ -20,6 +20,9 @@
|
||||||
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
|
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
|
||||||
<div class="action-block action-row">
|
<div class="action-block action-row">
|
||||||
<h2><%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %></h2>
|
<h2><%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %></h2>
|
||||||
|
<% if @notificationEnabled and @notificationChannelId is channel.id: %>
|
||||||
|
<span class="label label--success" style="margin-left: 10px;"><%- @T('Agent Notifications') %></span>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-flow action-flow--row">
|
<div class="action-flow action-flow--row">
|
||||||
<div class="action-block">
|
<div class="action-block">
|
||||||
|
|
@ -42,6 +45,11 @@
|
||||||
<% else: %>
|
<% else: %>
|
||||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if @notificationEnabled and @notificationChannelId is channel.id: %>
|
||||||
|
<div class="btn btn--secondary js-unset-notification"><%- @T('Disable Agent Notifications') %></div>
|
||||||
|
<% else if channel.active is true: %>
|
||||||
|
<div class="btn btn--success btn--secondary js-set-notification"><%- @T('Use for Agent Notifications') %></div>
|
||||||
|
<% end %>
|
||||||
<div class="btn js-edit"><%- @T('Edit') %></div>
|
<div class="btn js-edit"><%- @T('Edit') %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<% if @signal_notification_enabled: %>
|
||||||
|
<% colWidth = "13%" %>
|
||||||
|
<% channelWidth = "100px" %>
|
||||||
|
<% else: %>
|
||||||
|
<% colWidth = "16%" %>
|
||||||
|
<% channelWidth = "120px" %>
|
||||||
|
<% end %>
|
||||||
|
<table class="settings-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('My Tickets') %>
|
||||||
|
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('Not Assigned') %>*
|
||||||
|
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('Subscribed Tickets') %>
|
||||||
|
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('All Tickets') %>*
|
||||||
|
<th width="<%= channelWidth %>" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via email') %>
|
||||||
|
<% if @signal_notification_enabled: %>
|
||||||
|
<th width="<%= channelWidth %>" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via Signal') %>
|
||||||
|
<% end %>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if @matrixYAxe: %>
|
||||||
|
<% for key, value of @matrixYAxe: %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<%- @T(value.name) %>
|
||||||
|
<% criteria = @values[key]?.criteria %>
|
||||||
|
<% channel = @values[key]?.channel %>
|
||||||
|
<td class="u-positionOrigin">
|
||||||
|
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||||
|
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_me" value="true"<% if criteria && criteria.owned_by_me: %> checked<% end %> />
|
||||||
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
</label>
|
||||||
|
<td class="u-positionOrigin">
|
||||||
|
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||||
|
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_nobody" value="true"<% if criteria && criteria.owned_by_nobody: %> checked<% end %> />
|
||||||
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
</label>
|
||||||
|
<td class="u-positionOrigin">
|
||||||
|
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||||
|
<input type="checkbox" name="matrix.<%= key %>.criteria.subscribed" value="true"<% if criteria && criteria.subscribed: %> checked<% end %> />
|
||||||
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
</label>
|
||||||
|
<td class="u-positionOrigin">
|
||||||
|
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||||
|
<input type="checkbox" name="matrix.<%= key %>.criteria.no" value="true"<% if criteria && criteria.no: %> checked<% end %> />
|
||||||
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
</label>
|
||||||
|
<td class="u-positionOrigin settings-list-separator">
|
||||||
|
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||||
|
<input type="checkbox" name="matrix.<%= key %>.channel.email" value="true"<% if channel && channel.email: %> checked<% end %> />
|
||||||
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
</label>
|
||||||
|
<% if @signal_notification_enabled: %>
|
||||||
|
<td class="u-positionOrigin settings-list-separator">
|
||||||
|
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||||
|
<input type="checkbox" name="matrix.<%= key %>.channel.signal" value="true"<% if channel && channel.signal: %> checked<% end %> />
|
||||||
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-header-title"><h1><%- @T('Notifications') %></h1></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="page-content form--flexibleWidth profile-settings-notifications-content">
|
||||||
|
|
||||||
|
<div class="settings-entry">
|
||||||
|
<%- @matrixTableHTML %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @signal_notification_enabled: %>
|
||||||
|
<div class="js-signal-phone-container" style="<% if !@signal_has_checked: %>display: none;<% end %>">
|
||||||
|
<h2><%- @T('Signal Phone Number') %></h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" name="signal_uid" class="form-control" value="<%= @signal_uid %>" placeholder="+1234567890">
|
||||||
|
<p class="help-block"><%- @T('Use international format with country code (e.g., +1234567890)') %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @groups: %>
|
||||||
|
<div class="zammad-switch zammad-switch--small" data-name="profile-groups-limit">
|
||||||
|
<input type="checkbox" id="profile-groups-limit" <% if @user_group_config: %> checked <% end %>>
|
||||||
|
<label for="profile-groups-limit"></label>
|
||||||
|
</div>
|
||||||
|
<h2>
|
||||||
|
<%- @T('Limit Groups') %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="settings-entry profile-groups-limit-settings">
|
||||||
|
<div class="profile-groups-limit-settings-inner collapse <% if @user_group_config: %>in<% end %>">
|
||||||
|
<div class="alert alert--warning profile-groups-all-unchecked hide" role="alert">
|
||||||
|
<%- @T('Disabling the notifications from all groups will turn off the limit. Instead, to disable the notifications use the settings above.') %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="settings-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%- @T('Group') %>
|
||||||
|
<th><%- @T('Not Assigned') %> & <%- @T('All Tickets') %>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% for group in @groups: %>
|
||||||
|
<tr>
|
||||||
|
<td><%- @P(group, 'name') %>
|
||||||
|
<td class="u-positionOrigin">
|
||||||
|
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||||
|
<input type="checkbox" name="group_ids" value="<%= group.id %>" <% if _.include(_.map(@config.group_ids, (group_id) -> group_id.toString()), group.id.toString()): %>checked<% end %>/>
|
||||||
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h2><%- @T('Sounds') %></h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for="notification-sound"><%- @T('Notification Sound') %></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls controls--select">
|
||||||
|
<select class="form-control js-notificationSound" id="notification-sound" name="notification_sound::file">
|
||||||
|
<% for sound in @sounds: %>
|
||||||
|
<option value="<%= sound.file %>"<%= ' selected' if sound.selected %>><%= sound.name %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%- @Icon('arrow-down') %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="inline-label">
|
||||||
|
<span class="checkbox-replacement checkbox-replacement--inline">
|
||||||
|
<input type="checkbox" name="notification_sound::enabled" value="true" <% if @notificationSoundEnabled: %> checked<% end %> class="js-SoundEnableDisable">
|
||||||
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
</span>
|
||||||
|
<%- @T('Play user interface sound effects') %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn--primary"><%- @T( 'Submit' ) %></button>
|
||||||
|
<input type="button" class="btn btn--danger js-reset" value="<%- @T( 'Reset to default settings' ) %>">
|
||||||
|
</form>
|
||||||
|
|
@ -15,10 +15,34 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
end
|
end
|
||||||
render json: {
|
render json: {
|
||||||
assets: assets,
|
assets: assets,
|
||||||
channel_ids: channel_ids
|
channel_ids: channel_ids,
|
||||||
|
notification_enabled: Setting.get('signal_notification_enabled') == true,
|
||||||
|
notification_channel_id: Setting.get('signal_notification_channel_id')
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_notification_channel
|
||||||
|
channel_id = params[:id].to_i
|
||||||
|
channel = Channel.find_by(id: channel_id, area: 'Signal::Number')
|
||||||
|
|
||||||
|
unless channel
|
||||||
|
render json: { error: 'Channel not found' }, status: :not_found
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Setting.set('signal_notification_channel_id', channel_id)
|
||||||
|
Setting.set('signal_notification_enabled', true)
|
||||||
|
|
||||||
|
render json: { success: true, notification_channel_id: channel_id }
|
||||||
|
end
|
||||||
|
|
||||||
|
def unset_notification_channel
|
||||||
|
Setting.set('signal_notification_enabled', false)
|
||||||
|
Setting.set('signal_notification_channel_id', nil)
|
||||||
|
|
||||||
|
render json: { success: true }
|
||||||
|
end
|
||||||
|
|
||||||
def add
|
def add
|
||||||
begin
|
begin
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
@ -154,16 +178,31 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
||||||
|
|
||||||
receiver_phone_number = params[:to].strip
|
receiver_phone_number = params[:to].strip
|
||||||
sender_phone_number = params[:from].strip
|
sender_phone_number = params[:from].present? ? params[:from].strip : nil
|
||||||
|
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
|
||||||
|
|
||||||
# Check if this is a group message using the is_group flag from bridge-worker
|
# Check if this is a group message using the is_group flag from bridge-worker
|
||||||
# This flag is set when:
|
# This flag is set when:
|
||||||
# 1. The original message came from a Signal group
|
# 1. The original message came from a Signal group
|
||||||
# 2. Bridge-worker created a new group for the conversation
|
# 2. Bridge-worker created a new group for the conversation
|
||||||
is_group_message = params[:is_group].to_s == 'true' || params[:is_group].to_s == 'true'
|
is_group_message = params[:is_group].to_s == 'true'
|
||||||
|
|
||||||
|
# Lookup customer with fallback chain:
|
||||||
|
# 1. Phone number in phone/mobile fields (preferred)
|
||||||
|
# 2. Signal user ID in signal_uid field
|
||||||
|
# 3. User ID in phone/mobile fields (legacy - we used to store UUIDs there)
|
||||||
|
customer = nil
|
||||||
|
if sender_phone_number.present?
|
||||||
|
customer = User.find_by(phone: sender_phone_number)
|
||||||
|
customer ||= User.find_by(mobile: sender_phone_number)
|
||||||
|
end
|
||||||
|
if customer.nil? && sender_user_id.present?
|
||||||
|
customer = User.find_by(signal_uid: sender_user_id)
|
||||||
|
# Legacy fallback: user ID might be stored in phone field
|
||||||
|
customer ||= User.find_by(phone: sender_user_id)
|
||||||
|
customer ||= User.find_by(mobile: sender_user_id)
|
||||||
|
end
|
||||||
|
|
||||||
customer = User.find_by(phone: sender_phone_number)
|
|
||||||
customer ||= User.find_by(mobile: sender_phone_number)
|
|
||||||
unless customer
|
unless customer
|
||||||
role_ids = Role.signup_role_ids
|
role_ids = Role.signup_role_ids
|
||||||
customer = User.create(
|
customer = User.create(
|
||||||
|
|
@ -171,7 +210,8 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
lastname: '',
|
lastname: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
phone: sender_phone_number,
|
phone: sender_phone_number.presence || sender_user_id,
|
||||||
|
signal_uid: sender_user_id,
|
||||||
note: 'CDR Signal',
|
note: 'CDR Signal',
|
||||||
active: true,
|
active: true,
|
||||||
role_ids: role_ids,
|
role_ids: role_ids,
|
||||||
|
|
@ -180,6 +220,15 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update signal_uid if we have it and customer doesn't
|
||||||
|
if sender_user_id.present? && customer.signal_uid.blank?
|
||||||
|
customer.update(signal_uid: sender_user_id)
|
||||||
|
end
|
||||||
|
# Update phone if we have it and customer only has user_id in phone field
|
||||||
|
if sender_phone_number.present? && customer.phone == sender_user_id
|
||||||
|
customer.update(phone: sender_phone_number)
|
||||||
|
end
|
||||||
|
|
||||||
# set current user
|
# set current user
|
||||||
UserInfo.current_user_id = customer.id
|
UserInfo.current_user_id = customer.id
|
||||||
current_user_set(customer, 'token_auth')
|
current_user_set(customer, 'token_auth')
|
||||||
|
|
@ -208,7 +257,8 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
attachment_data_base64 = params[:attachment]
|
attachment_data_base64 = params[:attachment]
|
||||||
attachment_filename = params[:filename]
|
attachment_filename = params[:filename]
|
||||||
attachment_mimetype = params[:mime_type]
|
attachment_mimetype = params[:mime_type]
|
||||||
title = "Message from #{sender_phone_number} at #{sent_at}"
|
sender_display = sender_phone_number.presence || sender_user_id
|
||||||
|
title = "Message from #{sender_display} at #{sent_at}"
|
||||||
body = message
|
body = message
|
||||||
|
|
||||||
# find ticket or create one
|
# find ticket or create one
|
||||||
|
|
@ -218,7 +268,7 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
|
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
|
||||||
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
|
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
|
||||||
Rails.logger.info "Customer ID: #{customer.id}"
|
Rails.logger.info "Customer ID: #{customer.id}"
|
||||||
Rails.logger.info "Customer Phone: #{sender_phone_number}"
|
Rails.logger.info "Customer Phone: #{sender_display}"
|
||||||
Rails.logger.info "Channel ID: #{channel.id}"
|
Rails.logger.info "Channel ID: #{channel.id}"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
|
|
@ -256,14 +306,21 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||||
else
|
else
|
||||||
# Set up chat_id based on whether this is a group message
|
# Set up chat_id based on whether this is a group message
|
||||||
chat_id = is_group_message ? receiver_phone_number : sender_phone_number
|
# For direct messages, prefer UUID (more stable than phone numbers which can change)
|
||||||
|
chat_id = is_group_message ? receiver_phone_number : (sender_user_id.presence || sender_phone_number)
|
||||||
|
|
||||||
# Build preferences with group_id included if needed
|
# Build preferences with group_id included if needed
|
||||||
cdr_signal_prefs = {
|
cdr_signal_prefs = {
|
||||||
bot_token: channel.options[:bot_token], # change to bot id
|
bot_token: channel.options[:bot_token],
|
||||||
chat_id: chat_id
|
chat_id: chat_id,
|
||||||
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Store original recipient phone for group tickets to enable ticket splitting
|
||||||
|
if is_group_message
|
||||||
|
cdr_signal_prefs[:original_recipient] = sender_phone_number
|
||||||
|
end
|
||||||
|
|
||||||
Rails.logger.info "=== CREATING NEW TICKET ==="
|
Rails.logger.info "=== CREATING NEW TICKET ==="
|
||||||
Rails.logger.info "Preferences to be stored:"
|
Rails.logger.info "Preferences to be stored:"
|
||||||
Rails.logger.info " - channel_id: #{channel.id}"
|
Rails.logger.info " - channel_id: #{channel.id}"
|
||||||
|
|
@ -283,7 +340,7 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
ticket.save!
|
ticket.save!
|
||||||
|
|
||||||
article_params = {
|
article_params = {
|
||||||
from: sender_phone_number,
|
from: sender_display,
|
||||||
to: receiver_phone_number,
|
to: receiver_phone_number,
|
||||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||||
subject: title,
|
subject: title,
|
||||||
|
|
@ -296,7 +353,8 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
cdr_signal: {
|
cdr_signal: {
|
||||||
timestamp: sent_at,
|
timestamp: sent_at,
|
||||||
message_id: message_id,
|
message_id: message_id,
|
||||||
from: sender_phone_number
|
from: sender_phone_number,
|
||||||
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -374,6 +432,24 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Idempotency check: if chat_id is already a group ID, don't overwrite it
|
||||||
|
# This prevents race conditions where multiple group_created webhooks arrive
|
||||||
|
# (e.g., due to retries after API timeouts during group creation)
|
||||||
|
existing_chat_id = ticket.preferences&.dig(:cdr_signal, :chat_id) ||
|
||||||
|
ticket.preferences&.dig('cdr_signal', 'chat_id')
|
||||||
|
if existing_chat_id&.start_with?('group.')
|
||||||
|
Rails.logger.info "Signal group update: Ticket #{ticket.id} already has group #{existing_chat_id}, ignoring new group #{params[:group_id]}"
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
skipped: true,
|
||||||
|
reason: 'Ticket already has a group assigned',
|
||||||
|
existing_group_id: existing_chat_id,
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
ticket_number: ticket.number
|
||||||
|
}, status: :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Update ticket preferences with the group information
|
# Update ticket preferences with the group information
|
||||||
ticket.preferences ||= {}
|
ticket.preferences ||= {}
|
||||||
ticket.preferences[:cdr_signal] ||= {}
|
ticket.preferences[:cdr_signal] ||= {}
|
||||||
|
|
@ -458,6 +534,36 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
|
|
||||||
Rails.logger.info "Signal group member #{member_phone} joined group #{params[:group_id]} for ticket #{ticket.id}"
|
Rails.logger.info "Signal group member #{member_phone} joined group #{params[:group_id]} for ticket #{ticket.id}"
|
||||||
|
|
||||||
|
# Check if any articles had a group_not_joined notification and add resolution note
|
||||||
|
# Only add resolution note if we previously notified about the delivery issue
|
||||||
|
articles_with_pending_notification = Ticket::Article.where(ticket_id: ticket.id)
|
||||||
|
.where("preferences LIKE ?", "%group_not_joined_note_added: true%")
|
||||||
|
|
||||||
|
if articles_with_pending_notification.exists?
|
||||||
|
# Check if we already added a resolution note for this ticket
|
||||||
|
resolution_note_exists = Ticket::Article.where(ticket_id: ticket.id)
|
||||||
|
.where("preferences LIKE ?", "%group_joined_resolution: true%")
|
||||||
|
.exists?
|
||||||
|
|
||||||
|
unless resolution_note_exists
|
||||||
|
Ticket::Article.create(
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
content_type: 'text/plain',
|
||||||
|
body: 'Recipient has now joined the Signal group. Pending messages will be delivered shortly.',
|
||||||
|
internal: true,
|
||||||
|
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||||
|
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||||
|
preferences: {
|
||||||
|
delivery_message: true,
|
||||||
|
group_joined_resolution: true,
|
||||||
|
},
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1,
|
||||||
|
)
|
||||||
|
Rails.logger.info "Ticket ##{ticket.number}: Added resolution note about customer joining Signal group"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
success: true,
|
success: true,
|
||||||
ticket_id: ticket.id,
|
ticket_id: ticket.id,
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,16 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
%i[to
|
%i[to
|
||||||
from
|
|
||||||
message_id
|
message_id
|
||||||
sent_at].each do |field|
|
sent_at].each do |field|
|
||||||
errors[field] = 'required' if params[field].blank?
|
errors[field] = 'required' if params[field].blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# At least one of from (phone) or user_id must be present
|
||||||
|
if params[:from].blank? && params[:user_id].blank?
|
||||||
|
errors[:from] = 'required (or user_id)'
|
||||||
|
end
|
||||||
|
|
||||||
if errors.present?
|
if errors.present?
|
||||||
render json: {
|
render json: {
|
||||||
errors: errors
|
errors: errors
|
||||||
|
|
@ -141,9 +145,25 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}")
|
return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}")
|
||||||
|
|
||||||
receiver_phone_number = params[:to].strip
|
receiver_phone_number = params[:to].strip
|
||||||
sender_phone_number = params[:from].strip
|
sender_phone_number = params[:from].present? ? params[:from].strip : nil
|
||||||
customer = User.find_by(phone: sender_phone_number)
|
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
|
||||||
customer ||= User.find_by(mobile: sender_phone_number)
|
|
||||||
|
# Lookup customer with fallback chain:
|
||||||
|
# 1. Phone number in phone/mobile fields (preferred)
|
||||||
|
# 2. WhatsApp user ID in whatsapp_uid field
|
||||||
|
# 3. User ID in phone/mobile fields (legacy - we used to store LIDs there)
|
||||||
|
customer = nil
|
||||||
|
if sender_phone_number.present?
|
||||||
|
customer = User.find_by(phone: sender_phone_number)
|
||||||
|
customer ||= User.find_by(mobile: sender_phone_number)
|
||||||
|
end
|
||||||
|
if customer.nil? && sender_user_id.present?
|
||||||
|
customer = User.find_by(whatsapp_uid: sender_user_id)
|
||||||
|
# Legacy fallback: user ID might be stored in phone field
|
||||||
|
customer ||= User.find_by(phone: sender_user_id)
|
||||||
|
customer ||= User.find_by(mobile: sender_user_id)
|
||||||
|
end
|
||||||
|
|
||||||
unless customer
|
unless customer
|
||||||
role_ids = Role.signup_role_ids
|
role_ids = Role.signup_role_ids
|
||||||
customer = User.create(
|
customer = User.create(
|
||||||
|
|
@ -151,7 +171,8 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
lastname: '',
|
lastname: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
phone: sender_phone_number,
|
phone: sender_phone_number.presence || sender_user_id,
|
||||||
|
whatsapp_uid: sender_user_id,
|
||||||
note: 'CDR Whatsapp',
|
note: 'CDR Whatsapp',
|
||||||
active: true,
|
active: true,
|
||||||
role_ids: role_ids,
|
role_ids: role_ids,
|
||||||
|
|
@ -160,6 +181,15 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update whatsapp_uid if we have it and customer doesn't
|
||||||
|
if sender_user_id.present? && customer.whatsapp_uid.blank?
|
||||||
|
customer.update(whatsapp_uid: sender_user_id)
|
||||||
|
end
|
||||||
|
# Update phone if we have it and customer only has user_id in phone field
|
||||||
|
if sender_phone_number.present? && customer.phone == sender_user_id
|
||||||
|
customer.update(phone: sender_phone_number)
|
||||||
|
end
|
||||||
|
|
||||||
# set current user
|
# set current user
|
||||||
UserInfo.current_user_id = customer.id
|
UserInfo.current_user_id = customer.id
|
||||||
current_user_set(customer, 'token_auth')
|
current_user_set(customer, 'token_auth')
|
||||||
|
|
@ -188,7 +218,8 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
attachment_data_base64 = params[:attachment]
|
attachment_data_base64 = params[:attachment]
|
||||||
attachment_filename = params[:filename]
|
attachment_filename = params[:filename]
|
||||||
attachment_mimetype = params[:mime_type]
|
attachment_mimetype = params[:mime_type]
|
||||||
title = "Message from #{sender_phone_number} at #{sent_at}"
|
sender_display = sender_phone_number.presence || sender_user_id
|
||||||
|
title = "Message from #{sender_display} at #{sent_at}"
|
||||||
body = message
|
body = message
|
||||||
|
|
||||||
# find ticket or create one
|
# find ticket or create one
|
||||||
|
|
@ -207,8 +238,9 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
preferences: {
|
preferences: {
|
||||||
channel_id: channel.id,
|
channel_id: channel.id,
|
||||||
cdr_whatsapp: {
|
cdr_whatsapp: {
|
||||||
bot_token: channel.options[:bot_token], # change to bot id
|
bot_token: channel.options[:bot_token],
|
||||||
chat_id: sender_phone_number
|
chat_id: sender_phone_number.presence || sender_user_id,
|
||||||
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -217,7 +249,7 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
ticket.save!
|
ticket.save!
|
||||||
|
|
||||||
article_params = {
|
article_params = {
|
||||||
from: sender_phone_number,
|
from: sender_display,
|
||||||
to: receiver_phone_number,
|
to: receiver_phone_number,
|
||||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||||
subject: title,
|
subject: title,
|
||||||
|
|
@ -230,7 +262,8 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
cdr_whatsapp: {
|
cdr_whatsapp: {
|
||||||
timestamp: sent_at,
|
timestamp: sent_at,
|
||||||
message_id: message_id,
|
message_id: message_id,
|
||||||
from: sender_phone_number
|
from: sender_phone_number,
|
||||||
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,37 @@ class CommunicateCdrSignalJob < ApplicationJob
|
||||||
if is_group_chat && group_joined == false
|
if is_group_chat && group_joined == false
|
||||||
Rails.logger.info "Ticket ##{ticket.number}: User hasn't joined Signal group yet, skipping message delivery"
|
Rails.logger.info "Ticket ##{ticket.number}: User hasn't joined Signal group yet, skipping message delivery"
|
||||||
|
|
||||||
|
# Track group_not_joined retry attempts separately
|
||||||
|
article.preferences['group_not_joined_retry'] ||= 0
|
||||||
|
article.preferences['group_not_joined_retry'] += 1
|
||||||
|
|
||||||
# Mark article as pending delivery
|
# Mark article as pending delivery
|
||||||
article.preferences['delivery_status'] = 'pending'
|
article.preferences['delivery_status'] = 'pending'
|
||||||
article.preferences['delivery_status_message'] = 'Waiting for user to join Signal group'
|
article.preferences['delivery_status_message'] = 'Waiting for user to join Signal group'
|
||||||
article.preferences['delivery_status_date'] = Time.zone.now
|
article.preferences['delivery_status_date'] = Time.zone.now
|
||||||
|
|
||||||
|
# After 3 failed attempts, add a note to inform the agent (only once)
|
||||||
|
if article.preferences['group_not_joined_retry'] == 3 && !article.preferences['group_not_joined_note_added']
|
||||||
|
Ticket::Article.create(
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
content_type: 'text/plain',
|
||||||
|
body: 'Unable to send Signal message: Recipient has not yet joined the Signal group. ' \
|
||||||
|
'The message will be delivered automatically once they accept the group invitation.',
|
||||||
|
internal: true,
|
||||||
|
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||||
|
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||||
|
preferences: {
|
||||||
|
delivery_article_id_related: article.id,
|
||||||
|
delivery_message: true,
|
||||||
|
group_not_joined_notification: true,
|
||||||
|
},
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1,
|
||||||
|
)
|
||||||
|
article.preferences['group_not_joined_note_added'] = true
|
||||||
|
Rails.logger.info "Ticket ##{ticket.number}: Added notification note about pending group join"
|
||||||
|
end
|
||||||
|
|
||||||
article.save!
|
article.save!
|
||||||
|
|
||||||
# Retry later when user might have joined
|
# Retry later when user might have joined
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SignalNotificationJob < ApplicationJob
|
||||||
|
retry_on StandardError, attempts: 3, wait: lambda { |executions|
|
||||||
|
executions * 60.seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
def perform(ticket_id:, article_id:, user_id:, type:, changes:)
|
||||||
|
ticket = Ticket.find_by(id: ticket_id)
|
||||||
|
return if !ticket
|
||||||
|
|
||||||
|
user = User.find_by(id: user_id)
|
||||||
|
return if !user
|
||||||
|
|
||||||
|
signal_uid = user.preferences.dig('notification_config', 'signal_uid').presence
|
||||||
|
return if signal_uid.blank?
|
||||||
|
|
||||||
|
article = article_id ? Ticket::Article.find_by(id: article_id) : nil
|
||||||
|
|
||||||
|
channel = signal_channel
|
||||||
|
return if !channel
|
||||||
|
|
||||||
|
message = SignalNotificationSender.build_message(
|
||||||
|
ticket: ticket,
|
||||||
|
article: article,
|
||||||
|
user: user,
|
||||||
|
type: type,
|
||||||
|
changes: changes
|
||||||
|
)
|
||||||
|
|
||||||
|
return if message.blank?
|
||||||
|
|
||||||
|
SignalNotificationSender.send_message(
|
||||||
|
channel: channel,
|
||||||
|
recipient: signal_uid,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
|
||||||
|
add_history(ticket, user, signal_uid, type)
|
||||||
|
|
||||||
|
Rails.logger.info "Sent Signal notification to #{signal_uid} for ticket ##{ticket.number} (#{type})"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def signal_channel
|
||||||
|
channel_id = Setting.get('signal_notification_channel_id')
|
||||||
|
return unless channel_id
|
||||||
|
|
||||||
|
Channel.find_by(id: channel_id, area: 'Signal::Number', active: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_history(ticket, user, signal_uid, type)
|
||||||
|
identifier = signal_uid.presence || user.login
|
||||||
|
recipient_list = "#{identifier}(#{type}:signal)"
|
||||||
|
|
||||||
|
History.add(
|
||||||
|
o_id: ticket.id,
|
||||||
|
history_type: 'notification',
|
||||||
|
history_object: 'Ticket',
|
||||||
|
value_to: recipient_list,
|
||||||
|
created_by_id: 1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Link::SetupSplitSignalGroup
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
after_create :setup_signal_group_for_split_ticket
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def setup_signal_group_for_split_ticket
|
||||||
|
# Only if auto-groups enabled
|
||||||
|
return unless ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
|
||||||
|
|
||||||
|
# Only child links (splits create child->parent links)
|
||||||
|
return unless link_type_id == Link::Type.find_by(name: 'child')&.id
|
||||||
|
|
||||||
|
# Only Ticket-to-Ticket links
|
||||||
|
ticket_object_id = Link::Object.find_by(name: 'Ticket')&.id
|
||||||
|
return unless link_object_source_id == ticket_object_id
|
||||||
|
return unless link_object_target_id == ticket_object_id
|
||||||
|
|
||||||
|
child_ticket = Ticket.find_by(id: link_object_source_value)
|
||||||
|
parent_ticket = Ticket.find_by(id: link_object_target_value)
|
||||||
|
return unless child_ticket && parent_ticket
|
||||||
|
|
||||||
|
# Only if parent has Signal group (chat_id starts with "group.")
|
||||||
|
parent_signal_prefs = parent_ticket.preferences&.dig('cdr_signal')
|
||||||
|
return unless parent_signal_prefs.present?
|
||||||
|
return unless parent_signal_prefs['chat_id']&.start_with?('group.')
|
||||||
|
|
||||||
|
original_recipient = parent_signal_prefs['original_recipient']
|
||||||
|
return unless original_recipient.present?
|
||||||
|
|
||||||
|
# Set up child for lazy group creation:
|
||||||
|
# chat_id = phone number triggers new group on first message
|
||||||
|
child_ticket.preferences ||= {}
|
||||||
|
child_ticket.preferences['channel_id'] = parent_ticket.preferences['channel_id']
|
||||||
|
child_ticket.preferences['cdr_signal'] = {
|
||||||
|
'bot_token' => parent_signal_prefs['bot_token'],
|
||||||
|
'chat_id' => original_recipient, # Phone number, NOT group ID
|
||||||
|
'original_recipient' => original_recipient
|
||||||
|
}
|
||||||
|
# Set article type so Zammad shows Signal reply option
|
||||||
|
child_ticket.create_article_type_id = Ticket::Article::Type.find_by(name: 'cdr_signal')&.id
|
||||||
|
child_ticket.save!
|
||||||
|
|
||||||
|
Rails.logger.info "Signal split: Ticket ##{child_ticket.number} set up for new group (recipient: #{original_recipient})"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Transaction::SignalNotification
|
||||||
|
include ChecksHumanChanges
|
||||||
|
|
||||||
|
def initialize(item, params = {})
|
||||||
|
@item = item
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return if Setting.get('import_mode')
|
||||||
|
return if %w[Ticket Ticket::Article].exclude?(@item[:object])
|
||||||
|
return if @params[:disable_notification]
|
||||||
|
return if !ticket
|
||||||
|
return if !signal_notifications_enabled?
|
||||||
|
return if !signal_channel
|
||||||
|
|
||||||
|
collect_signal_recipients.each do |user|
|
||||||
|
SignalNotificationJob.perform_later(
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
article_id: @item[:article_id],
|
||||||
|
user_id: user.id,
|
||||||
|
type: @item[:type],
|
||||||
|
changes: human_changes(@item[:changes], ticket, user)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ticket
|
||||||
|
@ticket ||= Ticket.find_by(id: @item[:object_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def article
|
||||||
|
return if !@item[:article_id]
|
||||||
|
|
||||||
|
@article ||= begin
|
||||||
|
art = Ticket::Article.find_by(id: @item[:article_id])
|
||||||
|
return unless art
|
||||||
|
|
||||||
|
sender = Ticket::Article::Sender.lookup(id: art.sender_id)
|
||||||
|
if sender&.name == 'System'
|
||||||
|
return if @item[:changes].blank? && art.preferences[:notification] != true
|
||||||
|
return if art.preferences[:notification] != true
|
||||||
|
end
|
||||||
|
|
||||||
|
art
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_user
|
||||||
|
@current_user ||= User.lookup(id: @item[:user_id]) || User.lookup(id: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def signal_notifications_enabled?
|
||||||
|
Setting.get('signal_notification_enabled') == true
|
||||||
|
end
|
||||||
|
|
||||||
|
def signal_channel
|
||||||
|
@signal_channel ||= begin
|
||||||
|
channel_id = Setting.get('signal_notification_channel_id')
|
||||||
|
return unless channel_id
|
||||||
|
|
||||||
|
Channel.find_by(id: channel_id, area: 'Signal::Number', active: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def collect_signal_recipients
|
||||||
|
recipients = []
|
||||||
|
|
||||||
|
possible_recipients = possible_recipients_of_group(ticket.group_id)
|
||||||
|
|
||||||
|
mention_users = Mention.where(mentionable_type: @item[:object], mentionable_id: @item[:object_id]).map(&:user)
|
||||||
|
mention_users.each do |user|
|
||||||
|
next if !user.group_access?(ticket.group_id, 'read')
|
||||||
|
|
||||||
|
possible_recipients.push(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
if ticket.owner_id != 1
|
||||||
|
possible_recipients.push(ticket.owner)
|
||||||
|
end
|
||||||
|
|
||||||
|
possible_recipients_with_ooo = Set.new(possible_recipients)
|
||||||
|
possible_recipients.each do |user|
|
||||||
|
add_out_of_office_replacement(user, possible_recipients_with_ooo)
|
||||||
|
end
|
||||||
|
|
||||||
|
possible_recipients_with_ooo.each do |user|
|
||||||
|
next if recipient_is_current_user?(user)
|
||||||
|
next if !user.active?
|
||||||
|
next if user_signal_uid(user).blank?
|
||||||
|
next if !user_wants_signal_for_event?(user)
|
||||||
|
|
||||||
|
recipients.push(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
recipients.uniq(&:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def possible_recipients_of_group(group_id)
|
||||||
|
Rails.cache.fetch("User/signal_notification/possible_recipients_of_group/#{group_id}/#{User.latest_change}", expires_in: 20.seconds) do
|
||||||
|
User.group_access(group_id, 'full').sort_by(&:login)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_out_of_office_replacement(user, recipients)
|
||||||
|
replacement = user.out_of_office_agent
|
||||||
|
return unless replacement
|
||||||
|
return unless TicketPolicy.new(replacement, ticket).agent_read_access?
|
||||||
|
|
||||||
|
recipients.add(replacement)
|
||||||
|
end
|
||||||
|
|
||||||
|
def recipient_is_current_user?(user)
|
||||||
|
return false if @params[:interface_handle] != 'application_server'
|
||||||
|
return true if article&.updated_by_id == user.id
|
||||||
|
return true if !article && @item[:user_id] == user.id
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_signal_uid(user)
|
||||||
|
user.preferences.dig('notification_config', 'signal_uid').presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_wants_signal_for_event?(user)
|
||||||
|
event_type = @item[:type]
|
||||||
|
return false if event_type.blank?
|
||||||
|
|
||||||
|
event_key = case event_type
|
||||||
|
when 'create' then 'create'
|
||||||
|
when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction' then 'update'
|
||||||
|
when 'reminder_reached' then 'reminder_reached'
|
||||||
|
when 'escalation', 'escalation_warning' then 'escalation'
|
||||||
|
else return false
|
||||||
|
end
|
||||||
|
|
||||||
|
user.preferences.dig('notification_config', 'matrix', event_key, 'channel', 'signal') == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
[Ticket #<%= ticket.number %>] <%= ticket.title %>
|
||||||
|
|
||||||
|
NEW TICKET
|
||||||
|
|
||||||
|
Group: <%= ticket.group.name %>
|
||||||
|
Owner: <%= ticket.owner.fullname %>
|
||||||
|
State: <%= t(ticket.state.name) %>
|
||||||
|
Priority: <%= t(ticket.priority.name) %>
|
||||||
|
Customer: <%= ticket.customer.fullname %>
|
||||||
|
Created by: <%= current_user.fullname %>
|
||||||
|
<% if article -%>
|
||||||
|
|
||||||
|
<%= article_body_preview(500) %>
|
||||||
|
<% end -%>
|
||||||
|
|
||||||
|
<%= ticket_url %>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Ticket #<%= ticket.number %>] <%= ticket.title %>
|
||||||
|
|
||||||
|
ESCALATION
|
||||||
|
|
||||||
|
Group: <%= ticket.group.name %>
|
||||||
|
Owner: <%= ticket.owner.fullname %>
|
||||||
|
State: <%= t(ticket.state.name) %>
|
||||||
|
Priority: <%= t(ticket.priority.name) %>
|
||||||
|
Customer: <%= ticket.customer.fullname %>
|
||||||
|
|
||||||
|
<%= ticket_url %>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Ticket #<%= ticket.number %>] <%= ticket.title %>
|
||||||
|
|
||||||
|
REMINDER REACHED
|
||||||
|
|
||||||
|
Group: <%= ticket.group.name %>
|
||||||
|
Owner: <%= ticket.owner.fullname %>
|
||||||
|
State: <%= t(ticket.state.name) %>
|
||||||
|
Pending till: <%= ticket.pending_time&.strftime('%Y-%m-%d %H:%M') %>
|
||||||
|
|
||||||
|
<%= ticket_url %>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
[Ticket #<%= ticket.number %>] <%= ticket.title %>
|
||||||
|
|
||||||
|
TICKET UPDATED by <%= current_user.fullname %>
|
||||||
|
<% if changes.present? -%>
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
<%= changes_summary %>
|
||||||
|
<% end -%>
|
||||||
|
<% if article -%>
|
||||||
|
|
||||||
|
<%= article_body_preview(500) %>
|
||||||
|
<% end -%>
|
||||||
|
|
||||||
|
<%= ticket_url_with_article %>
|
||||||
|
|
@ -5,6 +5,11 @@ Rails.application.config.after_initialize do
|
||||||
include Ticket::Article::EnqueueCommunicateCdrSignalJob
|
include Ticket::Article::EnqueueCommunicateCdrSignalJob
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle Signal group setup for split tickets
|
||||||
|
class Link
|
||||||
|
include Link::SetupSplitSignalGroup
|
||||||
|
end
|
||||||
|
|
||||||
icon = File.read('public/assets/images/icons/cdr_signal.svg')
|
icon = File.read('public/assets/images/icons/cdr_signal.svg')
|
||||||
doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) }
|
doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) }
|
||||||
if !doc.at_css('#icon-cdr-signal')
|
if !doc.at_css('#icon-cdr-signal')
|
||||||
|
|
@ -15,4 +20,3 @@ Rails.application.config.after_initialize do
|
||||||
end
|
end
|
||||||
File.write('public/assets/images/icons.svg', doc.to_xml)
|
File.write('public/assets/images/icons.svg', doc.to_xml)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -12,4 +12,6 @@ Zammad::Application.routes.draw do
|
||||||
match "#{api_path}/channels_cdr_signal_enable", to: 'channels_cdr_signal#enable', via: :post
|
match "#{api_path}/channels_cdr_signal_enable", to: 'channels_cdr_signal#enable', via: :post
|
||||||
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#destroy', via: :delete
|
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#destroy', via: :delete
|
||||||
match "#{api_path}/channels_cdr_signal_rotate_token", to: 'channels_cdr_signal#rotate_token', via: :post
|
match "#{api_path}/channels_cdr_signal_rotate_token", to: 'channels_cdr_signal#rotate_token', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_signal_set_notification", to: 'channels_cdr_signal#set_notification_channel', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_signal_unset_notification", to: 'channels_cdr_signal#unset_notification_channel', via: :post
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
class AddMessagingUserIds < ActiveRecord::Migration[5.2]
|
||||||
|
def self.up
|
||||||
|
# Add WhatsApp UID column
|
||||||
|
unless column_exists?(:users, :whatsapp_uid)
|
||||||
|
add_column :users, :whatsapp_uid, :string, limit: 50
|
||||||
|
add_index :users, :whatsapp_uid
|
||||||
|
end
|
||||||
|
User.reset_column_information
|
||||||
|
|
||||||
|
# Add Signal UID column
|
||||||
|
unless column_exists?(:users, :signal_uid)
|
||||||
|
add_column :users, :signal_uid, :string, limit: 50
|
||||||
|
add_index :users, :signal_uid
|
||||||
|
end
|
||||||
|
User.reset_column_information
|
||||||
|
|
||||||
|
# Register WhatsApp UID with ObjectManager for UI
|
||||||
|
# Column name: whatsapp_uid, Display name: "WhatsApp User ID"
|
||||||
|
ObjectManager::Attribute.add(
|
||||||
|
force: true,
|
||||||
|
object: 'User',
|
||||||
|
name: 'whatsapp_uid',
|
||||||
|
display: 'WhatsApp User ID',
|
||||||
|
data_type: 'input',
|
||||||
|
data_option: {
|
||||||
|
type: 'text',
|
||||||
|
maxlength: 50,
|
||||||
|
null: true,
|
||||||
|
item_class: 'formGroup--halfSize',
|
||||||
|
},
|
||||||
|
editable: false,
|
||||||
|
active: true,
|
||||||
|
screens: {
|
||||||
|
signup: {},
|
||||||
|
invite_agent: {},
|
||||||
|
invite_customer: {},
|
||||||
|
edit: {
|
||||||
|
'-all-' => {
|
||||||
|
null: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
'-all-' => {
|
||||||
|
null: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
'-all-' => {
|
||||||
|
shown: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
to_create: false,
|
||||||
|
to_migrate: false,
|
||||||
|
to_delete: false,
|
||||||
|
position: 710,
|
||||||
|
created_by_id: 1,
|
||||||
|
updated_by_id: 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register Signal UID with ObjectManager for UI
|
||||||
|
# Column name: signal_uid, Display name: "Signal User ID"
|
||||||
|
ObjectManager::Attribute.add(
|
||||||
|
force: true,
|
||||||
|
object: 'User',
|
||||||
|
name: 'signal_uid',
|
||||||
|
display: 'Signal User ID',
|
||||||
|
data_type: 'input',
|
||||||
|
data_option: {
|
||||||
|
type: 'text',
|
||||||
|
maxlength: 50,
|
||||||
|
null: true,
|
||||||
|
item_class: 'formGroup--halfSize',
|
||||||
|
},
|
||||||
|
editable: false,
|
||||||
|
active: true,
|
||||||
|
screens: {
|
||||||
|
signup: {},
|
||||||
|
invite_agent: {},
|
||||||
|
invite_customer: {},
|
||||||
|
edit: {
|
||||||
|
'-all-' => {
|
||||||
|
null: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
'-all-' => {
|
||||||
|
null: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
'-all-' => {
|
||||||
|
shown: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
to_create: false,
|
||||||
|
to_migrate: false,
|
||||||
|
to_delete: false,
|
||||||
|
position: 720,
|
||||||
|
created_by_id: 1,
|
||||||
|
updated_by_id: 1,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
ObjectManager::Attribute.remove(
|
||||||
|
object: 'User',
|
||||||
|
name: 'whatsapp_uid',
|
||||||
|
)
|
||||||
|
|
||||||
|
ObjectManager::Attribute.remove(
|
||||||
|
object: 'User',
|
||||||
|
name: 'signal_uid',
|
||||||
|
)
|
||||||
|
|
||||||
|
remove_index :users, :whatsapp_uid if index_exists?(:users, :whatsapp_uid)
|
||||||
|
remove_column :users, :whatsapp_uid if column_exists?(:users, :whatsapp_uid)
|
||||||
|
|
||||||
|
remove_index :users, :signal_uid if index_exists?(:users, :signal_uid)
|
||||||
|
remove_column :users, :signal_uid if column_exists?(:users, :signal_uid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddSignalNotificationSettings < ActiveRecord::Migration[5.2]
|
||||||
|
def self.up
|
||||||
|
# Register Signal notification transaction backend
|
||||||
|
# Using 0105 to run after email notifications (0100)
|
||||||
|
Setting.create_if_not_exists(
|
||||||
|
title: 'Defines transaction backend.',
|
||||||
|
name: '0105_signal_notification',
|
||||||
|
area: 'Transaction::Backend::Async',
|
||||||
|
description: 'Defines the transaction backend to send Signal notifications.',
|
||||||
|
options: {},
|
||||||
|
state: 'Transaction::SignalNotification',
|
||||||
|
frontend: false
|
||||||
|
)
|
||||||
|
|
||||||
|
# Global enable/disable for Signal notifications
|
||||||
|
Setting.create_if_not_exists(
|
||||||
|
title: 'Signal Notifications',
|
||||||
|
name: 'signal_notification_enabled',
|
||||||
|
area: 'Integration::Switch',
|
||||||
|
description: 'Enable or disable Signal notifications for agents.',
|
||||||
|
options: {
|
||||||
|
form: [
|
||||||
|
{
|
||||||
|
display: '',
|
||||||
|
null: true,
|
||||||
|
name: 'signal_notification_enabled',
|
||||||
|
tag: 'boolean',
|
||||||
|
options: {
|
||||||
|
true => 'yes',
|
||||||
|
false => 'no',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
state: false,
|
||||||
|
preferences: {
|
||||||
|
prio: 1,
|
||||||
|
permission: ['admin.integration'],
|
||||||
|
},
|
||||||
|
frontend: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Which Signal channel/bot to use for sending notifications
|
||||||
|
Setting.create_if_not_exists(
|
||||||
|
title: 'Signal Notification Channel',
|
||||||
|
name: 'signal_notification_channel_id',
|
||||||
|
area: 'Integration::SignalNotification',
|
||||||
|
description: 'The Signal channel (bot) used to send notifications to agents.',
|
||||||
|
options: {},
|
||||||
|
state: nil,
|
||||||
|
preferences: {
|
||||||
|
prio: 2,
|
||||||
|
permission: ['admin.integration'],
|
||||||
|
},
|
||||||
|
frontend: false
|
||||||
|
)
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
# Only destroy the transaction backend registration.
|
||||||
|
# Preserve signal_notification_enabled and signal_notification_channel_id
|
||||||
|
# so admin configuration survives addon reinstalls (setup.rb runs
|
||||||
|
# uninstall + install on every container start).
|
||||||
|
Setting.find_by(name: '0105_signal_notification')&.destroy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'erb'
|
||||||
|
|
||||||
|
class SignalNotificationSender
|
||||||
|
TEMPLATE_DIR = Rails.root.join('app', 'views', 'signal_notification')
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def build_message(ticket:, article:, user:, type:, changes:)
|
||||||
|
template_name = template_for_type(type)
|
||||||
|
return if template_name.blank?
|
||||||
|
|
||||||
|
locale = user.locale || Setting.get('locale_default') || 'en'
|
||||||
|
template_path = find_template(template_name, locale)
|
||||||
|
return if template_path.blank?
|
||||||
|
|
||||||
|
render_template(template_path, binding_for(ticket, article, user, changes))
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_message(channel:, recipient:, message:)
|
||||||
|
return if Rails.env.test?
|
||||||
|
return if channel.blank?
|
||||||
|
return if recipient.blank?
|
||||||
|
return if message.blank?
|
||||||
|
|
||||||
|
api_url = channel.options['bot_endpoint'] || channel.options[:bot_endpoint]
|
||||||
|
api_token = channel.options['bot_token'] || channel.options[:bot_token]
|
||||||
|
|
||||||
|
return if api_url.blank? || api_token.blank?
|
||||||
|
|
||||||
|
api = CdrSignalApi.new(api_url, api_token)
|
||||||
|
api.send_message(recipient, message)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def template_for_type(type)
|
||||||
|
case type
|
||||||
|
when 'create'
|
||||||
|
'ticket_create'
|
||||||
|
when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction'
|
||||||
|
'ticket_update'
|
||||||
|
when 'reminder_reached'
|
||||||
|
'ticket_reminder_reached'
|
||||||
|
when 'escalation', 'escalation_warning'
|
||||||
|
'ticket_escalation'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_template(template_name, locale)
|
||||||
|
base_locale = locale.split('-').first
|
||||||
|
|
||||||
|
[locale, base_locale, 'en'].uniq.each do |try_locale|
|
||||||
|
path = TEMPLATE_DIR.join(template_name, "#{try_locale}.txt.erb")
|
||||||
|
return path if File.exist?(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def binding_for(ticket, article, user, changes)
|
||||||
|
TemplateContext.new(
|
||||||
|
ticket: ticket,
|
||||||
|
article: article,
|
||||||
|
user: user,
|
||||||
|
changes: changes,
|
||||||
|
config: {
|
||||||
|
http_type: Setting.get('http_type'),
|
||||||
|
fqdn: Setting.get('fqdn'),
|
||||||
|
product_name: Setting.get('product_name')
|
||||||
|
}
|
||||||
|
).get_binding
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_template(template_path, binding)
|
||||||
|
template = File.read(template_path)
|
||||||
|
erb = ERB.new(template, trim_mode: '-')
|
||||||
|
erb.result(binding).strip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TemplateContext
|
||||||
|
attr_reader :ticket, :article, :recipient, :changes, :config
|
||||||
|
|
||||||
|
def initialize(ticket:, article:, user:, changes:, config:)
|
||||||
|
@ticket = ticket
|
||||||
|
@article = article
|
||||||
|
@recipient = user
|
||||||
|
@changes = changes
|
||||||
|
@config = OpenStruct.new(config)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_binding
|
||||||
|
binding
|
||||||
|
end
|
||||||
|
|
||||||
|
def ticket_url
|
||||||
|
"#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def ticket_url_with_article
|
||||||
|
if article
|
||||||
|
"#{ticket_url}/#{article.id}"
|
||||||
|
else
|
||||||
|
ticket_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_user
|
||||||
|
@current_user ||= User.lookup(id: ticket.updated_by_id) || User.lookup(id: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def changes_summary
|
||||||
|
return '' if changes.blank?
|
||||||
|
|
||||||
|
changes.map { |key, values| "#{key}: #{values[0]} -> #{values[1]}" }.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def article_body_preview(max_length = 500)
|
||||||
|
return '' unless article
|
||||||
|
return '' if article.body.blank?
|
||||||
|
|
||||||
|
body = article.body.to_s
|
||||||
|
body = ActionController::Base.helpers.strip_tags(body) if article.content_type&.include?('html')
|
||||||
|
body = body.gsub(/\s+/, ' ').strip
|
||||||
|
|
||||||
|
if body.length > max_length
|
||||||
|
"#{body[0, max_length]}..."
|
||||||
|
else
|
||||||
|
body
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def t(text)
|
||||||
|
locale = recipient.locale || Setting.get('locale_default') || 'en'
|
||||||
|
Translation.translate(locale, text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
9
packages/zammad-addon-bridge/turbo.json
Normal file
9
packages/zammad-addon-bridge/turbo.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": ["../../docker/zammad/addons/zammad-addon-bridge-v*.zpm"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/zammad-addon-common",
|
"name": "@link-stack/zammad-addon-common",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"bin": {
|
"bin": {
|
||||||
"zpm-build": "./dist/build.js",
|
"zpm-build": "./dist/build.js",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/zammad-addon-hardening",
|
"name": "@link-stack/zammad-addon-hardening",
|
||||||
"displayName": "Hardening",
|
"displayName": "Hardening",
|
||||||
"version": "3.3.5",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.",
|
"description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
"build": "node '../zammad-addon-common/dist/build.js'",
|
||||||
|
|
|
||||||
9
packages/zammad-addon-hardening/turbo.json
Normal file
9
packages/zammad-addon-hardening/turbo.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": ["../../docker/zammad/addons/zammad-addon-hardening-v*.zpm"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1498
pnpm-lock.yaml
generated
1498
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue