@adobe/spacecat-shared-data-access 2.85.1 → 2.87.0

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [@adobe/spacecat-shared-data-access-v2.87.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v2.86.0...@adobe/spacecat-shared-data-access-v2.87.0) (2025-11-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * add page citability ([#1140](https://github.com/adobe/spacecat-shared/issues/1140)) ([e4dfcdc](https://github.com/adobe/spacecat-shared/commit/e4dfcdccf44ad4a303c3b68b0655e00b25f3da3c))
7
+
8
+ # [@adobe/spacecat-shared-data-access-v2.86.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v2.85.1...@adobe/spacecat-shared-data-access-v2.86.0) (2025-11-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * add configuration management APIs ([#1063](https://github.com/adobe/spacecat-shared/issues/1063)) ([d6988a9](https://github.com/adobe/spacecat-shared/commit/d6988a94ad79016c238f3f4d9426c328b52c3131))
14
+
1
15
  # [@adobe/spacecat-shared-data-access-v2.85.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v2.85.0...@adobe/spacecat-shared-data-access-v2.85.1) (2025-11-18)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "2.85.1",
3
+ "version": "2.87.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -40,6 +40,7 @@ import PageIntentCollection from '../page-intent/page-intent.collection.js';
40
40
  import ReportCollection from '../report/report.collection.js';
41
41
  import TrialUserCollection from '../trial-user/trial-user.collection.js';
42
42
  import TrialUserActivityCollection from '../trial-user-activity/trial-user-activity.collection.js';
43
+ import PageCitabilityCollection from '../page-citability/page-citability.collection.js';
43
44
 
44
45
  import ApiKeySchema from '../api-key/api-key.schema.js';
45
46
  import AsyncJobSchema from '../async-job/async-job.schema.js';
@@ -68,6 +69,7 @@ import PageIntentSchema from '../page-intent/page-intent.schema.js';
68
69
  import ReportSchema from '../report/report.schema.js';
69
70
  import TrialUserSchema from '../trial-user/trial-user.schema.js';
70
71
  import TrialUserActivitySchema from '../trial-user-activity/trial-user-activity.schema.js';
72
+ import PageCitabilitySchema from '../page-citability/page-citability.schema.js';
71
73
 
72
74
  /**
73
75
  * EntityRegistry - A registry class responsible for managing entities, their schema and collection.
@@ -165,5 +167,6 @@ EntityRegistry.registerEntity(PageIntentSchema, PageIntentCollection);
165
167
  EntityRegistry.registerEntity(ReportSchema, ReportCollection);
166
168
  EntityRegistry.registerEntity(TrialUserSchema, TrialUserCollection);
167
169
  EntityRegistry.registerEntity(TrialUserActivitySchema, TrialUserActivityCollection);
170
+ EntityRegistry.registerEntity(PageCitabilitySchema, PageCitabilityCollection);
168
171
 
169
172
  export default EntityRegistry;
@@ -14,7 +14,6 @@ import { isNonEmptyObject, isNonEmptyArray } from '@adobe/spacecat-shared-utils'
14
14
 
15
15
  import { sanitizeIdAndAuditFields } from '../../util/util.js';
16
16
  import BaseModel from '../base/base.model.js';
17
- import { Audit } from '../audit/index.js';
18
17
  import { Entitlement } from '../entitlement/index.js';
19
18
 
20
19
  /**
@@ -47,6 +46,11 @@ class Configuration extends BaseModel {
47
46
  FORTNIGHTLY_SUNDAY: 'fortnightly-sunday',
48
47
  MONTHLY: 'monthly',
49
48
  };
49
+
50
+ static AUDIT_NAME_REGEX = /^[a-z0-9-]+$/;
51
+
52
+ static AUDIT_NAME_MAX_LENGTH = 37;
53
+
50
54
  // add your custom methods or overrides here
51
55
 
52
56
  getHandler(type) {
@@ -253,23 +257,186 @@ class Configuration extends BaseModel {
253
257
  this.updateHandlerOrgs(type, orgId, false);
254
258
  }
255
259
 
260
+ /**
261
+ * Updates the queue URLs configuration by merging with existing queues.
262
+ * Only the specified queue URLs will be updated; others remain unchanged.
263
+ *
264
+ * @param {object} queues - Queue URLs to update (merged with existing)
265
+ * @throws {Error} If queues object is empty or invalid
266
+ */
267
+ updateQueues(queues) {
268
+ if (!isNonEmptyObject(queues)) {
269
+ throw new Error('Queues configuration cannot be empty');
270
+ }
271
+ const existingQueues = this.getQueues() || {};
272
+ const mergedQueues = { ...existingQueues, ...queues };
273
+ this.setQueues(mergedQueues);
274
+ }
275
+
276
+ /**
277
+ * Updates a job's properties (interval, group).
278
+ *
279
+ * @param {string} type - The job type to update
280
+ * @param {object} properties - Properties to update (interval, group)
281
+ * @throws {Error} If job not found or properties are invalid
282
+ */
283
+ updateJob(type, properties) {
284
+ const jobs = this.getJobs();
285
+ const jobIndex = jobs.findIndex((job) => job.type === type);
286
+
287
+ if (jobIndex === -1) {
288
+ throw new Error(`Job type "${type}" not found in configuration`);
289
+ }
290
+
291
+ if (properties.interval && !Object.values(Configuration.JOB_INTERVALS)
292
+ .includes(properties.interval)) {
293
+ throw new Error(`Invalid interval "${properties.interval}". Must be one of: ${Object.values(Configuration.JOB_INTERVALS).join(', ')}`);
294
+ }
295
+
296
+ if (properties.group && !Object.values(Configuration.JOB_GROUPS).includes(properties.group)) {
297
+ throw new Error(`Invalid group "${properties.group}". Must be one of: ${Object.values(Configuration.JOB_GROUPS).join(', ')}`);
298
+ }
299
+
300
+ jobs[jobIndex] = { ...jobs[jobIndex], ...properties };
301
+ this.setJobs(jobs);
302
+ }
303
+
304
+ /**
305
+ * Updates a handler's properties.
306
+ *
307
+ * @param {string} type - The handler type to update
308
+ * @param {object} properties - Properties to update
309
+ * @throws {Error} If handler not found or properties are invalid
310
+ */
311
+ updateHandlerProperties(type, properties) {
312
+ const handlers = this.getHandlers();
313
+ if (!handlers[type]) {
314
+ throw new Error(`Handler "${type}" not found in configuration`);
315
+ }
316
+
317
+ if (properties.productCodes !== undefined) {
318
+ if (!isNonEmptyArray(properties.productCodes)) {
319
+ throw new Error('productCodes must be a non-empty array');
320
+ }
321
+ const validProductCodes = Object.values(Entitlement.PRODUCT_CODES);
322
+ if (!properties.productCodes.every((pc) => validProductCodes.includes(pc))) {
323
+ throw new Error('Invalid product codes provided');
324
+ }
325
+ }
326
+
327
+ if (isNonEmptyArray(properties.dependencies)) {
328
+ for (const dep of properties.dependencies) {
329
+ if (!handlers[dep.handler]) {
330
+ throw new Error(`Dependency handler "${dep.handler}" does not exist in configuration`);
331
+ }
332
+ }
333
+ }
334
+
335
+ if (properties.movingAvgThreshold !== undefined && properties.movingAvgThreshold < 1) {
336
+ throw new Error('movingAvgThreshold must be greater than or equal to 1');
337
+ }
338
+
339
+ if (properties.percentageChangeThreshold !== undefined && properties
340
+ .percentageChangeThreshold < 1) {
341
+ throw new Error('percentageChangeThreshold must be greater than or equal to 1');
342
+ }
343
+
344
+ handlers[type] = { ...handlers[type], ...properties };
345
+ this.setHandlers(handlers);
346
+ }
347
+
348
+ /**
349
+ * Updates the configuration by merging changes into existing sections.
350
+ * This is a flexible update method that allows updating one or more sections at once.
351
+ * Changes are merged, not replaced - existing data is preserved.
352
+ *
353
+ * @param {object} data - Configuration data to update
354
+ * @param {object} [data.handlers] - Handlers to merge (adds new, updates existing)
355
+ * @param {Array} [data.jobs] - Jobs to merge (updates matching jobs by type)
356
+ * @param {object} [data.queues] - Queues to merge (updates specific queue URLs)
357
+ * @throws {Error} If validation fails
358
+ */
359
+ updateConfiguration(data) {
360
+ if (!isNonEmptyObject(data)) {
361
+ throw new Error('Configuration data cannot be empty');
362
+ }
363
+
364
+ if (data.handlers !== undefined) {
365
+ if (!isNonEmptyObject(data.handlers)) {
366
+ throw new Error('Handlers must be a non-empty object if provided');
367
+ }
368
+ const existingHandlers = this.getHandlers() || {};
369
+ const mergedHandlers = { ...existingHandlers };
370
+
371
+ Object.keys(data.handlers).forEach((handlerType) => {
372
+ mergedHandlers[handlerType] = {
373
+ ...existingHandlers[handlerType],
374
+ ...data.handlers[handlerType],
375
+ };
376
+ });
377
+
378
+ this.setHandlers(mergedHandlers);
379
+ }
380
+
381
+ if (data.jobs !== undefined) {
382
+ if (!isNonEmptyArray(data.jobs)) {
383
+ throw new Error('Jobs must be a non-empty array if provided');
384
+ }
385
+ const existingJobs = this.getJobs() || [];
386
+ const mergedJobs = [...existingJobs];
387
+
388
+ data.jobs.forEach((newJob) => {
389
+ const existingIndex = mergedJobs.findIndex(
390
+ (job) => job.type === newJob.type && job.group === newJob.group,
391
+ );
392
+
393
+ if (existingIndex !== -1) {
394
+ mergedJobs[existingIndex] = { ...mergedJobs[existingIndex], ...newJob };
395
+ } else {
396
+ mergedJobs.push(newJob);
397
+ }
398
+ });
399
+
400
+ this.setJobs(mergedJobs);
401
+ }
402
+
403
+ if (data.queues !== undefined) {
404
+ if (!isNonEmptyObject(data.queues)) {
405
+ throw new Error('Queues must be a non-empty object if provided');
406
+ }
407
+ const existingQueues = this.getQueues() || {};
408
+ const mergedQueues = { ...existingQueues, ...data.queues };
409
+
410
+ this.setQueues(mergedQueues);
411
+ }
412
+ }
413
+
256
414
  registerAudit(
257
415
  type,
258
416
  enabledByDefault = false,
259
417
  interval = Configuration.JOB_INTERVALS.NEVER,
260
418
  productCodes = [],
261
419
  ) {
262
- // Validate audit type
263
- if (!Object.values(Audit.AUDIT_TYPES).includes(type)) {
264
- throw new Error(`Audit type ${type} is not a valid audit type in the data model`);
420
+ if (!type || typeof type !== 'string' || type.trim() === '') {
421
+ throw new Error('Audit type must be a non-empty string');
422
+ }
423
+
424
+ if (type.length > Configuration.AUDIT_NAME_MAX_LENGTH) {
425
+ throw new Error(`Audit type must not exceed ${Configuration.AUDIT_NAME_MAX_LENGTH} characters`);
426
+ }
427
+ if (!Configuration.AUDIT_NAME_REGEX.test(type)) {
428
+ throw new Error('Audit type can only contain lowercase letters, numbers, and hyphens');
429
+ }
430
+
431
+ const handlers = this.getHandlers();
432
+ if (handlers && handlers[type]) {
433
+ throw new Error(`Audit type "${type}" is already registered`);
265
434
  }
266
435
 
267
- // Validate job interval
268
436
  if (!Object.values(Configuration.JOB_INTERVALS).includes(interval)) {
269
437
  throw new Error(`Invalid interval ${interval}`);
270
438
  }
271
439
 
272
- // Validate product codes
273
440
  if (!isNonEmptyArray(productCodes)) {
274
441
  throw new Error('No product codes provided');
275
442
  }
@@ -277,26 +444,22 @@ class Configuration extends BaseModel {
277
444
  throw new Error('Invalid product codes provided');
278
445
  }
279
446
 
280
- // Add to handlers if not already registered
281
- const handlers = this.getHandlers();
282
- if (!handlers[type]) {
283
- handlers[type] = {
284
- enabledByDefault,
285
- enabled: {
286
- sites: [],
287
- orgs: [],
288
- },
289
- disabled: {
290
- sites: [],
291
- orgs: [],
292
- },
293
- dependencies: [],
294
- productCodes,
295
- };
296
- this.setHandlers(handlers);
297
- }
298
-
299
- // Add to jobs if not already registered
447
+ const updatedHandlers = handlers || {};
448
+ updatedHandlers[type] = {
449
+ enabledByDefault,
450
+ enabled: {
451
+ sites: [],
452
+ orgs: [],
453
+ },
454
+ disabled: {
455
+ sites: [],
456
+ orgs: [],
457
+ },
458
+ dependencies: [],
459
+ productCodes,
460
+ };
461
+ this.setHandlers(updatedHandlers);
462
+
300
463
  const jobs = this.getJobs();
301
464
  const exists = jobs.find((job) => job.group === 'audits' && job.type === type);
302
465
  if (!exists) {
@@ -310,19 +473,18 @@ class Configuration extends BaseModel {
310
473
  }
311
474
 
312
475
  unregisterAudit(type) {
313
- // Validate audit type
314
- if (!Object.values(Audit.AUDIT_TYPES).includes(type)) {
315
- throw new Error(`Audit type ${type} is not a valid audit type in the data model`);
476
+ if (!type || typeof type !== 'string' || type.trim() === '') {
477
+ throw new Error('Audit type must be a non-empty string');
316
478
  }
317
479
 
318
- // Remove from handlers
319
480
  const handlers = this.getHandlers();
320
- if (handlers[type]) {
321
- delete handlers[type];
322
- this.setHandlers(handlers);
481
+ if (!handlers || !handlers[type]) {
482
+ throw new Error(`Audit type "${type}" is not registered`);
323
483
  }
324
484
 
325
- // Remove from jobs
485
+ delete handlers[type];
486
+ this.setHandlers(handlers);
487
+
326
488
  const jobs = this.getJobs();
327
489
  const jobIndex = jobs.findIndex((job) => job.group === 'audits' && job.type === type);
328
490
  if (jobIndex !== -1) {
@@ -40,7 +40,7 @@ const handlerSchema = Joi.object().pattern(Joi.string(), Joi.object(
40
40
  actions: Joi.array().items(Joi.string()),
41
41
  },
42
42
  )),
43
- productCodes: Joi.array().items(Joi.string()),
43
+ productCodes: Joi.array().items(Joi.string()).min(1).required(),
44
44
  },
45
45
  )).unknown(true);
46
46
 
@@ -12,8 +12,10 @@
12
12
 
13
13
  import Configuration from './configuration.model.js';
14
14
  import ConfigurationCollection from './configuration.collection.js';
15
+ import { checkConfiguration } from './configuration.schema.js';
15
16
 
16
17
  export {
17
18
  Configuration,
18
19
  ConfigurationCollection,
20
+ checkConfiguration,
19
21
  };
@@ -38,3 +38,4 @@ export * from './page-intent/index.js';
38
38
  export * from './report/index.js';
39
39
  export * from './trial-user/index.js';
40
40
  export * from './trial-user-activity/index.js';
41
+ export * from './page-citability/index.js';
@@ -0,0 +1,39 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import type { BaseCollection, BaseModel, Site } from '../index.js';
14
+
15
+ export interface PageCitability extends BaseModel {
16
+ getSiteId(): string;
17
+ getSite(): Promise<Site>;
18
+ getUrl(): string;
19
+ getCitabilityScore(): number | undefined;
20
+ getContentRatio(): number | undefined;
21
+ getWordDifference(): number | undefined;
22
+ getBotWords(): number | undefined;
23
+ getNormalWords(): number | undefined;
24
+
25
+ setSiteId(siteId: string): PageCitability;
26
+ setUrl(url: string): PageCitability;
27
+ setCitabilityScore(citabilityScore?: number): PageCitability;
28
+ setContentRatio(contentRatio?: number): PageCitability;
29
+ setWordDifference(wordDifference?: number): PageCitability;
30
+ setBotWords(botWords?: number): PageCitability;
31
+ setNormalWords(normalWords?: number): PageCitability;
32
+ }
33
+
34
+ export interface PageCitabilityCollection extends BaseCollection<PageCitability> {
35
+ allBySiteId(siteId: string): Promise<PageCitability[]>;
36
+ findBySiteId(siteId: string): Promise<PageCitability | null>;
37
+ allByUrl(url: string): Promise<PageCitability[]>;
38
+ findByUrl(url: string): Promise<PageCitability | null>;
39
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import PageCitability from './page-citability.model.js';
14
+ import PageCitabilityCollection from './page-citability.collection.js';
15
+
16
+ export {
17
+ PageCitability,
18
+ PageCitabilityCollection,
19
+ };
@@ -0,0 +1,25 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import BaseCollection from '../base/base.collection.js';
14
+
15
+ /**
16
+ * PageCitabilityCollection - Manages PageCitability entities.
17
+ *
18
+ * @class PageCitabilityCollection
19
+ * @extends BaseCollection
20
+ */
21
+ class PageCitabilityCollection extends BaseCollection {
22
+ // add custom collection-level methods here, if needed
23
+ }
24
+
25
+ export default PageCitabilityCollection;
@@ -0,0 +1,27 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import BaseModel from '../base/base.model.js';
14
+
15
+ /**
16
+ * PageCitability - Represents a page's citability metrics within a site.
17
+ *
18
+ * @class PageCitability
19
+ * @extends BaseModel
20
+ */
21
+ class PageCitability extends BaseModel {
22
+ static DEFAULT_UPDATED_BY = 'spacecat';
23
+
24
+ // add any custom methods or overrides here
25
+ }
26
+
27
+ export default PageCitability;
@@ -0,0 +1,83 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { isValidUrl } from '@adobe/spacecat-shared-utils';
14
+
15
+ import SchemaBuilder from '../base/schema.builder.js';
16
+ import PageCitability from './page-citability.model.js';
17
+ import PageCitabilityCollection from './page-citability.collection.js';
18
+
19
+ /*
20
+ Schema: https://electrodb.dev/en/modeling/schema/
21
+ Attributes: https://electrodb.dev/en/modeling/attributes/
22
+ Indexes: https://electrodb.dev/en/modeling/indexes/
23
+ */
24
+
25
+ const schema = new SchemaBuilder(PageCitability, PageCitabilityCollection)
26
+ // link back to Site entity
27
+ .addReference('belongs_to', 'Site')
28
+
29
+ // page's full URL (must be unique)
30
+ .addAttribute('url', {
31
+ type: 'string',
32
+ required: true,
33
+ validate: (value) => isValidUrl(value),
34
+ })
35
+
36
+ // citation readability score
37
+ .addAttribute('citabilityScore', {
38
+ type: 'number',
39
+ required: false,
40
+ validate: (value) => !value || (typeof value === 'number' && !Number.isNaN(value)),
41
+ })
42
+
43
+ // content increase ratio
44
+ .addAttribute('contentRatio', {
45
+ type: 'number',
46
+ required: false,
47
+ validate: (value) => !value || (typeof value === 'number' && !Number.isNaN(value)),
48
+ })
49
+
50
+ // word difference between before and after
51
+ .addAttribute('wordDifference', {
52
+ type: 'number',
53
+ required: false,
54
+ validate: (value) => !value || (typeof value === 'number' && !Number.isNaN(value)),
55
+ })
56
+
57
+ // word count before processing (bot words)
58
+ .addAttribute('botWords', {
59
+ type: 'number',
60
+ required: false,
61
+ validate: (value) => !value || (typeof value === 'number' && !Number.isNaN(value)),
62
+ })
63
+
64
+ // word count after processing (normal words)
65
+ .addAttribute('normalWords', {
66
+ type: 'number',
67
+ required: false,
68
+ validate: (value) => !value || (typeof value === 'number' && !Number.isNaN(value)),
69
+ })
70
+
71
+ // optionally track who last updated
72
+ .addAttribute('updatedBy', {
73
+ type: 'string',
74
+ default: PageCitability.DEFAULT_UPDATED_BY,
75
+ })
76
+
77
+ // allow fetching the single record by its URL
78
+ .addIndex(
79
+ { composite: ['url'] },
80
+ { composite: ['updatedAt'] },
81
+ );
82
+
83
+ export default schema.build();
@@ -47,6 +47,7 @@ const schema = new SchemaBuilder(Site, SiteCollection)
47
47
  .addReference('has_many', 'SiteTopPages')
48
48
  .addReference('has_many', 'TrialUserActivities')
49
49
  .addReference('has_many', 'PageIntents')
50
+ .addReference('has_many', 'PageCitabilities')
50
51
  .addAttribute('baseURL', {
51
52
  type: 'string',
52
53
  required: true,