Compare commits

..

35 Commits

Author SHA1 Message Date
0cce4f8085 Use stringify to escape newlines on value.
Fixes #1
2023-11-07 12:26:00 -06:00
816e0a1381 Added POST, DELETE and GET (all) 2023-11-06 23:45:47 -06:00
4973a80f64 return NotFound if no property key is provided 2023-10-16 21:51:15 -05:00
7d6d218e08 fixed typo on logging 2023-10-16 21:50:57 -05:00
ca8d50241e better config for store. better error logging for
redis connection
2023-10-12 21:23:14 -05:00
5dd83863cb removed old config and now /config returns env 2023-10-12 20:42:27 -05:00
c51ed1ba8f Added configurable store for local-storage or redis 2023-10-12 20:34:26 -05:00
be1495d8b3 format 2023-10-12 19:01:11 -05:00
b314b361b2 have separate start and dev 2023-10-11 19:19:22 -05:00
2a4a1e8e14 Dont push .env and other '.' folders 2023-10-11 19:19:01 -05:00
0e64f654ec no need for jwt token/secret on the proxy 2023-10-11 19:18:26 -05:00
037b001de6 Add Tenent Properties service route 2023-10-11 19:17:57 -05:00
ae9f0fae73 Switch from local store to redis 2023-10-11 19:17:13 -05:00
58f176b344 UDG example 2023-10-11 19:15:51 -05:00
6c1b3f8b20 Added handling for OUTBOUND and fractional links.
Also removed filtering (too complex for now)
2023-08-07 20:53:08 -05:00
3f788733c8 Separated out query for reuse and interactions-flow route
no longer uses sample data
2023-08-05 16:51:12 -05:00
ca011abee5 Formatting 2023-08-05 16:50:33 -05:00
83769bb816 Formatting 2023-08-05 16:50:21 -05:00
4d4d649458 added cookies and http-errors handling 2023-07-26 16:59:13 -05:00
3da24d067b use express errorHandling 2023-07-26 16:58:48 -05:00
b43fed0fec use express errorHandling 2023-07-26 16:58:27 -05:00
9bbb23de48 Added cookie handling and morgan logging updates 2023-07-26 16:58:13 -05:00
a114b1aaaf use authKey 2023-07-26 16:57:48 -05:00
6fa75f531a Added middlewares for authKey and detailed request
logging
2023-07-26 16:57:21 -05:00
f0d4d29181 auth now accepts cookie based OIDC token 2023-07-26 16:57:09 -05:00
f56fae304e Added middlewares for authKey and detailed request
logging
2023-07-26 16:56:38 -05:00
8b0969792a verify using iss attribute 2023-07-24 20:17:24 -05:00
b48a7cd4f2 Added auth route with basic debug information only at this
time
2023-07-21 10:38:57 -05:00
74e2fcee52 formatting 2023-07-10 04:31:28 +01:00
c1be8ed22e refactored to pair down code. 2023-07-10 04:30:35 +01:00
ebbbb6ad57 em5 sample data 2023-06-30 16:34:07 -05:00
fa15cce2e1 refactoring to a better nodejs template and adding
better filtering using sample data
2023-06-30 16:28:52 -05:00
a141e18120 added config and interactions flow routes 2023-06-29 21:46:29 -05:00
7df9f1e481 Added lint and format scripts 2023-06-27 15:22:50 -05:00
4650a4d5a3 Error logging, go back 2 years and 0 contact
handling
2023-06-25 17:56:53 -05:00
32 changed files with 29321 additions and 3346 deletions

View File

@@ -1,2 +1,3 @@
node_modules
npm-debug.log
.*

14
.env
View File

@@ -1,7 +1,7 @@
EO_API_USERNAME = apiclient
EO_API_PASSWORD = apiclient12345
EO_API_SCOPE = oidc tags context_entitlements content_entitlements em_api_access
EO_API_CLIENT_ID = default
EO_API_CLIENT_SECRET =
EO_API_ACCESS_TOKEN_URL = https://em5.verint.training/oidc-token-service/default/token
EO_API_UDG_URL = https://em5.verint.training/unified-data-gateway/default/graphql
PORT = 3000
## Store type 'local-storage' or 'redis' supported
STORE_PROVIDER = redis
# for example if STORE = redis
STORE_URL = redis://localhost:6379

22
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,22 @@
module.exports = {
env: {
node: true,
},
extends: ["eslint:recommended", "prettier"],
overrides: [
{
env: {
node: true,
},
files: [".eslintrc.{js,cjs}"],
parserOptions: {
sourceType: "module",
},
},
],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
rules: {},
};

1869
.gitignore vendored

File diff suppressed because it is too large Load Diff

1
.prettierrc.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -12,7 +12,7 @@ WORKDIR /usr/src/app
COPY package*.json ./
# continuous integration for production
RUN npm ci --only=production
RUN npm ci --omit=dev
# Bundle app source
COPY . .

View File

@@ -1,10 +1,29 @@
# eo-services
created from template docker-image-template
This is the API backend for the eo-services client
## Authentication
Using Desktop rules call \<hostname>/api/auth using an Adaptives Framework's URL 'POST' with Application Sercurity turned on and the following application/json request body:
```json
{
"data": {
"type": "authentication",
"id": "1",
"attributes": {
"host": "em5.verint.training"
}
}
}
```
> Note: you will need to add the cacert for the API host to your Application Server environment otherwise you will receive a "Remote Exception thrown while invoking external URLhttps://\<yourhost>:443/api/auth Exception:sun.security.validator.ValidatorException: PKIX "
>
> Exception:sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
## Key Files
* Jenkinsfile: Automation of build and deploy of docker image.
* Dockerfile: Docker Image specification.
* docker-bake.hcl: Docker build defintion file.
- Jenkinsfile: Automation of build and deploy of docker image.
- Dockerfile: Docker Image specification.
- docker-bake.hcl: Docker build defintion file.

File diff suppressed because it is too large Load Diff

1159
node_modules/.package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

16
nodemon.json Normal file
View File

@@ -0,0 +1,16 @@
{
"restartable": "rs",
"watch": ["src", ".env"],
"ext": "js,ts,json",
"execMap": {
"js": "node --harmony"
},
"ignore": [".git", "node_modules/**/node_modules"],
"events": {
"restart": "osascript -e 'display notification \"App restarted due to:\n'$FILENAME'\" with title \"nodemon\"'"
},
"verbose": true,
"env": {
"NODE_ENV": "development"
}
}

5091
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,10 @@
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js",
"dev": "nodemon index.js",
"lint": "eslint --ext .js,.cjs --ignore-path .gitignore --fix src/",
"format": "prettier . --write",
"dev": "nodemon src/app.js",
"start": "NODE_ENV=production node src/app.js",
"build": "docker buildx bake",
"push": "docker buildx bake --push"
},
@@ -15,12 +17,34 @@
"license": "ISC",
"dependencies": {
"axios": "^1.1.2",
"base64url": "^3.0.1",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-session": "^1.17.3",
"helmet": "^7.0.0",
"http-errors": "^2.0.0",
"jsonld": "^8.3.1",
"jsonwebtoken": "^9.0.1",
"jwk-to-pem": "^2.0.5",
"local-storage": "^2.0.0",
"morgan": "^1.10.0",
"nodemon": "^2.0.20",
"query-string": "^7.1.1"
}
"nodemon": "^3.0.1",
"promise": "^8.3.0",
"query-string": "^7.1.1",
"redis": "^4.6.7",
"winston": "^3.9.0"
},
"devDependencies": {
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^16.0.1",
"eslint-plugin-promise": "^6.1.1",
"prettier": "2.8.8"
},
"type": "module"
}

View File

@@ -1,240 +0,0 @@
const express = require("express");
const router = express.Router();
const axios = require("axios");
const url = require("url");
const util = require("util");
router.get("/", (req, res) => {
// token in session -> get user data and send it back to the vue app
if (req.session.token) {
query(req.query.referenceId);
}
// no token -> send nothing
else {
const params = new url.URLSearchParams({
grant_type: "password",
username: process.env.EO_API_USERNAME,
password: process.env.EO_API_PASSWORD,
scope: process.env.EO_API_SCOPE,
client_id: process.env.EO_API_CLIENT_ID,
client_secret: process.env.EO_API_SECRET,
});
axios
.post(process.env.EO_API_ACCESS_TOKEN_URL, params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
.then((result) => {
// save token to session
req.session.token = result.data.access_token;
console.log(result);
query(req.query.referenceId);
})
.catch((err) => {
sendError(err, res);
});
}
function query(referenceId) {
console.log("Executing Query");
var query = `query ($startTime: DateTime, $endTime: DateTime) {
findContactsCompletedBetween(startTime: $startTime, endTime: $endTime, filter: {interactionTypes : EMAIL}) {
totalCount
edges {
node {
systemId
startTime
endTime
direction
handledBy {
username
firstName
lastName
nickname
orgScope
}
activeDuration
notes {
totalCount
edges {
node {
text
}
}
}
interaction {
systemId
locale
__typename
... on Email {
messageId
threadId
sentDate
receivedDate
subject
fromAddress
toAddresses
ccAddresses
bccAddresses
detectedLanguage
mailboxName
attachmentCount
isDuplicate
}
}
outcome {
totalCount
edges {
node {
text
isActive
isVisible
}
}
}
customer {
totalCount
edges {
node {
ref
firstName
lastName
}
}
}
queue {
name
orgScope
}
}
}
}
}`;
var startTime = new Date(
new Date().setFullYear(new Date().getFullYear() - 1)
);
var endTime = new Date(new Date().setHours(new Date().getHours() - 1));
axios
.post(
process.env.EO_API_UDG_URL,
JSON.stringify({
query,
variables: { startTime, endTime },
}),
{
headers: {
Authorization: `OIDC_id_token ${req.session.token}`,
"Content-Type": "application/json",
},
}
)
.then((result) => {
var contacts = result.data.data.findContactsCompletedBetween.edges;
var filteredContacts = [];
contacts.forEach(function (contact, i) {
if (contact.node.interaction.__typename === "Email") {
// threadId if Reference in Subject line will do
if (
(contact.node.interaction.threadId === referenceId) |
contact.node.interaction.subject.includes(
`<< Ref:${referenceId} >>`
)
) {
filteredContacts.push(contact);
}
}
});
result.data.data.findContactsCompletedBetween.edges = filteredContacts;
result.data.data.findContactsCompletedBetween.totalCount =
filteredContacts.length;
// Summary Values
var summary = {};
summary.totalCount = filteredContacts.length;
summary.totalInboundCount = 0;
summary.totalInboundActiveSeconds = 0;
summary.firstContactReceivedDate = new Date(
filteredContacts[0].node.interaction.receivedDate
);
filteredContacts.forEach(function (contact, i) {
if (contact.node.direction === "INBOUND") {
summary.totalInboundCount++;
if (!summary.firstInboundContactStartDate) {
summary.firstInboundContactStartDate = new Date(
contact.node.startTime
);
summary.firstContactReceivedDate = new Date(
contact.node.interaction.receivedDate
);
}
summary.totalInboundActiveSeconds += contact.node.activeDuration;
}
});
// TODO: Because of overlapping contacts, we may need to calculate max instead of last.
summary.lastContactEndTime = new Date(
filteredContacts[filteredContacts.length - 1].node.endTime
);
summary.totalHTHours =
(summary.lastContactEndTime.getTime() -
summary.firstContactReceivedDate.getTime()) /
(1000 * 3600);
summary.activeHTMinutes =
(summary.lastContactEndTime.getTime() -
summary.firstInboundContactStartDate.getTime()) /
(1000 * 60);
result.data.data.summary = summary;
if (result.data) {
console.log(
util.inspect(summary, {
showHidden: false,
depth: null,
colors: true,
})
);
res.send(result.data);
}
// expired token -> send nothing
else {
req.session.destroy();
res.send({});
}
})
.catch((err) => {
// bin the token on error
req.session.destroy();
sendError(err, res);
});
}
});
module.exports = router;
function sendError(err, res) {
console.error(err);
let errStatus = 500;
if (err.response) errStatus = err.response.status;
res.status(errStatus).send({
errors: [
{
status: errStatus,
title: err.code,
detail: err.message,
},
],
});
}

View File

@@ -0,0 +1,16 @@
import { store } from "../../utils/index.js";
import createHttpError from "http-errors";
export default (req, res, next) => {
store.get(req.query.authKey).then((value) => {
if (value == null) {
next(
new createHttpError.Forbidden(
`middleware:authKey ${req.query.authKey} not authenticated`
)
);
return;
}
});
next();
};

View File

@@ -0,0 +1,12 @@
import { logger } from "../../utils/index.js";
export default (req, res, next) => {
logger.debug(
`URI: ${req.method} ${req.protocol}://${req.hostname}:${req.client.localPort}${req.originalUrl}`
);
logger.debug(`Headers: ${JSON.stringify(req.headers, null, 2)}`);
logger.debug(`Cookies: ${JSON.stringify(req.cookies, null, 2)}`);
logger.debug(`Query: ${JSON.stringify(req.query, null, 2)}`);
logger.debug(`Body: ${JSON.stringify(req.body, null, 2)}`);
next();
};

View File

@@ -0,0 +1,9 @@
// import { Router } from "express";
import authKey from "./authKey.js";
import detailedRequestLogging from "./detailedRequestLogging.js";
export default (router) => {
router.use("/", detailedRequestLogging);
router.use("/interactions-flow", authKey);
router.use("/unified-data-gateway", authKey);
};

112
src/api/routes/auth.js Normal file
View File

@@ -0,0 +1,112 @@
import { Router } from "express";
import axios from "axios";
import { logger, store } from "../../utils/index.js";
import { decode } from "jsonwebtoken";
import jwt from "jsonwebtoken";
import jwkToPem from "jwk-to-pem";
const router = Router();
router.all("/", (req, res, next) => {
var token = null;
var sessionIdentifier = null;
const cookiePrefix = "__Host-VRNTOTCT";
if (req.cookies) {
logger.debug(
`Checking ${cookiePrefix}... Cookie as this takes presidence over Authorization Header ...`
);
Object.keys(req.cookies).forEach((key) => {
if (key.startsWith(cookiePrefix)) {
logger.info(`Found ${key} cookie using value for token`);
token = req.cookies[key];
sessionIdentifier = key.substring(cookiePrefix.length);
}
});
}
if (!token && req.headers.authorization) {
logger.debug("Checking Authorization Header for OIDC_id_token ...");
var authHeader = req.headers.authorization;
const prefix = "OIDC_id_token ";
if (authHeader && authHeader.startsWith(prefix)) {
logger.info("Found OIDC_id_token in Authorization Header");
token = authHeader.substring(prefix.length, authHeader.length);
}
}
if (token) {
const decoded = decode(token);
logger.debug(`Decoded [${JSON.stringify(decoded, null, 2)}]`);
if (decoded.iss) {
const jwkURI = decoded.iss + "/connect/jwk_uri";
logger.debug(`Requesting JWK on ${jwkURI}`);
axios
.get(jwkURI)
.then((jwkResponse) => {
logger.debug(
`Response JWK on ${JSON.stringify(jwkResponse.data, null, 2)}`
);
if (
jwkResponse.data &&
jwkResponse.data.keys &&
jwkResponse.data.keys.length > 0
) {
const key = jwkResponse.data.keys[0];
var pem = jwkToPem(key);
try {
const verified = jwt.verify(token, pem, {
algorithms: [key.alg],
});
logger.debug(`Verified [${JSON.stringify(verified, null, 2)}]`);
const issSplit = verified.iss.split("/oidc-token-service/");
const authData = {
token: token,
host: issSplit[0],
tenant: issSplit[1],
};
const authDataString = JSON.stringify(authData);
logger.info(
`Adding agent ${verified.sub} to store for environment ${authDataString}}`
);
store
.set(verified.sub, authData)
.then(() => {
if (sessionIdentifier && sessionIdentifier.length > 0) {
logger.info(
`Adding sessionIdentifier ${sessionIdentifier} to store for environment ${authDataString}`
);
return store.set(sessionIdentifier, authData);
}
})
.then(() => {
res.send(decoded);
})
.catch((e) => {
logger.error(e); // "Uh-oh!"
});
} catch (err) {
logger.error(`Verify failed [${JSON.stringify(err, null, 2)}].`);
next(err);
}
}
})
.catch((err) => {
logger.error(`JWK Request failed [${JSON.stringify(err, null, 2)}].`);
next(err);
});
}
} else {
next(new Error("Unable to authenticate, no token found"));
}
});
export default router;

12
src/api/routes/config.js Normal file
View File

@@ -0,0 +1,12 @@
import { Router } from "express";
import config from "../../config/index.js";
const router = Router();
router.get("/", (req, res) => {
res.send({
config: config,
env: process.env,
});
});
export default router;

19
src/api/routes/index.js Normal file
View File

@@ -0,0 +1,19 @@
import { Router } from "express";
import config from "./config.js";
import auth from "./auth.js";
import interactionsFlows from "./interactions-flow.js";
import udg from "./unified-data-gateway.js";
import tps from "./tps.js";
const router = Router();
import middlewares from "../middlewares/index.js";
middlewares(router);
router.use("/config", config);
router.use("/auth", auth);
router.use("/interactions-flow", interactionsFlows);
router.use("/unified-data-gateway", udg);
router.use("/tps", tps);
export default router;

View File

@@ -0,0 +1,174 @@
import { Router } from "express";
import axios from "axios";
const router = Router();
import { logger, store } from "../../utils/index.js";
import { referenceIdQuery } from "../../utils/graphQueries.js";
router.get("/", async (req, res) => {
const data = {
nodes: [],
links: [],
};
const filter = req.query.filter;
var chain = {
"Direction-Channel": true,
"Channel-SubChannel": true,
"Channel-Queue": true,
"SubChannel-Queue": true,
"Queue-Outcome": true,
"Channel-Outcome": true, // special case for outbound that does not go throught queue
};
// TODO: Chain will be hardcoded until I can work out filter logic
// var filterNames = filter.split(",");
// for (let index = 0; index < filterNames.length - 1; index++) {
// chain[filterNames[index] + "-" + filterNames[index + 1]] = true;
// }
const startTime = new Date(
new Date().setFullYear(new Date().getFullYear() - 2)
);
var endTime = new Date();
// FIX for 'endTime' parameter cannot be later than one minute prior to now.
endTime.setMinutes(endTime.getMinutes() - 1);
const auth = await store.get(req.query.authKey);
const { host, tenant, token } = auth;
axios
.post(
`${host}/unified-data-gateway/${tenant}/graphql`,
JSON.stringify({
query: referenceIdQuery,
variables: { startTime, endTime },
}),
{
headers: {
Authorization: `OIDC_id_token ${token}`,
"Content-Type": "application/json",
},
}
)
.then((result) => {
if (result.data.errors && result.data.errors.length > 0) {
result.data.errors.forEach(function (error) {
logger.error("ERROR: Errors in results - " + error.message);
});
// TODO: Should keep errors for filteredContacts
result.data.errors = [];
}
if (!result.data.data.findContactsCompletedBetween) {
logger.debug("No findContactsCompletedBetween returned.");
res.send(data);
return;
}
result.data.data.findContactsCompletedBetween.edges.forEach((value) => {
// TODO: Should add INBOUND and OUTBOUND Nodes before Channel
addNode(filter, "Direction", value.node.direction, data);
addNode(filter, "Channel", value.node.interaction.__typename, data);
if (chain["Direction-Channel"]) {
addLink(
data,
value.node.direction,
value.node.interaction.__typename
);
}
if (value.node.interaction.subChannel) {
addNode(
filter,
"SubChannel",
value.node.interaction.subChannel,
data
);
if (chain["Channel-SubChannel"]) {
addLink(
data,
value.node.interaction.__typename,
value.node.interaction.subChannel
);
}
}
if (value.node.queue) {
const queueName = `${value.node.queue.name}(${value.node.queue.orgScope})`;
addNode(filter, "Queue", queueName, data);
if (
chain["SubChannel-Queue"] &&
!!value.node.interaction.subChannel
) {
addLink(data, value.node.interaction.subChannel, queueName);
} else if (chain["Channel-Queue"]) {
addLink(data, value.node.interaction.__typename, queueName);
}
}
if (value.node.outcome) {
value.node.outcome.edges.forEach((element) => {
addNode(filter, "Outcome", element.node.text, data);
if (chain["Queue-Outcome"] && !!value.node.queue) {
const queueName = `${value.node.queue.name}(${value.node.queue.orgScope})`;
addLink(
data,
queueName,
element.node.text,
1 / value.node.outcome.edges.length
);
}
if (chain["Channel-Outcome"] && !value.node.queue) {
addLink(
data,
value.node.interaction.__typename,
element.node.text,
1 / value.node.outcome.edges.length
);
}
});
}
});
logger.debug(`Sending data: ${JSON.stringify(data, null, 2)}`);
res.send(data);
})
.catch((e) => {
logger.error(e); // "Uh-oh!"
});
});
function addLink(data, source, target, _fraction) {
var fraction = _fraction ? _fraction : 1;
var index = data.links.findIndex((i) => {
return i.source == source && i.target == target;
});
if (index != -1) {
data.links[index].value++;
} else {
data.links.push({
source: source,
target: target,
value: fraction,
});
}
}
function addNode(filter, category, name, data) {
if (
filter.split(",").indexOf(category) != -1 &&
!!name &&
data.nodes.findIndex(
(value) => value.name === name && value.category === category
) == -1
) {
data.nodes.push({
name: name,
category: category,
});
}
}
export default router;

208
src/api/routes/tps.js Normal file
View File

@@ -0,0 +1,208 @@
import { Router } from "express";
import axios from "axios";
import { logger, store, isEmpty } from "../../utils/index.js";
import createHttpError from "http-errors";
const router = Router();
router.get("/", async (req, res, next) => {
const auth = await store.get(req.query.authKey);
if (isEmpty(auth)) {
next(
new createHttpError.Forbidden(
`No authenication information found in store`
)
);
return;
}
const { host, tenant, token } = auth;
logger.debug(`tps GET all properties from ${host}`);
axios
.get(
`${host}/tenant-properties-service/${tenant}/properties?fields=name,value,lastModifiedDate,lastModifiedBy`,
{
headers: {
Authorization: `OIDC_id_token ${token}`,
"Content-Type": "application/json",
},
}
)
.then((result) => {
if (result.data.errors && result.data.errors.length > 0) {
result.data.errors.forEach(function (error) {
logger.error("ERROR: Errors in results - " + error.message);
});
next(new Error("Error(s) getting properties "));
return;
}
const totalItems = result.data["hydra:totalItems"];
logger.debug("tps result has hydra:totalItems [" + totalItems + "]");
if (totalItems > 0) {
const data = result.data["hydra:member"].map(function (member) {
return {
name: member["vcfg:name"],
value: member["vcfg:value"],
lastModifiedDate: member["vcfg:lastModifiedDate"],
lastModifiedBy: member["vcfg:lastModifiedBy"],
};
});
res.json({ data: data });
} else {
res.json({ date: [] });
}
});
});
router.get("/:name", async (req, res, next) => {
const auth = await store.get(req.query.authKey);
if (isEmpty(auth)) {
next(
new createHttpError.Forbidden(
`No authenication information found in store`
)
);
return;
}
const propertyName = req.params.name;
const { host, tenant, token } = auth;
logger.debug(`tps GET ${propertyName} from ${host}`);
if (!propertyName || propertyName.length == 0) {
next(new createHttpError.NotFound(`Blank property keys not allowed`));
return;
}
axios
.get(
`${host}/tenant-properties-service/${tenant}/properties?q=${propertyName}`,
{
headers: {
Authorization: `OIDC_id_token ${token}`,
"Content-Type": "application/json",
},
}
)
.then((result) => {
if (result.data.errors && result.data.errors.length > 0) {
result.data.errors.forEach(function (error) {
logger.error("ERROR: Errors in results - " + error.message);
});
next(new Error("Error(s) getting propertyName " + propertyName));
return;
}
const totalItems = result.data["hydra:totalItems"];
logger.debug("tps result has hydra:totalItems [" + totalItems + "]");
if (totalItems > 0) {
const member = result.data["hydra:member"][0];
res.json({
data: { name: member["vcfg:name"], value: member["vcfg:value"] },
});
} else {
next(
new createHttpError.NotFound(`property ${propertyName} not found`)
);
}
});
});
router.post("/", async (req, res, next) => {
const auth = await store.get(req.query.authKey);
if (isEmpty(auth)) {
next(
new createHttpError.Forbidden(
`No authenication information found in store`
)
);
return;
}
const { host, tenant, token } = auth;
const property = req.body.data;
const body = `[
{
"@type" : "vcfg:PropertyUpdateOrCreate",
"vcfg:name" :"${property.name}",
"vcfg:value" : ${JSON.stringify(property.value)}
}
]`;
logger.debug(`tps PATCH ${body} to ${host}`);
axios
.patch(`${host}/tenant-properties-service/${tenant}/properties`, body, {
headers: {
Authorization: `OIDC_id_token ${token}`,
"Content-Type": "application/ld+json",
},
})
.catch(function (error) {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
logger.error(JSON.stringify(error.response.data));
logger.error(JSON.stringify(error.response.status));
logger.error(JSON.stringify(error.response.headers));
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser
// and an instance of http.ClientRequest in node.js
logger.error(JSON.stringify(error.request));
} else {
// Something happened in setting up the request that triggered an Error
logger.error("Error", error.message);
}
});
});
router.delete("/:name", async (req, res, next) => {
const auth = await store.get(req.query.authKey);
if (isEmpty(auth)) {
next(
new createHttpError.Forbidden(
`No authenication information found in store`
)
);
return;
}
const { host, tenant, token } = auth;
const propertyName = req.params.name;
const body = `[
{
"@type" : "vcfg:PropertyDelete",
"vcfg:name" : "${propertyName}"
}
]`;
logger.debug(`tps PATCH ${body} to ${host}`);
axios
.patch(`${host}/tenant-properties-service/${tenant}/properties`, body, {
headers: {
Authorization: `OIDC_id_token ${token}`,
"Content-Type": "application/ld+json",
},
})
.catch(function (error) {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
logger.error(JSON.stringify(error.response.data));
logger.error(JSON.stringify(error.response.status));
logger.error(JSON.stringify(error.response.headers));
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser
// and an instance of http.ClientRequest in node.js
logger.error(JSON.stringify(error.request));
} else {
// Something happened in setting up the request that triggered an Error
logger.error("Error", error.message);
}
});
});
export default router;

View File

@@ -0,0 +1,139 @@
import { Router } from "express";
import axios from "axios";
import { logger } from "../../utils/index.js";
// import { URLSearchParams } from "url";
import { inspect } from "util";
import localStorage from "local-storage";
import { referenceIdQuery } from "../../utils/graphQueries.js";
const router = Router();
router.get("/", (req, res, next) => {
query(req.query.referenceId);
function query(referenceId) {
logger.info(`Executing Query with ${referenceId}`);
const startTime = new Date(
new Date().setFullYear(new Date().getFullYear() - 2)
);
var endTime = new Date();
// FIX for 'endTime' parameter cannot be later than one minute prior to now.
endTime.setMinutes(endTime.getMinutes() - 1);
const { host, tenant, token } = localStorage(req.query.authKey);
axios
.post(
`${host}/unified-data-gateway/${tenant}/graphql`,
JSON.stringify({
query: referenceIdQuery,
variables: { startTime, endTime },
}),
{
headers: {
Authorization: `OIDC_id_token ${token}`,
"Content-Type": "application/json",
},
}
)
.then((result) => {
if (result.data.errors && result.data.errors.length > 0) {
result.data.errors.forEach(function (error) {
logger.error("ERROR: Errors in results - " + error.message);
});
// TODO: Should keep errors for filteredContacts
result.data.errors = [];
}
const contacts = result.data.data.findContactsCompletedBetween.edges;
const filteredContacts = [];
contacts.forEach(function (contact) {
if (contact.node.interaction.__typename === "Email") {
// threadId if Reference in Subject line will do
if (
(contact.node.interaction.threadId === referenceId) |
contact.node.interaction.subject.includes(
`<< Ref:${referenceId} >>`
)
) {
filteredContacts.push(contact);
}
}
});
result.data.data.findContactsCompletedBetween.edges = filteredContacts;
result.data.data.findContactsCompletedBetween.totalCount =
filteredContacts.length;
// Summary Values
const summary = {};
summary.totalCount = filteredContacts.length;
summary.totalInboundCount = 0;
summary.totalInboundActiveSeconds = 0;
if (summary.totalCount > 0) {
summary.firstContactReceivedDate = new Date(
filteredContacts[0].node.interaction.receivedDate
);
filteredContacts.forEach(function (contact) {
if (contact.node.direction === "INBOUND") {
summary.totalInboundCount++;
if (!summary.firstInboundContactStartDate) {
summary.firstInboundContactStartDate = new Date(
contact.node.startTime
);
summary.firstContactReceivedDate = new Date(
contact.node.interaction.receivedDate
);
}
summary.totalInboundActiveSeconds += contact.node.activeDuration;
}
});
// TODO: Because of overlapping contacts, we may need to calculate max instead of last.
summary.lastContactEndTime = new Date(
filteredContacts[filteredContacts.length - 1].node.endTime
);
summary.totalHTHours =
(summary.lastContactEndTime.getTime() -
summary.firstContactReceivedDate.getTime()) /
(1000 * 3600);
summary.activeHTMinutes =
(summary.lastContactEndTime.getTime() -
summary.firstInboundContactStartDate.getTime()) /
(1000 * 60);
}
result.data.data.summary = summary;
if (result.data) {
logger.debug(
inspect(summary, {
showHidden: false,
depth: null,
colors: true,
})
);
res.send(result.data);
}
// expired token -> send nothing
else {
req.session.destroy();
res.send({});
}
})
.catch((err) => {
// bin the token on error
req.session.destroy();
next(err);
});
}
});
export default router;

18
src/app.js Normal file
View File

@@ -0,0 +1,18 @@
import express from "express";
import config from "./config/index.js";
import loader from "./loaders/index.js";
import { logger } from "./utils/index.js";
const app = express();
loader(app);
app.listen(config.port, (err) => {
if (err) {
logger.info(err);
return process.exit(1);
}
logger.info(`Server is running on ${config.port}`);
});
export default app;

39
src/config/index.js Normal file
View File

@@ -0,0 +1,39 @@
import dotenv from "dotenv";
// Set the NODE_ENV to 'development' by default
process.env.NODE_ENV = process.env.NODE_ENV || "development";
if (process.env.NODE_ENV !== "production") {
const envFound = dotenv.config({ debug: true });
if (envFound.error) {
// This error should crash whole process
throw new Error("⚠️ Couldn't find .env file ⚠️");
}
}
export default {
/**
* Your favorite port
*/
port: parseInt(process.env.PORT || 3000, 10),
/**
* Used by winston logger
*/
logs: {
level: process.env.LOG_LEVEL || "silly",
},
store: {
provider: process.env.STORE_PROVIDER || "local-storage",
url: process.env.STORE_URL || "redis://localhost:6379",
},
/**
* API configs
*/
api: {
prefix: process.env.API_PREFIX || "/api",
},
};

View File

@@ -1,9 +1,7 @@
const express = require("express");
const cors = require("cors");
const morgan = require("morgan");
const session = require("express-session");
const axios = require("axios");
const qs = require("query-string");
import { logger } from "./utils/index.js";
// dotenv
require("dotenv").config();
@@ -12,7 +10,6 @@ const app = express();
// Use our middlewares
app.use(cors({ origin: true, credentials: true }));
app.use(morgan("common"));
app.use(express.json());
app.use(
session({
@@ -32,16 +29,18 @@ const port = process.env.SERVER_PORT || 3000;
// Listen to server
app.listen(port, () => {
console.log(`Listening on port ${port}`);
logger.info(`Listening on port ${port}`);
});
//...
// ...
// Main Page
app.get("/", (req, res) => {
res.send({
message: "Engagement Orchestration Services",
});
});
//...
// ...
app.use("/config", require("./routes/config"));
app.use("/unified-data-gateway", require("./routes/unified-data-gateway"));
app.use("/interactions-flow", require("./routes/interactions-flow"));

103
src/loaders/express.js Normal file
View File

@@ -0,0 +1,103 @@
import express from "express";
import cors from "cors";
import compression from "compression";
import morgan from "morgan";
import helmet from "helmet";
import config from "./../config/index.js";
import routes from "./../api/routes/index.js";
import { logger } from "../utils/index.js";
// import { rateLimiter } from '../api/middlewares/index.js';
import bodyParser from "body-parser";
import session from "express-session";
import cookieParser from "cookie-parser";
export default (app) => {
logger.info("Loading Express ...");
process.on("uncaughtException", async (error) => {
logger.error(error);
});
process.on("unhandledRejection", async (ex) => {
logger.error(ex);
});
app.enable("trust proxy");
// app.use(cors());
app.use(cors({ origin: true, credentials: true }));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// app.use(morgan("dev"));
app.use(
morgan("common", {
stream: {
write: (message) => logger.info(message.trim()),
},
})
);
app.use(helmet());
app.use(compression());
app.use(cookieParser());
app.use(express.static("public"));
app.disable("x-powered-by");
app.disable("etag");
app.use(
session({
secret: "1234567890", // don't use this secret in prod :)
resave: false,
saveUninitialized: false,
cookie: {
secure: "auto",
httpOnly: true,
maxAge: 3600000,
},
})
);
// app.use(rateLimiter);
logger.info(`Mounting routes on ${config.api.prefix}`);
app.use(config.api.prefix, routes);
app.get("/", (_req, res) => {
return res
.status(200)
.json({
resultMessage: {
en: "Project is successfully working...",
},
resultCode: "00004",
})
.end();
});
app.use((req, res, next) => {
// res.header("Access-Control-Allow-Origin", "*");
// res.header(
// "Access-Control-Allow-Headers",
// "Origin, X-Requested-With, Content-Type, Accept, Authorization"
// );
// res.header("Content-Security-Policy-Report-Only", "default-src: https:");
// if (req.method === "OPTIONS") {
// res.header("Access-Control-Allow-Methods", "PUT POST PATCH DELETE GET");
// return res.status(200).json({});
// }
next();
});
app.use((_req, _res, next) => {
const error = new Error("Endpoint could not find!");
error.status = 404;
next(error);
});
app.use((error, req, res) => {
res.status(error.status || 500);
logger.error(error.message);
return res.json({
resultMessage: {
en: error.message,
tr: error.message,
},
});
});
};

5
src/loaders/index.js Normal file
View File

@@ -0,0 +1,5 @@
import expressLoader from "./express.js";
export default async (app) => {
expressLoader(app);
};

74
src/utils/graphQueries.js Normal file
View File

@@ -0,0 +1,74 @@
export const referenceIdQuery = `query ($startTime: DateTime, $endTime: DateTime) {
findContactsCompletedBetween(startTime: $startTime, endTime: $endTime) {
totalCount
edges {
node {
systemId
startTime
endTime
direction
handledBy {
username
firstName
lastName
nickname
orgScope
}
activeDuration
notes {
totalCount
edges {
node {
text
}
}
}
interaction {
systemId
locale
__typename
... on Email {
messageId
threadId
sentDate
receivedDate
subject
fromAddress
toAddresses
ccAddresses
bccAddresses
detectedLanguage
mailboxName
attachmentCount
isDuplicate
}
}
outcome {
totalCount
edges {
node {
text
isActive
isVisible
}
}
}
customer {
totalCount
edges {
node {
ref
firstName
lastName
}
}
}
queue {
name
orgScope
}
}
}
}
}`;

11
src/utils/index.js Normal file
View File

@@ -0,0 +1,11 @@
export { default as logger } from "./logger.js";
export { default as store } from "./store.js";
export function isEmpty(obj) {
for (const prop in obj) {
if (Object.hasOwn(obj, prop)) {
return false;
}
}
return true;
}

32
src/utils/logger.js Normal file
View File

@@ -0,0 +1,32 @@
import winston from "winston";
import config from "../config/index.js";
const transports = [];
if (process.env.NODE_ENV !== "development") {
transports.push(new winston.transports.Console());
} else {
transports.push(
new winston.transports.Console({
format: winston.format.combine(
winston.format.cli(),
winston.format.splat()
),
})
);
}
const LoggerInstance = winston.createLogger({
level: config.logs.level,
levels: winston.config.npm.levels,
format: winston.format.combine(
winston.format.timestamp({
format: "YYYY-MM-DD HH:mm:ss",
}),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
transports,
});
export default LoggerInstance;

21142
src/utils/sampleUDGresponse.js Normal file

File diff suppressed because one or more lines are too long

59
src/utils/store.js Normal file
View File

@@ -0,0 +1,59 @@
import { createClient } from "redis";
import localStorage from "local-storage";
import config from "../config/index.js";
import { logger } from "./index.js";
import Promise from "promise";
const store = await createStore();
async function createStore() {
if (config.store.provider === "redis") {
return {
client: await createRedisClient(),
set: async function (key, value) {
return this.client.hSet(key, value);
},
get: async function (key) {
return this.client.hGetAll(key);
},
};
} else {
return {
set: function (key, value) {
return new Promise(function (resolve, reject) {
if (localStorage(key, value)) resolve(true);
else reject(Error("unable to set using local-storage"));
});
},
get: function (key) {
return new Promise(function (resolve) {
resolve(localStorage(key));
});
},
};
}
}
async function createRedisClient() {
const client = createClient({
url: config.store.url,
});
client.on("error", (err) => {
if (err.errors) {
err.errors.forEach((error) => {
logger.error(`Redis Client Error ${error}`);
});
} else {
logger.error(`Redis Client Error ${err.message}`);
}
});
await client.connect();
logger.info(`Connect store to ${config.store.url}`);
return client;
}
export default store;