Compare commits

...

5 Commits

Author SHA1 Message Date
Dave Richer
fa7e0a107b hotfix/AdditionalProductFruitsIds - Add additional IDs for product fruits 2025-01-17 10:13:41 -08:00
Patrick Fic
d187ed6f73 Merged in release/2025-01-10 (pull request #2049)
Release/2025 01 10
2025-01-10 22:03:04 +00:00
Patrick Fic
68c1ac3e70 Merged in feature/IO-3076-daily-usage-reports (pull request #2047)
Feature/IO-3076 daily usage reports

Approved-by: Dave Richer
2025-01-10 19:47:37 +00:00
Patrick Fic
2f267a9f2c IO-3076 updates to usage report. 2025-01-10 11:39:18 -08:00
Patrick Fic
d6fbf02092 IO-3076 Initial usage reports design. 2025-01-09 11:22:08 -08:00
10 changed files with 307 additions and 135 deletions

View File

@@ -519,6 +519,7 @@ export function JobLinesComponent({
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
<Button
id="job-lines-order-parts-button"
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
onClick={() => {
setPartsOrderContext({
@@ -541,6 +542,7 @@ export function JobLinesComponent({
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
<Button
id="job-lines-filter-parts-only-button"
onClick={() => {
setState((state) => ({
...state,
@@ -554,7 +556,7 @@ export function JobLinesComponent({
<FilterFilled /> {t("jobs.actions.filterpartsonly")}
</Button>
<Dropdown menu={markMenu} trigger={["click"]}>
<Button>{t("jobs.actions.mark")}</Button>
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
</Dropdown>
<Button
disabled={jobRO || technician}

View File

@@ -38,7 +38,11 @@ export default function OwnerFindModalContainer({
}, [callSearchowners, modalProps.open, owner]);
return (
<Modal title={t("owners.labels.existing_owners")} width={"80%"} {...modalProps}>
<Modal
title={<span id="owner-find-modal-title">{t("owners.labels.existing_owners")}</span>}
width={"80%"}
{...modalProps}
>
{loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null}
{owner ? (

View File

@@ -39,50 +39,50 @@ END;
$$;
CREATE FUNCTION public.assign_ro_number() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
IF NEW.converted = true and (new.ro_number is null or new.ro_number = '') THEN
UPDATE counters
SET count = count + 1 where shopid=new.shopid AND countertype = 'ronum'
RETURNING concat(prefix,count) into new.ro_number;
END IF;
RETURN NEW;
END;
AS $$
begin
IF NEW.converted = true and (new.ro_number is null or new.ro_number = '') THEN
UPDATE counters
SET count = count + 1 where shopid=new.shopid AND countertype = 'ronum'
RETURNING concat(prefix,count) into new.ro_number;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.audit_trigger() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
shopid uuid ;
email text;
BEGIN
select b.id, u.email INTO shopid, email from users u join associations a on u.email = a.useremail join bodyshops b on b.id = a.shopid where u.authid = current_setting('hasura.user', 't')::jsonb->>'x-hasura-user-id' and a.active = true;
IF TG_OP = 'INSERT'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, new_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(NEW), NEW.id, shopid, email);
RETURN NEW;
ELSIF TG_OP = 'UPDATE'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, old_val, new_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP,
json_diff(to_jsonb(OLD), to_jsonb(NEW)) , json_diff(to_jsonb(NEW), to_jsonb(OLD)), OLD.id, shopid, email);
RETURN NEW;
ELSIF TG_OP = 'DELETE'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, old_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(OLD), OLD.ID, shopid, email);
RETURN OLD;
END IF;
END;
AS $$
DECLARE
shopid uuid ;
email text;
BEGIN
select b.id, u.email INTO shopid, email from users u join associations a on u.email = a.useremail join bodyshops b on b.id = a.shopid where u.authid = current_setting('hasura.user', 't')::jsonb->>'x-hasura-user-id' and a.active = true;
IF TG_OP = 'INSERT'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, new_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(NEW), NEW.id, shopid, email);
RETURN NEW;
ELSIF TG_OP = 'UPDATE'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, old_val, new_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP,
json_diff(to_jsonb(OLD), to_jsonb(NEW)) , json_diff(to_jsonb(NEW), to_jsonb(OLD)), OLD.id, shopid, email);
RETURN NEW;
ELSIF TG_OP = 'DELETE'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, old_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(OLD), OLD.ID, shopid, email);
RETURN OLD;
END IF;
END;
$$;
CREATE FUNCTION public.json_diff(l jsonb, r jsonb) RETURNS jsonb
LANGUAGE sql
AS $$
SELECT jsonb_object_agg(a.key, a.value) FROM
( SELECT key, value FROM jsonb_each(l) ) a LEFT OUTER JOIN
( SELECT key, value FROM jsonb_each(r) ) b ON a.key = b.key
WHERE a.value != b.value OR b.key IS NULL;
AS $$
SELECT jsonb_object_agg(a.key, a.value) FROM
( SELECT key, value FROM jsonb_each(l) ) a LEFT OUTER JOIN
( SELECT key, value FROM jsonb_each(r) ) b ON a.key = b.key
WHERE a.value != b.value OR b.key IS NULL;
$$;
CREATE TABLE public.bills (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,
@@ -211,33 +211,33 @@ CREATE TABLE public.exportlog (
);
CREATE FUNCTION public.search_exportlog(search text) RETURNS SETOF public.exportlog
LANGUAGE plpgsql STABLE
AS $$ BEGIN IF search = '' THEN RETURN query
SELECT
*
FROM
exportlog e;
ELSE RETURN query
SELECT
e.*
FROM
exportlog e
LEFT JOIN jobs j on j.id = e.jobid
LEFT JOIN payments p
ON p.id = e.paymentid
LEFT JOIN bills b
ON e.billid = b.id
WHERE
(
j.ro_number ILIKE '%' || search || '%'
OR b.invoice_number ILIKE '%' || search || '%'
OR p.paymentnum ILIKE '%' || search || '%'
OR e.useremail ILIKE '%' || search || '%'
)
AND (e.jobid = j.id
or e.paymentid = p.id
or e.billid = b.id)
;
END IF;
AS $$ BEGIN IF search = '' THEN RETURN query
SELECT
*
FROM
exportlog e;
ELSE RETURN query
SELECT
e.*
FROM
exportlog e
LEFT JOIN jobs j on j.id = e.jobid
LEFT JOIN payments p
ON p.id = e.paymentid
LEFT JOIN bills b
ON e.billid = b.id
WHERE
(
j.ro_number ILIKE '%' || search || '%'
OR b.invoice_number ILIKE '%' || search || '%'
OR p.paymentnum ILIKE '%' || search || '%'
OR e.useremail ILIKE '%' || search || '%'
)
AND (e.jobid = j.id
or e.paymentid = p.id
or e.billid = b.id)
;
END IF;
END $$;
CREATE TABLE public.jobs (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,

View File

@@ -39,50 +39,50 @@ END;
$$;
CREATE FUNCTION public.assign_ro_number() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
IF NEW.converted = true and (new.ro_number is null or new.ro_number = '') THEN
UPDATE counters
SET count = count + 1 where shopid=new.shopid AND countertype = 'ronum'
RETURNING concat(prefix,count) into new.ro_number;
END IF;
RETURN NEW;
END;
AS $$
begin
IF NEW.converted = true and (new.ro_number is null or new.ro_number = '') THEN
UPDATE counters
SET count = count + 1 where shopid=new.shopid AND countertype = 'ronum'
RETURNING concat(prefix,count) into new.ro_number;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.audit_trigger() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
shopid uuid ;
email text;
BEGIN
select b.id, u.email INTO shopid, email from users u join associations a on u.email = a.useremail join bodyshops b on b.id = a.shopid where u.authid = current_setting('hasura.user', 't')::jsonb->>'x-hasura-user-id' and a.active = true;
IF TG_OP = 'INSERT'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, new_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(NEW), NEW.id, shopid, email);
RETURN NEW;
ELSIF TG_OP = 'UPDATE'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, old_val, new_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP,
json_diff(to_jsonb(OLD), to_jsonb(NEW)) , json_diff(to_jsonb(NEW), to_jsonb(OLD)), OLD.id, shopid, email);
RETURN NEW;
ELSIF TG_OP = 'DELETE'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, old_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(OLD), OLD.ID, shopid, email);
RETURN OLD;
END IF;
END;
AS $$
DECLARE
shopid uuid ;
email text;
BEGIN
select b.id, u.email INTO shopid, email from users u join associations a on u.email = a.useremail join bodyshops b on b.id = a.shopid where u.authid = current_setting('hasura.user', 't')::jsonb->>'x-hasura-user-id' and a.active = true;
IF TG_OP = 'INSERT'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, new_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(NEW), NEW.id, shopid, email);
RETURN NEW;
ELSIF TG_OP = 'UPDATE'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, old_val, new_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP,
json_diff(to_jsonb(OLD), to_jsonb(NEW)) , json_diff(to_jsonb(NEW), to_jsonb(OLD)), OLD.id, shopid, email);
RETURN NEW;
ELSIF TG_OP = 'DELETE'
THEN
INSERT INTO public.audit_trail (tabname, schemaname, operation, old_val, recordid, bodyshopid, useremail)
VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(OLD), OLD.ID, shopid, email);
RETURN OLD;
END IF;
END;
$$;
CREATE FUNCTION public.json_diff(l jsonb, r jsonb) RETURNS jsonb
LANGUAGE sql
AS $$
SELECT jsonb_object_agg(a.key, a.value) FROM
( SELECT key, value FROM jsonb_each(l) ) a LEFT OUTER JOIN
( SELECT key, value FROM jsonb_each(r) ) b ON a.key = b.key
WHERE a.value != b.value OR b.key IS NULL;
AS $$
SELECT jsonb_object_agg(a.key, a.value) FROM
( SELECT key, value FROM jsonb_each(l) ) a LEFT OUTER JOIN
( SELECT key, value FROM jsonb_each(r) ) b ON a.key = b.key
WHERE a.value != b.value OR b.key IS NULL;
$$;
CREATE TABLE public.bills (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,
@@ -211,33 +211,33 @@ CREATE TABLE public.exportlog (
);
CREATE FUNCTION public.search_exportlog(search text) RETURNS SETOF public.exportlog
LANGUAGE plpgsql STABLE
AS $$ BEGIN IF search = '' THEN RETURN query
SELECT
*
FROM
exportlog e;
ELSE RETURN query
SELECT
e.*
FROM
exportlog e
LEFT JOIN jobs j on j.id = e.jobid
LEFT JOIN payments p
ON p.id = e.paymentid
LEFT JOIN bills b
ON e.billid = b.id
WHERE
(
j.ro_number ILIKE '%' || search || '%'
OR b.invoice_number ILIKE '%' || search || '%'
OR p.paymentnum ILIKE '%' || search || '%'
OR e.useremail ILIKE '%' || search || '%'
)
AND (e.jobid = j.id
or e.paymentid = p.id
or e.billid = b.id)
;
END IF;
AS $$ BEGIN IF search = '' THEN RETURN query
SELECT
*
FROM
exportlog e;
ELSE RETURN query
SELECT
e.*
FROM
exportlog e
LEFT JOIN jobs j on j.id = e.jobid
LEFT JOIN payments p
ON p.id = e.paymentid
LEFT JOIN bills b
ON e.billid = b.id
WHERE
(
j.ro_number ILIKE '%' || search || '%'
OR b.invoice_number ILIKE '%' || search || '%'
OR p.paymentnum ILIKE '%' || search || '%'
OR e.useremail ILIKE '%' || search || '%'
)
AND (e.jobid = j.id
or e.paymentid = p.id
or e.billid = b.id)
;
END IF;
END $$;
CREATE TABLE public.jobs (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,

View File

@@ -329,7 +329,8 @@ const main = async () => {
main().catch((error) => {
logger.log(`Main-API-Error: Something was not caught in the application.`, "error", "api", null, {
error: error.message,
errorjson: JSON.stringify(error)
errorjson: JSON.stringify(error),
stack: error.stack
});
// Note: If we want the app to crash on all uncaught async operations, we would
// need to put a `process.exit(1);` here

View File

@@ -3,3 +3,4 @@ exports.autohouse = require("./autohouse").default;
exports.chatter = require("./chatter").default;
exports.claimscorp = require("./claimscorp").default;
exports.kaizen = require("./kaizen").default;
exports.usageReport = require("./usageReport").default;

View File

@@ -0,0 +1,90 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const client = require("../graphql-client/graphql-client").client;
const emailer = require("../email/sendemail");
const moment = require("moment-timezone");
const converter = require("json-2-csv");
const logger = require("../utils/logger");
const queries = require("../graphql-client/queries");
const InstanceMgr = require("../utils/instanceMgr").default;
exports.default = async (req, res) => {
try {
logger.log("usage-report-email-start", "debug", req?.user?.email, null, {});
if (InstanceMgr({ rome: false, imex: true })) {
//Disable for ImEX at the moment.
res.sendStatus(403);
logger.log("usage-report-email-forbidden", "warn", req?.user?.email, null, {});
return;
}
if (process.env.NODE_ENV !== "production") {
res.sendStatus(403);
return;
}
// Validate using autohouse token header.
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) {
res.sendStatus(401);
logger.log("usage-report-email-forbidden", "warn", req?.user?.email, null, {});
return;
}
//Query the usage data.
const queryResults = await client.request(queries.STATUS_UPDATE, {
today: moment().startOf("day").subtract(3, "days"),
period: moment().subtract(90, "days").startOf("day")
});
//Massage the data.
const shopList = queryResults.bodyshops.map((shop) => ({
"Shop Name": shop.shopname,
"Days Since Creation": moment().diff(moment(shop.created_at), "days"),
"Jobs Created": shop.jobs_created.aggregate.count,
"Jobs Updated": shop.jobs_updated.aggregate.count,
"Owners Created": shop.owners_created.aggregate.count,
"Owners Updated": shop.owners_updated.aggregate.count,
"Vehicles Created": shop.vehicles_created.aggregate.count,
"Vehicles Updated": shop.vehicles_updated.aggregate.count,
"Tasks Created": shop.tasks_created.aggregate.count,
"Tasks Updated": shop.tasks_updated.aggregate.count
}));
const csv = converter.json2csv(shopList, { emptyFieldValue: "" });
emailer
.sendTaskEmail({
to: ["patrick.fic@convenient-brands.com", "bradley.rhoades@convenient-brands.com"],
subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`,
text: `
Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers.
Notes:
- Days Since Creation: The number of days since the shop was created. Only shops created in the last 90 days are included.
- Updated values should be higher than created values.
- Counts are inclusive of the last 3 days of data.
`,
attachments: [{ filename: `RO Usage Report ${moment().format("MM/DD/YYYY")}.csv`, content: csv }]
})
.then(() => {
logger.log("usage-report-email-success", "debug", req?.user?.email, null, {
csv
});
})
.catch((error) => {
logger.log("usage-report-email-send-error", "ERROR", req?.user?.email, null, {
error: error.message,
stack: error.stack
});
});
res.sendStatus(200);
return;
} catch (error) {
logger.log("usage-report-email-error", "ERROR", req?.user?.email, null, {
error: error.message,
stack: error.stack
});
res.status(500).json({ error: error.message, stack: error.stack });
}
};

View File

@@ -2617,3 +2617,76 @@ exports.CREATE_CONVERSATION = `mutation CREATE_CONVERSATION($conversation: [conv
}
}
`;
exports.STATUS_UPDATE = `query STATUS_UPDATE($period: timestamptz!, $today: timestamptz!) {
bodyshops(where: { created_at: { _gte: $period } }) {
shopname
id
created_at
jobs_created: jobs_aggregate(where: { created_at: { _gte: $today } }) {
aggregate {
count
}
}
jobs_updated: jobs_aggregate(where: { updated_at: { _gte: $today } }) {
aggregate {
count
}
}
owners_created: owners_aggregate(where: { created_at: { _gte: $today } }) {
aggregate {
count
}
}
owners_updated: owners_aggregate(where: { updated_at: { _gte: $today } }) {
aggregate {
count
}
}
vehicles_created: vehicles_aggregate(
where: { created_at: { _gte: $today } }
) {
aggregate {
count
}
}
vehicles_updated: vehicles_aggregate(
where: { updated_at: { _gte: $today } }
) {
aggregate {
count
}
}
tasks_created: tasks_aggregate(where: { created_at: { _gte: $today } }) {
aggregate {
count
}
}
tasks_updated: tasks_aggregate(where: { updated_at: { _gte: $today } }) {
aggregate {
count
}
}
jobs {
parts_orders_created: parts_orders_aggregate(
where: { created_at: { _gte: $today } }
) {
aggregate {
count
}
}
parts_orders_updated: parts_orders_aggregate(
where: { updated_at: { _gte: $today } }
) {
aggregate {
count
}
}
}
}
}
`

View File

@@ -1,10 +1,11 @@
const express = require("express");
const router = express.Router();
const { autohouse, claimscorp, chatter, kaizen } = require("../data/data");
const { autohouse, claimscorp, chatter, kaizen, usageReport } = require("../data/data");
router.post("/ah", autohouse);
router.post("/cc", claimscorp);
router.post("/chatter", chatter);
router.post("/kaizen", kaizen);
router.post("/usagereport", usageReport);
module.exports = router;

View File

@@ -41,7 +41,7 @@ exports.taskHandler = async (req, res) => {
return res.status(200).send(csv);
} catch (error) {
res.status(500).json({ error: error.message, stack: error.stackTrace });
res.status(500).json({ error: error.message, stack: error.stack });
}
};