@flusys/nestjs-event-manager 6.0.2 → 6.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.
@@ -0,0 +1,194 @@
1
+ import { MultiTenantDataSourceService } from '@flusys/nestjs-shared/modules';
2
+ import { Event, EventParticipant, EventWithCompany } from '../entities';
3
+ import { EventManagerDataSourceProvider } from './event-manager-datasource.provider';
4
+ function buildConfigService(overrides = {}) {
5
+ return {
6
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(overrides.isCompanyFeatureEnabled ?? false),
7
+ getDatabaseMode: jest.fn().mockReturnValue('single'),
8
+ isMultiTenant: jest.fn().mockReturnValue(false),
9
+ getDefaultColor: jest.fn(),
10
+ getMaxRecurrenceOccurrences: jest.fn(),
11
+ getOptions: jest.fn().mockReturnValue(overrides.options ?? {
12
+ bootstrapAppConfig: {
13
+ enableCompanyFeature: false,
14
+ databaseMode: 'single'
15
+ },
16
+ config: {
17
+ defaultDatabaseConfig: {
18
+ type: 'mysql',
19
+ host: 'localhost'
20
+ },
21
+ tenantDefaultDatabaseConfig: undefined,
22
+ tenants: []
23
+ }
24
+ })
25
+ };
26
+ }
27
+ describe('EventManagerDataSourceProvider', ()=>{
28
+ afterEach(()=>{
29
+ jest.restoreAllMocks();
30
+ });
31
+ describe('constructor / buildParentOptions', ()=>{
32
+ it('maps module options to the parent IDataSourceServiceOptions shape', ()=>{
33
+ const configService = buildConfigService({
34
+ options: {
35
+ bootstrapAppConfig: {
36
+ enableCompanyFeature: true,
37
+ databaseMode: 'multi-tenant'
38
+ },
39
+ config: {
40
+ defaultDatabaseConfig: {
41
+ type: 'mysql',
42
+ host: 'db-host'
43
+ },
44
+ tenantDefaultDatabaseConfig: {
45
+ type: 'mysql',
46
+ host: 'tenant-host'
47
+ },
48
+ tenants: [
49
+ {
50
+ id: 'tenant-1',
51
+ database: 'tenant_db'
52
+ }
53
+ ]
54
+ }
55
+ }
56
+ });
57
+ const provider = new EventManagerDataSourceProvider(configService);
58
+ expect(provider.options).toEqual({
59
+ bootstrapAppConfig: {
60
+ enableCompanyFeature: true,
61
+ databaseMode: 'multi-tenant'
62
+ },
63
+ defaultDatabaseConfig: {
64
+ type: 'mysql',
65
+ host: 'db-host'
66
+ },
67
+ tenantDefaultDatabaseConfig: {
68
+ type: 'mysql',
69
+ host: 'tenant-host'
70
+ },
71
+ tenants: [
72
+ {
73
+ id: 'tenant-1',
74
+ database: 'tenant_db'
75
+ }
76
+ ]
77
+ });
78
+ });
79
+ it('builds parent options gracefully when config is not provided', ()=>{
80
+ const configService = buildConfigService({
81
+ options: {
82
+ bootstrapAppConfig: undefined,
83
+ config: undefined
84
+ }
85
+ });
86
+ const provider = new EventManagerDataSourceProvider(configService);
87
+ expect(provider.options).toEqual({
88
+ bootstrapAppConfig: undefined,
89
+ defaultDatabaseConfig: undefined,
90
+ tenantDefaultDatabaseConfig: undefined,
91
+ tenants: undefined
92
+ });
93
+ });
94
+ });
95
+ describe('getEnableCompanyFeatureForTenant', ()=>{
96
+ it('returns the tenant override when explicitly true', ()=>{
97
+ const configService = buildConfigService({
98
+ isCompanyFeatureEnabled: false
99
+ });
100
+ const provider = new EventManagerDataSourceProvider(configService);
101
+ const result = provider.getEnableCompanyFeatureForTenant({
102
+ enableCompanyFeature: true
103
+ });
104
+ expect(result).toBe(true);
105
+ });
106
+ it('returns the tenant override when explicitly false', ()=>{
107
+ const configService = buildConfigService({
108
+ isCompanyFeatureEnabled: true
109
+ });
110
+ const provider = new EventManagerDataSourceProvider(configService);
111
+ const result = provider.getEnableCompanyFeatureForTenant({
112
+ enableCompanyFeature: false
113
+ });
114
+ expect(result).toBe(false);
115
+ });
116
+ it('falls back to the config service when tenant flag is undefined', ()=>{
117
+ const configService = buildConfigService({
118
+ isCompanyFeatureEnabled: true
119
+ });
120
+ const provider = new EventManagerDataSourceProvider(configService);
121
+ const result = provider.getEnableCompanyFeatureForTenant({});
122
+ expect(result).toBe(true);
123
+ });
124
+ it('falls back to the config service when no tenant is provided', ()=>{
125
+ const configService = buildConfigService({
126
+ isCompanyFeatureEnabled: false
127
+ });
128
+ const provider = new EventManagerDataSourceProvider(configService);
129
+ const result = provider.getEnableCompanyFeatureForTenant(undefined);
130
+ expect(result).toBe(false);
131
+ });
132
+ });
133
+ describe('createDataSourceFromConfig', ()=>{
134
+ it('loads core event-manager entities when the company feature is disabled', async ()=>{
135
+ const configService = buildConfigService({
136
+ isCompanyFeatureEnabled: false
137
+ });
138
+ const provider = new EventManagerDataSourceProvider(configService);
139
+ const spy = jest.spyOn(MultiTenantDataSourceService.prototype, 'createDataSourceFromConfig').mockResolvedValue({
140
+ isInitialized: true
141
+ });
142
+ const config = {
143
+ type: 'mysql',
144
+ host: 'localhost'
145
+ };
146
+ await provider.createDataSourceFromConfig(config);
147
+ expect(spy).toHaveBeenCalledWith(config, expect.arrayContaining([
148
+ Event,
149
+ EventParticipant
150
+ ]));
151
+ expect(spy.mock.calls[0][1]).not.toContain(EventWithCompany);
152
+ });
153
+ it('loads company-scoped event-manager entities when the company feature is enabled', async ()=>{
154
+ const configService = buildConfigService({
155
+ isCompanyFeatureEnabled: true
156
+ });
157
+ const provider = new EventManagerDataSourceProvider(configService);
158
+ const spy = jest.spyOn(MultiTenantDataSourceService.prototype, 'createDataSourceFromConfig').mockResolvedValue({
159
+ isInitialized: true
160
+ });
161
+ const config = {
162
+ type: 'mysql',
163
+ host: 'localhost'
164
+ };
165
+ await provider.createDataSourceFromConfig(config);
166
+ expect(spy).toHaveBeenCalledWith(config, expect.arrayContaining([
167
+ EventWithCompany,
168
+ EventParticipant
169
+ ]));
170
+ expect(spy.mock.calls[0][1]).not.toContain(Event);
171
+ });
172
+ it('uses the current tenant override to resolve entities when a request is present', async ()=>{
173
+ const configService = buildConfigService({
174
+ isCompanyFeatureEnabled: false
175
+ });
176
+ const provider = new EventManagerDataSourceProvider(configService);
177
+ jest.spyOn(provider, 'getCurrentTenant').mockReturnValue({
178
+ enableCompanyFeature: true
179
+ });
180
+ const spy = jest.spyOn(MultiTenantDataSourceService.prototype, 'createDataSourceFromConfig').mockResolvedValue({
181
+ isInitialized: true
182
+ });
183
+ const config = {
184
+ type: 'mysql',
185
+ host: 'localhost'
186
+ };
187
+ await provider.createDataSourceFromConfig(config);
188
+ expect(spy).toHaveBeenCalledWith(config, expect.arrayContaining([
189
+ EventWithCompany,
190
+ EventParticipant
191
+ ]));
192
+ });
193
+ });
194
+ });
@@ -0,0 +1,323 @@
1
+ import { NotFoundException } from '@nestjs/common';
2
+ import { ParticipantStatus, RecurrenceType } from '@flusys/nestjs-shared';
3
+ import { createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { EVENT_PARTICIPANT_MESSAGES } from '../config';
5
+ import { EventParticipant, EventWithCompany } from '../entities';
6
+ import { EventManagerHelperService } from './event-manager-helper.service';
7
+ function buildEvent(overrides = {}) {
8
+ return {
9
+ id: 'event-uuid-1',
10
+ title: 'Sprint Planning',
11
+ description: null,
12
+ eventDate: '2026-04-03',
13
+ startTime: '09:00',
14
+ endTime: '10:00',
15
+ isAllDay: false,
16
+ recurrenceType: RecurrenceType.NONE,
17
+ recurrenceEndDate: null,
18
+ weekDays: null,
19
+ color: '#3B82F6',
20
+ meetingLink: null,
21
+ isActive: true,
22
+ createdAt: new Date('2026-01-01T00:00:00Z'),
23
+ updatedAt: new Date('2026-01-01T00:00:00Z'),
24
+ deletedAt: null,
25
+ createdById: null,
26
+ updatedById: null,
27
+ deletedById: null,
28
+ ...overrides
29
+ };
30
+ }
31
+ function buildParticipant(overrides = {}) {
32
+ return {
33
+ id: 'participant-uuid-1',
34
+ eventId: 'event-uuid-1',
35
+ userId: 'user-uuid-1',
36
+ status: ParticipantStatus.PENDING,
37
+ isOrganizer: false,
38
+ createdAt: new Date('2026-01-01T00:00:00Z'),
39
+ updatedAt: new Date('2026-01-01T00:00:00Z'),
40
+ deletedAt: null,
41
+ createdById: null,
42
+ updatedById: null,
43
+ deletedById: null,
44
+ ...overrides
45
+ };
46
+ }
47
+ describe('EventManagerHelperService', ()=>{
48
+ let service;
49
+ let mockEventRepo;
50
+ let mockParticipantRepo;
51
+ let mockConfigService;
52
+ let mockDataSourceProvider;
53
+ let mockUtilsService;
54
+ // `getRawMany` isn't part of the shared MockQueryBuilder — the participant queries in this
55
+ // service use `.getRawMany()`, so we augment the shared mock query builder instances here.
56
+ let mockEventQb;
57
+ let mockParticipantQb;
58
+ beforeEach(()=>{
59
+ mockEventRepo = createMockRepository();
60
+ mockParticipantRepo = createMockRepository();
61
+ mockEventQb = mockEventRepo.createQueryBuilder();
62
+ mockEventQb.getRawMany = jest.fn();
63
+ mockParticipantQb = mockParticipantRepo.createQueryBuilder();
64
+ mockParticipantQb.getRawMany = jest.fn();
65
+ mockConfigService = {
66
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
67
+ getDefaultColor: jest.fn().mockReturnValue('#3B82F6')
68
+ };
69
+ const fakeDataSource = {
70
+ getRepository: jest.fn((entity)=>entity === EventParticipant ? mockParticipantRepo : mockEventRepo)
71
+ };
72
+ mockDataSourceProvider = {
73
+ getDataSource: jest.fn().mockResolvedValue(fakeDataSource)
74
+ };
75
+ mockUtilsService = {
76
+ clearCache: jest.fn()
77
+ };
78
+ service = new EventManagerHelperService({}, mockUtilsService, mockConfigService, mockDataSourceProvider);
79
+ });
80
+ describe('createEvent', ()=>{
81
+ it('creates a timed event with the given fields and clears the cache', async ()=>{
82
+ const saved = buildEvent();
83
+ mockEventRepo.save.mockResolvedValue(saved);
84
+ mockParticipantRepo.save.mockResolvedValue([]);
85
+ const result = await service.createEvent({
86
+ title: 'Sprint Planning',
87
+ eventDate: '2026-04-03',
88
+ startTime: '09:00',
89
+ endTime: '10:00'
90
+ });
91
+ expect(mockEventRepo.save).toHaveBeenCalledWith(expect.objectContaining({
92
+ title: 'Sprint Planning',
93
+ startTime: '09:00',
94
+ endTime: '10:00',
95
+ isAllDay: false,
96
+ recurrenceType: RecurrenceType.NONE,
97
+ color: '#3B82F6'
98
+ }));
99
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('event', {});
100
+ expect(result.id).toBe(saved.id);
101
+ expect(result.companyId).toBeNull();
102
+ });
103
+ it('forces symbolic 00:00 / 23:59 markers for all-day events regardless of supplied times', async ()=>{
104
+ const saved = buildEvent({
105
+ isAllDay: true,
106
+ startTime: '00:00',
107
+ endTime: '23:59'
108
+ });
109
+ mockEventRepo.save.mockResolvedValue(saved);
110
+ await service.createEvent({
111
+ title: 'Company Holiday',
112
+ eventDate: '2026-12-25',
113
+ startTime: '09:00',
114
+ endTime: '17:00',
115
+ isAllDay: true
116
+ });
117
+ expect(mockEventRepo.save).toHaveBeenCalledWith(expect.objectContaining({
118
+ isAllDay: true,
119
+ startTime: '00:00',
120
+ endTime: '23:59'
121
+ }));
122
+ });
123
+ it('attaches companyId only when the company feature is enabled and a companyId is supplied', async ()=>{
124
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
125
+ const saved = buildEvent();
126
+ mockEventRepo.save.mockResolvedValue(saved);
127
+ await service.createEvent({
128
+ title: 'Sprint Planning',
129
+ eventDate: '2026-04-03',
130
+ startTime: '09:00',
131
+ endTime: '10:00',
132
+ companyId: 'company-uuid-1'
133
+ });
134
+ expect(mockEventRepo.save).toHaveBeenCalledWith(expect.objectContaining({
135
+ companyId: 'company-uuid-1'
136
+ }));
137
+ });
138
+ it('does not attach companyId when the company feature is disabled, even if supplied', async ()=>{
139
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
140
+ const saved = buildEvent();
141
+ mockEventRepo.save.mockResolvedValue(saved);
142
+ await service.createEvent({
143
+ title: 'Sprint Planning',
144
+ eventDate: '2026-04-03',
145
+ startTime: '09:00',
146
+ endTime: '10:00',
147
+ companyId: 'company-uuid-1'
148
+ });
149
+ const savedArg = mockEventRepo.save.mock.calls[0][0];
150
+ expect(savedArg).not.toHaveProperty('companyId');
151
+ });
152
+ it('adds the organizer and all participants with PENDING status, de-duplicated', async ()=>{
153
+ const saved = buildEvent();
154
+ mockEventRepo.save.mockResolvedValue(saved);
155
+ mockParticipantRepo.save.mockResolvedValue([]);
156
+ await service.createEvent({
157
+ title: 'Sprint Planning',
158
+ eventDate: '2026-04-03',
159
+ startTime: '09:00',
160
+ endTime: '10:00',
161
+ organizerId: 'organizer-uuid-1',
162
+ participantIds: [
163
+ 'organizer-uuid-1',
164
+ 'participant-uuid-2'
165
+ ]
166
+ });
167
+ const savedParticipants = mockParticipantRepo.save.mock.calls[0][0];
168
+ expect(savedParticipants).toHaveLength(2);
169
+ expect(savedParticipants).toEqual(expect.arrayContaining([
170
+ expect.objectContaining({
171
+ userId: 'organizer-uuid-1',
172
+ isOrganizer: true,
173
+ status: ParticipantStatus.PENDING
174
+ }),
175
+ expect.objectContaining({
176
+ userId: 'participant-uuid-2',
177
+ isOrganizer: false,
178
+ status: ParticipantStatus.PENDING
179
+ })
180
+ ]));
181
+ });
182
+ it('skips participant creation entirely when no organizer or participants are given', async ()=>{
183
+ const saved = buildEvent();
184
+ mockEventRepo.save.mockResolvedValue(saved);
185
+ await service.createEvent({
186
+ title: 'Sprint Planning',
187
+ eventDate: '2026-04-03',
188
+ startTime: '09:00',
189
+ endTime: '10:00'
190
+ });
191
+ expect(mockParticipantRepo.save).not.toHaveBeenCalled();
192
+ });
193
+ });
194
+ describe('updateParticipantStatus', ()=>{
195
+ it('updates the status of an existing participant', async ()=>{
196
+ const participant = buildParticipant({
197
+ status: ParticipantStatus.PENDING
198
+ });
199
+ mockParticipantRepo.findOne.mockResolvedValue(participant);
200
+ mockParticipantRepo.save.mockResolvedValue({
201
+ ...participant,
202
+ status: ParticipantStatus.ACCEPTED
203
+ });
204
+ await service.updateParticipantStatus('participant-uuid-1', ParticipantStatus.ACCEPTED);
205
+ expect(mockParticipantRepo.save).toHaveBeenCalledWith(expect.objectContaining({
206
+ status: ParticipantStatus.ACCEPTED
207
+ }));
208
+ });
209
+ it('throws NotFoundException with the correct messageKey when the participant does not exist', async ()=>{
210
+ mockParticipantRepo.findOne.mockResolvedValue(null);
211
+ await expect(service.updateParticipantStatus('missing-uuid', ParticipantStatus.ACCEPTED)).rejects.toMatchObject({
212
+ response: expect.objectContaining({
213
+ messageKey: EVENT_PARTICIPANT_MESSAGES.NOT_FOUND
214
+ })
215
+ });
216
+ await expect(service.updateParticipantStatus('missing-uuid', ParticipantStatus.ACCEPTED)).rejects.toBeInstanceOf(NotFoundException);
217
+ });
218
+ });
219
+ describe('getEventsForUser', ()=>{
220
+ it('returns an empty list without querying events when the user has no participant rows', async ()=>{
221
+ mockParticipantQb.getRawMany.mockResolvedValue([]);
222
+ const result = await service.getEventsForUser('user-uuid-1', '2026-04-01', '2026-04-30');
223
+ expect(result).toEqual([]);
224
+ expect(mockEventQb.getMany).not.toHaveBeenCalled();
225
+ expect(mockEventQb.where).not.toHaveBeenCalled();
226
+ });
227
+ it('queries events scoped to the participant event ids and date range', async ()=>{
228
+ mockParticipantQb.getRawMany.mockResolvedValue([
229
+ {
230
+ ep_event_id: 'event-uuid-1'
231
+ }
232
+ ]);
233
+ const events = [
234
+ buildEvent()
235
+ ];
236
+ mockEventQb.getMany.mockResolvedValue(events);
237
+ const result = await service.getEventsForUser('user-uuid-1', '2026-04-01', '2026-04-30');
238
+ const eventQb = mockEventQb;
239
+ expect(eventQb.where).toHaveBeenCalledWith('event.id IN (:...eventIds)', {
240
+ eventIds: [
241
+ 'event-uuid-1'
242
+ ]
243
+ });
244
+ expect(eventQb.andWhere).toHaveBeenCalledWith('event.eventDate <= :endDate', {
245
+ endDate: '2026-04-30'
246
+ });
247
+ expect(eventQb.andWhere).toHaveBeenCalledWith('event.eventDate >= :startDate', {
248
+ startDate: '2026-04-01'
249
+ });
250
+ expect(result).toHaveLength(1);
251
+ expect(result[0].id).toBe('event-uuid-1');
252
+ });
253
+ it('handles a zero-length range (startDate === endDate)', async ()=>{
254
+ mockParticipantQb.getRawMany.mockResolvedValue([
255
+ {
256
+ ep_event_id: 'event-uuid-1'
257
+ }
258
+ ]);
259
+ mockEventQb.getMany.mockResolvedValue([
260
+ buildEvent()
261
+ ]);
262
+ const result = await service.getEventsForUser('user-uuid-1', '2026-04-03', '2026-04-03');
263
+ const eventQb = mockEventQb;
264
+ expect(eventQb.andWhere).toHaveBeenCalledWith('event.eventDate <= :endDate', {
265
+ endDate: '2026-04-03'
266
+ });
267
+ expect(eventQb.andWhere).toHaveBeenCalledWith('event.eventDate >= :startDate', {
268
+ startDate: '2026-04-03'
269
+ });
270
+ expect(result).toHaveLength(1);
271
+ });
272
+ it('scopes the query to the company when the company feature is enabled and a companyId is given', async ()=>{
273
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
274
+ mockParticipantQb.getRawMany.mockResolvedValue([
275
+ {
276
+ ep_event_id: 'event-uuid-1'
277
+ }
278
+ ]);
279
+ mockEventQb.getMany.mockResolvedValue([
280
+ buildEvent()
281
+ ]);
282
+ await service.getEventsForUser('user-uuid-1', '2026-04-01', '2026-04-30', 'company-uuid-1');
283
+ const eventQb = mockEventQb;
284
+ expect(eventQb.andWhere).toHaveBeenCalledWith('event.companyId = :companyId', {
285
+ companyId: 'company-uuid-1'
286
+ });
287
+ });
288
+ it('does not scope the query to the company when the company feature is disabled', async ()=>{
289
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
290
+ mockParticipantQb.getRawMany.mockResolvedValue([
291
+ {
292
+ ep_event_id: 'event-uuid-1'
293
+ }
294
+ ]);
295
+ mockEventQb.getMany.mockResolvedValue([
296
+ buildEvent()
297
+ ]);
298
+ await service.getEventsForUser('user-uuid-1', '2026-04-01', '2026-04-30', 'company-uuid-1');
299
+ const eventQb = mockEventQb;
300
+ const companyCalls = eventQb.andWhere.mock.calls.filter((call)=>call[0] === 'event.companyId = :companyId');
301
+ expect(companyCalls).toHaveLength(0);
302
+ });
303
+ it('requests the correct entity for the company feature toggle', async ()=>{
304
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
305
+ mockParticipantQb.getRawMany.mockResolvedValue([]);
306
+ await service.getEventsForUser('user-uuid-1', '2026-04-01', '2026-04-30');
307
+ const fakeDataSource = await mockDataSourceProvider.getDataSource();
308
+ expect(fakeDataSource.getRepository).toHaveBeenCalledWith(EventWithCompany);
309
+ });
310
+ });
311
+ describe('getEventById', ()=>{
312
+ it('returns the mapped event when found', async ()=>{
313
+ mockEventRepo.findOne.mockResolvedValue(buildEvent());
314
+ const result = await service.getEventById('event-uuid-1');
315
+ expect(result?.id).toBe('event-uuid-1');
316
+ });
317
+ it('returns null when the event does not exist', async ()=>{
318
+ mockEventRepo.findOne.mockResolvedValue(null);
319
+ const result = await service.getEventById('missing-uuid');
320
+ expect(result).toBeNull();
321
+ });
322
+ });
323
+ });
@@ -355,11 +355,16 @@ export class EventService extends ApiService {
355
355
  break;
356
356
  case RecurrenceType.MONTHLY:
357
357
  {
358
+ // Compute the target year/month from the *original* date before any mutation.
359
+ // Mutating `d` first (e.g. via setUTCMonth) can overflow past short months
360
+ // (Jan 31 -> Feb 31 normalizes to Mar 3), which would then compute
361
+ // `lastDayOfMonth` for the wrong (already-overflowed) month and silently
362
+ // skip target months that have fewer days than the original day-of-month.
358
363
  const targetDay = originalDayOfMonth ?? d.getUTCDate();
359
- d.setUTCMonth(d.getUTCMonth() + 1);
360
- const lastDayOfMonth = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
361
- d.setUTCDate(Math.min(targetDay, lastDayOfMonth));
362
- break;
364
+ const targetYear = d.getUTCFullYear();
365
+ const targetMonth = d.getUTCMonth() + 1;
366
+ const lastDayOfMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate();
367
+ return new Date(Date.UTC(targetYear, targetMonth, Math.min(targetDay, lastDayOfMonth))).toISOString().slice(0, 10);
363
368
  }
364
369
  default:
365
370
  break;