@grouparoo/core
Version:
The Grouparoo Core
807 lines (708 loc) • 23.8 kB
text/typescript
import { api, config } from "actionhero";
import Moment from "moment";
import Sequelize, { Op, WhereAttributeHash, Includeable } from "sequelize";
import {
AfterDestroy,
AllowNull,
BeforeCreate,
BeforeDestroy,
BeforeSave,
BelongsTo,
BelongsToMany,
Column,
DataType,
Default,
DefaultScope,
ForeignKey,
HasMany,
Is,
Length,
Scopes,
Table,
} from "sequelize-typescript";
import {
GroupConfigurationObject,
GroupRuleConfigurationObject,
} from "../classes/codeConfig";
import { APIData } from "../modules/apiData";
import { ConfigWriter } from "../modules/configWriter";
import { LockableHelper } from "../modules/lockableHelper";
import { GroupOps } from "../modules/ops/group";
import {
GroupRuleOpType,
PropertyOpsDictionary,
RelativeMatchDirectionType,
RelativeMatchUnitType,
} from "../modules/ruleOpsDictionary";
import { TopLevelGroupRules } from "../modules/topLevelGroupRules";
import { StateMachine } from "./../modules/stateMachine";
import { Destination } from "./Destination";
import { DestinationGroupMembership } from "./DestinationGroupMembership";
import { GroupMember } from "./GroupMember";
import { GrouparooRecord } from "./GrouparooRecord";
import { GroupRule } from "./GroupRule";
import { RecordProperty } from "./RecordProperty";
import { Property, propertyJSToSQLType } from "./Property";
import { Run } from "./Run";
import { Setting } from "./Setting";
import { GrouparooModel } from "./GrouparooModel";
import { Source } from "./Source";
import { RunOps } from "../modules/ops/runs";
import { ModelGuard } from "../modules/modelGuard";
import { getGrouparooRunMode } from "../modules/runMode";
import { CommonModel } from "../classes/commonModel";
import { PropertiesCache } from "../modules/caches/propertiesCache";
export const GROUP_RULE_LIMIT = 10;
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export interface GroupRuleOperation {
op: GroupRuleOpType;
description?: string;
}
export interface GroupRuleWithKey {
key?: string;
type?: Property["type"];
topLevel?: boolean;
operation: GroupRuleOperation;
match?: string | number | boolean;
relativeMatchNumber?: number;
relativeMatchUnit?: RelativeMatchUnitType;
relativeMatchDirection?: RelativeMatchDirectionType;
}
const matchTypes = ["any", "all"] as const;
const STATES = [
"draft",
"ready",
"initializing",
"updating",
"deleted",
] as const;
// we have no checks, as those are managed by the lifecycle methods below (and tasks)
const STATE_TRANSITIONS: StateMachine.StateTransition[] = [
{ from: "draft", to: "ready", checks: [] },
{ from: "draft", to: "initializing", checks: [] },
{ from: "draft", to: "deleted", checks: [] },
{ from: "draft", to: "updating", checks: [] },
{ from: "ready", to: "initializing", checks: [] },
{ from: "initializing", to: "ready", checks: [] },
{ from: "initializing", to: "updating", checks: [] },
{ from: "initializing", to: "deleted", checks: [] },
{ from: "ready", to: "updating", checks: [] },
{ from: "updating", to: "ready", checks: [] },
{ from: "updating", to: "initializing", checks: [] },
{ from: "updating", to: "deleted", checks: [] },
{ from: "ready", to: "deleted", checks: [] },
{ from: "deleted", to: "ready", checks: [] },
];
export class Group extends CommonModel<Group> {
idPrefix() {
return "grp";
}
name: string;
matchType: typeof matchTypes[number];
state: typeof STATES[number];
locked: string;
calculatedAt: Date;
modelId: string;
groupMembers: GroupMember[];
groupRules: GroupRule[];
destinations: Destination[];
records: GrouparooRecord[];
model: GrouparooModel;
async recordsCount(options = {}) {
let queryOptions = { where: { groupId: this.id } };
if (options) {
queryOptions = Object.assign(queryOptions, options);
}
return this.$count("groupMembers", queryOptions);
}
async getRules() {
// We won't be deleting the model for GroupRule until the group is really deleted (to validate other models)
// But we want to be sure that all membership matching will fail
if (this.state === "deleted") return [];
const rulesWithKey: GroupRuleWithKey[] = [];
const rules = this.groupRules
? this.groupRules.sort((a, b) => a.position - b.position)
: await this.$get("groupRules", {
order: [["position", "asc"]],
});
for (const i in rules) {
const rule: GroupRule = rules[i];
const property = await PropertiesCache.findOneWithCache(
rule.propertyId,
this.modelId,
"ready"
);
const type = property
? property.type
: TopLevelGroupRules.find((tlgr) => tlgr.key === rule.recordColumn)
.type;
rulesWithKey.push({
key: property ? property.key : rule.recordColumn,
topLevel: property ? false : true,
type: type,
operation: {
op: rule.op,
description: PropertyOpsDictionary[type].find(
(operation) => operation.op === rule.op
).description,
},
match: rule.match,
relativeMatchNumber: rule.relativeMatchNumber,
relativeMatchUnit: rule.relativeMatchUnit,
relativeMatchDirection: rule.relativeMatchDirection,
});
}
return this.fromConvenientRules(rulesWithKey);
}
async setRules(rules: GroupRuleWithKey[]) {
if (Object.keys(rules).length > GROUP_RULE_LIMIT) {
throw new Error("too many group rules");
}
const topLevelRuleKeys = TopLevelGroupRules.map((tlr) => tlr.key);
const existingRules = await this.getRules();
const rulesAreEqual = GroupOps.rulesAreEqual(existingRules, rules);
if (rulesAreEqual) return;
await GroupRule.destroy({
where: { groupId: this.id },
});
for (const i in rules) {
const rule = rules[i];
const key = rule.key;
const property = await Property.findOne({
where: { key },
include: [Source],
});
if (
!property &&
!topLevelRuleKeys.includes(key as typeof topLevelRuleKeys[number])
) {
throw new Error(`cannot find property ${key}`);
}
let type = property?.type;
if (topLevelRuleKeys.includes(key as typeof topLevelRuleKeys[number])) {
type = TopLevelGroupRules.find((tlgr) => tlgr.key === key).type as
| "string"
| "date";
}
const dictionaryEntries = PropertyOpsDictionary[type].filter(
(operation) => operation.op === rule.operation.op
);
if (!dictionaryEntries || dictionaryEntries.length === 0) {
throw new Error(
`invalid group rule operation "${rule.operation.op}" for property of type ${property.type}`
);
}
if (property && this.modelId !== property.source.modelId) {
throw new Error(
`${property.key} does not belong to the ${this.modelId} model`
);
}
await GroupRule.create({
position: parseInt(i) + 1,
groupId: this.id,
propertyId: property ? property.id : null,
recordColumn: property ? null : key,
op: rule.operation.op,
match: rule.match,
relativeMatchNumber: rule.relativeMatchNumber,
relativeMatchUnit: rule.relativeMatchUnit,
relativeMatchDirection: rule.relativeMatchDirection,
});
}
// test the rules
const savedRules = await this.getRules();
await this.countPotentialMembers(savedRules, null);
if (this.state !== "deleted" && rules.length > 0) {
this.state =
getGrouparooRunMode() === "cli:config" ? "ready" : "initializing";
this.changed("updatedAt", true);
await this.save();
return this.run();
}
}
async apiData() {
const model = await this.$get("model");
const recordsCount = await this.recordsCount(null);
const rules = await this.getRules();
const nextCalculatedAt = await this.nextCalculatedAt();
return {
id: this.id,
name: this.name,
modelId: this.modelId,
modelName: model.name,
rules,
matchType: this.matchType,
state: this.state,
locked: this.locked,
recordsCount,
createdAt: APIData.formatDate(this.createdAt),
updatedAt: APIData.formatDate(this.updatedAt),
nextCalculatedAt: APIData.formatDate(nextCalculatedAt),
calculatedAt: APIData.formatDate(this.calculatedAt),
};
}
fromConvenientRules(rules: GroupRuleWithKey[]) {
const convenientRules = PropertyOpsDictionary._convenientRules;
for (const i in rules) {
//@ts-ignore
if (convenientRules[rules[i].operation.op]) {
//@ts-ignore
const convenientRule = convenientRules[rules[i].operation.op];
if (convenientRule.operation.op)
rules[i].operation.op = convenientRule.operation.op;
if (convenientRule.relativeMatchDirection)
rules[i].relativeMatchDirection =
convenientRule.relativeMatchDirection;
if (convenientRule.match) rules[i].match = convenientRule.match;
} else if (
rules[i].relativeMatchNumber &&
rules[i].operation.op.match(/^relative_/)
) {
rules[i].operation.op = rules[i].operation.op.replace(
/^relative_/,
""
) as GroupRuleOpType;
}
}
return rules;
}
toConvenientRules(rules: GroupRuleWithKey[]) {
const convenientRules = PropertyOpsDictionary._convenientRules;
for (const i in rules) {
for (const k in convenientRules) {
if (
!rules[i].relativeMatchNumber &&
//@ts-ignore
convenientRules[k].operation.op === rules[i].operation.op &&
//@ts-ignore
convenientRules[k].match === rules[i].match
) {
rules[i] = Object.assign(rules[i], {
operation: { op: k },
match: undefined,
});
}
}
if (
rules[i].relativeMatchNumber &&
!rules[i].operation.op.match(/^relative_/)
) {
rules[i].operation.op =
`relative_${rules[i].operation.op}` as GroupRuleOpType;
rules[i].operation.description = PropertyOpsDictionary[
rules[i].type
].filter(
(operation) => operation.op === rules[i].operation.op
)[0].description;
delete rules[i].relativeMatchDirection;
}
}
return rules;
}
async nextCalculatedAt() {
let hasRelativeRule = false;
const rules = await this.getRules();
for (const rule of rules) {
if (rule.relativeMatchDirection) hasRelativeRule = true;
}
if (!hasRelativeRule) return null;
if (!this.calculatedAt) return Moment().toDate();
const setting = await Setting.findOne({
where: { pluginName: "core", key: "groups-calculation-delay-minutes" },
});
const delayMinutes = parseInt(setting.value);
return Moment(this.calculatedAt).add(delayMinutes, "minutes").toDate();
}
async run(destinationId?: string) {
return RunOps.run(this, destinationId);
}
async stopPreviousRuns() {
return RunOps.stopPreviousRuns(this);
}
async runAddGroupMembers(
run: Run,
limit = 1000,
offset = 0,
highWaterMark: number = null,
destinationId?: string
) {
return GroupOps.runAddGroupMembers(
this,
run,
limit,
offset,
highWaterMark,
destinationId
);
}
async runRemoveGroupMembers(run: Run, limit = 1000, destinationId?: string) {
return GroupOps.runRemoveGroupMembers(this, run, limit, destinationId);
}
async removePreviousRunGroupMembers(run: Run, limit = 100) {
return GroupOps.removePreviousRunGroupMembers(this, run, limit);
}
async updateRecordsMembership(records: GrouparooRecord[]) {
return GroupOps.updateRecordsMembership(this, records);
}
async countPotentialMembers(
rules?: GroupRuleWithKey[],
matchType: typeof matchTypes[number] = this.matchType
) {
return GroupOps.countPotentialMembers(this, rules, matchType);
}
/**
* Take the rules for the group and check how many group members there would have been both individually for each single rule, and then by building up the query step-by-step
*/
async countComponentMembersFromRules(rules?: GroupRuleWithKey[]) {
return GroupOps.countComponentMembersFromRules(this, rules);
}
/**
* Build the where-clause for the query to determine Group membership
*/
async _buildGroupMemberQueryParts(
rules?: GroupRuleWithKey[],
matchType: typeof matchTypes[number] = this.matchType,
recordState?: string
) {
if (!rules) rules = await this.getRules();
const include: Includeable[] = [];
const wheres: WhereAttributeHash[] = [];
const localNumbers = [].concat(numbers);
for (const i in rules) {
const rule = rules[i];
const number = localNumbers.pop();
const alias = `RecordProperties_${number}`;
const {
key,
operation,
type,
topLevel,
relativeMatchNumber,
relativeMatchDirection,
relativeMatchUnit,
} = rule;
let { match } = rule;
const localWhereGroup: WhereAttributeHash = {};
let rawValueMatch: WhereAttributeHash = {};
const property = await PropertiesCache.findOneWithCache(
key,
this.modelId,
"ready",
"key"
);
if (!property && !topLevel) {
throw new Error(`cannot find type for Property ${key}`);
}
if (match !== null && match !== undefined) {
// special cases for dialects
if (config.sequelize.dialect === "sqlite") {
if (property?.type === "boolean") {
if (match === "1") match = "true";
if (match === "0") match = "false";
}
}
// rewrite null matches
// @ts-ignore
rawValueMatch[Op[operation.op]] =
match.toString().toLocaleLowerCase() === "null" ? null : match;
// in the case of Array property negation, we also want to consider those records with the property never set
if (
!topLevel &&
property.isArray &&
["ne", "notLike", "notILike"].includes(operation.op)
) {
rawValueMatch = { [Op.or]: [rawValueMatch, { [Op.eq]: null }] };
}
} else if (relativeMatchNumber && !match) {
const now = Moment();
const timestamp = now[relativeMatchDirection](
relativeMatchNumber,
relativeMatchUnit
) // i.e.: Moment().add(3, 'days')
.toDate()
.getTime();
// @ts-ignore
rawValueMatch[Op[operation.op]] = timestamp;
} else {
throw new Error("either match or relativeMatch is required");
}
if (!topLevel) {
// when we are considering a record property
// @ts-ignore
localWhereGroup[Op.and] = [
Sequelize.where(
Sequelize.cast(
Sequelize.col(`${alias}.rawValue`),
propertyJSToSQLType(property.type)
),
rawValueMatch
),
];
} else {
// when we are considering a column on the records table
const topLevelGroupRule = TopLevelGroupRules.find(
(tlgr) => tlgr.key === key
);
if (!topLevelGroupRule)
throw new Error(`cannot find TopLevelGroupRule where for key ${key}`);
// @ts-ignore
if (rawValueMatch[Op[operation.op]] && type === "date") {
// @ts-ignore
rawValueMatch[Op[operation.op]] = new Date(
// @ts-ignore
parseInt(rawValueMatch[Op[operation.op]])
).getTime();
}
const topLevelWhere = {
[topLevelGroupRule.column]: rawValueMatch,
modelId: this.modelId,
};
// @ts-ignore
localWhereGroup[Op.and] = [topLevelWhere];
}
// also upper/lower bound against 'now' in the relative date case (ie: if we want 'in the past month', that means a) greater than one month ago and B) less than now)
// this is not needed in the topLevel case as the timestamps cannot be in the future
if (relativeMatchNumber && !match && !topLevel) {
const todayBoundWhereGroup = {};
const todayBoundMatch = {};
// @ts-ignore
todayBoundMatch[Op[operation.op === "gt" ? "lte" : "gte"]] =
new Date().getTime();
// @ts-ignore
todayBoundWhereGroup[Op.and] = Sequelize.where(
Sequelize.cast(
Sequelize.col(`${alias}.rawValue`),
propertyJSToSQLType(property.type)
),
todayBoundMatch
);
// @ts-ignore
localWhereGroup[Op.and].push(todayBoundWhereGroup);
}
// in the case of Array property negation, we also need to do a sub-query to subtract the records which would match the affirmative match for this match
if (
!topLevel &&
match !== null &&
match !== undefined &&
property.isArray &&
["ne", "notLike", "notILike"].includes(operation.op)
) {
let reverseMatchWhere: {
[Op.and]: (Sequelize.Utils.Where | WhereAttributeHash)[];
} = {
[Op.and]: [{ propertyId: property.id }],
};
const castedValue = Sequelize.cast(
Sequelize.col(`rawValue`),
propertyJSToSQLType(property.type)
);
const nullCheckedMatch =
match.toString().toLocaleLowerCase() === "null" ? null : match;
switch (operation.op) {
case "ne":
reverseMatchWhere[Op.and].push(
Sequelize.where(castedValue, nullCheckedMatch?.toString())
);
break;
case "notLike":
reverseMatchWhere[Op.and].push(
Sequelize.where(castedValue, {
[Op.like]: nullCheckedMatch as string,
})
);
break;
case "notILike":
reverseMatchWhere[Op.and].push(
Sequelize.where(castedValue, {
[Op.iLike]: nullCheckedMatch as string,
})
);
break;
}
const whereClause: string =
//@ts-ignore
api.sequelize.queryInterface.queryGenerator.getWhereConditions(
reverseMatchWhere
);
const affirmativeArrayMatch = Sequelize.literal(
`"RecordMultipleAssociationShim"."id" NOT IN (SELECT "recordId" FROM "recordProperties" WHERE ${whereClause})`
);
// @ts-ignore
localWhereGroup[Op.and].push(affirmativeArrayMatch);
}
wheres.push(localWhereGroup);
if (!topLevel) {
include.push({
// $_$ wrapping is an option with eager loading
// https://sequelize.org/master/manual/models-usage.html#eager-loading
where: {
[`$${alias}.propertyId$`]: property.id,
},
attributes: [],
model: RecordProperty,
as: alias,
});
}
}
if (rules.length === 0) wheres.push({ id: "" });
const joinType = matchType === "all" ? Op.and : Op.or;
const whereContainer: WhereAttributeHash = {};
if (recordState) whereContainer.state = recordState;
// @ts-ignore
whereContainer[joinType] = wheres;
return { where: whereContainer, include };
}
getConfigId() {
return this.idIsDefault() ? ConfigWriter.generateId(this.name) : this.id;
}
async getConfigObject(): Promise<GroupConfigurationObject> {
const { name } = this;
this.model = await this.$get("model");
const modelId = this.model?.getConfigId();
if (!name || !modelId) return;
const rules: GroupRuleConfigurationObject[] = [];
const groupRules = await this.getRules();
const convenientRules = this.toConvenientRules(groupRules);
for (const rule of convenientRules) {
const property = await PropertiesCache.findOneWithCache(
rule.key,
modelId,
"ready",
"key"
);
rules.push({
propertyId: rule.topLevel ? rule.key : property.getConfigId(),
op: rule.operation.op,
match: rule.match,
relativeMatchNumber: rule.relativeMatchNumber,
relativeMatchUnit: rule.relativeMatchUnit,
relativeMatchDirection: rule.relativeMatchDirection,
});
}
const configObject: {
class: string;
id: string;
modelId: string;
name: string;
rules: any[];
} = {
class: "Group",
id: this.getConfigId(),
modelId,
name,
rules,
};
return configObject;
}
// --- Class Methods --- //
static async ensureModel(instance: Group) {
return ModelGuard.check(instance);
}
static async updateState(instance: Group) {
await StateMachine.transition(instance, STATE_TRANSITIONS);
}
static async noUpdateIfLocked(instance: Group) {
await LockableHelper.beforeSave(instance, ["state", "calculatedAt"]);
}
static async checkGroupMembers(instance: Group) {
const count = await instance.$count("groupMembers");
if (count > 0) {
throw new Error(`this group still has ${count} members, cannot delete`);
}
}
static async ensureNotInUse(instance: Group) {
const count = await instance.$count("destinations", { scope: "notDraft" });
if (count > 0) {
throw new Error(
`this group still in use by ${count} destinations, cannot delete`
);
}
}
static async noDestroyIfLocked(instance: Group) {
await LockableHelper.beforeDestroy(instance);
}
static async destroyDestinationGroupTracking(instance: Group) {
const destinations = await instance.$get("destinations", {
scope: null,
});
for (const i in destinations) {
await destinations[i].update({ groupId: null });
}
}
static async destroyDestinationGroupMembership(instance: Group) {
const destinationGroupMemberships =
await DestinationGroupMembership.findAll({
where: { groupId: instance.id },
});
for (const i in destinationGroupMemberships) {
const destination = await destinationGroupMemberships[i].$get(
"destination"
);
await destinationGroupMemberships[i].destroy();
if (destination) await destination.exportMembers();
}
}
static async destroyGroupRules(instance: Group) {
return GroupRule.destroy({
where: {
groupId: instance.id,
},
});
}
static async stopRuns(instance: Group) {
const runs = await Run.findAll({
where: { creatorId: instance.id, state: "running" },
});
for (const i in runs) {
await runs[i].update({ state: "stopped" });
}
}
}