@dismissible/nestjs-dismissible 0.0.2-canary.738340d.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 (62) hide show
  1. package/README.md +506 -0
  2. package/jest.config.ts +29 -0
  3. package/package.json +63 -0
  4. package/project.json +42 -0
  5. package/src/api/dismissible-item-response.dto.ts +38 -0
  6. package/src/api/dismissible-item.mapper.spec.ts +63 -0
  7. package/src/api/dismissible-item.mapper.ts +33 -0
  8. package/src/api/index.ts +7 -0
  9. package/src/api/use-cases/api-tags.constants.ts +4 -0
  10. package/src/api/use-cases/dismiss/dismiss.controller.spec.ts +42 -0
  11. package/src/api/use-cases/dismiss/dismiss.controller.ts +63 -0
  12. package/src/api/use-cases/dismiss/dismiss.response.dto.ts +7 -0
  13. package/src/api/use-cases/dismiss/index.ts +2 -0
  14. package/src/api/use-cases/get-or-create/get-or-create.controller.spec.ts +76 -0
  15. package/src/api/use-cases/get-or-create/get-or-create.controller.ts +106 -0
  16. package/src/api/use-cases/get-or-create/get-or-create.request.dto.ts +17 -0
  17. package/src/api/use-cases/get-or-create/get-or-create.response.dto.ts +7 -0
  18. package/src/api/use-cases/get-or-create/index.ts +3 -0
  19. package/src/api/use-cases/index.ts +3 -0
  20. package/src/api/use-cases/restore/index.ts +2 -0
  21. package/src/api/use-cases/restore/restore.controller.spec.ts +42 -0
  22. package/src/api/use-cases/restore/restore.controller.ts +63 -0
  23. package/src/api/use-cases/restore/restore.response.dto.ts +7 -0
  24. package/src/core/create-options.ts +9 -0
  25. package/src/core/dismissible-core.service.spec.ts +357 -0
  26. package/src/core/dismissible-core.service.ts +161 -0
  27. package/src/core/dismissible.service.spec.ts +144 -0
  28. package/src/core/dismissible.service.ts +188 -0
  29. package/src/core/hook-runner.service.spec.ts +304 -0
  30. package/src/core/hook-runner.service.ts +267 -0
  31. package/src/core/index.ts +6 -0
  32. package/src/core/lifecycle-hook.interface.ts +122 -0
  33. package/src/core/service-responses.interface.ts +34 -0
  34. package/src/dismissible.module.ts +83 -0
  35. package/src/events/dismissible.events.ts +105 -0
  36. package/src/events/events.constants.ts +21 -0
  37. package/src/events/index.ts +2 -0
  38. package/src/exceptions/dismissible.exceptions.spec.ts +50 -0
  39. package/src/exceptions/dismissible.exceptions.ts +69 -0
  40. package/src/exceptions/index.ts +1 -0
  41. package/src/index.ts +8 -0
  42. package/src/request/index.ts +2 -0
  43. package/src/request/request-context.decorator.ts +14 -0
  44. package/src/request/request-context.interface.ts +6 -0
  45. package/src/response/dtos/base-response.dto.ts +11 -0
  46. package/src/response/dtos/error-response.dto.ts +36 -0
  47. package/src/response/dtos/index.ts +3 -0
  48. package/src/response/dtos/success-response.dto.ts +34 -0
  49. package/src/response/http-exception-filter.ts +21 -0
  50. package/src/response/index.ts +4 -0
  51. package/src/response/response.module.ts +9 -0
  52. package/src/response/response.service.spec.ts +86 -0
  53. package/src/response/response.service.ts +20 -0
  54. package/src/testing/factories.ts +45 -0
  55. package/src/testing/index.ts +1 -0
  56. package/src/utils/date/date.service.spec.ts +104 -0
  57. package/src/utils/date/date.service.ts +19 -0
  58. package/src/utils/date/index.ts +1 -0
  59. package/src/utils/dismissible.helper.ts +9 -0
  60. package/src/utils/index.ts +3 -0
  61. package/tsconfig.json +13 -0
  62. package/tsconfig.lib.json +14 -0
@@ -0,0 +1,188 @@
1
+ import { Injectable, Inject } from '@nestjs/common';
2
+ import { EventEmitter2 } from '@nestjs/event-emitter';
3
+ import { DismissibleCoreService } from './dismissible-core.service';
4
+ import { HookRunner } from './hook-runner.service';
5
+ import { DISMISSIBLE_LOGGER, IDismissibleLogger } from '@dismissible/nestjs-logger';
6
+ import {
7
+ IGetOrCreateServiceResponse,
8
+ IDismissServiceResponse,
9
+ IRestoreServiceResponse,
10
+ } from './service-responses.interface';
11
+ import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
12
+ import { ICreateItemOptions } from './create-options';
13
+ import { IRequestContext } from '../request/request-context.interface';
14
+ import { DismissibleEvents } from '../events';
15
+ import {
16
+ ItemCreatedEvent,
17
+ ItemRetrievedEvent,
18
+ ItemDismissedEvent,
19
+ ItemRestoredEvent,
20
+ } from '../events';
21
+
22
+ /**
23
+ * Main orchestration service for dismissible operations.
24
+ * Coordinates core logic, hooks, and events.
25
+ */
26
+ @Injectable()
27
+ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
28
+ constructor(
29
+ private readonly coreService: DismissibleCoreService<TMetadata>,
30
+ private readonly hookRunner: HookRunner<TMetadata>,
31
+ private readonly eventEmitter: EventEmitter2,
32
+ @Inject(DISMISSIBLE_LOGGER)
33
+ private readonly logger: IDismissibleLogger,
34
+ ) {}
35
+
36
+ /**
37
+ * Get an existing item or create a new one.
38
+ * @param itemId The item identifier
39
+ * @param userId The user identifier (required)
40
+ * @param options Optional creation options (metadata)
41
+ * @param context Optional request context for tracing
42
+ */
43
+ async getOrCreate(
44
+ itemId: string,
45
+ userId: string,
46
+ options?: ICreateItemOptions<TMetadata>,
47
+ context?: IRequestContext,
48
+ ): Promise<IGetOrCreateServiceResponse<TMetadata>> {
49
+ this.logger.debug(`getOrCreate called`, { itemId, userId });
50
+
51
+ // Run pre-getOrCreate hooks
52
+ const preResult = await this.hookRunner.runPreGetOrCreate(itemId, userId, context);
53
+ HookRunner.throwIfBlocked(preResult);
54
+
55
+ const resolvedId = preResult.id;
56
+ const resolvedUserId = preResult.userId;
57
+ const resolvedContext = preResult.context;
58
+
59
+ // Check if we need to run create hooks (for potential new item)
60
+ const result = await this.coreService.getOrCreate(resolvedId, resolvedUserId, options);
61
+
62
+ // If item was created, run create-specific hooks
63
+ if (result.created) {
64
+ // Run pre-create hooks
65
+ const preCreateResult = await this.hookRunner.runPreCreate(
66
+ resolvedId,
67
+ resolvedUserId,
68
+ resolvedContext,
69
+ );
70
+ HookRunner.throwIfBlocked(preCreateResult);
71
+
72
+ // Run post-create hooks
73
+ await this.hookRunner.runPostCreate(resolvedId, result.item, resolvedUserId, resolvedContext);
74
+
75
+ // Emit created event (async)
76
+ this.eventEmitter.emit(
77
+ DismissibleEvents.ITEM_CREATED,
78
+ new ItemCreatedEvent(resolvedId, result.item, resolvedUserId, resolvedContext),
79
+ );
80
+ } else {
81
+ // Emit retrieved event (async)
82
+ this.eventEmitter.emit(
83
+ DismissibleEvents.ITEM_RETRIEVED,
84
+ new ItemRetrievedEvent(resolvedId, result.item, resolvedUserId, resolvedContext),
85
+ );
86
+ }
87
+
88
+ // Run post-getOrCreate hooks
89
+ await this.hookRunner.runPostGetOrCreate(
90
+ resolvedId,
91
+ result.item,
92
+ resolvedUserId,
93
+ resolvedContext,
94
+ );
95
+
96
+ this.logger.debug(`getOrCreate completed`, { itemId, created: result.created });
97
+
98
+ return result;
99
+ }
100
+
101
+ /**
102
+ * Dismiss an item.
103
+ * @param itemId The item identifier
104
+ * @param userId The user identifier (required)
105
+ * @param context Optional request context for tracing
106
+ */
107
+ async dismiss(
108
+ itemId: string,
109
+ userId: string,
110
+ context?: IRequestContext,
111
+ ): Promise<IDismissServiceResponse<TMetadata>> {
112
+ this.logger.debug(`dismiss called`, { itemId, userId });
113
+
114
+ // Run pre-dismiss hooks
115
+ const preResult = await this.hookRunner.runPreDismiss(itemId, userId, context);
116
+ HookRunner.throwIfBlocked(preResult);
117
+
118
+ const resolvedId = preResult.id;
119
+ const resolvedUserId = preResult.userId;
120
+ const resolvedContext = preResult.context;
121
+
122
+ // Execute core dismiss operation
123
+ const result = await this.coreService.dismiss(resolvedId, resolvedUserId);
124
+
125
+ // Run post-dismiss hooks
126
+ await this.hookRunner.runPostDismiss(resolvedId, result.item, resolvedUserId, resolvedContext);
127
+
128
+ // Emit dismissed event (async)
129
+ this.eventEmitter.emit(
130
+ DismissibleEvents.ITEM_DISMISSED,
131
+ new ItemDismissedEvent(
132
+ resolvedId,
133
+ result.item,
134
+ result.previousItem,
135
+ resolvedUserId,
136
+ resolvedContext,
137
+ ),
138
+ );
139
+
140
+ this.logger.debug(`dismiss completed`, { itemId });
141
+
142
+ return result;
143
+ }
144
+
145
+ /**
146
+ * Restore a dismissed item.
147
+ * @param itemId The item identifier
148
+ * @param userId The user identifier (required)
149
+ * @param context Optional request context for tracing
150
+ */
151
+ async restore(
152
+ itemId: string,
153
+ userId: string,
154
+ context?: IRequestContext,
155
+ ): Promise<IRestoreServiceResponse<TMetadata>> {
156
+ this.logger.debug(`restore called`, { itemId, userId });
157
+
158
+ // Run pre-restore hooks
159
+ const preResult = await this.hookRunner.runPreRestore(itemId, userId, context);
160
+ HookRunner.throwIfBlocked(preResult);
161
+
162
+ const resolvedId = preResult.id;
163
+ const resolvedUserId = preResult.userId;
164
+ const resolvedContext = preResult.context;
165
+
166
+ // Execute core restore operation
167
+ const result = await this.coreService.restore(resolvedId, resolvedUserId);
168
+
169
+ // Run post-restore hooks
170
+ await this.hookRunner.runPostRestore(resolvedId, result.item, resolvedUserId, resolvedContext);
171
+
172
+ // Emit restored event (async)
173
+ this.eventEmitter.emit(
174
+ DismissibleEvents.ITEM_RESTORED,
175
+ new ItemRestoredEvent(
176
+ resolvedId,
177
+ result.item,
178
+ result.previousItem,
179
+ resolvedUserId,
180
+ resolvedContext,
181
+ ),
182
+ );
183
+
184
+ this.logger.debug(`restore completed`, { itemId });
185
+
186
+ return result;
187
+ }
188
+ }
@@ -0,0 +1,304 @@
1
+ import { mock } from 'ts-jest-mocker';
2
+ import { ForbiddenException } from '@nestjs/common';
3
+ import { HookRunner } from './hook-runner.service';
4
+ import { IDismissibleLifecycleHook } from './lifecycle-hook.interface';
5
+ import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
6
+ import { createTestItem, createTestContext } from '../testing/factories';
7
+ import { IDismissibleLogger } from '@dismissible/nestjs-logger';
8
+
9
+ describe('HookRunner', () => {
10
+ let hookRunner: HookRunner<BaseMetadata>;
11
+ let mockLogger: jest.Mocked<IDismissibleLogger>;
12
+
13
+ const testUserId = 'test-user-id';
14
+
15
+ beforeEach(() => {
16
+ mockLogger = mock<IDismissibleLogger>({ failIfMockNotProvided: false });
17
+ });
18
+
19
+ describe('with no hooks', () => {
20
+ beforeEach(() => {
21
+ hookRunner = new HookRunner([], mockLogger);
22
+ });
23
+
24
+ it('should return proceed: true for pre-getOrCreate', async () => {
25
+ const context = createTestContext();
26
+ const result = await hookRunner.runPreGetOrCreate('test-id', testUserId, context);
27
+
28
+ expect(result.proceed).toBe(true);
29
+ expect(result.id).toBe('test-id');
30
+ expect(result.userId).toBe(testUserId);
31
+ expect(result.context).toEqual(context);
32
+ });
33
+
34
+ it('should complete post-getOrCreate without error', async () => {
35
+ const item = createTestItem();
36
+ const context = createTestContext();
37
+
38
+ await expect(
39
+ hookRunner.runPostGetOrCreate('test-id', item, testUserId, context),
40
+ ).resolves.not.toThrow();
41
+ });
42
+ });
43
+
44
+ describe('with hooks', () => {
45
+ it('should execute hooks in priority order (low to high) for pre-hooks', async () => {
46
+ const executionOrder: number[] = [];
47
+
48
+ const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
49
+ priority: 10,
50
+ onBeforeGetOrCreate: jest.fn(async () => {
51
+ executionOrder.push(10);
52
+ return { proceed: true };
53
+ }),
54
+ };
55
+
56
+ const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
57
+ priority: 5,
58
+ onBeforeGetOrCreate: jest.fn(async () => {
59
+ executionOrder.push(5);
60
+ return { proceed: true };
61
+ }),
62
+ };
63
+
64
+ const hook3: IDismissibleLifecycleHook<BaseMetadata> = {
65
+ priority: 15,
66
+ onBeforeGetOrCreate: jest.fn(async () => {
67
+ executionOrder.push(15);
68
+ return { proceed: true };
69
+ }),
70
+ };
71
+
72
+ hookRunner = new HookRunner([hook1, hook2, hook3], mockLogger);
73
+ await hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext());
74
+
75
+ expect(executionOrder).toEqual([5, 10, 15]);
76
+ });
77
+
78
+ it('should execute hooks in reverse priority order for post-hooks', async () => {
79
+ const executionOrder: number[] = [];
80
+
81
+ const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
82
+ priority: 10,
83
+ onAfterGetOrCreate: jest.fn(async () => {
84
+ executionOrder.push(10);
85
+ }),
86
+ };
87
+
88
+ const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
89
+ priority: 5,
90
+ onAfterGetOrCreate: jest.fn(async () => {
91
+ executionOrder.push(5);
92
+ }),
93
+ };
94
+
95
+ const hook3: IDismissibleLifecycleHook<BaseMetadata> = {
96
+ priority: 15,
97
+ onAfterGetOrCreate: jest.fn(async () => {
98
+ executionOrder.push(15);
99
+ }),
100
+ };
101
+
102
+ hookRunner = new HookRunner([hook1, hook2, hook3], mockLogger);
103
+ await hookRunner.runPostGetOrCreate(
104
+ 'test-id',
105
+ createTestItem(),
106
+ testUserId,
107
+ createTestContext(),
108
+ );
109
+
110
+ expect(executionOrder).toEqual([15, 10, 5]);
111
+ });
112
+
113
+ it('should block operation when pre-hook returns proceed: false', async () => {
114
+ const blockingHook: IDismissibleLifecycleHook<BaseMetadata> = {
115
+ onBeforeGetOrCreate: jest.fn(async () => ({
116
+ proceed: false,
117
+ reason: 'Rate limit exceeded',
118
+ })),
119
+ };
120
+
121
+ hookRunner = new HookRunner([blockingHook], mockLogger);
122
+ const result = await hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext());
123
+
124
+ expect(result.proceed).toBe(false);
125
+ expect(result.reason).toBe('Rate limit exceeded');
126
+ });
127
+
128
+ it('should apply mutations from pre-hooks', async () => {
129
+ const mutatingHook: IDismissibleLifecycleHook<BaseMetadata> = {
130
+ onBeforeGetOrCreate: jest.fn(async () => ({
131
+ proceed: true,
132
+ mutations: {
133
+ id: 'mutated-id',
134
+ userId: 'mutated-user',
135
+ },
136
+ })),
137
+ };
138
+
139
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
140
+ const result = await hookRunner.runPreGetOrCreate(
141
+ 'original-id',
142
+ testUserId,
143
+ createTestContext(),
144
+ );
145
+
146
+ expect(result.id).toBe('mutated-id');
147
+ expect(result.userId).toBe('mutated-user');
148
+ });
149
+
150
+ it('should pass mutations through multiple hooks', async () => {
151
+ const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
152
+ priority: 1,
153
+ onBeforeGetOrCreate: jest.fn(async (itemId) => ({
154
+ proceed: true,
155
+ mutations: { id: `${itemId}-hook1` },
156
+ })),
157
+ };
158
+
159
+ const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
160
+ priority: 2,
161
+ onBeforeGetOrCreate: jest.fn(async (itemId) => ({
162
+ proceed: true,
163
+ mutations: { id: `${itemId}-hook2` },
164
+ })),
165
+ };
166
+
167
+ hookRunner = new HookRunner([hook1, hook2], mockLogger);
168
+ const result = await hookRunner.runPreGetOrCreate(
169
+ 'original',
170
+ testUserId,
171
+ createTestContext(),
172
+ );
173
+
174
+ expect(result.id).toBe('original-hook1-hook2');
175
+ });
176
+ });
177
+
178
+ describe('error handling', () => {
179
+ it('should throw error from pre-hook', async () => {
180
+ const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
181
+ onBeforeGetOrCreate: jest.fn(async () => {
182
+ throw new Error('Hook error');
183
+ }),
184
+ };
185
+
186
+ hookRunner = new HookRunner([errorHook], mockLogger);
187
+
188
+ await expect(
189
+ hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext()),
190
+ ).rejects.toThrow('Hook error');
191
+
192
+ expect(mockLogger.error).toHaveBeenCalled();
193
+ });
194
+
195
+ it('should log but not throw errors from post-hooks', async () => {
196
+ const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
197
+ onAfterGetOrCreate: jest.fn(async () => {
198
+ throw new Error('Post-hook error');
199
+ }),
200
+ };
201
+
202
+ hookRunner = new HookRunner([errorHook], mockLogger);
203
+
204
+ await expect(
205
+ hookRunner.runPostGetOrCreate('test-id', createTestItem(), testUserId, createTestContext()),
206
+ ).resolves.not.toThrow();
207
+
208
+ expect(mockLogger.error).toHaveBeenCalled();
209
+ });
210
+ });
211
+
212
+ describe('throwIfBlocked', () => {
213
+ it('should throw ForbiddenException when blocked', () => {
214
+ expect(() =>
215
+ HookRunner.throwIfBlocked({
216
+ proceed: false,
217
+ id: 'test',
218
+ userId: testUserId,
219
+ context: createTestContext(),
220
+ reason: 'Not allowed',
221
+ }),
222
+ ).toThrow(ForbiddenException);
223
+ });
224
+
225
+ it('should not throw when not blocked', () => {
226
+ expect(() =>
227
+ HookRunner.throwIfBlocked({
228
+ proceed: true,
229
+ id: 'test',
230
+ userId: testUserId,
231
+ context: createTestContext(),
232
+ }),
233
+ ).not.toThrow();
234
+ });
235
+ });
236
+
237
+ describe('all hook methods', () => {
238
+ let allMethodsHook: IDismissibleLifecycleHook<BaseMetadata>;
239
+
240
+ beforeEach(() => {
241
+ allMethodsHook = {
242
+ priority: 0,
243
+ onBeforeGetOrCreate: jest.fn().mockResolvedValue({ proceed: true }),
244
+ onAfterGetOrCreate: jest.fn(),
245
+ onBeforeCreate: jest.fn().mockResolvedValue({ proceed: true }),
246
+ onAfterCreate: jest.fn(),
247
+ onBeforeDismiss: jest.fn().mockResolvedValue({ proceed: true }),
248
+ onAfterDismiss: jest.fn(),
249
+ onBeforeRestore: jest.fn().mockResolvedValue({ proceed: true }),
250
+ onAfterRestore: jest.fn(),
251
+ };
252
+
253
+ hookRunner = new HookRunner([allMethodsHook], mockLogger);
254
+ });
255
+
256
+ it('should run pre and post create hooks', async () => {
257
+ const context = createTestContext();
258
+ const item = createTestItem();
259
+
260
+ await hookRunner.runPreCreate('test-id', testUserId, context);
261
+ await hookRunner.runPostCreate('test-id', item, testUserId, context);
262
+
263
+ expect(allMethodsHook.onBeforeCreate).toHaveBeenCalledWith('test-id', testUserId, context);
264
+ expect(allMethodsHook.onAfterCreate).toHaveBeenCalledWith(
265
+ 'test-id',
266
+ item,
267
+ testUserId,
268
+ context,
269
+ );
270
+ });
271
+
272
+ it('should run pre and post dismiss hooks', async () => {
273
+ const context = createTestContext();
274
+ const item = createTestItem();
275
+
276
+ await hookRunner.runPreDismiss('test-id', testUserId, context);
277
+ await hookRunner.runPostDismiss('test-id', item, testUserId, context);
278
+
279
+ expect(allMethodsHook.onBeforeDismiss).toHaveBeenCalledWith('test-id', testUserId, context);
280
+ expect(allMethodsHook.onAfterDismiss).toHaveBeenCalledWith(
281
+ 'test-id',
282
+ item,
283
+ testUserId,
284
+ context,
285
+ );
286
+ });
287
+
288
+ it('should run pre and post restore hooks', async () => {
289
+ const context = createTestContext();
290
+ const item = createTestItem();
291
+
292
+ await hookRunner.runPreRestore('test-id', testUserId, context);
293
+ await hookRunner.runPostRestore('test-id', item, testUserId, context);
294
+
295
+ expect(allMethodsHook.onBeforeRestore).toHaveBeenCalledWith('test-id', testUserId, context);
296
+ expect(allMethodsHook.onAfterRestore).toHaveBeenCalledWith(
297
+ 'test-id',
298
+ item,
299
+ testUserId,
300
+ context,
301
+ );
302
+ });
303
+ });
304
+ });