data-primals-engine 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +37 -22
  2. package/client/package-lock.json +33 -0
  3. package/client/package.json +1 -0
  4. package/client/src/App.scss +12 -4
  5. package/client/src/AssistantChat.jsx +48 -5
  6. package/client/src/AssistantChat.scss +0 -1
  7. package/client/src/ChartConfigModal.jsx +34 -9
  8. package/client/src/ConditionBuilder.jsx +1 -1
  9. package/client/src/ConditionBuilder2.jsx +2 -1
  10. package/client/src/Dashboard.jsx +396 -349
  11. package/client/src/DashboardChart.jsx +91 -12
  12. package/client/src/DataEditor.jsx +376 -368
  13. package/client/src/DataLayout.jsx +3 -2
  14. package/client/src/DataTable.jsx +60 -31
  15. package/client/src/Field.jsx +1788 -1782
  16. package/client/src/GeolocationField.jsx +94 -0
  17. package/client/src/ModelCreatorField.jsx +1 -0
  18. package/client/src/RelationField.jsx +2 -2
  19. package/client/src/constants.js +2 -2
  20. package/client/src/contexts/UIContext.jsx +5 -2
  21. package/client/src/filter.js +0 -155
  22. package/client/src/translations.js +16828 -16750
  23. package/package.json +10 -2
  24. package/src/config.js +2 -2
  25. package/src/constants.js +262 -4
  26. package/src/core.js +21 -3
  27. package/src/defaultModels.js +12 -12
  28. package/src/engine.js +7 -1
  29. package/src/events.js +4 -3
  30. package/src/filter.js +272 -260
  31. package/src/i18n.js +187 -177
  32. package/src/middlewares/middleware-mongodb.js +3 -1
  33. package/src/modules/assistant/assistant.js +131 -42
  34. package/src/modules/bucket.js +3 -2
  35. package/src/modules/data/data.backup.js +374 -0
  36. package/src/modules/data/data.core.js +2 -1
  37. package/src/modules/data/data.history.js +11 -8
  38. package/src/modules/data/data.js +190 -4551
  39. package/src/modules/data/data.operations.js +3000 -0
  40. package/src/modules/data/data.relations.js +687 -0
  41. package/src/modules/data/data.routes.js +132 -38
  42. package/src/modules/data/data.scheduling.js +274 -0
  43. package/src/modules/data/data.validation.js +248 -0
  44. package/src/modules/data/index.js +29 -1
  45. package/src/modules/user.js +2 -1
  46. package/src/modules/workflow.js +3 -2
  47. package/src/services/stripe.js +3 -2
  48. package/swagger-en.yml +133 -0
  49. package/test/model.integration.test.js +377 -221
@@ -0,0 +1,248 @@
1
+ import {Event} from "../../events.js";
2
+ import i18n from "../../i18n.js";
3
+ import {allowedFields, maxFileSize, maxModelNameLength, maxStringLength} from "../../constants.js";
4
+ import {getDefaultForType} from "../../data.js";
5
+
6
+ import {dataTypes} from "./data.operations.js";
7
+ import {Logger} from "../../gameObject.js";
8
+
9
+
10
+ let engine, logger;
11
+ export function onInit(defaultEngine) {
12
+ engine = defaultEngine;
13
+ logger = engine.getComponent(Logger);
14
+ }
15
+
16
+ export async function validateModelStructure(modelStructure) {
17
+ return await Event.Trigger("OnValidateModelStructure", "event", "system", modelStructure);
18
+ }
19
+
20
+ export const validateField = (field) => {
21
+
22
+ const allowedFieldTest = (fields) => {
23
+ // Check for unknown fields
24
+ const unknownFields = Object.keys(field).filter(f => ![...allowedFields, ...fields].includes(f));
25
+
26
+ if (unknownFields.length > 0) {
27
+ throw new Error(i18n.t('api.validate.unknowField', `Propriété(s) non reconnue(s): '{{0}}' pour le champ '{{1}}'`, [unknownFields.join(', '), field.name]));
28
+ }
29
+
30
+ const fieldInvalid = Object.keys(fields).find(f => JSON.stringify(field[f] || '').length > maxStringLength);
31
+ if (fieldInvalid) {
32
+ throw new Error(i18n.t('api.validate.invalidField', `Champ(s) non valide(s): '{{0}}'`, [fieldInvalid.name]));
33
+ }
34
+ }
35
+
36
+ // Check for required fields
37
+ if (!field.name || typeof field.name !== 'string') {
38
+ throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["name"]));
39
+ }
40
+ if (!field.type || typeof field.type !== 'string') {
41
+ throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["type"]));
42
+ }
43
+
44
+ // Check for specific field types
45
+ switch (field.type) {
46
+ case 'relation':
47
+ allowedFieldTest(['relation', 'multiple', 'relationFilter']);
48
+ if (!field.relation || typeof field.relation !== 'string' || field.relation.length > maxModelNameLength) {
49
+ throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["relation"]));
50
+ }
51
+ if (field.multiple !== undefined && typeof field.multiple !== 'boolean') {
52
+ throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["multiple"]));
53
+ }
54
+ if (field.relationFilter && typeof field.relationFilter !== 'object') {
55
+ throw new Error(i18n.t('api.validate.fieldObject', "L'attribut '{{0}}' doit être un objet.", ["relationFilter"]));
56
+ }
57
+ break;
58
+ case 'enum': {
59
+ allowedFieldTest(['items']);
60
+ if (!field.items || !Array.isArray(field.items) || field.items.length === 0) {
61
+ throw new Error(i18n.t('api.validate.fieldStringArray', "L'attribut '{{0}}' doit être un tableau de chaines de caractères.", ["items"]));
62
+ }
63
+ let id = field.items.findIndex(item => typeof item !== 'string');
64
+ if (id !== -1) {
65
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["items[" + id + "]"]));
66
+ }
67
+ break;
68
+ }
69
+ case 'number':
70
+ allowedFieldTest(['min', 'max', 'step', 'unit', 'delay', 'gauge', 'percent']);
71
+ if (field.min !== undefined && typeof field.min !== 'number') {
72
+ throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["min"]));
73
+ }
74
+ if (field.max !== undefined && typeof field.max !== 'number') {
75
+ throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["max"]));
76
+ }
77
+ if (field.max < field.min) {
78
+ throw new Error(i18n.t('api.validate.inferiorTo', "L'attribut '{{0}}' doit être inférieur à l'attribut '{{1}}'.", ["min", "max"]));
79
+ }
80
+ if (field.step !== undefined && typeof field.step !== 'number') {
81
+ throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["step"]));
82
+ }
83
+ if (field.unit !== undefined && typeof field.unit !== 'string') {
84
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["unit"]));
85
+ }
86
+ if (field.delay !== undefined && typeof field.delay !== 'boolean') {
87
+ throw new Error(i18n.t('api.validate.fieldBoolean', "Le champ '{{0}}' doit être un booléen.", ["unit"]));
88
+ }
89
+ if (field.gauge !== undefined && typeof field.gauge !== 'boolean') {
90
+ throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["gauge"]));
91
+ }
92
+ if (field.percent !== undefined && typeof field.percent !== 'boolean') {
93
+ throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["percent"]));
94
+ }
95
+ break;
96
+ case 'string':
97
+ case 'string_t':
98
+ case 'richtext':
99
+ case 'richtext_t':
100
+ case 'url':
101
+ case 'email':
102
+ case 'phone':
103
+ case 'password':
104
+ case 'code':
105
+ if (field.type === 'code')
106
+ allowedFieldTest(['maxlength', 'language', 'conditionBuilder', 'targetModel']);
107
+ else if (['string_t', 'string'].includes(field.type))
108
+ allowedFieldTest(['maxlength', 'multiline']);
109
+ else
110
+ allowedFieldTest(['maxlength']);
111
+ if (field.maxlength !== undefined && typeof field.maxlength !== 'number') {
112
+ throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxlength"]));
113
+ }
114
+ break;
115
+ case 'model':
116
+ case 'modelField':
117
+ allowedFieldTest([]);
118
+ break;
119
+ case 'object':
120
+ allowedFieldTest([]);
121
+ break;
122
+ case 'boolean':
123
+ allowedFieldTest([]);
124
+ break;
125
+ case 'date':
126
+ case 'datetime': {
127
+ allowedFieldTest(['min', 'max']);
128
+ if (field.min !== undefined && typeof field.min !== 'string') {
129
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["min"]));
130
+ }
131
+ if (field.max !== undefined && typeof field.max !== 'string') {
132
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["max"]));
133
+ }
134
+ const dtMin = field.min ? new Date(field.min) : null;
135
+ const dtMax = field.max ? new Date(field.max) : null;
136
+ if (dtMin && dtMax && dtMin > dtMax) {
137
+ throw new Error(i18n.t('api.validate.inferiorTo', "L'attribut '{{0}}' doit être inférieur à l'attribut '{{1}}'.", ["min", "max"]));
138
+ }
139
+ break;
140
+ }
141
+ case 'image':
142
+ case 'file': {
143
+ allowedFieldTest(['mimeTypes', 'maxSize']);
144
+ if (field.mimeTypes !== undefined && !Array.isArray(field.mimeTypes)) {
145
+ throw new Error(i18n.t('api.validate.fieldStringArray', "L'attribut '{{0}}' doit être un tableau de chaines de caractères.", ["mimeTypes"]));
146
+ }
147
+ let id;
148
+ if (field.mimeTypes !== undefined && (id = field.mimeTypes.findIndex(item => typeof item !== 'string')) !== -1) {
149
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["mimeTypes[" + id + "]"]));
150
+ }
151
+ if (field.maxSize !== undefined && typeof field.maxSize !== 'number') {
152
+ throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxSize"]));
153
+ }
154
+ if (field.maxSize !== undefined && field.maxSize > maxFileSize) {
155
+ throw new Error(i18n.t('api.validate.fileSize', `L'attribut 'maxSize' ne doit pas dépasser {{0}} octets.`, [maxFileSize]));
156
+ }
157
+ break;
158
+ }
159
+ case 'color':
160
+ allowedFieldTest([]);
161
+ return true;
162
+ case 'cronSchedule':
163
+ allowedFieldTest(['cronMask']);
164
+ return true;
165
+ case 'geolocation':
166
+ allowedFieldTest([]);
167
+ return true;
168
+ case 'calculated':
169
+ allowedFieldTest(['calculation']);
170
+ return true;
171
+ case 'array':
172
+ if (!field.itemsType || typeof field.itemsType !== 'string') {
173
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["itemsType"]));
174
+ }
175
+ if (!dataTypes[field.itemsType]) {
176
+ throw new Error(i18n.t('api.validate.invalidField', `Champ(s) non valide(s): '{{0}}'`, ["itemsType"]));
177
+ }
178
+ if (field.minItems !== undefined && typeof field.minItems !== 'number') {
179
+ throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["minItems"]));
180
+ }
181
+ if (field.maxItems !== undefined && typeof field.maxItems !== 'number') {
182
+ throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxItems"]));
183
+ }
184
+ break;
185
+ default:
186
+ throw new Error(i18n.t('api.validate.unknowType', `Le type '{{0}}' n'est pas reconnu.`, [field.type]));
187
+ }
188
+
189
+ // Check for optional fields
190
+ if (field.required !== undefined && typeof field.required !== 'boolean') {
191
+ throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["required"]));
192
+ }
193
+ if (field.hint !== undefined && typeof field.hint !== 'string') {
194
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["hint"]));
195
+ }
196
+ if (field.default !== undefined && field.default !== null && typeof field.default !== typeof getDefaultForType(field) && typeof field.default !== 'function') {
197
+ throw new Error(i18n.t('api.validate.sameType', `L'attribut '{{0}}' doit être du même type que l'attribut '{{0}}' (${field.type}).`, ['default', 'type']));
198
+ }
199
+ if (field.validate !== undefined && typeof field.validate !== 'function') {
200
+ throw new Error(i18n.t('api.validate.fieldFunction', "L'attribut '{{0}}' doit être une fonction.", ['validate']));
201
+ }
202
+ if (field.unique !== undefined && typeof field.unique !== 'boolean') {
203
+ throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["unique"]));
204
+ }
205
+ if (field.placeholder !== undefined && typeof field.placeholder !== 'string') {
206
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["placeholder"]));
207
+ }
208
+ if (field.asMain !== undefined && typeof field.asMain !== 'boolean') {
209
+ throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["asMain"]));
210
+ }
211
+ if (field.unit !== undefined && typeof field.unit !== 'string') {
212
+ throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["unit"]));
213
+ }
214
+
215
+ return true;
216
+ };
217
+
218
+ /**
219
+ * Valide la structure et le contenu du document selon le modèle
220
+ */
221
+ export async function validateModelData(doc, model, isPatch = false) {
222
+ if (!isPatch) {
223
+ model.fields.forEach(field => {
224
+ const value = doc[field.name];
225
+ if (field.required) {
226
+ if (value === undefined && !('default' in field)) {
227
+ throw new Error(i18n.t('api.field.missingRequired', {field: field.name + " (" + model.name + ")"}));
228
+ }
229
+ if (value === '' || value === null) {
230
+ throw new Error(i18n.t('api.field.requiredCannotBeEmpty', {field: field.name}));
231
+ }
232
+ }
233
+ });
234
+ }
235
+
236
+ // 2. Validation des types de champs (toujours exécutée pour les champs fournis)
237
+ for (const [fieldName, value] of Object.entries(doc)) {
238
+ const fieldDef = model.fields.find(f => f.name === fieldName);
239
+ if (!fieldDef) continue; // On ignore les champs supplémentaires
240
+
241
+ const validator = dataTypes[fieldDef.type]?.validate;
242
+ const valid = validator && validator(value, fieldDef);
243
+ const realValidation = await Event.Trigger('OnDataValidate', "event", "system", value, fieldDef, doc);
244
+ if (!(valid || realValidation)) {
245
+ throw new Error(i18n.t('api.field.validationFailed', {field: fieldName, value}));
246
+ }
247
+ }
248
+ }
@@ -1 +1,29 @@
1
- export * from './data.js';
1
+ export * from './data.js';
2
+ export {validateModelData} from "./data.validation.js";
3
+ export {validateField} from "./data.validation.js";
4
+ export {validateModelStructure} from "./data.validation.js";
5
+ export {scheduleAlerts} from "./data.scheduling.js";
6
+ export {runStatefulAlertJob} from "./data.scheduling.js";
7
+ export {cancelAlerts} from "./data.scheduling.js";
8
+ export {installAllPacks} from "./data.operations.js";
9
+ export {installPack} from "./data.operations.js";
10
+ export {exportData} from "./data.operations.js";
11
+ export {importData} from "./data.operations.js";
12
+ export {searchData} from "./data.operations.js";
13
+ export {deleteData} from "./data.operations.js";
14
+ export {editData} from "./data.operations.js";
15
+ export {patchData} from "./data.operations.js";
16
+ export {pushDataUnsecure} from "./data.operations.js";
17
+ export {insertData} from "./data.operations.js";
18
+ export {getModels} from "./data.operations.js";
19
+ export {getModel} from "./data.operations.js";
20
+ export {deleteModels} from "./data.operations.js";
21
+ export {createModel} from "./data.operations.js";
22
+ export {editModel} from "./data.operations.js";
23
+ export {dataTypes} from "./data.operations.js";
24
+ export {dumpUserData} from "./data.backup.js";
25
+ export {loadFromDump} from "./data.backup.js";
26
+ export {validateRestoreRequest} from "./data.backup.js";
27
+ export {jobDumpUserData} from "./data.backup.js";
28
+ export {handleCustomEndpointRequest} from "./data.routes.js";
29
+ export {middlewareEndpointAuthenticator} from "./data.routes.js";
@@ -14,6 +14,7 @@ import {Logger} from "../gameObject.js";
14
14
  import rateLimit from "express-rate-limit";
15
15
  import ivm from "isolated-vm";
16
16
  import {emailDefaultConfig} from "../constants.js";
17
+ import {safeAssignObject} from "../core.js";
17
18
 
18
19
  export const userInitiator = async (req, res, next) => {
19
20
 
@@ -269,7 +270,7 @@ export async function calculateTotalUserStorageUsage(user) {
269
270
  export async function getEnv(user){
270
271
  const result = await searchData({ model: 'env' }, user);
271
272
  const envObject = result.data.reduce((acc, v) => {
272
- acc[v.name] = v.value;
273
+ safeAssignObject(acc, v.name, v.value);
273
274
  return acc;
274
275
  }, {});
275
276
  return envObject;
@@ -23,7 +23,7 @@ import {getHost} from "../constants.js";
23
23
  import {providers} from "./assistant/constants.js";
24
24
  import {ChatAnthropic} from "@langchain/anthropic";
25
25
  import {getAIProvider} from "./assistant/assistant.js";
26
- import {escapeRegex} from "../core.js";
26
+ import {escapeRegex, safeAssignObject} from "../core.js";
27
27
 
28
28
  let logger = null;
29
29
  export async function onInit(defaultEngine) {
@@ -1143,7 +1143,8 @@ export async function substituteVariables(template, contextData, user) {
1143
1143
  const newObj = {};
1144
1144
  for (const key in template) {
1145
1145
  if (Object.prototype.hasOwnProperty.call(template, key)) {
1146
- newObj[key] = await substituteVariables(template[key], contextData, user);
1146
+ const val = await substituteVariables(template[key], contextData, user);
1147
+ safeAssignObject(newObj, key, val);
1147
1148
  }
1148
1149
  }
1149
1150
  return newObj;
@@ -1,6 +1,7 @@
1
1
  import Stripe from 'stripe';
2
2
  import {editData, insertData, searchData} from '../modules/data/index.js';
3
3
  import {getEnv} from "../modules/user.js";
4
+ import {safeAssignObject} from "../core.js";
4
5
 
5
6
  const stripeInstances = {};
6
7
 
@@ -15,10 +16,10 @@ async function getStripeClient(user) {
15
16
  if (!stripeInstances[userId]) {
16
17
  const env = await getEnv(user);
17
18
  if (env.STRIPE_SECRET_KEY) {
18
- stripeInstances[userId] = {
19
+ safeAssignObject(stripeInstances, userId, {
19
20
  client: new Stripe(env.STRIPE_SECRET_KEY),
20
21
  webhookSecret: env.STRIPE_WEBHOOK_SECRET
21
- };
22
+ });
22
23
  }
23
24
  }
24
25
  return stripeInstances[userId] || null;
package/swagger-en.yml CHANGED
@@ -599,6 +599,47 @@ components:
599
599
  description: ID of a token document
600
600
  required:
601
601
  - username
602
+ userPermission:
603
+ type: object
604
+ properties:
605
+ _id:
606
+ type: string
607
+ description: Unique document identifier (MongoDB ObjectId)
608
+ readOnly: true
609
+ _user:
610
+ type: string
611
+ description: Identifier of the user owning this document
612
+ readOnly: true
613
+ _model:
614
+ type: string
615
+ description: Name of the model this document belongs to
616
+ readOnly: true
617
+ default: userPermission
618
+ _hash:
619
+ type: number
620
+ description: Document hash for integrity verification
621
+ readOnly: true
622
+ _pack:
623
+ type: string
624
+ description: Name of the pack this document belongs to (if applicable)
625
+ readOnly: true
626
+ user:
627
+ type: string
628
+ description: ID of a user document
629
+ permission:
630
+ type: string
631
+ description: ID of a permission document
632
+ isGranted:
633
+ type: boolean
634
+ description: "True to grant the permission, False to revoke it explicitly."
635
+ expiresAt:
636
+ type: string
637
+ format: date-time
638
+ description: "If set, the exception (grant or revocation) is temporary."
639
+ required:
640
+ - user
641
+ - permission
642
+ - isGranted
602
643
  token:
603
644
  type: object
604
645
  properties:
@@ -792,6 +833,39 @@ components:
792
833
  required:
793
834
  - name
794
835
  - type
836
+ env:
837
+ type: object
838
+ properties:
839
+ _id:
840
+ type: string
841
+ description: Unique document identifier (MongoDB ObjectId)
842
+ readOnly: true
843
+ _user:
844
+ type: string
845
+ description: Identifier of the user owning this document
846
+ readOnly: true
847
+ _model:
848
+ type: string
849
+ description: Name of the model this document belongs to
850
+ readOnly: true
851
+ default: env
852
+ _hash:
853
+ type: number
854
+ description: Document hash for integrity verification
855
+ readOnly: true
856
+ _pack:
857
+ type: string
858
+ description: Name of the pack this document belongs to (if applicable)
859
+ readOnly: true
860
+ name:
861
+ type: string
862
+ description: ""
863
+ value:
864
+ type: string
865
+ description: ""
866
+ required:
867
+ - name
868
+ - type
795
869
  message:
796
870
  type: object
797
871
  properties:
@@ -864,6 +938,65 @@ components:
864
938
  description: ""
865
939
  required:
866
940
  - content
941
+ alert:
942
+ type: object
943
+ properties:
944
+ _id:
945
+ type: string
946
+ description: Unique document identifier (MongoDB ObjectId)
947
+ readOnly: true
948
+ _user:
949
+ type: string
950
+ description: Identifier of the user owning this document
951
+ readOnly: true
952
+ _model:
953
+ type: string
954
+ description: Name of the model this document belongs to
955
+ readOnly: true
956
+ default: alert
957
+ _hash:
958
+ type: number
959
+ description: Document hash for integrity verification
960
+ readOnly: true
961
+ _pack:
962
+ type: string
963
+ description: Name of the pack this document belongs to (if applicable)
964
+ readOnly: true
965
+ name:
966
+ type: string
967
+ description: ""
968
+ targetModel:
969
+ type: string
970
+ description: "The name of the model to target."
971
+ description:
972
+ type: string
973
+ description: ""
974
+ triggerCondition:
975
+ type: string
976
+ description: "The condition that, if met, will trigger the alert."
977
+ frequency:
978
+ type: string
979
+ description: "How often to check if the condition is met (CRON format)."
980
+ isActive:
981
+ type: boolean
982
+ default: true
983
+ description: ""
984
+ sendEmail:
985
+ type: boolean
986
+ default: false
987
+ description: "Check to also send an email notification."
988
+ lastNotifiedAt:
989
+ type: string
990
+ format: date-time
991
+ description: "Timestamp of the last notification sent for this alert."
992
+ message:
993
+ type: string
994
+ description: "Custom message for the email. If empty, a default message will be used. You can use variables like {count}, {alert.name}..."
995
+ required:
996
+ - name
997
+ - targetModel
998
+ - triggerCondition
999
+ - frequency
867
1000
  webpage:
868
1001
  type: object
869
1002
  properties: