@solidxai/core 0.1.12-beta.0 → 0.1.12-beta.10

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 (208) hide show
  1. package/dist/commands/seed.command.d.ts.map +1 -1
  2. package/dist/commands/seed.command.js +10 -2
  3. package/dist/commands/seed.command.js.map +1 -1
  4. package/dist/constants/error-messages.d.ts +1 -0
  5. package/dist/constants/error-messages.d.ts.map +1 -1
  6. package/dist/constants/error-messages.js +1 -0
  7. package/dist/constants/error-messages.js.map +1 -1
  8. package/dist/constants/media-file-types.d.ts +5 -0
  9. package/dist/constants/media-file-types.d.ts.map +1 -0
  10. package/dist/constants/media-file-types.js +27 -0
  11. package/dist/constants/media-file-types.js.map +1 -0
  12. package/dist/controllers/media.controller.d.ts +14 -11
  13. package/dist/controllers/media.controller.d.ts.map +1 -1
  14. package/dist/controllers/media.controller.js +72 -33
  15. package/dist/controllers/media.controller.js.map +1 -1
  16. package/dist/dtos/create-chatter-message.dto.d.ts +1 -0
  17. package/dist/dtos/create-chatter-message.dto.d.ts.map +1 -1
  18. package/dist/dtos/create-chatter-message.dto.js +7 -1
  19. package/dist/dtos/create-chatter-message.dto.js.map +1 -1
  20. package/dist/dtos/create-media.dto.d.ts +1 -0
  21. package/dist/dtos/create-media.dto.d.ts.map +1 -1
  22. package/dist/dtos/create-media.dto.js +7 -1
  23. package/dist/dtos/create-media.dto.js.map +1 -1
  24. package/dist/dtos/create-role-metadata.dto.d.ts +2 -0
  25. package/dist/dtos/create-role-metadata.dto.d.ts.map +1 -1
  26. package/dist/dtos/create-role-metadata.dto.js +3 -1
  27. package/dist/dtos/create-role-metadata.dto.js.map +1 -1
  28. package/dist/dtos/post-chatter-message.dto.d.ts +1 -0
  29. package/dist/dtos/post-chatter-message.dto.d.ts.map +1 -1
  30. package/dist/dtos/post-chatter-message.dto.js +6 -1
  31. package/dist/dtos/post-chatter-message.dto.js.map +1 -1
  32. package/dist/dtos/update-chatter-message.dto.d.ts +1 -0
  33. package/dist/dtos/update-chatter-message.dto.d.ts.map +1 -1
  34. package/dist/dtos/update-chatter-message.dto.js +8 -1
  35. package/dist/dtos/update-chatter-message.dto.js.map +1 -1
  36. package/dist/dtos/update-chatter-note-message.dto.d.ts +1 -0
  37. package/dist/dtos/update-chatter-note-message.dto.d.ts.map +1 -1
  38. package/dist/dtos/update-chatter-note-message.dto.js +7 -1
  39. package/dist/dtos/update-chatter-note-message.dto.js.map +1 -1
  40. package/dist/dtos/update-media.dto.d.ts +1 -0
  41. package/dist/dtos/update-media.dto.d.ts.map +1 -1
  42. package/dist/dtos/update-media.dto.js +7 -1
  43. package/dist/dtos/update-media.dto.js.map +1 -1
  44. package/dist/entities/chatter-message.entity.d.ts +1 -0
  45. package/dist/entities/chatter-message.entity.d.ts.map +1 -1
  46. package/dist/entities/chatter-message.entity.js +5 -1
  47. package/dist/entities/chatter-message.entity.js.map +1 -1
  48. package/dist/entities/media.entity.js.map +1 -1
  49. package/dist/enums/auth-type.enum.d.ts +2 -1
  50. package/dist/enums/auth-type.enum.d.ts.map +1 -1
  51. package/dist/enums/auth-type.enum.js +1 -0
  52. package/dist/enums/auth-type.enum.js.map +1 -1
  53. package/dist/guards/authentication.guard.d.ts +3 -1
  54. package/dist/guards/authentication.guard.d.ts.map +1 -1
  55. package/dist/guards/authentication.guard.js +5 -1
  56. package/dist/guards/authentication.guard.js.map +1 -1
  57. package/dist/guards/media-signed-url.guard.d.ts +9 -0
  58. package/dist/guards/media-signed-url.guard.d.ts.map +1 -0
  59. package/dist/guards/media-signed-url.guard.js +49 -0
  60. package/dist/guards/media-signed-url.guard.js.map +1 -0
  61. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.d.ts.map +1 -1
  62. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.js +10 -13
  63. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.js.map +1 -1
  64. package/dist/helpers/solid-core-error-codes-provider.service.d.ts.map +1 -1
  65. package/dist/helpers/solid-core-error-codes-provider.service.js +9 -0
  66. package/dist/helpers/solid-core-error-codes-provider.service.js.map +1 -1
  67. package/dist/interfaces/chatter-mention-notification.interface.d.ts +27 -0
  68. package/dist/interfaces/chatter-mention-notification.interface.d.ts.map +1 -0
  69. package/dist/interfaces/chatter-mention-notification.interface.js +3 -0
  70. package/dist/interfaces/chatter-mention-notification.interface.js.map +1 -0
  71. package/dist/interfaces.d.ts +19 -1
  72. package/dist/interfaces.d.ts.map +1 -1
  73. package/dist/interfaces.js +2 -0
  74. package/dist/interfaces.js.map +1 -1
  75. package/dist/jobs/chatter-mention-notification-email-queue-handler.service.d.ts +17 -0
  76. package/dist/jobs/chatter-mention-notification-email-queue-handler.service.d.ts.map +1 -0
  77. package/dist/jobs/chatter-mention-notification-email-queue-handler.service.js +106 -0
  78. package/dist/jobs/chatter-mention-notification-email-queue-handler.service.js.map +1 -0
  79. package/dist/jobs/database/chatter-mention-notification-email-publisher-database.service.d.ts +12 -0
  80. package/dist/jobs/database/chatter-mention-notification-email-publisher-database.service.d.ts.map +1 -0
  81. package/dist/jobs/database/chatter-mention-notification-email-publisher-database.service.js +39 -0
  82. package/dist/jobs/database/chatter-mention-notification-email-publisher-database.service.js.map +1 -0
  83. package/dist/jobs/database/chatter-mention-notification-email-queue-options-database.d.ts +8 -0
  84. package/dist/jobs/database/chatter-mention-notification-email-queue-options-database.d.ts.map +1 -0
  85. package/dist/jobs/database/chatter-mention-notification-email-queue-options-database.js +10 -0
  86. package/dist/jobs/database/chatter-mention-notification-email-queue-options-database.js.map +1 -0
  87. package/dist/jobs/database/chatter-mention-notification-email-subscriber-database.service.d.ts +20 -0
  88. package/dist/jobs/database/chatter-mention-notification-email-subscriber-database.service.d.ts.map +1 -0
  89. package/dist/jobs/database/chatter-mention-notification-email-subscriber-database.service.js +48 -0
  90. package/dist/jobs/database/chatter-mention-notification-email-subscriber-database.service.js.map +1 -0
  91. package/dist/jobs/rabbitmq/chatter-mention-notification-email-publisher.service.d.ts +12 -0
  92. package/dist/jobs/rabbitmq/chatter-mention-notification-email-publisher.service.d.ts.map +1 -0
  93. package/dist/jobs/rabbitmq/chatter-mention-notification-email-publisher.service.js +39 -0
  94. package/dist/jobs/rabbitmq/chatter-mention-notification-email-publisher.service.js.map +1 -0
  95. package/dist/jobs/rabbitmq/chatter-mention-notification-email-queue-options.d.ts +8 -0
  96. package/dist/jobs/rabbitmq/chatter-mention-notification-email-queue-options.d.ts.map +1 -0
  97. package/dist/jobs/rabbitmq/chatter-mention-notification-email-queue-options.js +10 -0
  98. package/dist/jobs/rabbitmq/chatter-mention-notification-email-queue-options.js.map +1 -0
  99. package/dist/jobs/rabbitmq/chatter-mention-notification-email-subscriber.service.d.ts +18 -0
  100. package/dist/jobs/rabbitmq/chatter-mention-notification-email-subscriber.service.d.ts.map +1 -0
  101. package/dist/jobs/rabbitmq/chatter-mention-notification-email-subscriber.service.js +45 -0
  102. package/dist/jobs/rabbitmq/chatter-mention-notification-email-subscriber.service.js.map +1 -0
  103. package/dist/jobs/redis/chatter-mention-notification-email-publisher-redis.service.d.ts +12 -0
  104. package/dist/jobs/redis/chatter-mention-notification-email-publisher-redis.service.d.ts.map +1 -0
  105. package/dist/jobs/redis/chatter-mention-notification-email-publisher-redis.service.js +39 -0
  106. package/dist/jobs/redis/chatter-mention-notification-email-publisher-redis.service.js.map +1 -0
  107. package/dist/jobs/redis/chatter-mention-notification-email-queue-options-redis.d.ts +8 -0
  108. package/dist/jobs/redis/chatter-mention-notification-email-queue-options-redis.d.ts.map +1 -0
  109. package/dist/jobs/redis/chatter-mention-notification-email-queue-options-redis.js +10 -0
  110. package/dist/jobs/redis/chatter-mention-notification-email-queue-options-redis.js.map +1 -0
  111. package/dist/jobs/redis/chatter-mention-notification-email-subscriber-redis.service.d.ts +18 -0
  112. package/dist/jobs/redis/chatter-mention-notification-email-subscriber-redis.service.d.ts.map +1 -0
  113. package/dist/jobs/redis/chatter-mention-notification-email-subscriber-redis.service.js +45 -0
  114. package/dist/jobs/redis/chatter-mention-notification-email-subscriber-redis.service.js.map +1 -0
  115. package/dist/repository/media.repository.d.ts.map +1 -1
  116. package/dist/repository/media.repository.js.map +1 -1
  117. package/dist/seeders/module-metadata-seeder.service.d.ts +7 -1
  118. package/dist/seeders/module-metadata-seeder.service.d.ts.map +1 -1
  119. package/dist/seeders/module-metadata-seeder.service.js +87 -3
  120. package/dist/seeders/module-metadata-seeder.service.js.map +1 -1
  121. package/dist/seeders/permission-metadata-seeder.service.d.ts +1 -0
  122. package/dist/seeders/permission-metadata-seeder.service.d.ts.map +1 -1
  123. package/dist/seeders/permission-metadata-seeder.service.js +6 -1
  124. package/dist/seeders/permission-metadata-seeder.service.js.map +1 -1
  125. package/dist/seeders/seed-data/solid-core-metadata.json +95 -13
  126. package/dist/services/authentication.service.d.ts +2 -2
  127. package/dist/services/authentication.service.d.ts.map +1 -1
  128. package/dist/services/authentication.service.js +52 -43
  129. package/dist/services/authentication.service.js.map +1 -1
  130. package/dist/services/chatter-message.service.d.ts +7 -1
  131. package/dist/services/chatter-message.service.d.ts.map +1 -1
  132. package/dist/services/chatter-message.service.js +85 -4
  133. package/dist/services/chatter-message.service.js.map +1 -1
  134. package/dist/services/media-download-url.service.d.ts +22 -0
  135. package/dist/services/media-download-url.service.d.ts.map +1 -0
  136. package/dist/services/media-download-url.service.js +70 -0
  137. package/dist/services/media-download-url.service.js.map +1 -0
  138. package/dist/services/media-storage.utils.d.ts +24 -0
  139. package/dist/services/media-storage.utils.d.ts.map +1 -0
  140. package/dist/services/media-storage.utils.js +73 -0
  141. package/dist/services/media-storage.utils.js.map +1 -0
  142. package/dist/services/media.service.d.ts +20 -4
  143. package/dist/services/media.service.d.ts.map +1 -1
  144. package/dist/services/media.service.js +155 -55
  145. package/dist/services/media.service.js.map +1 -1
  146. package/dist/services/mediaStorageProviders/file-s3-storage-provider.d.ts +4 -4
  147. package/dist/services/mediaStorageProviders/file-s3-storage-provider.d.ts.map +1 -1
  148. package/dist/services/mediaStorageProviders/file-s3-storage-provider.js +34 -63
  149. package/dist/services/mediaStorageProviders/file-s3-storage-provider.js.map +1 -1
  150. package/dist/services/mediaStorageProviders/file-storage-provider.d.ts +5 -5
  151. package/dist/services/mediaStorageProviders/file-storage-provider.d.ts.map +1 -1
  152. package/dist/services/mediaStorageProviders/file-storage-provider.js +34 -38
  153. package/dist/services/mediaStorageProviders/file-storage-provider.js.map +1 -1
  154. package/dist/services/role-metadata.service.d.ts +1 -0
  155. package/dist/services/role-metadata.service.d.ts.map +1 -1
  156. package/dist/services/role-metadata.service.js +8 -5
  157. package/dist/services/role-metadata.service.js.map +1 -1
  158. package/dist/solid-core.module.d.ts.map +1 -1
  159. package/dist/solid-core.module.js +25 -1
  160. package/dist/solid-core.module.js.map +1 -1
  161. package/dist/subscribers/audit.subscriber.js +1 -1
  162. package/dist/subscribers/audit.subscriber.js.map +1 -1
  163. package/package.json +1 -1
  164. package/src/commands/seed.command.ts +12 -3
  165. package/src/constants/error-messages.ts +1 -0
  166. package/src/constants/media-file-types.ts +44 -0
  167. package/src/controllers/media.controller.ts +44 -24
  168. package/src/dtos/create-chatter-message.dto.ts +5 -0
  169. package/src/dtos/create-media.dto.ts +8 -3
  170. package/src/dtos/create-role-metadata.dto.ts +2 -0
  171. package/src/dtos/post-chatter-message.dto.ts +4 -0
  172. package/src/dtos/update-chatter-message.dto.ts +6 -0
  173. package/src/dtos/update-chatter-note-message.dto.ts +5 -0
  174. package/src/dtos/update-media.dto.ts +8 -3
  175. package/src/entities/chatter-message.entity.ts +3 -0
  176. package/src/entities/media.entity.ts +1 -1
  177. package/src/enums/auth-type.enum.ts +1 -0
  178. package/src/guards/authentication.guard.ts +3 -0
  179. package/src/guards/media-signed-url.guard.ts +41 -0
  180. package/src/helpers/field-crud-managers/MediaFieldCrudManager.ts +12 -24
  181. package/src/helpers/solid-core-error-codes-provider.service.ts +9 -0
  182. package/src/interfaces/chatter-mention-notification.interface.ts +29 -0
  183. package/src/interfaces.ts +20 -0
  184. package/src/jobs/chatter-mention-notification-email-queue-handler.service.ts +114 -0
  185. package/src/jobs/database/chatter-mention-notification-email-publisher-database.service.ts +23 -0
  186. package/src/jobs/database/chatter-mention-notification-email-queue-options-database.ts +9 -0
  187. package/src/jobs/database/chatter-mention-notification-email-subscriber-database.service.ts +32 -0
  188. package/src/jobs/rabbitmq/chatter-mention-notification-email-publisher.service.ts +23 -0
  189. package/src/jobs/rabbitmq/chatter-mention-notification-email-queue-options.ts +9 -0
  190. package/src/jobs/rabbitmq/chatter-mention-notification-email-subscriber.service.ts +30 -0
  191. package/src/jobs/redis/chatter-mention-notification-email-publisher-redis.service.ts +23 -0
  192. package/src/jobs/redis/chatter-mention-notification-email-queue-options-redis.ts +9 -0
  193. package/src/jobs/redis/chatter-mention-notification-email-subscriber-redis.service.ts +30 -0
  194. package/src/repository/media.repository.ts +1 -0
  195. package/src/seeders/module-metadata-seeder.service.ts +109 -3
  196. package/src/seeders/permission-metadata-seeder.service.ts +6 -1
  197. package/src/seeders/seed-data/email-templates/chatter-mention-notification.handlebars.html +200 -0
  198. package/src/seeders/seed-data/solid-core-metadata.json +95 -13
  199. package/src/services/authentication.service.ts +66 -55
  200. package/src/services/chatter-message.service.ts +97 -2
  201. package/src/services/media-download-url.service.ts +74 -0
  202. package/src/services/media-storage.utils.ts +70 -0
  203. package/src/services/media.service.ts +187 -59
  204. package/src/services/mediaStorageProviders/file-s3-storage-provider.ts +49 -76
  205. package/src/services/mediaStorageProviders/file-storage-provider.ts +41 -48
  206. package/src/services/role-metadata.service.ts +17 -5
  207. package/src/solid-core.module.ts +35 -2
  208. package/src/subscribers/audit.subscriber.ts +1 -1
@@ -11,6 +11,7 @@ import { PostChatterMessageDto } from 'src/dtos/post-chatter-message.dto';
11
11
  import { UpdateChatterNoteMessageDto } from 'src/dtos/update-chatter-note-message.dto';
12
12
  import { ModelMetadataHelperService } from 'src/helpers/model-metadata-helper.service';
13
13
  import { lowerFirst } from 'src/helpers/string.helper';
14
+ import { ChatterMentionNotificationPayload } from 'src/interfaces/chatter-mention-notification.interface';
14
15
  import { ChatterMessageDetailsRepository } from 'src/repository/chatter-message-details.repository';
15
16
  import { ChatterMessageRepository } from 'src/repository/chatter-message.repository';
16
17
  import { FieldMetadataRepository } from 'src/repository/field-metadata.repository';
@@ -24,6 +25,14 @@ import { getMediaStorageProvider } from './mediaStorageProviders';
24
25
  import { RequestContextService } from './request-context.service';
25
26
  import { Logger } from '@nestjs/common';
26
27
  import { SolidIntrospectService } from './solid-introspect.service';
28
+ import { PublisherFactory } from './queues/publisher-factory.service';
29
+
30
+ interface ChatterMention {
31
+ username: string;
32
+ display_name?: string;
33
+ displayName?: string;
34
+ id?: string | number;
35
+ }
27
36
 
28
37
  @Injectable()
29
38
  export class ChatterMessageService extends CRUDService<ChatterMessage> {
@@ -47,6 +56,7 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
47
56
  private readonly modelMetadataRepo: ModelMetadataRepository,
48
57
  readonly requestContextService: RequestContextService,
49
58
  private readonly modelMetadataHelperService: ModelMetadataHelperService,
59
+ private readonly publisherFactory: PublisherFactory<ChatterMentionNotificationPayload>,
50
60
  ) {
51
61
  super(entityManager, repo, 'chatterMessage', 'solid-core', moduleRef);
52
62
  }
@@ -106,6 +116,78 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
106
116
  .filter(v => Number.isInteger(v) && v > 0);
107
117
  }
108
118
 
119
+ private parseMessageBodyMentions(value?: string): ChatterMention[] {
120
+ if (!value || typeof value !== 'string') return [];
121
+ try {
122
+ const parsed = JSON.parse(value);
123
+ return Array.isArray(parsed) ? parsed : [];
124
+ } catch {
125
+ return [];
126
+ }
127
+ }
128
+
129
+ private renderMentionTokens(messageBody: string, mentions: ChatterMention[]) {
130
+ return mentions.reduce((nextMessage, mention, index) => {
131
+ const username = mention.username;
132
+ if (!username) return nextMessage;
133
+ return nextMessage.replace(new RegExp(`\\{\\{\\s*${index}\\s*\\}\\}`, 'g'), `@${username}`);
134
+ }, messageBody || '');
135
+ }
136
+
137
+ private async publishChatterMentionNotifications(message: ChatterMessage, model: any) {
138
+ if (message.messageType !== CHATTER_MESSAGE_TYPE.CUSTOM || message.messageSubType !== CHATTER_MESSAGE_SUBTYPE.NOTE) {
139
+ return;
140
+ }
141
+
142
+ const mentions = this.parseMessageBodyMentions(message.messageBodyMentions);
143
+ const mentionUserIds = mentions
144
+ .map(mention => Number(mention.id))
145
+ .filter(id => Number.isInteger(id) && id > 0);
146
+ const uniqueMentionUserIds = Array.from(new Set(mentionUserIds));
147
+ if (uniqueMentionUserIds.length === 0) return;
148
+
149
+ const activeUser = this.requestContextService.getActiveUser();
150
+
151
+ const payload: ChatterMentionNotificationPayload = {
152
+ templateName: 'chatter-mention-notification',
153
+ mentions: mentions.map(mention => ({
154
+ id: Number(mention.id) || undefined,
155
+ username: mention.username,
156
+ displayName: mention.display_name || mention.displayName || mention.username,
157
+ })),
158
+ actor: {
159
+ id: activeUser?.sub,
160
+ username: activeUser?.username,
161
+ email: activeUser?.email,
162
+ },
163
+ noteBody: this.renderMentionTokens(message.messageBody, mentions),
164
+ entity: {
165
+ id: message.coModelEntityId,
166
+ modelName: model?.singularName || message.coModelName,
167
+ moduleName: model?.module?.name,
168
+ displayName: model?.displayName || message.coModelName,
169
+ userKey: message.modelUserKey,
170
+ },
171
+ parentEntity: 'chatterMessage',
172
+ parentEntityId: message.id,
173
+ };
174
+
175
+ try {
176
+ await this.publisherFactory.publish(
177
+ {
178
+ payload,
179
+ parentEntity: 'chatterMessage',
180
+ parentEntityId: message.id,
181
+ retryCount: 3,
182
+ retryInterval: 5000,
183
+ },
184
+ 'ChatterMentionNotificationEmailQueuePublisher',
185
+ );
186
+ } catch (error: any) {
187
+ this._logger.error(`Failed to publish chatter mention notification email job: ${error.message}`, error.stack);
188
+ }
189
+ }
190
+
109
191
  async markCompleted(id: number) {
110
192
  const activeUser = this.requestContextService.getActiveUser();
111
193
  if (!activeUser) {
@@ -146,10 +228,11 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
146
228
 
147
229
  const removeAttachmentIds = this.parseAttachmentIds(updateDto?.removeAttachmentIds);
148
230
  const hasMessageBody = typeof updateDto?.messageBody === 'string';
231
+ const hasMessageBodyMentions = typeof updateDto?.messageBodyMentions === 'string';
149
232
  const trimmedMessageBody = (updateDto?.messageBody ?? '').trim();
150
233
  const hasNewFiles = Array.isArray(files) && files.length > 0;
151
234
 
152
- if (!hasMessageBody && removeAttachmentIds.length === 0 && !hasNewFiles) {
235
+ if (!hasMessageBody && !hasMessageBodyMentions && removeAttachmentIds.length === 0 && !hasNewFiles) {
153
236
  throw new BadRequestException('No note changes submitted.');
154
237
  }
155
238
 
@@ -160,6 +243,13 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
160
243
  if (hasMessageBody) {
161
244
  message.messageBody = trimmedMessageBody;
162
245
  }
246
+ if (hasMessageBodyMentions) {
247
+ message.messageBodyMentions = updateDto.messageBodyMentions;
248
+ }
249
+ const model = await this.modelMetadataRepo.findOne({
250
+ where: { singularName: message.coModelName },
251
+ relations: { module: true }
252
+ });
163
253
  message.updatedBy = activeUser.sub;
164
254
  // Ensure updatedAt changes even for attachment-only edits.
165
255
  message.updatedAt = new Date();
@@ -214,6 +304,8 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
214
304
  }
215
305
  }
216
306
 
307
+ await this.publishChatterMentionNotifications(savedMessage, model);
308
+
217
309
  return savedMessage;
218
310
  }
219
311
 
@@ -226,13 +318,14 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
226
318
  chatterMessage.messageSubType = postDto.messageSubType || CHATTER_MESSAGE_SUBTYPE.CUSTOM;
227
319
  chatterMessage.status = postDto.status ?? CHATTER_MESSAGE_STATUS.PENDING;
228
320
  chatterMessage.messageBody = postDto.messageBody;
321
+ chatterMessage.messageBodyMentions = postDto.messageBodyMentions;
229
322
  chatterMessage.coModelEntityId = postDto.coModelEntityId;
230
323
  chatterMessage.coModelName = coModelName;
231
324
  chatterMessage.modelUserKey = postDto.modelUserKey ?? null;
232
325
 
233
326
  const model = await this.modelMetadataRepo.findOne({
234
327
  where: { singularName: coModelName },
235
- relations: { userKeyField: true }
328
+ relations: { userKeyField: true, module: true }
236
329
  });
237
330
  chatterMessage.modelDisplayName = model?.displayName ?? null;
238
331
 
@@ -262,6 +355,8 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
262
355
  }
263
356
  }
264
357
 
358
+ await this.publishChatterMentionNotifications(savedMessage, model);
359
+
265
360
  return savedMessage;
266
361
  }
267
362
 
@@ -0,0 +1,74 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { JwtService } from '@nestjs/jwt';
3
+ import { MediaStorageProviderType } from 'src/dtos/create-media-storage-provider-metadata.dto';
4
+ import { MediaStorageProviderMetadata } from 'src/entities/media-storage-provider-metadata.entity';
5
+ import { SettingService } from 'src/services/setting.service';
6
+ import type { SolidCoreSetting } from 'src/services/settings/default-settings-provider.service';
7
+ import { getEffectiveS3Region } from './media-storage.utils';
8
+ import { ConfigService } from '@nestjs/config';
9
+ import { S3FileService } from './file';
10
+
11
+ export const MEDIA_DOWNLOAD_TOKEN_AUDIENCE = 'media-download';
12
+ const DEFAULT_SIGNED_URL_EXPIRY_MINUTES = 60;
13
+
14
+ export interface MediaDownloadTokenPayload {
15
+ mediaId: number;
16
+ }
17
+
18
+ @Injectable()
19
+ export class MediaDownloadUrlService {
20
+ private readonly logger = new Logger(MediaDownloadUrlService.name);
21
+
22
+ constructor(
23
+ private readonly jwtService: JwtService,
24
+ private readonly s3FileService: S3FileService,
25
+ private readonly settingService: SettingService,
26
+ private readonly configService: ConfigService,
27
+ ) { }
28
+
29
+ async resolveDownloadUrl(
30
+ mediaId: number,
31
+ relativeUri: string,
32
+ storageProvider?: MediaStorageProviderMetadata,
33
+ ): Promise<string> {
34
+ const expiryMinutes = storageProvider?.signedUrlExpiry ?? DEFAULT_SIGNED_URL_EXPIRY_MINUTES;
35
+
36
+ if ((storageProvider?.type as MediaStorageProviderType) === MediaStorageProviderType.AwsS3) {
37
+ if (storageProvider?.bucketName) {
38
+ return this.s3FileService.getUrl(
39
+ `${storageProvider.bucketName}:${relativeUri}`,
40
+ { region: getEffectiveS3Region(this.configService, storageProvider.region), expiresIn: expiryMinutes * 60 },
41
+ );
42
+ }
43
+ this.logger.warn(`Media id ${mediaId}: AwsS3 provider missing bucketName; falling back to app-hosted token URL`);
44
+ }
45
+ // Filesystem (or misconfigured S3) has no native presigned-URL mechanism, so mint our
46
+ // own short-lived token and embed it in the download URL - the token alone authorizes
47
+ // the request (see MediaSignedUrlGuard), so it works in a plain <img src> with no
48
+ // Authorization header needed.
49
+ const token = await this.mintDownloadToken(mediaId, expiryMinutes);
50
+ const downloadPath = `/api/media/${mediaId}/download?token=${token}`;
51
+ const baseUrl = this.settingService.getConfigValue<SolidCoreSetting>('baseUrl');
52
+ return `${String(baseUrl).replace(/\/+$/, '')}${downloadPath}`;
53
+ }
54
+
55
+ async getPrivateUrl(mediaId: number, relativeUri: string, storageProvider?: MediaStorageProviderMetadata): Promise<string> {
56
+ return this.resolveDownloadUrl(mediaId, relativeUri, storageProvider);
57
+ }
58
+
59
+ async verifyDownloadToken(token: string): Promise<MediaDownloadTokenPayload> {
60
+ return this.jwtService.verifyAsync<MediaDownloadTokenPayload>(token, {
61
+ secret: this.settingService.getConfigValue<SolidCoreSetting>('secret'),
62
+ audience: MEDIA_DOWNLOAD_TOKEN_AUDIENCE,
63
+ });
64
+ }
65
+
66
+ private mintDownloadToken(mediaId: number, expiryMinutes: number): Promise<string> {
67
+ const payload: MediaDownloadTokenPayload = { mediaId };
68
+ return this.jwtService.signAsync(payload, {
69
+ secret: this.settingService.getConfigValue<SolidCoreSetting>('secret'),
70
+ audience: MEDIA_DOWNLOAD_TOKEN_AUDIENCE,
71
+ expiresIn: expiryMinutes * 60,
72
+ });
73
+ }
74
+ }
@@ -0,0 +1,70 @@
1
+ import { ConfigService } from '@nestjs/config';
2
+ import * as path from 'path';
3
+ import { FieldMetadata } from 'src/entities/field-metadata.entity';
4
+ import { MediaStorageProviderMetadata } from 'src/entities/media-storage-provider-metadata.entity';
5
+ import { SettingService } from 'src/services/setting.service';
6
+ import { DEFAULT_MEDIA_FILE_STORAGE_DIR } from 'src/services/settings/default-settings-provider.service';
7
+ import type { SolidCoreSetting } from 'src/services/settings/default-settings-provider.service';
8
+
9
+
10
+ export interface MediaRecordCreateInput {
11
+ entityId: number;
12
+ modelMetadataId: number;
13
+ mediaStorageProviderMetadataId: number;
14
+ fieldMetadataId: number;
15
+ relativeUri: string;
16
+ mimeType?: string;
17
+ fileSize?: number;
18
+ originalFileName?: string;
19
+ }
20
+
21
+ // Builds the stored file name format shared by media storage implementations.
22
+ export function buildStoredMediaFileName(file: Pick<Express.Multer.File, 'filename' | 'originalname'>): string {
23
+ return `${file.filename}-${file.originalname}`;
24
+ }
25
+
26
+ // Resolves the on-disk path for a media file based on settings and provider visibility.
27
+ export function buildDiskMediaPath(fileName: string,settingService: Pick<SettingService, 'getConfigValue'>,storageProvider?: Pick<MediaStorageProviderMetadata, 'localPath' | 'isPublic'>,): string {
28
+ const publicBase = settingService.getConfigValue<SolidCoreSetting>('fileStorageDir') || DEFAULT_MEDIA_FILE_STORAGE_DIR;
29
+ const privateBase = `${publicBase}-private`;
30
+ // const localPath = storageProvider?.localPath;
31
+ // const isUsingLegacyDefaultPath = localPath === publicBase || localPath === privateBase || localPath === privateBase;
32
+ const providerBase = storageProvider?.isPublic === false ? privateBase : publicBase
33
+
34
+ if ( path.isAbsolute(fileName) || fileName.startsWith(`${publicBase}/`) || fileName.startsWith(`${privateBase}/`) ) {
35
+ return fileName;
36
+ }
37
+
38
+ return `${providerBase}/${fileName}`;
39
+ }
40
+
41
+ // Returns the S3 region from the provider when present, otherwise falls back to app config.
42
+ export function getEffectiveS3Region(configService: Pick<ConfigService, 'get'>, providerRegion?: string): string | undefined {
43
+ return providerRegion || configService.get<string>('S3_AWS_REGION_NAME');
44
+ }
45
+
46
+ // Resolves the effective public/private flag for a media storage provider.
47
+ export function resolveMediaIsPublic(storageProvider?: Pick<MediaStorageProviderMetadata, 'isPublic'>): boolean | undefined {
48
+ if (!storageProvider) {
49
+ return undefined;
50
+ }
51
+
52
+ return storageProvider.isPublic !== false;
53
+ }
54
+
55
+ // Builds the shared media record payload used before saving media metadata to the database.
56
+ export function buildMediaRecordCreateInput(
57
+ entityId: number,
58
+ mediaFieldMetadata: Pick<FieldMetadata, 'id' | 'model'> & { model: { id: number } },
59
+ storageProvider: Pick<MediaStorageProviderMetadata, 'id'>,
60
+ overrides: Pick<MediaRecordCreateInput, 'relativeUri'>
61
+ & Partial<Omit<MediaRecordCreateInput, 'entityId' | 'modelMetadataId' | 'mediaStorageProviderMetadataId' | 'fieldMetadataId' | 'relativeUri'>>,
62
+ ): MediaRecordCreateInput {
63
+ return {
64
+ entityId,
65
+ modelMetadataId: mediaFieldMetadata.model.id,
66
+ mediaStorageProviderMetadataId: storageProvider.id,
67
+ fieldMetadataId: mediaFieldMetadata.id,
68
+ ...overrides,
69
+ };
70
+ }
@@ -1,33 +1,37 @@
1
- import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common';
1
+ import { forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
2
2
  import { ModuleRef } from "@nestjs/core";
3
3
  import { InjectEntityManager } from '@nestjs/typeorm';
4
4
  import { EntityManager, In } from 'typeorm';
5
5
  import * as path from 'path';
6
- import { DEFAULT_MEDIA_FILE_STORAGE_DIR } from "src/services/settings/default-settings-provider.service";
7
- import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service";
6
+ import { Readable } from 'stream';
8
7
 
9
8
  import { ConfigService } from '@nestjs/config';
10
9
  import { CRUDService } from 'src/services/crud.service';
11
10
  import { DiskFileService, S3FileService } from 'src/services/file';
11
+ import { MediaDownloadUrlService } from 'src/services/media-download-url.service';
12
12
 
13
13
 
14
14
  import { ERROR_MESSAGES } from 'src/constants/error-messages';
15
15
  import { BasicFilterDto } from 'src/dtos/basic-filters.dto';
16
16
  import { MediaStorageProviderType } from 'src/dtos/create-media-storage-provider-metadata.dto';
17
17
  import { Media } from 'src/entities/media.entity';
18
+ import { MediaStorageProviderMetadata } from 'src/entities/media-storage-provider-metadata.entity';
18
19
  import { FieldMetadataRepository } from 'src/repository/field-metadata.repository';
19
20
  import { MediaStorageProviderMetadataRepository } from 'src/repository/media-storage-provider-metadata.repository';
20
21
  import { MediaRepository } from 'src/repository/media.repository';
21
22
  import { ModelMetadataRepository } from 'src/repository/model-metadata.repository';
23
+ import { buildDiskMediaPath, buildStoredMediaFileName, getEffectiveS3Region, resolveMediaIsPublic, } from 'src/services/media-storage.utils';
22
24
  import { getMediaStorageProvider } from "./mediaStorageProviders";
23
25
 
24
-
25
26
  @Injectable()
26
27
  export class MediaService extends CRUDService<Media> {
28
+ private readonly logger = new Logger(MediaService.name);
29
+
27
30
  constructor(
28
31
  readonly configService: ConfigService,
29
32
  readonly diskFileService: DiskFileService,
30
33
  readonly s3FileService: S3FileService,
34
+ private readonly mediaDownloadUrlService: MediaDownloadUrlService,
31
35
  @InjectEntityManager()
32
36
  readonly entityManager: EntityManager,
33
37
  // @InjectRepository(Media, 'default')
@@ -51,75 +55,73 @@ export class MediaService extends CRUDService<Media> {
51
55
  async find(basicFilterDto: BasicFilterDto, solidRequestContext: any = {}) {
52
56
  const data = await super.find(basicFilterDto, solidRequestContext);
53
57
  if (data.records) {
54
-
55
- for (const media of data.records) {
56
- const mediaStorageProvider = media.mediaStorageProviderMetadata;
57
-
58
- if (mediaStorageProvider?.type === MediaStorageProviderType.Filesystem) {
59
- media.relativeUri = await this.diskFileService.getUrl(this.getFullFilePathForDisk(media.relativeUri));
60
- } else if (mediaStorageProvider?.type === MediaStorageProviderType.AwsS3) {
61
- media.relativeUri = await this.s3FileService.getUrl(`${mediaStorageProvider.bucketName}:${media.relativeUri}`, { region: mediaStorageProvider.region });
62
- }
63
- }
58
+ await this.decorateMediaRecords(data.records);
64
59
  }
65
60
  if (data.groupRecords) {
66
-
67
61
  for (const group of data.groupRecords) {
68
- for (const media of group.groupData.records) {
69
- const mediaStorageProvider = media.mediaStorageProviderMetadata;
70
-
71
- if (mediaStorageProvider?.type === MediaStorageProviderType.Filesystem) {
72
- media.relativeUri = await this.diskFileService.getUrl(this.getFullFilePathForDisk(media.relativeUri));
73
- }
74
- else if (mediaStorageProvider?.type === MediaStorageProviderType.AwsS3) {
75
- media.relativeUri = await this.s3FileService.getUrl(`${mediaStorageProvider.bucketName}:${media.relativeUri}`, { region: mediaStorageProvider.region });
76
- }
77
- }
62
+ await this.decorateMediaRecords(group.groupData.records);
78
63
  }
79
64
  }
80
65
  return data
81
66
  }
82
67
 
83
- async upload(createDto: any, files: Array<Express.Multer.File>) {
68
+ async findOne(id: number, query: any = {}, solidRequestContext: any = {}) {
69
+ const media = await super.findOne(id, query, solidRequestContext);
70
+ if (media) {
71
+ await this.decorateMediaRecord(media);
72
+ }
73
+ return media;
74
+ }
84
75
 
85
- if (!files) {
76
+ async upload(createDto: any, files: Array<Express.Multer.File>, _solidRequestContext: any = {}) {
77
+
78
+ if (!files || files.length === 0) {
86
79
  throw new NotFoundException(ERROR_MESSAGES.FILE_NOT_FOUND);
87
80
 
88
81
  }
89
- const savedMedias = [];
90
- for (let i = 0; i < files.length; i++) {
91
82
 
92
- createDto['fieldMetadata'] = await this.fieldMetadataRepo.findOne({
93
- where: {
94
- id: createDto['fieldMetadataId']
95
- },
96
- });
97
- createDto['modelMetadata'] = await this.modelMetadataRepo.findOne({
98
- where: {
99
- id: createDto['modelMetadataId']
100
- },
101
- });
102
- createDto['mediaStorageProviderMetadata'] = await this.mediaStorageProviderMetadataRepo.findOne({
103
- where: {
104
- id: createDto['mediaStorageProviderMetadataId']
105
- },
106
- });
83
+ createDto['fieldMetadata'] = await this.fieldMetadataRepo.findOne({
84
+ where: {
85
+ id: createDto['fieldMetadataId']
86
+ },
87
+ relations: ['mediaStorageProvider', 'model'],
88
+ });
89
+ createDto['modelMetadata'] = await this.modelMetadataRepo.findOne({
90
+ where: {
91
+ id: createDto['modelMetadataId']
92
+ },
93
+ });
94
+ createDto['mediaStorageProviderMetadata'] = await this.mediaStorageProviderMetadataRepo.findOne({
95
+ where: {
96
+ id: createDto['mediaStorageProviderMetadataId']
97
+ },
98
+ });
99
+ createDto['mediaStorageProviderMetadata'] = createDto['mediaStorageProviderMetadata'] || createDto['fieldMetadata']?.mediaStorageProvider;
100
+ createDto['modelMetadata'] = createDto['modelMetadata'] || createDto['fieldMetadata']?.model;
107
101
 
102
+ if (!createDto['mediaStorageProviderMetadata']) {
103
+ throw new NotFoundException('Media storage provider metadata not found');
104
+ }
105
+
106
+ const savedMedias = [];
107
+ for (let i = 0; i < files.length; i++) {
108
108
  const file = files[i];
109
+ const storageProvider = createDto.mediaStorageProviderMetadata as MediaStorageProviderMetadata;
110
+ const fileName = buildStoredMediaFileName(file);
109
111
 
110
- switch (createDto.mediaStorageProviderMetadata.type) {
112
+ switch (storageProvider.type) {
111
113
  case MediaStorageProviderType.Filesystem:
112
- const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(file));
114
+ const fileStoragePath = buildDiskMediaPath(fileName, this.settingService, storageProvider);
113
115
  await this.diskFileService.copy(file.path, fileStoragePath);
114
- createDto['relativeUri'] = this.getFileName(file);
116
+ createDto['relativeUri'] = fileName;
115
117
  break;
116
118
  case MediaStorageProviderType.AwsS3:
117
- const fileName = this.getFileName(file);
118
- const bucketName = createDto.mediaStorageProviderMetadata.bucketName;
119
+ const bucketName = storageProvider.bucketName;
120
+ const region = getEffectiveS3Region(this.configService, storageProvider.region);
119
121
 
120
122
  // Read file from disk and upload to S3
121
123
  const fileData = await this.diskFileService.read(file.path);
122
- await this.s3FileService.write(`${bucketName}:${fileName}`, fileData, { contentType: file.mimetype });
124
+ await this.s3FileService.write(`${bucketName}:${fileName}`, fileData, { contentType: file.mimetype, region });
123
125
 
124
126
  createDto['relativeUri'] = fileName;
125
127
  break;
@@ -129,7 +131,8 @@ export class MediaService extends CRUDService<Media> {
129
131
  // Delete temp file from disk
130
132
  await this.diskFileService.delete(file.path);
131
133
 
132
- const media = this.repo.create(createDto);
134
+ delete createDto['isPublic'];
135
+ const media = this.repo.create(createDto as Partial<Media>) as Media;
133
136
  const savedMedia = await this.repo.save(media);
134
137
  savedMedias.push(savedMedia)
135
138
  }
@@ -157,17 +160,142 @@ export class MediaService extends CRUDService<Media> {
157
160
  return this.repo.remove(media);
158
161
  }
159
162
 
160
- private getFullFilePathForDisk(fileName: string): string {
161
- const base = this.settingService.getConfigValue<SolidCoreSetting>("fileStorageDir")
162
- || DEFAULT_MEDIA_FILE_STORAGE_DIR;
163
- if (path.isAbsolute(fileName) || fileName.startsWith(`${base}/`)) {
164
- return fileName;
163
+ async fileDownloadStream(media: Media): Promise<{ stream: Readable | null, fileName: string, mimeType: string, redirectUrl?: string }> {
164
+ const loadedMedia = await this.repo.findOne({
165
+ where: { id: media.id },
166
+ relations: ['mediaStorageProviderMetadata'],
167
+ });
168
+
169
+ if (!loadedMedia || !loadedMedia.mediaStorageProviderMetadata) {
170
+ throw new NotFoundException(`Media with id ${media.id} not found`);
171
+ }
172
+
173
+ const fileName = loadedMedia.originalFileName || path.basename(loadedMedia.relativeUri || `${loadedMedia.id}`);
174
+ const mimeType = loadedMedia.mimeType || 'application/octet-stream';
175
+
176
+ switch (loadedMedia.mediaStorageProviderMetadata.type as MediaStorageProviderType) {
177
+ case MediaStorageProviderType.Filesystem:
178
+ return {
179
+ stream: await this.diskFileService.readStream(
180
+ buildDiskMediaPath(loadedMedia.relativeUri, this.settingService, loadedMedia.mediaStorageProviderMetadata),
181
+ ),
182
+ fileName,
183
+ mimeType,
184
+ };
185
+ default:
186
+ throw new Error(`Unsupported media storage provider type ${loadedMedia.mediaStorageProviderMetadata.type}`);
165
187
  }
166
- return `${base}/${fileName}`;
167
188
  }
168
189
 
169
- private getFileName(file: Express.Multer.File): string {
170
- return `${file.filename}-${file.originalname}`;
190
+ async delete(id: number, solidRequestContext: any = {}) {
191
+ const media = await this.repo.findOne({
192
+ where: { id },
193
+ relations: ['mediaStorageProviderMetadata'],
194
+ });
195
+
196
+ const result = await super.delete(id, solidRequestContext);
197
+
198
+ if (media) {
199
+ await this.deletePhysicalFile(media);
200
+ }
201
+
202
+ return result;
171
203
  }
172
204
 
205
+ async deleteMany(ids: number[], solidRequestContext: any = {}) {
206
+ const mediaRecords = await this.repo.find({
207
+ where: {
208
+ id: In(ids),
209
+ },
210
+ relations: ['mediaStorageProviderMetadata'],
211
+ });
212
+
213
+ const result = await super.deleteMany(ids, solidRequestContext);
214
+
215
+ for (const media of mediaRecords) {
216
+ await this.deletePhysicalFile(media);
217
+ }
218
+
219
+ return result;
220
+ }
221
+
222
+ private async decorateMediaRecords(medias: Media[]): Promise<void> {
223
+ for (const media of medias) {
224
+ await this.decorateMediaRecord(media);
225
+ }
226
+ }
227
+
228
+ private async decorateMediaRecord(media: Media): Promise<void> {
229
+ const mediaStorageProvider = await this.resolveMediaStorageProvider(media);
230
+ media.relativeUri = await this.resolveMediaUrl(media, mediaStorageProvider);
231
+ }
232
+
233
+ private async resolveMediaUrl(media: Media, mediaStorageProvider?: MediaStorageProviderMetadata): Promise<string> {
234
+ const resolvedMediaStorageProvider = mediaStorageProvider || await this.resolveMediaStorageProvider(media);
235
+ const isPublic = resolveMediaIsPublic(resolvedMediaStorageProvider);
236
+
237
+ if (isPublic === false) {
238
+ return this.mediaDownloadUrlService.getPrivateUrl(media.id, media.relativeUri, resolvedMediaStorageProvider);
239
+ }
240
+
241
+ if (resolvedMediaStorageProvider?.type === MediaStorageProviderType.Filesystem) {
242
+ return this.diskFileService.getUrl(buildDiskMediaPath(media.relativeUri, this.settingService, resolvedMediaStorageProvider));
243
+ }
244
+
245
+ if (resolvedMediaStorageProvider?.type === MediaStorageProviderType.AwsS3) {
246
+ return this.s3FileService.getUrl(
247
+ `${resolvedMediaStorageProvider.bucketName}:${media.relativeUri}`,
248
+ { region: getEffectiveS3Region(this.configService, resolvedMediaStorageProvider.region), expiresIn: 0 },
249
+ );
250
+ }
251
+
252
+ return media.relativeUri;
253
+ }
254
+
255
+ private async deletePhysicalFile(media: Media): Promise<void> {
256
+ if (!media?.relativeUri) {
257
+ return;
258
+ }
259
+
260
+ try {
261
+ const mediaStorageProvider = await this.resolveMediaStorageProvider(media);
262
+ if (!mediaStorageProvider) {
263
+ return;
264
+ }
265
+
266
+ switch (mediaStorageProvider.type as MediaStorageProviderType) {
267
+ case MediaStorageProviderType.Filesystem:
268
+ await this.diskFileService.delete(buildDiskMediaPath(media.relativeUri, this.settingService, mediaStorageProvider));
269
+ return;
270
+ case MediaStorageProviderType.AwsS3:
271
+ await this.s3FileService.delete(
272
+ `${mediaStorageProvider.bucketName}:${media.relativeUri}`,
273
+ { region: getEffectiveS3Region(this.configService, mediaStorageProvider.region) },
274
+ );
275
+ return;
276
+ default:
277
+ this.logger.warn(`Skipping physical delete for unsupported media storage provider type ${mediaStorageProvider.type}`);
278
+ }
279
+ } catch (error: any) {
280
+ const message = error?.message ?? String(error);
281
+ this.logger.warn(`Failed to delete physical media file for media id ${media.id}: ${message}`);
282
+ }
283
+ }
284
+
285
+ private async resolveMediaStorageProvider(media: Media): Promise<MediaStorageProviderMetadata | undefined> {
286
+ if (media.mediaStorageProviderMetadata) {
287
+ return media.mediaStorageProviderMetadata;
288
+ }
289
+
290
+ if (!media.id) {
291
+ return undefined;
292
+ }
293
+
294
+ const loadedMedia = await this.repo.findOne({
295
+ where: { id: media.id },
296
+ relations: ['mediaStorageProviderMetadata'],
297
+ });
298
+
299
+ return loadedMedia?.mediaStorageProviderMetadata;
300
+ }
173
301
  }