@directus/api 23.0.0 → 23.1.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.
Files changed (42) hide show
  1. package/dist/app.js +2 -0
  2. package/dist/controllers/activity.js +30 -27
  3. package/dist/controllers/assets.js +1 -1
  4. package/dist/controllers/comments.d.ts +2 -0
  5. package/dist/controllers/comments.js +153 -0
  6. package/dist/controllers/versions.js +10 -5
  7. package/dist/database/index.js +3 -0
  8. package/dist/database/migrations/20240909A-separate-comments.d.ts +3 -0
  9. package/dist/database/migrations/20240909A-separate-comments.js +65 -0
  10. package/dist/database/migrations/20240909B-consolidate-content-versioning.d.ts +3 -0
  11. package/dist/database/migrations/20240909B-consolidate-content-versioning.js +10 -0
  12. package/dist/database/run-ast/lib/get-db-query.d.ts +12 -2
  13. package/dist/database/run-ast/lib/get-db-query.js +2 -2
  14. package/dist/database/run-ast/modules/fetch-permitted-ast-root-fields.d.ts +15 -0
  15. package/dist/database/run-ast/modules/fetch-permitted-ast-root-fields.js +29 -0
  16. package/dist/database/run-ast/run-ast.js +8 -1
  17. package/dist/database/run-ast/utils/get-column-pre-processor.d.ts +1 -1
  18. package/dist/database/run-ast/utils/get-column-pre-processor.js +10 -2
  19. package/dist/permissions/modules/validate-access/lib/validate-item-access.d.ts +2 -1
  20. package/dist/permissions/modules/validate-access/lib/validate-item-access.js +18 -13
  21. package/dist/permissions/modules/validate-access/validate-access.d.ts +1 -0
  22. package/dist/permissions/modules/validate-access/validate-access.js +14 -1
  23. package/dist/services/activity.d.ts +1 -7
  24. package/dist/services/activity.js +0 -103
  25. package/dist/services/assets.js +5 -4
  26. package/dist/services/collections.js +6 -4
  27. package/dist/services/comments.d.ts +31 -0
  28. package/dist/services/comments.js +374 -0
  29. package/dist/services/graphql/index.js +17 -16
  30. package/dist/services/index.d.ts +1 -0
  31. package/dist/services/index.js +1 -0
  32. package/dist/services/items.js +3 -1
  33. package/dist/services/mail/index.d.ts +2 -1
  34. package/dist/services/mail/index.js +4 -1
  35. package/dist/services/payload.js +15 -14
  36. package/dist/services/users.js +1 -0
  37. package/dist/services/versions.js +59 -44
  38. package/dist/utils/apply-diff.js +5 -6
  39. package/dist/utils/get-service.js +3 -1
  40. package/dist/utils/sanitize-schema.d.ts +1 -1
  41. package/dist/utils/sanitize-schema.js +2 -0
  42. package/package.json +51 -51
@@ -7,6 +7,12 @@ import { validateItemAccess } from './lib/validate-item-access.js';
7
7
  * control rules and checking if we got the expected result back
8
8
  */
9
9
  export async function validateAccess(options, context) {
10
+ // Skip further validation if the collection does not exist
11
+ if (options.collection in context.schema.collections === false) {
12
+ throw new ForbiddenError({
13
+ reason: `You don't have permission to "${options.action}" from collection "${options.collection}" or it does not exist.`,
14
+ });
15
+ }
10
16
  if (options.accountability.admin === true) {
11
17
  return;
12
18
  }
@@ -21,8 +27,15 @@ export async function validateAccess(options, context) {
21
27
  access = await validateCollectionAccess(options, context);
22
28
  }
23
29
  if (!access) {
30
+ if (options.fields?.length ?? 0 > 0) {
31
+ throw new ForbiddenError({
32
+ reason: `You don't have permissions to perform "${options.action}" for the field(s) ${options
33
+ .fields.map((field) => `"${field}"`)
34
+ .join(', ')} in collection "${options.collection}" or it does not exist.`,
35
+ });
36
+ }
24
37
  throw new ForbiddenError({
25
- reason: `You don't have permission to "${options.action}" from collection "${options.collection}" or it does not exist.`,
38
+ reason: `You don't have permission to perform "${options.action}" for collection "${options.collection}" or it does not exist.`,
26
39
  });
27
40
  }
28
41
  }
@@ -1,11 +1,5 @@
1
- import type { Item, PrimaryKey } from '@directus/types';
2
- import type { AbstractServiceOptions, MutationOptions } from '../types/index.js';
1
+ import type { AbstractServiceOptions } from '../types/index.js';
3
2
  import { ItemsService } from './items.js';
4
- import { NotificationsService } from './notifications.js';
5
- import { UsersService } from './users.js';
6
3
  export declare class ActivityService extends ItemsService {
7
- notificationsService: NotificationsService;
8
- usersService: UsersService;
9
4
  constructor(options: AbstractServiceOptions);
10
- createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
11
5
  }
@@ -1,109 +1,6 @@
1
- import { Action } from '@directus/constants';
2
- import { useEnv } from '@directus/env';
3
- import { ErrorCode, isDirectusError } from '@directus/errors';
4
- import { uniq } from 'lodash-es';
5
- import { useLogger } from '../logger/index.js';
6
- import { fetchRolesTree } from '../permissions/lib/fetch-roles-tree.js';
7
- import { fetchGlobalAccess } from '../permissions/modules/fetch-global-access/fetch-global-access.js';
8
- import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
9
- import { createDefaultAccountability } from '../permissions/utils/create-default-accountability.js';
10
- import { isValidUuid } from '../utils/is-valid-uuid.js';
11
- import { Url } from '../utils/url.js';
12
- import { userName } from '../utils/user-name.js';
13
1
  import { ItemsService } from './items.js';
14
- import { NotificationsService } from './notifications.js';
15
- import { UsersService } from './users.js';
16
- const env = useEnv();
17
- const logger = useLogger();
18
2
  export class ActivityService extends ItemsService {
19
- notificationsService;
20
- usersService;
21
3
  constructor(options) {
22
4
  super('directus_activity', options);
23
- this.notificationsService = new NotificationsService({ schema: this.schema });
24
- this.usersService = new UsersService({ schema: this.schema });
25
- }
26
- async createOne(data, opts) {
27
- if (data['action'] === Action.COMMENT && typeof data['comment'] === 'string') {
28
- const usersRegExp = new RegExp(/@[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}/gi);
29
- const mentions = uniq(data['comment'].match(usersRegExp) ?? []);
30
- const sender = await this.usersService.readOne(this.accountability.user, {
31
- fields: ['id', 'first_name', 'last_name', 'email'],
32
- });
33
- for (const mention of mentions) {
34
- const userID = mention.substring(1);
35
- const user = await this.usersService.readOne(userID, {
36
- fields: ['id', 'first_name', 'last_name', 'email', 'role'],
37
- });
38
- const roles = await fetchRolesTree(user['role'], this.knex);
39
- const globalAccess = await fetchGlobalAccess({ user: user['id'], roles, ip: null }, this.knex);
40
- const accountability = createDefaultAccountability({
41
- user: userID,
42
- role: user['role']?.id ?? null,
43
- roles,
44
- ...globalAccess,
45
- });
46
- const usersService = new UsersService({ schema: this.schema, accountability });
47
- try {
48
- if (this.accountability) {
49
- await validateAccess({
50
- accountability: this.accountability,
51
- action: 'read',
52
- collection: data['collection'],
53
- primaryKeys: [data['item']],
54
- }, {
55
- knex: this.knex,
56
- schema: this.schema,
57
- });
58
- }
59
- const templateData = await usersService.readByQuery({
60
- fields: ['id', 'first_name', 'last_name', 'email'],
61
- filter: { id: { _in: mentions.map((mention) => mention.substring(1)) } },
62
- });
63
- const userPreviews = templateData.reduce((acc, user) => {
64
- acc[user['id']] = `<em>${userName(user)}</em>`;
65
- return acc;
66
- }, {});
67
- let comment = data['comment'];
68
- for (const mention of mentions) {
69
- const uuid = mention.substring(1);
70
- // We only match on UUIDs in the first place. This is just an extra sanity check.
71
- if (isValidUuid(uuid) === false)
72
- continue;
73
- comment = comment.replace(new RegExp(mention, 'gm'), userPreviews[uuid] ?? '@Unknown User');
74
- }
75
- comment = `> ${comment.replace(/\n+/gm, '\n> ')}`;
76
- const href = new Url(env['PUBLIC_URL'])
77
- .addPath('admin', 'content', data['collection'], data['item'])
78
- .toString();
79
- const message = `
80
- Hello ${userName(user)},
81
-
82
- ${userName(sender)} has mentioned you in a comment:
83
-
84
- ${comment}
85
-
86
- <a href="${href}">Click here to view.</a>
87
- `;
88
- await this.notificationsService.createOne({
89
- recipient: userID,
90
- sender: sender['id'],
91
- subject: `You were mentioned in ${data['collection']}`,
92
- message,
93
- collection: data['collection'],
94
- item: data['item'],
95
- });
96
- }
97
- catch (err) {
98
- if (isDirectusError(err, ErrorCode.Forbidden)) {
99
- logger.warn(`User ${userID} doesn't have proper permissions to receive notification for this item.`);
100
- }
101
- else {
102
- throw err;
103
- }
104
- }
105
- }
106
- }
107
- return super.createOne(data, opts);
108
5
  }
109
6
  }
@@ -98,7 +98,7 @@ export class AssetsService {
98
98
  }
99
99
  if (exists) {
100
100
  return {
101
- stream: await storage.location(file.storage).read(assetFilename, range),
101
+ stream: await storage.location(file.storage).read(assetFilename, { range }),
102
102
  file,
103
103
  stat: await storage.location(file.storage).stat(assetFilename),
104
104
  };
@@ -121,7 +121,8 @@ export class AssetsService {
121
121
  reason: 'Server too busy',
122
122
  });
123
123
  }
124
- const readStream = await storage.location(file.storage).read(file.filename_disk, range);
124
+ const version = file.modified_on !== undefined ? String(new Date(file.modified_on).getTime() / 1000) : undefined;
125
+ const readStream = await storage.location(file.storage).read(file.filename_disk, { range, version });
125
126
  const transformer = getSharpInstance();
126
127
  transformer.timeout({
127
128
  seconds: clamp(Math.round(getMilliseconds(env['ASSETS_TRANSFORM_TIMEOUT'], 0) / 1000), 1, 3600),
@@ -151,13 +152,13 @@ export class AssetsService {
151
152
  }
152
153
  }
153
154
  return {
154
- stream: await storage.location(file.storage).read(assetFilename, range),
155
+ stream: await storage.location(file.storage).read(assetFilename, { range }),
155
156
  stat: await storage.location(file.storage).stat(assetFilename),
156
157
  file,
157
158
  };
158
159
  }
159
160
  else {
160
- const readStream = await storage.location(file.storage).read(file.filename_disk, range);
161
+ const readStream = await storage.location(file.storage).read(file.filename_disk, { range });
161
162
  const stat = await storage.location(file.storage).stat(file.filename_disk);
162
163
  return { stream: readStream, file, stat };
163
164
  }
@@ -155,10 +155,11 @@ export class CollectionsService {
155
155
  if (opts?.autoPurgeSystemCache !== false) {
156
156
  await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
157
157
  }
158
+ // Refresh the schema for subsequent reads
159
+ this.schema = await getSchema();
158
160
  if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
159
- const updatedSchema = await getSchema();
160
161
  for (const nestedActionEvent of nestedActionEvents) {
161
- nestedActionEvent.context.schema = updatedSchema;
162
+ nestedActionEvent.context.schema = this.schema;
162
163
  emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
163
164
  }
164
165
  }
@@ -196,10 +197,11 @@ export class CollectionsService {
196
197
  if (opts?.autoPurgeSystemCache !== false) {
197
198
  await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
198
199
  }
200
+ // Refresh the schema for subsequent reads
201
+ this.schema = await getSchema();
199
202
  if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
200
- const updatedSchema = await getSchema();
201
203
  for (const nestedActionEvent of nestedActionEvents) {
202
- nestedActionEvent.context.schema = updatedSchema;
204
+ nestedActionEvent.context.schema = this.schema;
203
205
  emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
204
206
  }
205
207
  }
@@ -0,0 +1,31 @@
1
+ import type { Comment, Item, PrimaryKey, Query } from '@directus/types';
2
+ import type { AbstractServiceOptions, MutationOptions } from '../types/index.js';
3
+ import { ActivityService } from './activity.js';
4
+ import { ItemsService, type QueryOptions } from './items.js';
5
+ import { NotificationsService } from './notifications.js';
6
+ import { UsersService } from './users.js';
7
+ type serviceOrigin = 'activity' | 'comments';
8
+ export declare class CommentsService extends ItemsService {
9
+ activityService: ActivityService;
10
+ notificationsService: NotificationsService;
11
+ usersService: UsersService;
12
+ serviceOrigin: serviceOrigin;
13
+ constructor(options: AbstractServiceOptions & {
14
+ serviceOrigin: serviceOrigin;
15
+ });
16
+ readOne(key: PrimaryKey, query?: Query, opts?: QueryOptions): Promise<Item>;
17
+ readByQuery(query: Query, opts?: QueryOptions): Promise<Item[]>;
18
+ readMany(keys: PrimaryKey[], query?: Query, opts?: QueryOptions): Promise<Item[]>;
19
+ createOne(data: Partial<Comment>, opts?: MutationOptions): Promise<PrimaryKey>;
20
+ updateByQuery(query: Query, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
21
+ updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
22
+ updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
23
+ deleteByQuery(query: Query, opts?: MutationOptions): Promise<PrimaryKey[]>;
24
+ deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]>;
25
+ deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey>;
26
+ private processPrimaryKeys;
27
+ migrateLegacyComment(activityPk: PrimaryKey): Promise<PrimaryKey>;
28
+ generateQuery(type: serviceOrigin, originalQuery: Query): Query;
29
+ private sortLegacyResults;
30
+ }
31
+ export {};
@@ -0,0 +1,374 @@
1
+ import { Action } from '@directus/constants';
2
+ import { useEnv } from '@directus/env';
3
+ import { ErrorCode, ForbiddenError, InvalidPayloadError, isDirectusError } from '@directus/errors';
4
+ import { cloneDeep, mergeWith, uniq } from 'lodash-es';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { useLogger } from '../logger/index.js';
7
+ import { fetchRolesTree } from '../permissions/lib/fetch-roles-tree.js';
8
+ import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
9
+ import { isValidUuid } from '../utils/is-valid-uuid.js';
10
+ import { transaction } from '../utils/transaction.js';
11
+ import { Url } from '../utils/url.js';
12
+ import { userName } from '../utils/user-name.js';
13
+ import { ActivityService } from './activity.js';
14
+ import { ItemsService } from './items.js';
15
+ import { NotificationsService } from './notifications.js';
16
+ import { UsersService } from './users.js';
17
+ const env = useEnv();
18
+ const logger = useLogger();
19
+ // TODO: Remove legacy comments logic
20
+ export class CommentsService extends ItemsService {
21
+ activityService;
22
+ notificationsService;
23
+ usersService;
24
+ serviceOrigin;
25
+ constructor(options) {
26
+ super('directus_comments', options);
27
+ this.activityService = new ActivityService(options);
28
+ this.notificationsService = new NotificationsService({ schema: this.schema });
29
+ this.usersService = new UsersService({ schema: this.schema });
30
+ this.serviceOrigin = options.serviceOrigin ?? 'comments';
31
+ }
32
+ readOne(key, query, opts) {
33
+ const isLegacyComment = isNaN(Number(key));
34
+ let result;
35
+ if (isLegacyComment) {
36
+ const activityQuery = this.serviceOrigin === 'activity' ? query : this.generateQuery('activity', query || {});
37
+ result = this.activityService.readOne(key, activityQuery, opts);
38
+ }
39
+ else {
40
+ const commentsQuery = this.serviceOrigin === 'comments' ? query : this.generateQuery('comments', query || {});
41
+ result = super.readOne(key, commentsQuery, opts);
42
+ }
43
+ return result;
44
+ }
45
+ async readByQuery(query, opts) {
46
+ const activityQuery = this.serviceOrigin === 'activity' ? query : this.generateQuery('activity', query);
47
+ const commentsQuery = this.serviceOrigin === 'comments' ? query : this.generateQuery('comments', query);
48
+ const activityResult = await this.activityService.readByQuery(activityQuery, opts);
49
+ const commentsResult = await super.readByQuery(commentsQuery, opts);
50
+ if (query.aggregate) {
51
+ // Merging the first result only as the app does not utilise group
52
+ return [
53
+ mergeWith({}, activityResult[0], commentsResult[0], (a, b) => {
54
+ const numA = Number(a);
55
+ const numB = Number(b);
56
+ if (!isNaN(numA) && !isNaN(numB)) {
57
+ return numA + numB;
58
+ }
59
+ return;
60
+ }),
61
+ ];
62
+ }
63
+ else if (query.sort) {
64
+ return this.sortLegacyResults([...activityResult, ...commentsResult], query.sort);
65
+ }
66
+ else {
67
+ return [...activityResult, ...commentsResult];
68
+ }
69
+ }
70
+ async readMany(keys, query, opts) {
71
+ const commentsKeys = [];
72
+ const activityKeys = [];
73
+ for (const key of keys) {
74
+ if (isNaN(Number(key))) {
75
+ commentsKeys.push(key);
76
+ }
77
+ else {
78
+ activityKeys.push(key);
79
+ }
80
+ }
81
+ const activityQuery = this.serviceOrigin === 'activity' ? query : this.generateQuery('activity', query || {});
82
+ const commentsQuery = this.serviceOrigin === 'comments' ? query : this.generateQuery('comments', query || {});
83
+ const activityResult = await this.activityService.readMany(activityKeys, activityQuery, opts);
84
+ const commentsResult = await super.readMany(commentsKeys, commentsQuery, opts);
85
+ if (query?.sort) {
86
+ return this.sortLegacyResults([...activityResult, ...commentsResult], query.sort);
87
+ }
88
+ else {
89
+ return [...activityResult, ...commentsResult];
90
+ }
91
+ }
92
+ async createOne(data, opts) {
93
+ if (!data['comment']) {
94
+ throw new InvalidPayloadError({ reason: `"comment" is required` });
95
+ }
96
+ if (!data['collection']) {
97
+ throw new InvalidPayloadError({ reason: `"collection" is required` });
98
+ }
99
+ if (!data['item']) {
100
+ throw new InvalidPayloadError({ reason: `"item" is required` });
101
+ }
102
+ if (this.accountability) {
103
+ await validateAccess({
104
+ accountability: this.accountability,
105
+ action: 'read',
106
+ collection: data['collection'],
107
+ primaryKeys: [data['item']],
108
+ }, {
109
+ schema: this.schema,
110
+ knex: this.knex,
111
+ });
112
+ }
113
+ const usersRegExp = new RegExp(/@[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}/gi);
114
+ const mentions = uniq(data['comment'].match(usersRegExp) ?? []);
115
+ const sender = await this.usersService.readOne(this.accountability.user, {
116
+ fields: ['id', 'first_name', 'last_name', 'email'],
117
+ });
118
+ for (const mention of mentions) {
119
+ const userID = mention.substring(1);
120
+ const user = await this.usersService.readOne(userID, {
121
+ fields: ['id', 'first_name', 'last_name', 'email', 'role.id', 'role.admin_access', 'role.app_access'],
122
+ });
123
+ const accountability = {
124
+ user: userID,
125
+ role: user['role']?.id ?? null,
126
+ admin: user['role']?.admin_access ?? null,
127
+ app: user['role']?.app_access ?? null,
128
+ roles: await fetchRolesTree(user['role']?.id, this.knex),
129
+ ip: null,
130
+ };
131
+ const usersService = new UsersService({ schema: this.schema, accountability });
132
+ try {
133
+ await validateAccess({
134
+ accountability,
135
+ action: 'read',
136
+ collection: data['collection'],
137
+ primaryKeys: [data['item']],
138
+ }, {
139
+ schema: this.schema,
140
+ knex: this.knex,
141
+ });
142
+ const templateData = await usersService.readByQuery({
143
+ fields: ['id', 'first_name', 'last_name', 'email'],
144
+ filter: { id: { _in: mentions.map((mention) => mention.substring(1)) } },
145
+ });
146
+ const userPreviews = templateData.reduce((acc, user) => {
147
+ acc[user['id']] = `<em>${userName(user)}</em>`;
148
+ return acc;
149
+ }, {});
150
+ let comment = data['comment'];
151
+ for (const mention of mentions) {
152
+ const uuid = mention.substring(1);
153
+ // We only match on UUIDs in the first place. This is just an extra sanity check.
154
+ if (isValidUuid(uuid) === false)
155
+ continue;
156
+ comment = comment.replace(new RegExp(mention, 'gm'), userPreviews[uuid] ?? '@Unknown User');
157
+ }
158
+ comment = `> ${comment.replace(/\n+/gm, '\n> ')}`;
159
+ const href = new Url(env['PUBLIC_URL'])
160
+ .addPath('admin', 'content', data['collection'], data['item'])
161
+ .toString();
162
+ const message = `
163
+ Hello ${userName(user)},
164
+
165
+ ${userName(sender)} has mentioned you in a comment:
166
+
167
+ ${comment}
168
+
169
+ <a href="${href}">Click here to view.</a>
170
+ `;
171
+ await this.notificationsService.createOne({
172
+ recipient: userID,
173
+ sender: sender['id'],
174
+ subject: `You were mentioned in ${data['collection']}`,
175
+ message,
176
+ collection: data['collection'],
177
+ item: data['item'],
178
+ });
179
+ }
180
+ catch (err) {
181
+ if (isDirectusError(err, ErrorCode.Forbidden)) {
182
+ logger.warn(`User ${userID} doesn't have proper permissions to receive notification for this item.`);
183
+ }
184
+ else {
185
+ throw err;
186
+ }
187
+ }
188
+ }
189
+ return super.createOne(data, opts);
190
+ }
191
+ async updateByQuery(query, data, opts) {
192
+ const keys = await this.getKeysByQuery(query);
193
+ const migratedKeys = await this.processPrimaryKeys(keys);
194
+ return super.updateMany(migratedKeys, data, opts);
195
+ }
196
+ async updateMany(keys, data, opts) {
197
+ const migratedKeys = await this.processPrimaryKeys(keys);
198
+ return super.updateMany(migratedKeys, data, opts);
199
+ }
200
+ async updateOne(key, data, opts) {
201
+ const migratedKey = await this.migrateLegacyComment(key);
202
+ return super.updateOne(migratedKey, data, opts);
203
+ }
204
+ async deleteByQuery(query, opts) {
205
+ const keys = await this.getKeysByQuery(query);
206
+ const migratedKeys = await this.processPrimaryKeys(keys);
207
+ return super.deleteMany(migratedKeys, opts);
208
+ }
209
+ async deleteMany(keys, opts) {
210
+ const migratedKeys = await this.processPrimaryKeys(keys);
211
+ return super.deleteMany(migratedKeys, opts);
212
+ }
213
+ async deleteOne(key, opts) {
214
+ const migratedKey = await this.migrateLegacyComment(key);
215
+ return super.deleteOne(migratedKey, opts);
216
+ }
217
+ async processPrimaryKeys(keys) {
218
+ const migratedKeys = [];
219
+ for (const key of keys) {
220
+ if (isNaN(Number(key))) {
221
+ migratedKeys.push(key);
222
+ continue;
223
+ }
224
+ migratedKeys.push(await this.migrateLegacyComment(key));
225
+ }
226
+ return migratedKeys;
227
+ }
228
+ async migrateLegacyComment(activityPk) {
229
+ // Skip migration if not a legacy comment
230
+ if (isNaN(Number(activityPk))) {
231
+ return activityPk;
232
+ }
233
+ return transaction(this.knex, async (trx) => {
234
+ let primaryKey;
235
+ const legacyComment = await trx('directus_activity').select('*').where('id', '=', activityPk).first();
236
+ // Legacy comment
237
+ if (legacyComment['action'] === Action.COMMENT) {
238
+ primaryKey = randomUUID();
239
+ await trx('directus_comments').insert({
240
+ id: primaryKey,
241
+ collection: legacyComment.collection,
242
+ item: legacyComment.item,
243
+ comment: legacyComment.comment,
244
+ user_created: legacyComment.user,
245
+ date_created: legacyComment.timestamp,
246
+ });
247
+ await trx('directus_activity')
248
+ .update({
249
+ action: Action.CREATE,
250
+ collection: 'directus_comments',
251
+ item: primaryKey,
252
+ comment: null,
253
+ })
254
+ .where('id', '=', activityPk);
255
+ }
256
+ // Migrated comment
257
+ else if (legacyComment.collection === 'directus_comment' && legacyComment.action === Action.CREATE) {
258
+ primaryKey = legacyComment.item;
259
+ }
260
+ if (!primaryKey) {
261
+ throw new ForbiddenError();
262
+ }
263
+ return primaryKey;
264
+ });
265
+ }
266
+ generateQuery(type, originalQuery) {
267
+ const query = cloneDeep(originalQuery);
268
+ const defaultActivityCommentFilter = { action: { _eq: Action.COMMENT } };
269
+ const commentsToActivityFieldMap = {
270
+ id: 'id',
271
+ comment: 'comment',
272
+ item: 'item',
273
+ collection: 'collection',
274
+ user_created: 'user',
275
+ date_created: 'timestamp',
276
+ };
277
+ const activityToCommentsFieldMap = {
278
+ id: 'id',
279
+ comment: 'comment',
280
+ item: 'item',
281
+ collection: 'collection',
282
+ user: 'user_created',
283
+ timestamp: 'date_created',
284
+ };
285
+ const targetFieldMap = type === 'activity' ? commentsToActivityFieldMap : activityToCommentsFieldMap;
286
+ for (const key of Object.keys(originalQuery)) {
287
+ switch (key) {
288
+ case 'fields':
289
+ if (!originalQuery.fields)
290
+ break;
291
+ query.fields = [];
292
+ for (const field of originalQuery.fields) {
293
+ if (field === '*') {
294
+ query.fields = ['*'];
295
+ break;
296
+ }
297
+ const parts = field.split('.');
298
+ const firstPart = parts[0];
299
+ if (firstPart && targetFieldMap[firstPart]) {
300
+ query.fields.push(field);
301
+ if (firstPart !== targetFieldMap[firstPart]) {
302
+ (query.alias = query.alias || {})[firstPart] = targetFieldMap[firstPart];
303
+ }
304
+ }
305
+ }
306
+ break;
307
+ case 'filter':
308
+ if (!originalQuery.filter)
309
+ break;
310
+ if (type === 'activity') {
311
+ query.filter = { _and: [defaultActivityCommentFilter, originalQuery.filter] };
312
+ }
313
+ if (type === 'comments' && this.serviceOrigin === 'activity') {
314
+ if ('_and' in originalQuery.filter && Array.isArray(originalQuery.filter['_and'])) {
315
+ query.filter = {
316
+ _and: originalQuery.filter['_and'].filter((andItem) => !('action' in andItem && '_eq' in andItem['action'] && andItem['action']['_eq'] === 'comment')),
317
+ };
318
+ }
319
+ else {
320
+ query.filter = originalQuery.filter;
321
+ }
322
+ }
323
+ break;
324
+ case 'aggregate':
325
+ if (originalQuery.aggregate) {
326
+ query.aggregate = originalQuery.aggregate;
327
+ }
328
+ break;
329
+ case 'sort':
330
+ if (!originalQuery.sort)
331
+ break;
332
+ query.sort = [];
333
+ for (const sort of originalQuery.sort) {
334
+ const isDescending = sort.startsWith('-');
335
+ const field = isDescending ? sort.slice(1) : sort;
336
+ if (field && targetFieldMap[field]) {
337
+ query.sort.push(`${isDescending ? '-' : ''}${targetFieldMap[field]}`);
338
+ }
339
+ }
340
+ break;
341
+ }
342
+ }
343
+ if (type === 'activity' && !query.filter) {
344
+ query.filter = defaultActivityCommentFilter;
345
+ }
346
+ return query;
347
+ }
348
+ sortLegacyResults(results, sort) {
349
+ if (!sort)
350
+ return results;
351
+ let sortKeys = sort;
352
+ // Fix legacy app sort query which uses id
353
+ if (sortKeys.length === 1 && sortKeys[0]?.endsWith('id') && results[0]?.['timestamp']) {
354
+ sortKeys = [`${sortKeys[0].startsWith('-') ? '-' : ''}timestamp`];
355
+ }
356
+ return results.sort((a, b) => {
357
+ for (const key of sortKeys) {
358
+ const isDescending = key.startsWith('-');
359
+ const actualKey = isDescending ? key.substring(1) : key;
360
+ let aValue = a[actualKey];
361
+ let bValue = b[actualKey];
362
+ if (actualKey === 'date_created' || actualKey === 'timestamp') {
363
+ aValue = new Date(aValue);
364
+ bValue = new Date(bValue);
365
+ }
366
+ if (aValue < bValue)
367
+ return isDescending ? 1 : -1;
368
+ if (aValue > bValue)
369
+ return isDescending ? -1 : 1;
370
+ }
371
+ return 0;
372
+ });
373
+ }
374
+ }