Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,13 @@
import { FormulaComponentDto } from "../dtos/FormulaDto";
import FormulaHelpers from "../utils/FormulaHelpers";
export default function FormulaComponentBadge({ item }: { item: FormulaComponentDto }) {
return (
<div>
{item.type === "variable" && <span className="text-muted-foreground text-sm">{item.value}</span>}
{item.type === "operator" && <span className="text-foreground text-sm font-bold">{FormulaHelpers.getOperatorSymbol(item.value)}</span>}
{item.type === "parenthesis" && <span className="text-muted-foreground">{item.value === "OPEN" ? "(" : ")"}</span>}
{item.type === "value" && <span className="text-sm font-bold text-blue-900">{item.value}</span>}
</div>
);
}
@@ -0,0 +1,133 @@
import { Form } from "react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import InputText from "~/components/ui/input/InputText";
import Modal from "~/components/ui/modals/Modal";
import { FormulaComponentDto, FormulaOperatorTypes, FormulaComponentType } from "../dtos/FormulaDto";
import FormulaHelpers from "../utils/FormulaHelpers";
import InputSelect from "~/components/ui/input/InputSelect";
import InputRadioGroupCards from "~/components/ui/input/InputRadioGroupCards";
export default function FormulaComponentModal({
item,
idx,
order,
open,
onClose,
onSave,
onRemove,
}: {
item: FormulaComponentDto | undefined;
idx: number | undefined;
order: number;
open: boolean;
onClose: () => void;
onSave: (item: FormulaComponentDto) => void;
onRemove?: (idx: number) => void;
}) {
const { t } = useTranslation();
const [type, setType] = useState<FormulaComponentType>(item?.type ?? "variable");
const [value, setValue] = useState<string>(item?.value ?? "");
useEffect(() => {
setType(item?.type ?? "variable");
setValue(item?.value ?? "");
}, [item]);
function onConfirm() {
if (!type) {
return;
}
onSave({ order, type, value });
}
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
onConfirm();
}
function onClosing() {
setType("variable");
setValue("");
onClose();
}
return (
<Modal open={open} setOpen={onClosing} size="lg">
<Form onSubmit={onSubmit} className="inline-block h-full w-full overflow-visible p-1 text-left align-bottom sm:align-middle">
<input name="action" type="hidden" value="create" readOnly hidden />
<div className="mt-3">
<h3 className="text-foreground text-lg font-medium leading-6">
{idx === undefined ? t("pages.formulas.components.new") : t("pages.formulas.components.edit")}
</h3>
</div>
<div className="mt-4 space-y-2" key={idx}>
<div className="grid gap-2">
<div className="">
<InputRadioGroupCards
columns={3}
name="type"
value={type}
onChange={(e) => setType(e?.toString() as FormulaComponentType)}
required
options={[
{ name: t("pages.formulas.components.types.variable"), value: "variable" },
{ name: t("pages.formulas.components.types.operator"), value: "operator" },
// { name: "parenthesis", value: "parenthesis" },
{ name: t("pages.formulas.components.types.value"), value: "value" },
]}
/>
</div>
{type === "variable" ? (
<InputText autoFocus name="name" title={t("shared.name")} value={value} setValue={(e) => setValue(e.toString())} required />
) : type === "operator" ? (
<InputSelect
autoFocus
name="operator"
title={t("pages.formulas.operator")}
value={value}
setValue={(e) => setValue(FormulaHelpers.getOperatorType(e?.toString() ?? ""))}
required
options={FormulaOperatorTypes.map((item) => {
return { name: t(`pages.formulas.operators.${item}`), value: item };
})}
/>
) : type === "parenthesis" ? (
<InputSelect
autoFocus
name="parenthesis"
title={t("pages.formulas.parenthesis.title")}
value={value}
setValue={(e) => setValue(FormulaHelpers.getParenthesisType(e?.toString() ?? ""))}
required
options={["OPEN", "CLOSE"].map((item) => {
return { name: t(`pages.formulas.parenthesis.${item}`), value: item };
})}
/>
) : type === "value" ? (
<InputText autoFocus name="value" title={t("shared.value")} value={value} setValue={(e) => setValue(e.toString())} required />
) : null}
</div>
</div>
<div className="mt-3 flex justify-between space-x-2">
<div>
{onRemove && idx !== undefined && (
<ButtonSecondary
type="button"
destructive
onClick={() => {
onRemove(idx);
onClosing();
}}
>
{t("shared.remove")}
</ButtonSecondary>
)}
</div>
<ButtonPrimary type="submit">{t("shared.save")}</ButtonPrimary>
</div>
</Form>
</Modal>
);
}
@@ -0,0 +1,170 @@
import { useNavigation, useNavigate, Form } from "react-router";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText from "~/components/ui/input/InputText";
import InputSelect from "~/components/ui/input/InputSelect";
import { FormulaCalculationTriggerTypes, FormulaComponentDto, FormulaDto, FormulaResultAsTypes } from "../dtos/FormulaDto";
import FormulaHelpers from "../utils/FormulaHelpers";
import FormulaComponentModal from "./FormulaComponentModal";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
import { getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import FormulaComponentBadge from "./FormulaComponentBadge";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
export default function FormulaForm({ item, onDelete }: { item?: FormulaDto; onDelete?: () => void }) {
const { t } = useTranslation();
const appOrAdminData = useAppOrAdminData();
const navigation = useNavigation();
const navigate = useNavigate();
const confirmModalDelete = useRef<RefConfirmModal>(null);
const [showModal, setShowModal] = useState<{
item?: FormulaComponentDto;
idx?: number;
}>();
const [name, setName] = useState(item?.name || "");
const [description, setDescription] = useState(item?.description || "");
const [resultAs, setResultAs] = useState(item?.resultAs || "number");
const [calculationTrigger, setCalculationTrigger] = useState(item?.calculationTrigger || "IF_UNSET");
const [withLogs, setWithLogs] = useState(item?.withLogs || false);
const [components, setComponents] = useState<FormulaComponentDto[]>(item?.components || []);
function onSave(item: FormulaComponentDto) {
const idx = showModal?.idx;
if (idx !== undefined) {
components[idx] = item;
} else {
components.push({
...item,
order: components.length + 1,
});
}
setComponents([...components]);
setShowModal(undefined);
}
function getActionName() {
return item ? "edit" : "new";
}
function onDeleteClick() {
confirmModalDelete.current?.show(
t("pages.formulas.confirmDelete.title"),
t("shared.delete"),
t("shared.cancel"),
t("pages.formulas.confirmDelete.description")
);
}
return (
<div>
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<input type="hidden" name="action" value={getActionName()} hidden readOnly />
{components.map((component, index) => {
return <input type="hidden" name="components[]" value={JSON.stringify(component)} key={index} hidden readOnly />;
})}
<div className="grid grid-cols-2 gap-2">
<InputText autoFocus name="name" title={t("shared.name")} value={name} setValue={setName} required />
<InputText name="description" title={t("pages.formulas.fields.description")} value={description} setValue={setDescription} />
<InputSelect
name="resultAs"
title={t("pages.formulas.fields.resultAs")}
value={resultAs}
setValue={(e) => setResultAs(FormulaHelpers.getResultAs(e?.toString() ?? ""))}
options={FormulaResultAsTypes.map((item) => {
return { name: t(`pages.formulas.resultAs.${item}`), value: item };
})}
/>
<InputSelect
name="calculationTrigger"
title={t("pages.formulas.fields.calculationTrigger")}
value={calculationTrigger}
setValue={(e) => setCalculationTrigger(FormulaHelpers.getCalculationTrigger(e?.toString() ?? ""))}
options={FormulaCalculationTriggerTypes.map((item) => {
return { name: t(`pages.formulas.calculationTriggers.${item}`), value: item };
})}
/>
<InputCheckboxWithDescription
className="col-span-2"
name="withLogs"
title={t("pages.formulas.fields.withLogs")}
description={t("pages.formulas.fields.withLogsDescription")}
value={withLogs}
setValue={setWithLogs}
/>
<div className="col-span-2">
<div className="mb-1 flex items-center justify-between space-x-2 text-xs">
<label className="text-muted-foreground font-medium">{t("pages.formulas.components.title")}</label>
<button type="button" onClick={() => setComponents([])} className="text-muted-foreground hover:text-foreground/80">
{t("shared.clear")}
</button>
</div>
<div className="text-foreground flex flex-wrap">
{components.map((item, idx) => {
return (
<button
key={idx}
type="button"
onClick={() => setShowModal({ item, idx: idx })}
className="border-border hover:border-border relative m-1 block rounded-lg border-2 border-dashed p-1 text-center text-sm focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
<FormulaComponentBadge item={item} />
</button>
);
})}
<div className="">
<button
type="button"
onClick={() => setShowModal({ item: undefined, idx: undefined })}
className="relative m-1 block w-full rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 p-1 text-center text-blue-600 hover:border-blue-400 hover:bg-blue-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<span className="block text-sm font-medium">{t("pages.formulas.components.add")}</span>
</button>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
{onDelete && getUserHasPermission(appOrAdminData, "admin.formulas.delete") && (
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDeleteClick} destructive>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
)}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => navigate(adminPath("entities/formulas", { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
<LoadingButton actionName={getActionName()} type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
<FormulaComponentModal
open={showModal !== undefined}
item={showModal?.item}
idx={showModal?.idx}
order={components.length + 1}
onClose={() => setShowModal(undefined)}
onSave={(item) => onSave(item)}
onRemove={(idx) => {
components.splice(idx, 1);
setComponents([...components]);
}}
/>
<ConfirmModal ref={confirmModalDelete} onYes={onDelete} />
</div>
);
}
@@ -0,0 +1,127 @@
import { FormulaComponentLog, FormulaLog, Prisma } from "@toolctrl/db/prisma-browser";
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
import { db } from "~/utils/db.server";
export type FormulaLogWithDetails = FormulaLog & {
formula: { id: string; name: string };
user: { id: string; email: string } | null;
tenant: { id: string; name: string } | null;
components: FormulaComponentLog[];
};
export async function getFormulaLogs({
id,
pagination,
filters,
}: {
id: string | undefined;
pagination: { pageSize: number; page: number };
filters: FiltersDto;
}): Promise<{ items: FormulaLogWithDetails[]; total: number }> {
let where: Prisma.FormulaLogWhereInput = {
formulaId: id,
};
const statusFilter = filters.properties.find((filter) => filter.name === "status")?.value;
if (statusFilter) {
if (statusFilter === "success") {
where = { ...where, error: null, result: { not: "" } };
} else if (statusFilter === "error") {
where = { ...where, error: { not: null } };
} else if (statusFilter === "empty") {
where = { ...where, result: "", error: null };
}
}
const hasRowIdFilter = filters.properties.find((filter) => filter.name === "hasRowId")?.value;
if (hasRowIdFilter) {
where = { ...where, components: { some: { rowId: hasRowIdFilter } } };
}
filters.properties
.filter((f) => !f.manual && f.value)
.forEach((filter) => {
where = { ...where, [filter.name]: filter.value };
});
const items = await db.formulaLog.findMany({
take: pagination.pageSize,
skip: pagination.pageSize * (pagination.page - 1),
where,
include: {
formula: { select: { id: true, name: true } },
user: { select: { id: true, email: true } },
tenant: { select: { id: true, name: true } },
components: true,
},
orderBy: { createdAt: "desc" },
});
const total = await db.formulaLog.count({ where });
return {
items,
total,
};
}
export async function countLogs(ids: string[]): Promise<{ formulaId: string; count: number }[]> {
return (
await db.formulaLog.groupBy({
by: ["formulaId"],
where: { formulaId: { in: ids } },
_count: { formulaId: true },
})
).map((item) => ({ formulaId: item.formulaId, count: item._count.formulaId }));
}
export async function createFormulaLog(data: {
formulaId: string;
userId: string | null;
tenantId: string | null;
originalTrigger: string;
triggeredBy: string;
expresion: string;
result: string;
error: string | null;
rowValueId: string | null;
components: {
order: number;
type: string;
value: string;
rowId: string | null;
}[];
}) {
return await db.formulaLog.create({
data: {
formulaId: data.formulaId,
userId: data.userId,
tenantId: data.tenantId,
originalTrigger: data.originalTrigger,
triggeredBy: data.triggeredBy,
expression: data.expresion,
duration: 0,
result: data.result,
error: data.error,
rowValueId: data.rowValueId,
components: {
create: data.components.map((component) => ({
order: component.order,
type: component.type,
value: component.value,
rowId: component.rowId,
})),
},
},
});
}
export async function updateFormulaLog(
id: string,
data: {
duration: number;
result: string;
error: string | null;
}
) {
return await db.formulaLog.update({
where: { id },
data: {
result: data.result,
error: data.error,
},
});
}
@@ -0,0 +1,124 @@
import { Entity, Formula, FormulaComponent, Prisma, Property } from "@toolctrl/db/prisma-browser";
import { db } from "~/utils/db.server";
export type FormulaWithDetails = Formula & {
components: FormulaComponent[];
inProperties: (Property & { entity: Entity })[];
};
export async function getAllFormulas(): Promise<FormulaWithDetails[]> {
return await db.formula.findMany({
include: {
components: { orderBy: { order: "asc" } },
inProperties: { include: { entity: true } },
},
orderBy: { createdAt: "desc" },
});
}
export async function getAllFormulasIdsAndNames(): Promise<{ id: string; name: string }[]> {
return await db.formula.findMany({
select: { id: true, name: true },
orderBy: { createdAt: "desc" },
});
}
export async function getAllFormulasInIds(ids: string[]): Promise<FormulaWithDetails[]> {
return await db.formula.findMany({
where: { id: { in: ids } },
include: {
components: { orderBy: { order: "asc" } },
inProperties: { include: { entity: true } },
},
orderBy: { createdAt: "desc" },
});
}
export async function getFormula(id: string): Promise<FormulaWithDetails | null> {
return await db.formula.findUnique({
where: { id },
include: {
components: { orderBy: { order: "asc" } },
inProperties: { include: { entity: true } },
},
});
}
export async function getFormulaWithLogs(id: string): Promise<FormulaWithDetails | null> {
return await db.formula.findUnique({
where: { id },
include: {
components: { orderBy: { order: "asc" } },
inProperties: { include: { entity: true } },
},
});
}
export type CreateFormulaDto = {
name: string;
description: string | null;
resultAs: string;
calculationTrigger: string;
withLogs: boolean;
components: {
order: number;
type: string;
value: string;
}[];
}
export async function createFormula(data: CreateFormulaDto): Promise<Formula> {
return await db.formula.create({
data: {
name: data.name,
description: data.description,
resultAs: data.resultAs,
calculationTrigger: data.calculationTrigger,
withLogs: data.withLogs,
components: {
create: data.components.map((component) => ({
order: component.order,
type: component.type,
value: component.value,
})),
},
},
});
}
export async function updateFormula(
id: string,
data: {
name?: string;
description?: string | null;
resultAs?: string;
calculationTrigger?: string;
withLogs?: boolean;
components?: {
order: number;
type: string;
value: string;
}[];
}
): Promise<Formula> {
const update: Prisma.FormulaUpdateInput = {
name: data.name,
description: data.description,
resultAs: data.resultAs,
calculationTrigger: data.calculationTrigger,
withLogs: data.withLogs,
};
if (data.components) {
update.components = {
deleteMany: {},
create: data.components,
};
}
return await db.formula.update({
where: { id },
data: update,
});
}
export async function deleteFormula(id: string): Promise<Formula> {
return await db.formula.delete({ where: { id } });
}
@@ -0,0 +1,57 @@
import { Decimal } from "decimal.js";
export type FormulaDto = {
id?: string;
name: string;
description: string | null;
components: FormulaComponentDto[];
resultAs?: FormulaResultAsType;
calculationTrigger?: FormulaCalculationTriggerType;
withLogs?: boolean;
createdAt?: Date;
inProperties?: { id: string; name: string; entity: { id: string; name: string } }[];
};
export const FormulaCalculationTriggerTypes = ["ALWAYS", "NEVER", "BEFORE_LISTED", "AFTER_CREATED", "BEFORE_VIEWED", "AFTER_UPDATED", "IF_UNSET"] as const;
export type FormulaCalculationTriggerType = (typeof FormulaCalculationTriggerTypes)[number];
export const FormulaResultAsTypes = ["number", "boolean", "string", "date"] as const;
export type FormulaResultAsType = (typeof FormulaResultAsTypes)[number];
export const FormulaOperatorTypes = [
"ADD",
"SUBTRACT",
"MULTIPLY",
"DIVIDE",
"CONCAT",
"EQUALS",
"NOT_EQUALS",
"GREATER_THAN",
"LESS_THAN",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"AND",
"OR",
"NOT",
"DATE_ADD_DAYS",
] as const;
export type FormulaOperatorType = (typeof FormulaOperatorTypes)[number];
export type FormulaParenthesisType = "OPEN" | "CLOSE";
type RowMedia = {};
type RowValueMultiple = { order: number; value: string };
type RowValueRange = { numberMin: Decimal | null; numberMax: Decimal | null; dateMin: Date | null; dateMax: Date | null };
export type FormulaValueType = string | number | boolean | RowMedia[] | RowValueMultiple[] | RowValueRange | Date | null;
export type FormulaEndResult = string | number | boolean | Date | null;
export const FormulaComponentTypes = [
{ value: "variable", name: "Variable" },
{ value: "operator", name: "Operator" },
{ value: "parenthesis", name: "Parenthesis" },
{ value: "value", name: "Value" },
] as const;
export type FormulaComponentType = (typeof FormulaComponentTypes)[number]["value"];
export type FormulaComponentDto = {
order: number;
type: FormulaComponentType;
value: string;
};
@@ -0,0 +1,20 @@
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
import { TenantWithDetails } from "~/utils/db/tenants.db.server";
import { UserWithDetails } from "~/utils/db/users.db.server";
export type FormulaVariableValueDto = {
plain?: {
variable: string;
textValue?: string | undefined;
numberValue?: number | undefined;
dateValue?: Date | undefined;
booleanValue?: boolean | undefined;
};
row?: {
entity: EntityWithDetails;
item: RowWithDetails;
};
tenant?: TenantWithDetails;
user?: UserWithDetails;
};
@@ -0,0 +1,28 @@
import { CreateFormulaDto, createFormula } from "../../db/formulas.db.server";
import { defaultFormulas } from "../../utils/DefaultFormulas";
async function createDefault(formulaName?: string) {
let toCreate: CreateFormulaDto[] = defaultFormulas().map((f) => ({
name: f.name,
description: f.description,
resultAs: f.resultAs ?? "string",
calculationTrigger: f.calculationTrigger ?? "NEVER",
withLogs: false,
components: f.components,
}));
if (formulaName) {
toCreate = toCreate.filter((t) => t.name === formulaName);
}
const createdFormulas = await Promise.all(
toCreate.map(async (data) => {
await createFormula(data);
})
);
return createdFormulas;
}
export default {
createDefault,
};
@@ -0,0 +1,311 @@
import RowValueService, { RowValueUpdateDto } from "~/utils/helpers/.server/RowValueService";
import { FormulaWithDetails, getFormula } from "../../db/formulas.db.server";
import { FormulaCalculationTriggerType, FormulaDto, FormulaEndResult, FormulaValueType } from "../../dtos/FormulaDto";
import { FormulaVariableValueDto } from "../../dtos/FormulaVariableValueDto";
import FormulaHelpers from "../../utils/FormulaHelpers";
import FormulaCalculator from "../../utils/FormulaCalculator";
import FormulaRowService from "../FormulaRowService";
import { EntityWithDetails, PropertyWithDetails, getAllEntities } from "~/utils/db/entities/entities.db.server";
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
import RowHelper from "~/utils/helpers/RowHelper";
import { createFormulaLog, updateFormulaLog } from "../../db/formulaLogs.db.server";
import { FormulaLog } from "@toolctrl/db/prisma-browser";
import { TFunction } from "i18next";
async function calculate({
formula,
variables,
allEntities,
session,
originalTrigger,
triggeredBy,
rowValueId,
isDebugging,
t,
}: {
formula: FormulaWithDetails;
variables: FormulaVariableValueDto[];
allEntities: EntityWithDetails[];
session: { tenantId?: string | null; userId?: string } | undefined;
originalTrigger: string;
triggeredBy: string;
rowValueId?: string | null;
isDebugging?: boolean;
t: TFunction;
}): Promise<FormulaEndResult> {
const formulaDto = FormulaHelpers.getFormulaDto(formula);
const { values, usedRows } = getTranslatedValues(formulaDto, variables, allEntities, t);
// eslint-disable-next-line no-console
console.log("[FormulaService.calculate] variable values", { values });
const expression: { order: number; type: string; value: string; rowId: string | null }[] = [];
formulaDto.components
.sort((a, b) => a.order - b.order)
.forEach((component) => {
let value = "";
if (component.type === "variable") {
if (Object.keys(values).includes(component.value)) {
value = FormulaHelpers.getValueAsString(values[component.value]);
} else {
value = "[Unset]";
}
} else if (component.type === "operator") {
value = FormulaHelpers.getOperatorSymbol(component.value);
} else if (component.type === "parenthesis") {
value = FormulaHelpers.getParenthesisType(component.value);
} else {
value = `[Unknown component #${component.order}]`;
}
let rowId: string | null = null;
if (Object.keys(usedRows).includes(component.value ?? "")) {
rowId = usedRows[component.value ?? ""].id;
}
expression.push({ order: component.order, type: component.type, value, rowId });
});
// eslint-disable-next-line no-console
console.log({ components: formulaDto.components, values, variables });
let formulaLog: FormulaLog | null = null;
if (!isDebugging && formulaDto.id && formulaDto.withLogs) {
formulaLog = await createFormulaLog({
formulaId: formulaDto.id,
userId: session?.userId ?? null,
tenantId: session?.tenantId ?? null,
originalTrigger,
triggeredBy,
expresion: expression.map((e) => e.value).join(" "),
result: "",
error: null,
components: expression.map((e) => ({ order: e.order, type: e.type, value: e.value, rowId: e.rowId })),
rowValueId: rowValueId ?? null,
});
}
const startTime = performance.now();
try {
const result = FormulaCalculator.calculateFormula(formulaDto, values);
const endTime = performance.now();
const duration = endTime - startTime;
if (formulaLog) {
await updateFormulaLog(formulaLog.id, {
duration,
result: FormulaHelpers.getValueAsString(result),
error: null,
});
}
return result;
} catch (e: any) {
const endTime = performance.now();
const duration = endTime - startTime;
if (formulaLog) {
await updateFormulaLog(formulaLog.id, {
duration,
result: "",
error: e.message,
});
}
throw e;
}
}
async function trigger({
trigger,
entity,
rows,
session,
t,
}: {
trigger: FormulaCalculationTriggerType;
entity: EntityWithDetails;
rows: RowWithDetails[];
session: { tenantId?: string | null; userId?: string } | undefined;
t: TFunction;
}) {
const formulaProperties = entity.properties.filter((f) => f.formulaId);
if (formulaProperties.length === 0) {
return;
}
// eslint-disable-next-line no-console
console.log("[FormulaService.trigger]", { trigger, entity: entity.name, rows: rows.length });
const promises = formulaProperties.map(async (property) => {
return triggerRowProperty({ trigger, entity, rows, session, property, t }).catch((e) => {
// eslint-disable-next-line no-console
console.error("[FormulaService] Error", {
trigger,
property: property.name,
error: e.message,
});
return null;
});
});
return await Promise.all(promises);
// run in parallel
}
async function triggerRowProperty({
trigger,
entity,
rows,
session,
property,
t,
}: {
trigger: FormulaCalculationTriggerType;
entity: EntityWithDetails;
rows: RowWithDetails[];
session: { tenantId?: string | null; userId?: string } | undefined;
property: PropertyWithDetails;
t: TFunction;
}) {
if (rows.length === 0 || !property.formulaId) {
return;
}
const formula = await getFormula(property.formulaId);
if (!formula) {
return;
}
const formulaTrigger = FormulaHelpers.getCalculationTrigger(formula.calculationTrigger);
const allEntities = await getAllEntities({ tenantId: session?.tenantId });
// eslint-disable-next-line no-console
return await Promise.all(
rows.map(async (row) => {
const resultAs = FormulaHelpers.getResultAs(formula.resultAs);
let triggeredBy = trigger;
let willCalculate = formulaTrigger === trigger;
if (!willCalculate) {
if (formulaTrigger === "ALWAYS") {
willCalculate = true;
triggeredBy = "ALWAYS";
} else if (formulaTrigger === "IF_UNSET") {
const rowValue = RowHelper.getPropertyValue({
entity,
item: row,
property,
});
willCalculate = rowValue === null || rowValue === undefined;
if (willCalculate) {
triggeredBy = "IF_UNSET";
}
}
}
// eslint-disable-next-line no-console
console.log({ row: row.id, formulaName: formula.name, currentTrigger: trigger, formulaTrigger, willCalculate });
if (!willCalculate) {
return;
}
let rowValueId = row.values.find((v) => v.propertyId === property.id)?.id ?? null;
const variables: FormulaVariableValueDto[] = [{ row: { entity, item: row } }];
const value = await calculate({
formula,
variables,
allEntities,
session,
originalTrigger: trigger,
triggeredBy,
rowValueId,
t,
});
// eslint-disable-next-line no-console
console.log(`[FormulaService] Result for row ${RowHelper.getTextDescription({ entity, item: row, t })}`, {
value,
});
let valueToUpdate: RowValueUpdateDto | null = null;
switch (resultAs) {
case "number":
valueToUpdate = { name: property.name, numberValue: value as number };
break;
case "string":
valueToUpdate = { name: property.name, textValue: value as string };
break;
case "boolean":
valueToUpdate = { name: property.name, booleanValue: value as boolean };
break;
case "date":
valueToUpdate = { name: property.name, dateValue: value as Date };
break;
default:
break;
}
if (valueToUpdate) {
await RowValueService.update({
values: [valueToUpdate],
session: { tenantId: session?.tenantId ?? row.tenantId, userId: session?.userId },
entity,
row,
checkPermissions: false,
});
}
})
);
}
function getTranslatedValues(
formula: FormulaDto,
values: FormulaVariableValueDto[],
allEntities: EntityWithDetails[],
t: TFunction
): {
values: Record<string, FormulaValueType>;
usedRows: Record<string, RowWithDetails>;
} {
const variables: Record<string, FormulaValueType> = {};
const usedRows: Record<string, RowWithDetails> = {};
formula.components
.filter((c) => c.type === "variable" || c.type === "value")
.forEach((component) => {
const componentName = component.value ?? "";
if (component.type === "value") {
variables[componentName] = component.value;
return;
}
const plainValue = values.find((v) => v.plain?.variable === componentName)?.plain;
if (plainValue) {
if (plainValue?.textValue !== undefined) {
// if value.textValue is number an formula returnsAsNumber
if (formula.resultAs === "number") {
variables[componentName] = Number(plainValue.textValue);
} else {
variables[componentName] = plainValue.textValue;
}
} else if (plainValue?.numberValue !== undefined) {
variables[componentName] = plainValue.numberValue;
} else if (plainValue?.dateValue !== undefined) {
variables[componentName] = plainValue.dateValue;
} else if (plainValue?.booleanValue !== undefined) {
variables[componentName] = plainValue.booleanValue;
}
} else if (componentName.startsWith("row.")) {
const rowValue = values.find((v) => v.row);
if (rowValue?.row === undefined) {
throw new Error(`Row required for component ${componentName}`);
}
if (rowValue.row.item) {
usedRows[componentName] = rowValue.row.item;
}
variables[componentName] = FormulaRowService.getRowValue({ componentName, row: rowValue.row, allEntities, t });
} else if (componentName?.startsWith("user.")) {
throw new Error("User variables are not supported yet");
} else if (componentName?.startsWith("tenant.")) {
throw new Error("Tenant variables are not supported yet");
}
// else {
// throw new Error(`Formula component value is unset: ${componentName}`);
// }
});
return { values: variables, usedRows };
}
export default {
calculate,
trigger,
getTranslatedValues,
};
@@ -0,0 +1,102 @@
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
import { RowWithDetails, RowWithValues } from "~/utils/db/entities/rows.db.server";
import { FormulaValueType } from "../dtos/FormulaDto";
import { Property } from "@toolctrl/db/prisma-browser";
import RowHelper from "~/utils/helpers/RowHelper";
import { TFunction } from "i18next";
function getRowValue({
componentName,
row,
allEntities,
t,
}: {
componentName: string;
row: { entity: EntityWithDetails; item: RowWithDetails };
allEntities: EntityWithDetails[];
t: TFunction;
}): FormulaValueType {
const { entity, item } = row;
const parts = componentName.split(".");
if (parts.length === 0 || parts[0] !== "row") {
throw new Error(`Invalid component ${componentName} for entity (${entity.name})`);
}
const entityName = parts[1];
const propertyName = parts[2];
let customFunction: string | null = null;
if (parts.length > 3) {
customFunction = parts[3];
}
if (parts[1] === entity.name) {
if (propertyName === "order") {
return item.order;
}
}
const foundParentEntity = entity.parentEntities.find((i) => i.parent.name === entityName);
const foundChildEntity = entity.childEntities.find((i) => i.child.name === entityName);
let property: Property | undefined = entity.properties.find((i) => i.name === propertyName);
if (property) {
const value = RowHelper.getPropertyValue({ entity, item, propertyName });
if (customFunction === "inverse") {
throw new Error("Inverse not implemented");
}
return value;
}
if (foundParentEntity) {
const parentRows = item.parentRows.filter((i) => i.relationshipId === foundParentEntity.id);
const parentEntity = allEntities.find((i) => i.id === foundParentEntity.parent.id);
return findRelatedRowsValue({ entity: parentEntity!, rows: parentRows.map((f) => f.parent), propertyName });
} else if (foundChildEntity) {
const childRows = item.childRows.filter((i) => i.relationshipId === foundChildEntity.id);
const childEntity = allEntities.find((i) => i.id === foundChildEntity.child.id);
const value = findRelatedRowsValue({ entity: childEntity!, rows: childRows.map((f) => f.child), propertyName });
return value;
}
if (!property) {
throw new Error(
`Invalid component ${componentName} for entity (${entity.name}). Unknown property: ${propertyName} in (${entity.properties
.map((f) => f.name)
.join(",")}).`
);
}
throw new Error(
`Invalid component ${componentName} for entity (${entity.name}), property (${propertyName}), and row ${RowHelper.getTextDescription({ entity, item, t })}`
);
}
function findRelatedRowsValue({
entity,
rows,
propertyName,
customFunction,
}: {
entity: EntityWithDetails & { properties: Property[] };
rows: RowWithValues[];
propertyName: string;
customFunction?: string | null;
}): FormulaValueType {
if (propertyName === "order") {
return rows.length > 0 ? rows[0].order : 0;
}
const property = entity.properties.find((i) => i.name === propertyName);
if (!property) {
throw new Error(`Property ${propertyName} not found on entity ${entity.name}`);
}
if (rows.length === 0) {
return RowHelper.getPropertyDefaultValue(property);
} else if (rows.length === 1) {
const value = RowHelper.getPropertyValue({ entity, item: rows[0], propertyName });
if (customFunction === "inverse") {
throw new Error("Inverse not implemented");
}
return value;
} else if (!customFunction) {
throw new Error(`Property ${propertyName} has multiple values but no custom function was specified`);
}
throw new Error(`Property ${propertyName} has multiple values but custom function ${customFunction} is not implemented`);
}
export default {
getRowValue,
};
@@ -0,0 +1,151 @@
import { FormulaEndResult, FormulaOperatorType, FormulaValueType } from "../dtos/FormulaDto";
type BinaryOperatorFn = (leftOperand: FormulaValueType, rightOperand: FormulaValueType) => FormulaEndResult;
const binaryOperators: Record<FormulaOperatorType, BinaryOperatorFn> = {
ADD: (a: FormulaValueType, b: FormulaValueType) => {
if (a === null || b === null) {
if (a !== null) return a as number;
return 0;
} else if (typeof a === "number" && typeof b === "number") {
return a + b;
} else if (typeof a === "string" || typeof b === "string") {
return Number(a) + Number(b);
} else if (typeof a === "boolean" && typeof b === "boolean") {
return a || b;
} else if (a instanceof Date && typeof b === "number") {
const newDate = new Date(a);
newDate.setDate(newDate.getDate() + b);
return newDate;
} else {
return 0;
}
},
SUBTRACT: (a: FormulaValueType, b: FormulaValueType) => {
if (a === null || b === null) {
if (a !== null) return a as number;
return 0;
} else if (typeof a === "number" && typeof b === "number") {
return a - b;
} else if (typeof a === "string" && typeof b === "string") {
return a.replace(b, "");
} else if (typeof a === "boolean" && typeof b === "boolean") {
return a && !b;
} else if (a instanceof Date && typeof b === "number") {
const newDate = new Date(a);
newDate.setDate(newDate.getDate() - b);
return newDate;
} else {
return 0;
}
},
MULTIPLY: (a: FormulaValueType, b: FormulaValueType) => (a !== null && b !== null ? Number(a) * Number(b) : false),
DIVIDE: (a: FormulaValueType, b: FormulaValueType) => (a !== null && b !== null && b !== 0 ? Number(a) / Number(b) : false),
CONCAT: (a: FormulaValueType, b: FormulaValueType) => (a?.toString() ?? "") + (b?.toString() ?? ""),
EQUALS: (a: FormulaValueType, b: FormulaValueType) => {
if (a === null || b === null) {
return false;
} else if (typeof a === "number" && typeof b === "number") {
return a === b;
} else if (typeof a === "string" && typeof b === "string") {
return a === b;
} else if (typeof a === "boolean" && typeof b === "boolean") {
return a === b;
} else if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
} else {
return false;
}
},
NOT_EQUALS: (a: FormulaValueType, b: FormulaValueType) => {
if (a === null || b === null) {
return false;
} else if (typeof a === "number" && typeof b === "number") {
return a !== b;
} else if (typeof a === "string" && typeof b === "string") {
return a !== b;
} else if (typeof a === "boolean" && typeof b === "boolean") {
return a !== b;
} else if (a instanceof Date && b instanceof Date) {
return a.getTime() !== b.getTime();
} else {
return false;
}
},
GREATER_THAN: (a: FormulaValueType, b: FormulaValueType) => {
if (a === null || b === null) {
return false;
} else if (typeof a === "number" && typeof b === "number") {
return a > b;
} else if (typeof a === "string" && typeof b === "string") {
return a > b;
} else if (a instanceof Date && b instanceof Date) {
return a.getTime() > b.getTime();
} else {
return false;
}
},
LESS_THAN: (a: FormulaValueType, b: FormulaValueType) => {
if (a === null || b === null) {
return false;
} else if (typeof a === "number" && typeof b === "number") {
return a < b;
} else if (typeof a === "string" && typeof b === "string") {
return a < b;
} else if (a instanceof Date && b instanceof Date) {
return a.getTime() < b.getTime();
} else {
return false;
}
},
GREATER_THAN_OR_EQUAL: (a: FormulaValueType, b: FormulaValueType) => {
if (a === null || b === null) {
return false;
} else if (typeof a === "number" && typeof b === "number") {
return a >= b;
} else if (typeof a === "string" && typeof b === "string") {
return a >= b;
} else if (a instanceof Date && b instanceof Date) {
return a.getTime() >= b.getTime();
} else {
return false;
}
},
LESS_THAN_OR_EQUAL: (a: FormulaValueType, b: FormulaValueType) => {
if (a === null || b === null) {
return false;
} else if (typeof a === "number" && typeof b === "number") {
return a <= b;
} else if (typeof a === "string" && typeof b === "string") {
return a <= b;
} else if (a instanceof Date && b instanceof Date) {
return a.getTime() <= b.getTime();
} else {
return false;
}
},
AND: (a: FormulaValueType, b: FormulaValueType) => {
if (typeof a === "boolean" && typeof b === "boolean") {
return a && b;
} else {
let aIsTrue = (a || a === "true") && a !== "false";
let bIsTrue = (b || b === "true") && b !== "false";
return aIsTrue && bIsTrue;
}
},
OR: (a: FormulaValueType, b: FormulaValueType) => Boolean(a || b),
NOT: (a: FormulaValueType) => !a,
DATE_ADD_DAYS: (a: FormulaValueType, b: FormulaValueType) => {
if (a instanceof Date || typeof a === "string") {
const newDate = new Date(a);
newDate.setDate(newDate.getDate() + Number(b ?? 0));
return newDate;
} else {
return null;
}
},
};
export default {
binaryOperators,
};
@@ -0,0 +1,57 @@
import { FormulaDto } from "../dtos/FormulaDto";
export function defaultFormulas(): FormulaDto[] {
const formulas: FormulaDto[] = [
{
name: "Concatenates",
description: "calculates with texts",
resultAs: "string",
calculationTrigger: "NEVER",
components: [
{ order: 1, type: "variable", value: "text1" },
{ order: 2, type: "operator", value: "CONCAT" },
{ order: 3, type: "variable", value: "text2" },
{ order: 4, type: "operator", value: "CONCAT" },
{ order: 5, type: "variable", value: "text3" },
],
},
{
name: "Evaluates Booleans",
description: "calculates with booleans",
resultAs: "boolean",
components: [
{ order: 1, type: "variable", value: "boolean1" },
{ order: 2, type: "operator", value: "AND" },
{ order: 3, type: "variable", value: "boolean2" },
],
},
{
name: "Adds Days to Date",
description: "calculates with dates",
resultAs: "date",
components: [
{ order: 1, type: "variable", value: "date" },
{ order: 2, type: "operator", value: "DATE_ADD_DAYS" },
{ order: 3, type: "variable", value: "days" },
],
},
{
name: "Assigns Static Value",
description: "sets static value",
resultAs: "number",
components: [{ order: 1, type: "variable", value: "number" }],
},
{
name: "Adds Two Numbers",
description: "calculates addition with two values",
resultAs: "number",
components: [
{ order: 1, type: "variable", value: "number1" },
{ order: 2, type: "operator", value: "ADD" },
{ order: 3, type: "variable", value: "number2" },
],
},
];
return formulas;
}
@@ -0,0 +1,107 @@
import { FormulaOperatorType, FormulaDto, FormulaValueType, FormulaResultAsType, FormulaEndResult } from "../dtos/FormulaDto";
import BinaryOperatorUtils from "./BinaryOperatorUtils";
import FormulaHelpers from "./FormulaHelpers";
function calculateFormula(formula: FormulaDto, values: Record<string, FormulaValueType>): FormulaEndResult {
const resultAs: FormulaResultAsType = FormulaHelpers.getResultAs(formula.resultAs);
let lastComponent: string | null = null;
let result: FormulaValueType | null = null;
let currentOperand: FormulaValueType = null;
let currentOperator: FormulaOperatorType | null = null;
let openParenthesesCount = 0;
let closeParenthesesCount = 0;
let insideParentheses: FormulaDto | null = null;
formula.components.forEach((component) => {
if (component.type === "variable") {
lastComponent = "variable: " + component.value;
if (insideParentheses !== null) {
insideParentheses.components.push(component);
} else {
currentOperand = values[component.value];
}
} else if (component.type === "operator") {
lastComponent = "operator: " + component.value;
if (currentOperator !== null) {
if (insideParentheses !== null) {
insideParentheses.components.push(component);
} else {
result = BinaryOperatorUtils.binaryOperators[currentOperator](result, currentOperand);
}
} else {
if (insideParentheses !== null) {
throw new Error("Invalid formula: parentheses must be used in pairs");
} else {
result = currentOperand;
}
}
// Reset the operand for the next iteration
currentOperand = null;
currentOperator = FormulaHelpers.getOperatorType(component.value);
} else if (component.type === "parenthesis") {
lastComponent = "parenthesis: " + component.value;
const parenthesis = FormulaHelpers.getParenthesisType(component.value);
if (parenthesis === "OPEN") {
if (insideParentheses !== null) {
throw new Error("Invalid formula: parentheses must be used in pairs");
} else {
insideParentheses = { name: "", description: "", resultAs: "number", calculationTrigger: formula.calculationTrigger, components: [] };
openParenthesesCount++;
}
} else if (parenthesis === "CLOSE") {
if (insideParentheses === null) {
throw new Error("Invalid formula: parentheses must be used in pairs");
} else {
closeParenthesesCount++;
if (closeParenthesesCount > openParenthesesCount) {
throw new Error("Invalid formula: parentheses must be used in pairs");
} else if (closeParenthesesCount === openParenthesesCount) {
currentOperand = calculateFormula(insideParentheses, values);
insideParentheses = null;
} else {
insideParentheses.components.push(component);
}
}
}
} else if (component.type === "value") {
lastComponent = "value: " + component.value;
if (insideParentheses !== null) {
insideParentheses.components.push(component);
} else {
currentOperand = component.value;
}
}
});
if (formula.components.length === 1 && currentOperand) {
result = currentOperand;
}
if (currentOperand !== null && currentOperator !== null) {
const operator = currentOperator as FormulaOperatorType;
result = BinaryOperatorUtils.binaryOperators[operator](result, currentOperand);
} else if (result === null) {
if (!lastComponent) {
throw new Error("Invalid formula");
} else {
throw new Error(`Invalid formula component ${lastComponent}`);
}
}
switch (resultAs) {
case "number":
return result !== null ? Number(result) : null;
case "boolean":
return result !== null ? Boolean(result) : null;
case "date":
return !result === null || result instanceof Date === false ? null : result;
case "string":
return result !== null ? String(result) : null;
default:
throw new Error("Invalid resultAs value in formula");
}
}
export default {
calculateFormula,
};
@@ -0,0 +1,205 @@
import { FormulaWithDetails } from "../db/formulas.db.server";
import {
FormulaResultAsType,
FormulaOperatorType,
FormulaParenthesisType,
FormulaCalculationTriggerType,
FormulaDto,
FormulaValueType,
FormulaComponentType,
} from "../dtos/FormulaDto";
function getFormulaDto(formula: FormulaWithDetails) {
const formulaDto: FormulaDto = {
id: formula.id,
createdAt: formula.createdAt,
name: formula.name,
description: formula.description,
resultAs: getResultAs(formula.resultAs),
calculationTrigger: getCalculationTrigger(formula.calculationTrigger),
withLogs: formula.withLogs,
components: formula.components
.sort((a, b) => a.order - b.order)
.map((component) => ({
order: component.order,
type: getComponentType(component.type),
value: component.value ?? undefined,
})),
inProperties: formula.inProperties.map((x) => ({
id: x.id,
name: x.name,
entity: {
id: x.entity.id,
name: x.entity.name,
},
})),
};
return formulaDto;
}
function getResultAs(resultAs?: string): FormulaResultAsType {
switch (resultAs) {
case "number":
return "number";
case "string":
return "string";
case "boolean":
return "boolean";
case "date":
return "date";
default:
return "string";
}
}
function getComponentType(type: string): FormulaComponentType {
switch (type) {
case "variable":
return "variable";
case "operator":
return "operator";
case "value":
return "value";
case "parenthesis":
return "parenthesis";
default:
return "variable";
}
}
function getCalculationTrigger(calculationTrigger?: string): FormulaCalculationTriggerType {
switch (calculationTrigger) {
case "ALWAYS":
return "ALWAYS";
case "BEFORE_LISTED":
return "BEFORE_LISTED";
case "AFTER_CREATED":
return "AFTER_CREATED";
case "BEFORE_VIEWED":
return "BEFORE_VIEWED";
case "AFTER_UPDATED":
return "AFTER_UPDATED";
case "IF_UNSET":
return "IF_UNSET";
case "NEVER":
return "NEVER";
default:
return "NEVER";
}
}
function getOperatorType(operator: string): FormulaOperatorType {
switch (operator) {
case "ADD":
return "ADD";
case "SUBTRACT":
return "SUBTRACT";
case "MULTIPLY":
return "MULTIPLY";
case "DIVIDE":
return "DIVIDE";
case "CONCAT":
return "CONCAT";
case "EQUALS":
return "EQUALS";
case "NOT_EQUALS":
return "NOT_EQUALS";
case "GREATER_THAN":
return "GREATER_THAN";
case "LESS_THAN":
return "LESS_THAN";
case "GREATER_THAN_OR_EQUAL":
return "GREATER_THAN_OR_EQUAL";
case "LESS_THAN_OR_EQUAL":
return "LESS_THAN_OR_EQUAL";
case "AND":
return "AND";
case "OR":
return "OR";
case "NOT":
return "NOT";
case "DATE_ADD_DAYS":
return "DATE_ADD_DAYS";
default:
return "ADD";
}
}
function getOperatorSymbol(operator?: string): string {
switch (getOperatorType(operator ?? "")) {
case "ADD":
return "+";
case "DATE_ADD_DAYS":
return "+";
case "SUBTRACT":
return "-";
case "MULTIPLY":
return "*";
case "DIVIDE":
return "/";
case "CONCAT":
return "&";
case "EQUALS":
return "=";
case "NOT_EQUALS":
return "!=";
case "GREATER_THAN":
return ">";
case "LESS_THAN":
return "<";
case "GREATER_THAN_OR_EQUAL":
return ">=";
case "LESS_THAN_OR_EQUAL":
return "<=";
case "AND":
return "AND";
case "OR":
return "OR";
case "NOT":
return "NOT";
default:
return "";
}
}
function getParenthesisType(parenthesis: string): FormulaParenthesisType {
switch (parenthesis) {
case "OPEN":
return "OPEN";
case "CLOSE":
return "CLOSE";
default:
return "OPEN";
}
}
function getValueAsString(value: FormulaValueType) {
let valueStr = "";
if (value === null) {
valueStr = "[null]";
} else if (value === undefined) {
valueStr = "[undefined]";
} else if (typeof value === "string") {
valueStr = `"${value}"`;
} else if (typeof value === "boolean") {
valueStr = `${value ? "true" : "false"}`;
} else if (typeof value === "number") {
valueStr = `${value}`;
} else if (value instanceof Date) {
valueStr = `"${value.toISOString()}"`;
} else {
valueStr = `"${value.toString()}"`;
}
return valueStr;
}
export default {
getFormulaDto,
getResultAs,
getComponentType,
getCalculationTrigger,
getOperatorType,
getOperatorSymbol,
getParenthesisType,
getValueAsString,
};