@flusys/nestjs-event-manager 7.1.0 → 8.0.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 (103) hide show
  1. package/adapters/event-manager.adapter.d.ts +2 -2
  2. package/adapters/index.d.ts +1 -1
  3. package/config/index.d.ts +3 -3
  4. package/controllers/event.controller.d.ts +3 -3
  5. package/controllers/index.d.ts +1 -1
  6. package/docs/event-manager-swagger.config.d.ts +2 -2
  7. package/docs/index.d.ts +1 -1
  8. package/dtos/calendar-query.dto.d.ts +1 -1
  9. package/dtos/event.dto.d.ts +1 -1
  10. package/dtos/index.d.ts +3 -3
  11. package/entities/event-with-company.entity.d.ts +1 -1
  12. package/entities/index.d.ts +8 -8
  13. package/fesm/477.js +1798 -0
  14. package/fesm/833.js +840 -0
  15. package/fesm/adapters/index.js +884 -1
  16. package/fesm/config/index.js +87 -3
  17. package/fesm/controllers/index.js +365 -1
  18. package/fesm/docs/index.js +155 -2
  19. package/fesm/dtos/index.js +720 -3
  20. package/fesm/entities/index.js +350 -11
  21. package/fesm/index.js +121 -9
  22. package/fesm/interfaces/index.js +16 -2
  23. package/fesm/modules/index.js +13 -1
  24. package/fesm/runtime.js +85 -0
  25. package/fesm/services/index.js +272 -4
  26. package/index.d.ts +9 -9
  27. package/interfaces/event-manager-module.interface.d.ts +3 -3
  28. package/interfaces/event.interface.d.ts +1 -1
  29. package/interfaces/index.d.ts +2 -2
  30. package/modules/event-manager.module.d.ts +2 -2
  31. package/modules/index.d.ts +1 -1
  32. package/package.json +29 -39
  33. package/services/event-manager-config.service.d.ts +3 -3
  34. package/services/event-manager-datasource.provider.d.ts +3 -3
  35. package/services/event-manager-helper.service.d.ts +3 -3
  36. package/services/event.service.d.ts +7 -7
  37. package/services/index.d.ts +4 -4
  38. package/cjs/adapters/event-manager.adapter.js +0 -65
  39. package/cjs/adapters/event-manager.adapter.spec.js +0 -104
  40. package/cjs/adapters/index.js +0 -18
  41. package/cjs/config/event-actions.js +0 -31
  42. package/cjs/config/event-manager.constants.js +0 -13
  43. package/cjs/config/index.js +0 -20
  44. package/cjs/config/message-keys.js +0 -27
  45. package/cjs/controllers/event.controller.js +0 -190
  46. package/cjs/controllers/event.controller.spec.js +0 -156
  47. package/cjs/controllers/index.js +0 -18
  48. package/cjs/docs/event-manager-swagger.config.js +0 -125
  49. package/cjs/docs/event-manager-swagger.config.spec.js +0 -65
  50. package/cjs/docs/index.js +0 -21
  51. package/cjs/dtos/calendar-query.dto.js +0 -212
  52. package/cjs/dtos/event-participant.dto.js +0 -181
  53. package/cjs/dtos/event.dto.js +0 -343
  54. package/cjs/dtos/index.js +0 -20
  55. package/cjs/entities/event-participant.entity.js +0 -89
  56. package/cjs/entities/event-with-company.entity.js +0 -56
  57. package/cjs/entities/event.entity.js +0 -156
  58. package/cjs/entities/index.js +0 -55
  59. package/cjs/entities/index.spec.js +0 -18
  60. package/cjs/index.js +0 -27
  61. package/cjs/interfaces/event-manager-module.interface.js +0 -4
  62. package/cjs/interfaces/event.interface.js +0 -4
  63. package/cjs/interfaces/index.js +0 -19
  64. package/cjs/modules/event-manager.module.js +0 -132
  65. package/cjs/modules/event-manager.module.spec.js +0 -146
  66. package/cjs/modules/index.js +0 -18
  67. package/cjs/services/event-manager-config.service.js +0 -76
  68. package/cjs/services/event-manager-config.service.spec.js +0 -111
  69. package/cjs/services/event-manager-datasource.provider.js +0 -84
  70. package/cjs/services/event-manager-datasource.provider.spec.js +0 -198
  71. package/cjs/services/event-manager-helper.service.js +0 -235
  72. package/cjs/services/event-manager-helper.service.spec.js +0 -327
  73. package/cjs/services/event.service.js +0 -481
  74. package/cjs/services/event.service.spec.js +0 -929
  75. package/cjs/services/index.js +0 -21
  76. package/fesm/adapters/event-manager.adapter.js +0 -55
  77. package/fesm/adapters/event-manager.adapter.spec.js +0 -100
  78. package/fesm/config/event-actions.js +0 -10
  79. package/fesm/config/event-manager.constants.js +0 -3
  80. package/fesm/config/message-keys.js +0 -9
  81. package/fesm/controllers/event.controller.js +0 -180
  82. package/fesm/controllers/event.controller.spec.js +0 -152
  83. package/fesm/docs/event-manager-swagger.config.js +0 -118
  84. package/fesm/docs/event-manager-swagger.config.spec.js +0 -61
  85. package/fesm/dtos/calendar-query.dto.js +0 -194
  86. package/fesm/dtos/event-participant.dto.js +0 -157
  87. package/fesm/dtos/event.dto.js +0 -322
  88. package/fesm/entities/event-participant.entity.js +0 -79
  89. package/fesm/entities/event-with-company.entity.js +0 -46
  90. package/fesm/entities/event.entity.js +0 -146
  91. package/fesm/entities/index.spec.js +0 -14
  92. package/fesm/interfaces/event-manager-module.interface.js +0 -3
  93. package/fesm/interfaces/event.interface.js +0 -1
  94. package/fesm/modules/event-manager.module.js +0 -122
  95. package/fesm/modules/event-manager.module.spec.js +0 -142
  96. package/fesm/services/event-manager-config.service.js +0 -66
  97. package/fesm/services/event-manager-config.service.spec.js +0 -107
  98. package/fesm/services/event-manager-datasource.provider.js +0 -74
  99. package/fesm/services/event-manager-datasource.provider.spec.js +0 -194
  100. package/fesm/services/event-manager-helper.service.js +0 -225
  101. package/fesm/services/event-manager-helper.service.spec.js +0 -323
  102. package/fesm/services/event.service.js +0 -471
  103. package/fesm/services/event.service.spec.js +0 -925
@@ -1,925 +0,0 @@
1
- import { NotFoundException } from '@nestjs/common';
2
- import { EventBusRegistry } from '@flusys/nestjs-shared/classes';
3
- import { ParticipantStatus, RecurrenceType } from '@flusys/nestjs-shared/enums';
4
- import { createMockRepository } from '@test-utils/mocks/repository.mock';
5
- import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
6
- import { EVENT_MESSAGES, EVENT_PARTICIPANT_MESSAGES } from '../config';
7
- import { Event, EventParticipant, EventWithCompany } from '../entities';
8
- import { EventService } from './event.service';
9
- function buildEvent(overrides = {}) {
10
- return {
11
- id: 'event-uuid-1',
12
- title: 'Sprint Planning',
13
- description: null,
14
- eventDate: '2026-04-03',
15
- startTime: '09:00',
16
- endTime: '10:00',
17
- isAllDay: false,
18
- recurrenceType: RecurrenceType.NONE,
19
- recurrenceEndDate: null,
20
- weekDays: null,
21
- color: '#3B82F6',
22
- meetingLink: null,
23
- isActive: true,
24
- createdAt: new Date('2026-01-01T00:00:00Z'),
25
- updatedAt: new Date('2026-01-01T00:00:00Z'),
26
- deletedAt: null,
27
- createdById: null,
28
- updatedById: null,
29
- deletedById: null,
30
- ...overrides
31
- };
32
- }
33
- function buildParticipant(overrides = {}) {
34
- return {
35
- id: 'participant-uuid-1',
36
- eventId: 'event-uuid-1',
37
- userId: 'user-uuid-1',
38
- status: ParticipantStatus.PENDING,
39
- isOrganizer: false,
40
- createdAt: new Date('2026-01-01T00:00:00Z'),
41
- updatedAt: new Date('2026-01-01T00:00:00Z'),
42
- deletedAt: null,
43
- createdById: null,
44
- updatedById: null,
45
- deletedById: null,
46
- ...overrides
47
- };
48
- }
49
- describe('EventService', ()=>{
50
- const mockUtilsService = {
51
- clearCache: jest.fn(),
52
- getCacheKey: jest.fn().mockReturnValue('cache-key'),
53
- trackCacheKey: jest.fn()
54
- };
55
- // Call history is shared across tests in this file; reset it so each cache assertion is exact.
56
- beforeEach(()=>{
57
- mockUtilsService.clearCache.mockClear();
58
- });
59
- let service;
60
- let mockEventRepo;
61
- let mockParticipantRepo;
62
- let mockConfigService;
63
- let mockDataSourceProvider;
64
- let mockEventQb;
65
- const user = buildMockUser();
66
- beforeEach(()=>{
67
- mockEventRepo = createMockRepository();
68
- mockParticipantRepo = createMockRepository();
69
- mockEventQb = mockEventRepo.createQueryBuilder();
70
- // `leftJoinAndMapMany` isn't part of the shared MockQueryBuilder.
71
- mockEventQb.leftJoinAndMapMany = jest.fn().mockReturnThis();
72
- mockConfigService = {
73
- isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
74
- getDefaultColor: jest.fn().mockReturnValue('#3B82F6'),
75
- getMaxRecurrenceOccurrences: jest.fn().mockReturnValue(365)
76
- };
77
- mockDataSourceProvider = {
78
- getRepository: jest.fn((entity)=>Promise.resolve(entity === EventParticipant ? mockParticipantRepo : mockEventRepo)),
79
- getDataSource: jest.fn()
80
- };
81
- service = new EventService({}, mockUtilsService, mockConfigService, mockDataSourceProvider);
82
- });
83
- describe('entity selection', ()=>{
84
- function buildWithCompanyFeature(enabled) {
85
- mockConfigService.isCompanyFeatureEnabled.mockReturnValue(enabled);
86
- return new EventService({}, mockUtilsService, mockConfigService, mockDataSourceProvider);
87
- }
88
- it('uses the company-scoped entity when the company feature is enabled', ()=>{
89
- expect(buildWithCompanyFeature(true)['_entity']).toBe(EventWithCompany);
90
- });
91
- it('uses the core entity when the company feature is disabled', ()=>{
92
- expect(buildWithCompanyFeature(false)['_entity']).toBe(Event);
93
- });
94
- });
95
- describe('convertSingleDtoToEntity', ()=>{
96
- it('builds a new entity with sensible defaults on create', async ()=>{
97
- const dto = {
98
- title: 'Sprint Planning',
99
- eventDate: '2026-04-03',
100
- startTime: '09:00',
101
- endTime: '10:00'
102
- };
103
- const entity = await service.convertSingleDtoToEntity(dto, user);
104
- expect(entity).toMatchObject({
105
- title: 'Sprint Planning',
106
- startTime: '09:00',
107
- endTime: '10:00',
108
- isAllDay: false,
109
- recurrenceType: RecurrenceType.NONE,
110
- recurrenceEndDate: null,
111
- color: '#3B82F6',
112
- isActive: true
113
- });
114
- });
115
- it('forces 00:00 / 23:59 markers on create for all-day events', async ()=>{
116
- const dto = {
117
- title: 'Holiday',
118
- eventDate: '2026-12-25',
119
- startTime: '09:00',
120
- endTime: '17:00',
121
- isAllDay: true
122
- };
123
- const entity = await service.convertSingleDtoToEntity(dto, user);
124
- expect(entity.startTime).toBe('00:00');
125
- expect(entity.endTime).toBe('23:59');
126
- });
127
- it('attaches companyId from the current user when the company feature is enabled', async ()=>{
128
- mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
129
- const dto = {
130
- title: 'Sprint Planning',
131
- eventDate: '2026-04-03',
132
- startTime: '09:00',
133
- endTime: '10:00'
134
- };
135
- const entity = await service.convertSingleDtoToEntity(dto, user);
136
- expect(entity.companyId).toBe(user.companyId);
137
- });
138
- it('queues participantIds for the after-insert hook when supplied', async ()=>{
139
- const dto = {
140
- title: 'Sprint Planning',
141
- eventDate: '2026-04-03',
142
- startTime: '09:00',
143
- endTime: '10:00',
144
- participantIds: [
145
- 'participant-uuid-1'
146
- ]
147
- };
148
- await service.convertSingleDtoToEntity(dto, user);
149
- expect(service.pendingParticipantsList).toEqual([
150
- [
151
- 'participant-uuid-1'
152
- ]
153
- ]);
154
- });
155
- it('merges an update dto onto the existing entity, preserving untouched fields', async ()=>{
156
- service.repository = mockEventRepo;
157
- const existing = buildEvent({
158
- title: 'Original Title',
159
- color: '#111111'
160
- });
161
- mockEventRepo.findOne.mockResolvedValue(existing);
162
- const dto = {
163
- id: 'event-uuid-1',
164
- title: 'Updated Title'
165
- };
166
- const entity = await service.convertSingleDtoToEntity(dto, user);
167
- expect(entity.title).toBe('Updated Title');
168
- expect(entity.color).toBe('#111111');
169
- });
170
- it('throws NotFoundException with the correct messageKey when updating a missing event', async ()=>{
171
- service.repository = mockEventRepo;
172
- mockEventRepo.findOne.mockResolvedValue(null);
173
- const dto = {
174
- id: 'missing-uuid',
175
- title: 'Updated Title'
176
- };
177
- await expect(service.convertSingleDtoToEntity(dto, user)).rejects.toMatchObject({
178
- response: expect.objectContaining({
179
- messageKey: EVENT_MESSAGES.NOT_FOUND
180
- })
181
- });
182
- });
183
- it('clears an explicitly-null meetingLink but preserves the existing one when omitted', async ()=>{
184
- service.repository = mockEventRepo;
185
- const existing = buildEvent({
186
- meetingLink: 'https://meet.example.com/original'
187
- });
188
- mockEventRepo.findOne.mockResolvedValue(existing);
189
- const clearedDto = {
190
- id: 'event-uuid-1',
191
- meetingLink: null
192
- };
193
- const clearedEntity = await service.convertSingleDtoToEntity(clearedDto, user);
194
- expect(clearedEntity.meetingLink).toBeNull();
195
- const omittedDto = {
196
- id: 'event-uuid-1'
197
- };
198
- const preservedEntity = await service.convertSingleDtoToEntity(omittedDto, user);
199
- expect(preservedEntity.meetingLink).toBe('https://meet.example.com/original');
200
- });
201
- });
202
- describe('getFilterQuery', ()=>{
203
- it('filters by isActive when provided as a boolean', async ()=>{
204
- const query = mockEventQb;
205
- await service.getFilterQuery(query, {
206
- isActive: true
207
- }, user);
208
- expect(query.andWhere).toHaveBeenCalledWith('event.isActive = :isActive', {
209
- isActive: true
210
- });
211
- });
212
- it('filters by recurrenceType', async ()=>{
213
- const query = mockEventQb;
214
- await service.getFilterQuery(query, {
215
- recurrenceType: RecurrenceType.WEEKLY
216
- }, user);
217
- expect(query.andWhere).toHaveBeenCalledWith('event.recurrenceType = :recurrenceType', {
218
- recurrenceType: RecurrenceType.WEEKLY
219
- });
220
- });
221
- it('scopes to participant event ids when participantUserId is provided and matches exist', async ()=>{
222
- const participantQb = mockParticipantRepo.createQueryBuilder();
223
- participantQb.getRawMany = jest.fn().mockResolvedValue([
224
- {
225
- ep_event_id: 'event-uuid-1'
226
- }
227
- ]);
228
- const query = mockEventQb;
229
- await service.getFilterQuery(query, {
230
- participantUserId: 'user-uuid-1'
231
- }, user);
232
- expect(query.andWhere).toHaveBeenCalledWith('event.id IN (:...participantEventIds)', {
233
- participantEventIds: [
234
- 'event-uuid-1'
235
- ]
236
- });
237
- });
238
- it('short-circuits to an always-false predicate when the user participates in no events', async ()=>{
239
- const participantQb = mockParticipantRepo.createQueryBuilder();
240
- participantQb.getRawMany = jest.fn().mockResolvedValue([]);
241
- const query = mockEventQb;
242
- await service.getFilterQuery(query, {
243
- participantUserId: 'user-uuid-1'
244
- }, user);
245
- expect(query.andWhere).toHaveBeenCalledWith('1 = 0');
246
- });
247
- it('falls back to an equality filter for unrecognized keys', async ()=>{
248
- const query = mockEventQb;
249
- await service.getFilterQuery(query, {
250
- color: '#000000'
251
- }, user);
252
- expect(query.andWhere).toHaveBeenCalledWith('event.color = :value', {
253
- value: '#000000'
254
- });
255
- });
256
- });
257
- describe('getExtraManipulateQuery', ()=>{
258
- it('applies the company filter and left-joins participants', async ()=>{
259
- mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
260
- const query = mockEventQb;
261
- await service.getExtraManipulateQuery(query, {}, user);
262
- expect(query.andWhere).toHaveBeenCalledWith('event.companyId = :companyId', {
263
- companyId: user.companyId
264
- });
265
- expect(query.leftJoinAndMapMany).toHaveBeenCalledWith('event.participants', EventParticipant, 'ep', 'ep.event_id = event.id AND ep.deleted_at IS NULL');
266
- });
267
- it('does not apply a company filter when the company feature is disabled', async ()=>{
268
- mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
269
- const query = mockEventQb;
270
- await service.getExtraManipulateQuery(query, {}, user);
271
- const companyCalls = query.andWhere.mock.calls.filter((call)=>call[0] === 'event.companyId = :companyId');
272
- expect(companyCalls).toHaveLength(0);
273
- });
274
- });
275
- describe('afterInsertOperation / afterUpdateOperation', ()=>{
276
- it('adds the current user and requested participants as organizer + attendees on insert', async ()=>{
277
- mockParticipantRepo.find.mockResolvedValue([]);
278
- mockParticipantRepo.save.mockResolvedValue([]);
279
- service.pendingParticipantsList = [
280
- [
281
- 'participant-uuid-2'
282
- ]
283
- ];
284
- await service.afterInsertOperation([
285
- buildEvent()
286
- ], {}, user, {});
287
- const savedParticipants = mockParticipantRepo.save.mock.calls[0][0];
288
- expect(savedParticipants).toEqual(expect.arrayContaining([
289
- expect.objectContaining({
290
- userId: user.id,
291
- isOrganizer: true
292
- }),
293
- expect.objectContaining({
294
- userId: 'participant-uuid-2',
295
- isOrganizer: false
296
- })
297
- ]));
298
- expect(service.pendingParticipantsList).toEqual([]);
299
- });
300
- it('restores a previously soft-deleted participant instead of duplicating it', async ()=>{
301
- const softDeleted = buildParticipant({
302
- userId: user.id,
303
- deletedAt: new Date()
304
- });
305
- mockParticipantRepo.find.mockResolvedValue([
306
- softDeleted
307
- ]);
308
- mockParticipantRepo.save.mockResolvedValue([]);
309
- service.pendingParticipantsList = [
310
- []
311
- ];
312
- await service.afterInsertOperation([
313
- buildEvent()
314
- ], {}, user, {});
315
- const savedParticipants = mockParticipantRepo.save.mock.calls[0][0];
316
- expect(savedParticipants[0]).toMatchObject({
317
- userId: user.id,
318
- deletedAt: null,
319
- isOrganizer: true
320
- });
321
- });
322
- it('does nothing when there is no authenticated user', async ()=>{
323
- service.pendingParticipantsList = [
324
- [
325
- 'participant-uuid-2'
326
- ]
327
- ];
328
- await service.afterInsertOperation([
329
- buildEvent()
330
- ], {}, null, {});
331
- expect(mockParticipantRepo.save).not.toHaveBeenCalled();
332
- });
333
- it('soft-deletes existing participants before re-adding the current set on update', async ()=>{
334
- mockParticipantRepo.find.mockResolvedValue([]);
335
- mockParticipantRepo.save.mockResolvedValue([]);
336
- mockParticipantRepo.update.mockResolvedValue({});
337
- service.pendingParticipantsList = [
338
- [
339
- 'participant-uuid-2'
340
- ]
341
- ];
342
- await service.afterUpdateOperation([
343
- buildEvent()
344
- ], {}, user, {});
345
- expect(mockParticipantRepo.update).toHaveBeenCalledWith({
346
- eventId: 'event-uuid-1',
347
- deletedAt: expect.anything()
348
- }, {
349
- deletedAt: expect.any(Date)
350
- });
351
- expect(mockParticipantRepo.save).toHaveBeenCalled();
352
- expect(service.pendingParticipantsList).toEqual([]);
353
- });
354
- });
355
- describe('updateParticipantStatus', ()=>{
356
- it('updates the participant status and returns the saved entity', async ()=>{
357
- const participant = buildParticipant({
358
- status: ParticipantStatus.PENDING
359
- });
360
- mockParticipantRepo.findOne.mockResolvedValue(participant);
361
- mockParticipantRepo.save.mockImplementation(async (p)=>p);
362
- const dto = {
363
- id: 'participant-uuid-1',
364
- status: ParticipantStatus.TENTATIVE
365
- };
366
- const result = await service.updateParticipantStatus(dto, user);
367
- expect(result.status).toBe(ParticipantStatus.TENTATIVE);
368
- expect(mockParticipantRepo.save).toHaveBeenCalledWith(expect.objectContaining({
369
- status: ParticipantStatus.TENTATIVE
370
- }));
371
- });
372
- it('invalidates the event list cache and the parent event entry', async ()=>{
373
- const participant = buildParticipant({
374
- status: ParticipantStatus.PENDING
375
- });
376
- mockParticipantRepo.findOne.mockResolvedValue(participant);
377
- mockParticipantRepo.save.mockImplementation(async (p)=>p);
378
- await service.updateParticipantStatus({
379
- id: participant.id,
380
- status: ParticipantStatus.ACCEPTED
381
- }, user);
382
- expect(mockUtilsService.clearCache).toHaveBeenCalledWith('event', expect.anything());
383
- expect(mockUtilsService.clearCache).toHaveBeenCalledWith('event', expect.anything(), participant.eventId);
384
- });
385
- it('throws NotFoundException with the correct messageKey when the participant is missing', async ()=>{
386
- mockParticipantRepo.findOne.mockResolvedValue(null);
387
- const dto = {
388
- id: 'missing-uuid',
389
- status: ParticipantStatus.ACCEPTED
390
- };
391
- await expect(service.updateParticipantStatus(dto, user)).rejects.toMatchObject({
392
- response: expect.objectContaining({
393
- messageKey: EVENT_PARTICIPANT_MESSAGES.NOT_FOUND
394
- })
395
- });
396
- await expect(service.updateParticipantStatus(dto, user)).rejects.toBeInstanceOf(NotFoundException);
397
- });
398
- });
399
- describe('getEventsForCalendarRange', ()=>{
400
- function queryDto(overrides = {}) {
401
- return {
402
- startDate: '2026-04-01',
403
- endDate: '2026-04-30',
404
- ...overrides
405
- };
406
- }
407
- it('returns a single non-recurring event unchanged when it falls in range', async ()=>{
408
- mockEventQb.getMany.mockResolvedValue([
409
- buildEvent({
410
- eventDate: '2026-04-10'
411
- })
412
- ]);
413
- const result = await service.getEventsForCalendarRange(queryDto(), user);
414
- expect(result).toHaveLength(1);
415
- expect(result[0].id).toBe('event-uuid-1');
416
- expect(result[0].isRecurrenceInstance).toBeUndefined();
417
- });
418
- it('defaults activeOnly to true when not specified', async ()=>{
419
- mockEventQb.getMany.mockResolvedValue([]);
420
- await service.getEventsForCalendarRange(queryDto(), user);
421
- expect(mockEventQb.andWhere).toHaveBeenCalledWith('event.isActive = :isActive', {
422
- isActive: true
423
- });
424
- });
425
- it('respects activeOnly = false', async ()=>{
426
- mockEventQb.getMany.mockResolvedValue([]);
427
- await service.getEventsForCalendarRange(queryDto({
428
- activeOnly: false
429
- }), user);
430
- expect(mockEventQb.andWhere).toHaveBeenCalledWith('event.isActive = :isActive', {
431
- isActive: false
432
- });
433
- });
434
- it('handles a zero-length range (startDate === endDate)', async ()=>{
435
- mockEventQb.getMany.mockResolvedValue([
436
- buildEvent({
437
- eventDate: '2026-04-10'
438
- })
439
- ]);
440
- const result = await service.getEventsForCalendarRange(queryDto({
441
- startDate: '2026-04-10',
442
- endDate: '2026-04-10'
443
- }), user);
444
- expect(mockEventQb.andWhere).toHaveBeenCalledWith(expect.stringContaining('event.eventDate >= :startDate'), {
445
- startDate: '2026-04-10',
446
- endDate: '2026-04-10',
447
- none: RecurrenceType.NONE
448
- });
449
- expect(result).toHaveLength(1);
450
- });
451
- it('short-circuits to an empty array when filtering by a userId with no participant events', async ()=>{
452
- const participantQb = mockParticipantRepo.createQueryBuilder();
453
- participantQb.getRawMany = jest.fn().mockResolvedValue([]);
454
- const result = await service.getEventsForCalendarRange(queryDto({
455
- userId: 'user-uuid-1'
456
- }), user);
457
- expect(result).toEqual([]);
458
- expect(mockEventQb.getMany).not.toHaveBeenCalled();
459
- });
460
- it('filters events to the participant event ids when userId matches events', async ()=>{
461
- const participantQb = mockParticipantRepo.createQueryBuilder();
462
- participantQb.getRawMany = jest.fn().mockResolvedValue([
463
- {
464
- ep_event_id: 'event-uuid-1'
465
- }
466
- ]);
467
- mockEventQb.getMany.mockResolvedValue([
468
- buildEvent()
469
- ]);
470
- await service.getEventsForCalendarRange(queryDto({
471
- userId: 'user-uuid-1'
472
- }), user);
473
- expect(mockEventQb.andWhere).toHaveBeenCalledWith('event.id IN (:...eventIds)', {
474
- eventIds: [
475
- 'event-uuid-1'
476
- ]
477
- });
478
- });
479
- it('loads and attaches participants when includeParticipants is true', async ()=>{
480
- mockEventQb.getMany.mockResolvedValue([
481
- buildEvent()
482
- ]);
483
- mockParticipantRepo.find.mockResolvedValue([
484
- buildParticipant({
485
- eventId: 'event-uuid-1',
486
- userId: 'user-uuid-1'
487
- }),
488
- buildParticipant({
489
- id: 'participant-uuid-2',
490
- eventId: 'event-uuid-1',
491
- userId: 'user-uuid-2'
492
- })
493
- ]);
494
- const result = await service.getEventsForCalendarRange(queryDto({
495
- includeParticipants: true
496
- }), user);
497
- expect(result[0].participants).toHaveLength(2);
498
- expect(result[0].participants?.[0].userId).toBe('user-uuid-1');
499
- });
500
- it('does not query participants when includeParticipants is falsy', async ()=>{
501
- mockEventQb.getMany.mockResolvedValue([
502
- buildEvent()
503
- ]);
504
- await service.getEventsForCalendarRange(queryDto(), user);
505
- expect(mockParticipantRepo.find).not.toHaveBeenCalled();
506
- });
507
- it('does not query participants when there are no events to attach them to', async ()=>{
508
- mockEventQb.getMany.mockResolvedValue([]);
509
- await service.getEventsForCalendarRange(queryDto({
510
- includeParticipants: true
511
- }), user);
512
- expect(mockParticipantRepo.find).not.toHaveBeenCalled();
513
- });
514
- // ---------------------------------------------------------------------
515
- // Recurrence expansion — daily
516
- // ---------------------------------------------------------------------
517
- it('expands an infinite daily recurrence within the range, marking only the first occurrence as non-instance', async ()=>{
518
- mockEventQb.getMany.mockResolvedValue([
519
- buildEvent({
520
- eventDate: '2026-01-01',
521
- recurrenceType: RecurrenceType.DAILY
522
- })
523
- ]);
524
- const result = await service.getEventsForCalendarRange(queryDto({
525
- startDate: '2026-01-01',
526
- endDate: '2026-01-05'
527
- }), user);
528
- expect(result.map((e)=>e.eventDate)).toEqual([
529
- '2026-01-01',
530
- '2026-01-02',
531
- '2026-01-03',
532
- '2026-01-04',
533
- '2026-01-05'
534
- ]);
535
- expect(result[0].isRecurrenceInstance).toBe(false);
536
- expect(result.slice(1).every((e)=>e.isRecurrenceInstance === true)).toBe(true);
537
- expect(result.every((e)=>e.originalEventId === 'event-uuid-1')).toBe(true);
538
- expect(result[1].id).toBe('event-uuid-1_2026-01-02');
539
- });
540
- it('stops a daily recurrence at the until (recurrenceEndDate) bound, before the range end', async ()=>{
541
- mockEventQb.getMany.mockResolvedValue([
542
- buildEvent({
543
- eventDate: '2026-01-01',
544
- recurrenceType: RecurrenceType.DAILY,
545
- recurrenceEndDate: '2026-01-03'
546
- })
547
- ]);
548
- const result = await service.getEventsForCalendarRange(queryDto({
549
- startDate: '2026-01-01',
550
- endDate: '2026-01-10'
551
- }), user);
552
- expect(result.map((e)=>e.eventDate)).toEqual([
553
- '2026-01-01',
554
- '2026-01-02',
555
- '2026-01-03'
556
- ]);
557
- });
558
- it('caps a count-based (no end date) recurrence at the configured max occurrences', async ()=>{
559
- mockConfigService.getMaxRecurrenceOccurrences.mockReturnValue(3);
560
- mockEventQb.getMany.mockResolvedValue([
561
- buildEvent({
562
- eventDate: '2026-01-01',
563
- recurrenceType: RecurrenceType.DAILY
564
- })
565
- ]);
566
- const result = await service.getEventsForCalendarRange(queryDto({
567
- startDate: '2026-01-01',
568
- endDate: '2026-12-31'
569
- }), user);
570
- expect(result).toHaveLength(3);
571
- expect(result.map((e)=>e.eventDate)).toEqual([
572
- '2026-01-01',
573
- '2026-01-02',
574
- '2026-01-03'
575
- ]);
576
- });
577
- it('returns a single occurrence when the recurrence range only covers the start date', async ()=>{
578
- mockEventQb.getMany.mockResolvedValue([
579
- buildEvent({
580
- eventDate: '2026-01-01',
581
- recurrenceType: RecurrenceType.DAILY
582
- })
583
- ]);
584
- const result = await service.getEventsForCalendarRange(queryDto({
585
- startDate: '2026-01-01',
586
- endDate: '2026-01-01'
587
- }), user);
588
- expect(result).toHaveLength(1);
589
- expect(result[0].eventDate).toBe('2026-01-01');
590
- });
591
- it('produces no occurrences when the query range is entirely before the series start', async ()=>{
592
- mockEventQb.getMany.mockResolvedValue([
593
- buildEvent({
594
- eventDate: '2026-06-01',
595
- recurrenceType: RecurrenceType.DAILY,
596
- recurrenceEndDate: '2026-06-10'
597
- })
598
- ]);
599
- const result = await service.getEventsForCalendarRange(queryDto({
600
- startDate: '2026-01-01',
601
- endDate: '2026-01-31'
602
- }), user);
603
- expect(result).toEqual([]);
604
- });
605
- it('does not shift dates across a US daylight-saving boundary (spring-forward, 2026-03-08)', async ()=>{
606
- mockEventQb.getMany.mockResolvedValue([
607
- buildEvent({
608
- eventDate: '2026-03-06',
609
- recurrenceType: RecurrenceType.DAILY
610
- })
611
- ]);
612
- const result = await service.getEventsForCalendarRange(queryDto({
613
- startDate: '2026-03-06',
614
- endDate: '2026-03-10'
615
- }), user);
616
- expect(result.map((e)=>e.eventDate)).toEqual([
617
- '2026-03-06',
618
- '2026-03-07',
619
- '2026-03-08',
620
- '2026-03-09',
621
- '2026-03-10'
622
- ]);
623
- });
624
- it('does not shift dates across a US daylight-saving boundary (fall-back, 2026-11-01)', async ()=>{
625
- mockEventQb.getMany.mockResolvedValue([
626
- buildEvent({
627
- eventDate: '2026-10-30',
628
- recurrenceType: RecurrenceType.DAILY
629
- })
630
- ]);
631
- const result = await service.getEventsForCalendarRange(queryDto({
632
- startDate: '2026-10-30',
633
- endDate: '2026-11-03'
634
- }), user);
635
- expect(result.map((e)=>e.eventDate)).toEqual([
636
- '2026-10-30',
637
- '2026-10-31',
638
- '2026-11-01',
639
- '2026-11-02',
640
- '2026-11-03'
641
- ]);
642
- });
643
- // ---------------------------------------------------------------------
644
- // Recurrence expansion — weekly / biweekly
645
- // ---------------------------------------------------------------------
646
- it('expands a weekly recurrence using the event own weekday when weekDays is not set', async ()=>{
647
- // 2026-01-05 is a Monday
648
- mockEventQb.getMany.mockResolvedValue([
649
- buildEvent({
650
- eventDate: '2026-01-05',
651
- recurrenceType: RecurrenceType.WEEKLY,
652
- weekDays: null
653
- })
654
- ]);
655
- const result = await service.getEventsForCalendarRange(queryDto({
656
- startDate: '2026-01-05',
657
- endDate: '2026-01-26'
658
- }), user);
659
- expect(result.map((e)=>e.eventDate)).toEqual([
660
- '2026-01-05',
661
- '2026-01-12',
662
- '2026-01-19',
663
- '2026-01-26'
664
- ]);
665
- });
666
- it('expands a weekly recurrence across multiple weekDays', async ()=>{
667
- // 2026-01-05 is a Monday; weekDays MON + WED
668
- mockEventQb.getMany.mockResolvedValue([
669
- buildEvent({
670
- eventDate: '2026-01-05',
671
- recurrenceType: RecurrenceType.WEEKLY,
672
- weekDays: [
673
- 'MON',
674
- 'WED'
675
- ]
676
- })
677
- ]);
678
- const result = await service.getEventsForCalendarRange(queryDto({
679
- startDate: '2026-01-01',
680
- endDate: '2026-01-14'
681
- }), user);
682
- expect(result.map((e)=>e.eventDate)).toEqual([
683
- '2026-01-05',
684
- '2026-01-07',
685
- '2026-01-12',
686
- '2026-01-14'
687
- ]);
688
- expect(result[0].isRecurrenceInstance).toBe(false);
689
- expect(result.slice(1).every((e)=>e.isRecurrenceInstance === true)).toBe(true);
690
- });
691
- it('skips a weekday occurrence that falls before the series start date in its first week', async ()=>{
692
- // 2026-01-07 is a Wednesday; the same week's Monday (Jan 5) is before the series
693
- // start and must not produce an occurrence, but later weeks' Mondays should.
694
- mockEventQb.getMany.mockResolvedValue([
695
- buildEvent({
696
- eventDate: '2026-01-07',
697
- recurrenceType: RecurrenceType.WEEKLY,
698
- weekDays: [
699
- 'MON',
700
- 'WED'
701
- ]
702
- })
703
- ]);
704
- const result = await service.getEventsForCalendarRange(queryDto({
705
- startDate: '2026-01-01',
706
- endDate: '2026-01-21'
707
- }), user);
708
- expect(result.map((e)=>e.eventDate)).toEqual([
709
- '2026-01-07',
710
- '2026-01-12',
711
- '2026-01-14',
712
- '2026-01-19',
713
- '2026-01-21'
714
- ]);
715
- });
716
- it('expands a biweekly recurrence with two-week spacing', async ()=>{
717
- mockEventQb.getMany.mockResolvedValue([
718
- buildEvent({
719
- eventDate: '2026-01-05',
720
- recurrenceType: RecurrenceType.BIWEEKLY,
721
- weekDays: null
722
- })
723
- ]);
724
- const result = await service.getEventsForCalendarRange(queryDto({
725
- startDate: '2026-01-05',
726
- endDate: '2026-02-28'
727
- }), user);
728
- expect(result.map((e)=>e.eventDate)).toEqual([
729
- '2026-01-05',
730
- '2026-01-19',
731
- '2026-02-02',
732
- '2026-02-16'
733
- ]);
734
- });
735
- it('stops a weekly recurrence at the until bound mid-week', async ()=>{
736
- mockEventQb.getMany.mockResolvedValue([
737
- buildEvent({
738
- eventDate: '2026-01-05',
739
- recurrenceType: RecurrenceType.WEEKLY,
740
- weekDays: null,
741
- recurrenceEndDate: '2026-01-12'
742
- })
743
- ]);
744
- const result = await service.getEventsForCalendarRange(queryDto({
745
- startDate: '2026-01-01',
746
- endDate: '2026-01-31'
747
- }), user);
748
- expect(result.map((e)=>e.eventDate)).toEqual([
749
- '2026-01-05',
750
- '2026-01-12'
751
- ]);
752
- });
753
- // ---------------------------------------------------------------------
754
- // Recurrence expansion — monthly
755
- // ---------------------------------------------------------------------
756
- it('expands a monthly recurrence, clamping to the last day of shorter months', async ()=>{
757
- mockEventQb.getMany.mockResolvedValue([
758
- buildEvent({
759
- eventDate: '2026-01-31',
760
- recurrenceType: RecurrenceType.MONTHLY
761
- })
762
- ]);
763
- const result = await service.getEventsForCalendarRange(queryDto({
764
- startDate: '2026-01-01',
765
- endDate: '2026-04-30'
766
- }), user);
767
- // 2026 is not a leap year: Feb has 28 days; Mar has 31 (back to day 31); Apr has 30.
768
- expect(result.map((e)=>e.eventDate)).toEqual([
769
- '2026-01-31',
770
- '2026-02-28',
771
- '2026-03-31',
772
- '2026-04-30'
773
- ]);
774
- });
775
- it('restores the original day-of-month once a longer month is reached again', async ()=>{
776
- mockEventQb.getMany.mockResolvedValue([
777
- buildEvent({
778
- eventDate: '2026-01-31',
779
- recurrenceType: RecurrenceType.MONTHLY
780
- })
781
- ]);
782
- const result = await service.getEventsForCalendarRange(queryDto({
783
- startDate: '2026-02-01',
784
- endDate: '2026-03-31'
785
- }), user);
786
- expect(result.map((e)=>e.eventDate)).toEqual([
787
- '2026-02-28',
788
- '2026-03-31'
789
- ]);
790
- });
791
- it('clamps to Feb 29 on a leap year for a monthly recurrence starting Jan 31', async ()=>{
792
- mockEventQb.getMany.mockResolvedValue([
793
- buildEvent({
794
- eventDate: '2028-01-31',
795
- recurrenceType: RecurrenceType.MONTHLY
796
- })
797
- ]);
798
- const result = await service.getEventsForCalendarRange(queryDto({
799
- startDate: '2028-01-01',
800
- endDate: '2028-02-29'
801
- }), user);
802
- expect(result.map((e)=>e.eventDate)).toEqual([
803
- '2028-01-31',
804
- '2028-02-29'
805
- ]);
806
- });
807
- // ---------------------------------------------------------------------
808
- // All-day events
809
- // ---------------------------------------------------------------------
810
- it('preserves isAllDay through non-recurring expansion', async ()=>{
811
- mockEventQb.getMany.mockResolvedValue([
812
- buildEvent({
813
- eventDate: '2026-04-10',
814
- isAllDay: true,
815
- startTime: '00:00',
816
- endTime: '23:59'
817
- })
818
- ]);
819
- const result = await service.getEventsForCalendarRange(queryDto(), user);
820
- expect(result[0].isAllDay).toBe(true);
821
- expect(result[0].startTime).toBe('00:00');
822
- expect(result[0].endTime).toBe('23:59');
823
- });
824
- it('preserves isAllDay through recurring expansion', async ()=>{
825
- mockEventQb.getMany.mockResolvedValue([
826
- buildEvent({
827
- eventDate: '2026-01-01',
828
- recurrenceType: RecurrenceType.DAILY,
829
- isAllDay: true,
830
- startTime: '00:00',
831
- endTime: '23:59'
832
- })
833
- ]);
834
- const result = await service.getEventsForCalendarRange(queryDto({
835
- startDate: '2026-01-01',
836
- endDate: '2026-01-02'
837
- }), user);
838
- expect(result.every((e)=>e.isAllDay === true)).toBe(true);
839
- });
840
- // ---------------------------------------------------------------------
841
- // Sorting and multi-event ordering
842
- // ---------------------------------------------------------------------
843
- it('returns events sorted chronologically by eventDate, including expanded recurrence instances', async ()=>{
844
- mockEventQb.getMany.mockResolvedValue([
845
- buildEvent({
846
- id: 'event-b',
847
- eventDate: '2026-01-03',
848
- recurrenceType: RecurrenceType.NONE
849
- }),
850
- buildEvent({
851
- id: 'event-a',
852
- eventDate: '2026-01-01',
853
- recurrenceType: RecurrenceType.DAILY,
854
- recurrenceEndDate: '2026-01-02'
855
- })
856
- ]);
857
- const result = await service.getEventsForCalendarRange(queryDto({
858
- startDate: '2026-01-01',
859
- endDate: '2026-01-05'
860
- }), user);
861
- expect(result.map((e)=>e.eventDate)).toEqual([
862
- '2026-01-01',
863
- '2026-01-02',
864
- '2026-01-03'
865
- ]);
866
- });
867
- });
868
- describe('ensureParticipantRepositoryInitialized', ()=>{
869
- it('caches the participant repository after the first resolution', async ()=>{
870
- await service.ensureParticipantRepositoryInitialized();
871
- await service.ensureParticipantRepositoryInitialized();
872
- expect(mockDataSourceProvider.getRepository).toHaveBeenCalledTimes(1);
873
- expect(mockDataSourceProvider.getRepository).toHaveBeenCalledWith(EventParticipant);
874
- });
875
- });
876
- describe('domain events', ()=>{
877
- let publish;
878
- beforeEach(()=>{
879
- publish = jest.fn().mockResolvedValue(undefined);
880
- EventBusRegistry.reset();
881
- EventBusRegistry.setBus({
882
- publish,
883
- subscribe: jest.fn(),
884
- close: jest.fn()
885
- });
886
- EventBusRegistry.configureModule('event-manager', {
887
- enabled: true
888
- });
889
- });
890
- afterEach(()=>{
891
- EventBusRegistry.reset();
892
- });
893
- it('publishes event-manager.event.participant-status-changed with the new status', async ()=>{
894
- const participant = buildParticipant({
895
- status: ParticipantStatus.PENDING
896
- });
897
- mockParticipantRepo.findOne.mockResolvedValue(participant);
898
- mockParticipantRepo.save.mockImplementation(async (p)=>p);
899
- await service.updateParticipantStatus({
900
- id: 'participant-uuid-1',
901
- status: ParticipantStatus.ACCEPTED
902
- }, user);
903
- expect(publish).toHaveBeenCalledWith(expect.objectContaining({
904
- name: 'event-manager.event.participant-status-changed',
905
- payload: expect.objectContaining({
906
- metadata: expect.objectContaining({
907
- status: ParticipantStatus.ACCEPTED
908
- })
909
- })
910
- }));
911
- });
912
- it('does not publish when the event manager module has events disabled', async ()=>{
913
- EventBusRegistry.configureModule('event-manager', {
914
- enabled: false
915
- });
916
- mockParticipantRepo.findOne.mockResolvedValue(buildParticipant());
917
- mockParticipantRepo.save.mockImplementation(async (p)=>p);
918
- await service.updateParticipantStatus({
919
- id: 'participant-uuid-1',
920
- status: ParticipantStatus.ACCEPTED
921
- }, user);
922
- expect(publish).not.toHaveBeenCalled();
923
- });
924
- });
925
- });