@dismissible/nestjs-dismissible 0.0.2-canary.8976e84.0 → 0.0.2-canary.d2f56d7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +51 -67
  2. package/package.json +4 -4
  3. package/src/api/dismissible-item-response.dto.ts +0 -8
  4. package/src/api/dismissible-item.mapper.spec.ts +0 -12
  5. package/src/api/dismissible-item.mapper.ts +2 -8
  6. package/src/api/index.ts +3 -0
  7. package/src/api/use-cases/dismiss/dismiss.controller.spec.ts +1 -2
  8. package/src/api/use-cases/dismiss/dismiss.controller.ts +8 -8
  9. package/src/api/use-cases/get-or-create/get-or-create.controller.spec.ts +2 -42
  10. package/src/api/use-cases/get-or-create/get-or-create.controller.ts +10 -56
  11. package/src/api/use-cases/get-or-create/index.ts +0 -1
  12. package/src/api/use-cases/restore/restore.controller.spec.ts +1 -2
  13. package/src/api/use-cases/restore/restore.controller.ts +8 -8
  14. package/src/api/validation/index.ts +2 -0
  15. package/src/api/validation/param-validation.pipe.spec.ts +317 -0
  16. package/src/api/validation/param-validation.pipe.ts +42 -0
  17. package/src/api/validation/param.decorators.ts +32 -0
  18. package/src/core/dismissible-core.service.spec.ts +75 -29
  19. package/src/core/dismissible-core.service.ts +40 -24
  20. package/src/core/dismissible.service.spec.ts +111 -25
  21. package/src/core/dismissible.service.ts +115 -49
  22. package/src/core/hook-runner.service.spec.ts +486 -53
  23. package/src/core/hook-runner.service.ts +144 -18
  24. package/src/core/index.ts +0 -1
  25. package/src/core/lifecycle-hook.interface.ts +56 -10
  26. package/src/core/service-responses.interface.ts +9 -9
  27. package/src/dismissible.module.integration.spec.ts +685 -0
  28. package/src/dismissible.module.ts +6 -10
  29. package/src/events/dismissible.events.ts +16 -39
  30. package/src/index.ts +1 -0
  31. package/src/request/request-context.decorator.ts +1 -0
  32. package/src/request/request-context.interface.ts +6 -0
  33. package/src/response/http-exception-filter.spec.ts +213 -0
  34. package/src/response/http-exception-filter.ts +3 -3
  35. package/src/testing/factories.ts +5 -8
  36. package/src/utils/dismissible.helper.ts +2 -2
  37. package/src/validation/dismissible-input.dto.ts +47 -0
  38. package/src/validation/index.ts +1 -0
  39. package/tsconfig.json +3 -0
  40. package/tsconfig.spec.json +12 -0
  41. package/src/api/use-cases/get-or-create/get-or-create.request.dto.ts +0 -17
  42. package/src/core/create-options.ts +0 -9
@@ -0,0 +1,317 @@
1
+ import { BadRequestException, ArgumentMetadata } from '@nestjs/common';
2
+ import { ParamValidationPipe } from './param-validation.pipe';
3
+ import { VALIDATION_CONSTANTS } from '../../validation/dismissible-input.dto';
4
+
5
+ describe('ParamValidationPipe', () => {
6
+ let pipe: ParamValidationPipe;
7
+
8
+ beforeEach(() => {
9
+ pipe = new ParamValidationPipe();
10
+ });
11
+
12
+ describe('valid values', () => {
13
+ it('should pass validation for a valid alphanumeric string', () => {
14
+ const metadata: ArgumentMetadata = {
15
+ type: 'param',
16
+ data: 'userId',
17
+ };
18
+ const result = pipe.transform('user123', metadata);
19
+ expect(result).toBe('user123');
20
+ });
21
+
22
+ it('should pass validation for a string with dashes', () => {
23
+ const metadata: ArgumentMetadata = {
24
+ type: 'param',
25
+ data: 'itemId',
26
+ };
27
+ const result = pipe.transform('item-123', metadata);
28
+ expect(result).toBe('item-123');
29
+ });
30
+
31
+ it('should pass validation for a string with underscores', () => {
32
+ const metadata: ArgumentMetadata = {
33
+ type: 'param',
34
+ data: 'userId',
35
+ };
36
+ const result = pipe.transform('user_123', metadata);
37
+ expect(result).toBe('user_123');
38
+ });
39
+
40
+ it('should pass validation for a string with mixed valid characters', () => {
41
+ const metadata: ArgumentMetadata = {
42
+ type: 'param',
43
+ data: 'itemId',
44
+ };
45
+ const result = pipe.transform('item-123_test', metadata);
46
+ expect(result).toBe('item-123_test');
47
+ });
48
+
49
+ it('should pass validation for minimum length (1 character)', () => {
50
+ const metadata: ArgumentMetadata = {
51
+ type: 'param',
52
+ data: 'userId',
53
+ };
54
+ const result = pipe.transform('a', metadata);
55
+ expect(result).toBe('a');
56
+ });
57
+
58
+ it('should pass validation for maximum length (64 characters)', () => {
59
+ const metadata: ArgumentMetadata = {
60
+ type: 'param',
61
+ data: 'itemId',
62
+ };
63
+ const validMaxLength = 'a'.repeat(VALIDATION_CONSTANTS.ID_MAX_LENGTH);
64
+ const result = pipe.transform(validMaxLength, metadata);
65
+ expect(result).toBe(validMaxLength);
66
+ });
67
+ });
68
+
69
+ describe('empty or null values', () => {
70
+ it('should throw BadRequestException for empty string', () => {
71
+ const metadata: ArgumentMetadata = {
72
+ type: 'param',
73
+ data: 'userId',
74
+ };
75
+ expect(() => pipe.transform('', metadata)).toThrow(BadRequestException);
76
+ expect(() => pipe.transform('', metadata)).toThrow('userId is required');
77
+ });
78
+
79
+ it('should throw BadRequestException for whitespace-only string', () => {
80
+ const metadata: ArgumentMetadata = {
81
+ type: 'param',
82
+ data: 'itemId',
83
+ };
84
+ expect(() => pipe.transform(' ', metadata)).toThrow(BadRequestException);
85
+ expect(() => pipe.transform(' ', metadata)).toThrow('itemId is required');
86
+ });
87
+
88
+ it('should throw BadRequestException for null value', () => {
89
+ const metadata: ArgumentMetadata = {
90
+ type: 'param',
91
+ data: 'userId',
92
+ };
93
+ expect(() => pipe.transform(null as any, metadata)).toThrow(BadRequestException);
94
+ expect(() => pipe.transform(null as any, metadata)).toThrow('userId is required');
95
+ });
96
+
97
+ it('should throw BadRequestException for undefined value', () => {
98
+ const metadata: ArgumentMetadata = {
99
+ type: 'param',
100
+ data: 'itemId',
101
+ };
102
+ expect(() => pipe.transform(undefined as any, metadata)).toThrow(BadRequestException);
103
+ expect(() => pipe.transform(undefined as any, metadata)).toThrow('itemId is required');
104
+ });
105
+
106
+ it('should use default parameter name when metadata.data is not provided', () => {
107
+ const metadata: ArgumentMetadata = {
108
+ type: 'param',
109
+ };
110
+ expect(() => pipe.transform('', metadata)).toThrow(BadRequestException);
111
+ expect(() => pipe.transform('', metadata)).toThrow('parameter is required');
112
+ });
113
+ });
114
+
115
+ describe('length validation', () => {
116
+ it('should throw BadRequestException for value below minimum length', () => {
117
+ const metadata: ArgumentMetadata = {
118
+ type: 'param',
119
+ data: 'userId',
120
+ };
121
+ // Empty string is already handled, but we test with a value that would pass empty check
122
+ // but fail length check (though in practice, empty string is caught first)
123
+ expect(() => pipe.transform('', metadata)).toThrow(BadRequestException);
124
+ });
125
+
126
+ it('should throw BadRequestException for value exceeding maximum length', () => {
127
+ const metadata: ArgumentMetadata = {
128
+ type: 'param',
129
+ data: 'itemId',
130
+ };
131
+ const tooLong = 'a'.repeat(VALIDATION_CONSTANTS.ID_MAX_LENGTH + 1);
132
+ expect(() => pipe.transform(tooLong, metadata)).toThrow(BadRequestException);
133
+ expect(() => pipe.transform(tooLong, metadata)).toThrow(
134
+ `itemId must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
135
+ );
136
+ });
137
+ });
138
+
139
+ describe('pattern validation', () => {
140
+ it('should throw BadRequestException for string with spaces', () => {
141
+ const metadata: ArgumentMetadata = {
142
+ type: 'param',
143
+ data: 'userId',
144
+ };
145
+ expect(() => pipe.transform('user 123', metadata)).toThrow(BadRequestException);
146
+ expect(() => pipe.transform('user 123', metadata)).toThrow(
147
+ `userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
148
+ );
149
+ });
150
+
151
+ it('should throw BadRequestException for string with special characters', () => {
152
+ const metadata: ArgumentMetadata = {
153
+ type: 'param',
154
+ data: 'itemId',
155
+ };
156
+ expect(() => pipe.transform('item@123', metadata)).toThrow(BadRequestException);
157
+ expect(() => pipe.transform('item@123', metadata)).toThrow(
158
+ `itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
159
+ );
160
+ });
161
+
162
+ it('should throw BadRequestException for string with dots', () => {
163
+ const metadata: ArgumentMetadata = {
164
+ type: 'param',
165
+ data: 'userId',
166
+ };
167
+ expect(() => pipe.transform('user.123', metadata)).toThrow(BadRequestException);
168
+ expect(() => pipe.transform('user.123', metadata)).toThrow(
169
+ `userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
170
+ );
171
+ });
172
+
173
+ it('should throw BadRequestException for string with slashes', () => {
174
+ const metadata: ArgumentMetadata = {
175
+ type: 'param',
176
+ data: 'itemId',
177
+ };
178
+ expect(() => pipe.transform('item/123', metadata)).toThrow(BadRequestException);
179
+ expect(() => pipe.transform('item/123', metadata)).toThrow(
180
+ `itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
181
+ );
182
+ });
183
+
184
+ it('should throw BadRequestException for string with unicode characters', () => {
185
+ const metadata: ArgumentMetadata = {
186
+ type: 'param',
187
+ data: 'userId',
188
+ };
189
+ expect(() => pipe.transform('userñ123', metadata)).toThrow(BadRequestException);
190
+ expect(() => pipe.transform('userñ123', metadata)).toThrow(
191
+ `userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
192
+ );
193
+ });
194
+
195
+ it('should throw BadRequestException for string starting with invalid character', () => {
196
+ const metadata: ArgumentMetadata = {
197
+ type: 'param',
198
+ data: 'itemId',
199
+ };
200
+ expect(() => pipe.transform('@item123', metadata)).toThrow(BadRequestException);
201
+ expect(() => pipe.transform('@item123', metadata)).toThrow(
202
+ `itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
203
+ );
204
+ });
205
+
206
+ it('should throw BadRequestException for string ending with invalid character', () => {
207
+ const metadata: ArgumentMetadata = {
208
+ type: 'param',
209
+ data: 'userId',
210
+ };
211
+ expect(() => pipe.transform('user123#', metadata)).toThrow(BadRequestException);
212
+ expect(() => pipe.transform('user123#', metadata)).toThrow(
213
+ `userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
214
+ );
215
+ });
216
+ });
217
+
218
+ describe('error messages', () => {
219
+ it('should include parameter name in required error message', () => {
220
+ const metadata: ArgumentMetadata = {
221
+ type: 'param',
222
+ data: 'customParam',
223
+ };
224
+ try {
225
+ pipe.transform('', metadata);
226
+ fail('Should have thrown BadRequestException');
227
+ } catch (error) {
228
+ expect(error).toBeInstanceOf(BadRequestException);
229
+ expect((error as BadRequestException).message).toBe('customParam is required');
230
+ }
231
+ });
232
+
233
+ it('should include parameter name in length error message', () => {
234
+ const metadata: ArgumentMetadata = {
235
+ type: 'param',
236
+ data: 'testParam',
237
+ };
238
+ const tooLong = 'a'.repeat(VALIDATION_CONSTANTS.ID_MAX_LENGTH + 1);
239
+ try {
240
+ pipe.transform(tooLong, metadata);
241
+ fail('Should have thrown BadRequestException');
242
+ } catch (error) {
243
+ expect(error).toBeInstanceOf(BadRequestException);
244
+ expect((error as BadRequestException).message).toContain('testParam');
245
+ expect((error as BadRequestException).message).toContain(
246
+ `must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
247
+ );
248
+ }
249
+ });
250
+
251
+ it('should include parameter name in pattern error message', () => {
252
+ const metadata: ArgumentMetadata = {
253
+ type: 'param',
254
+ data: 'myParam',
255
+ };
256
+ try {
257
+ pipe.transform('invalid@value', metadata);
258
+ fail('Should have thrown BadRequestException');
259
+ } catch (error) {
260
+ expect(error).toBeInstanceOf(BadRequestException);
261
+ expect((error as BadRequestException).message).toContain('myParam');
262
+ expect((error as BadRequestException).message).toContain(
263
+ VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE,
264
+ );
265
+ }
266
+ });
267
+ });
268
+
269
+ describe('edge cases', () => {
270
+ it('should handle numeric-only strings', () => {
271
+ const metadata: ArgumentMetadata = {
272
+ type: 'param',
273
+ data: 'itemId',
274
+ };
275
+ const result = pipe.transform('123456', metadata);
276
+ expect(result).toBe('123456');
277
+ });
278
+
279
+ it('should handle uppercase letters', () => {
280
+ const metadata: ArgumentMetadata = {
281
+ type: 'param',
282
+ data: 'userId',
283
+ };
284
+ const result = pipe.transform('USER123', metadata);
285
+ expect(result).toBe('USER123');
286
+ });
287
+
288
+ it('should handle lowercase letters', () => {
289
+ const metadata: ArgumentMetadata = {
290
+ type: 'param',
291
+ data: 'itemId',
292
+ };
293
+ const result = pipe.transform('user123', metadata);
294
+ expect(result).toBe('user123');
295
+ });
296
+
297
+ it('should handle mixed case letters', () => {
298
+ const metadata: ArgumentMetadata = {
299
+ type: 'param',
300
+ data: 'userId',
301
+ };
302
+ const result = pipe.transform('User123Item', metadata);
303
+ expect(result).toBe('User123Item');
304
+ });
305
+
306
+ it('should handle string with only dashes and underscores', () => {
307
+ const metadata: ArgumentMetadata = {
308
+ type: 'param',
309
+ data: 'itemId',
310
+ };
311
+ // This should pass pattern validation but might fail length if too short
312
+ // Since min length is 1, a single dash or underscore should pass
313
+ const result = pipe.transform('_-', metadata);
314
+ expect(result).toBe('_-');
315
+ });
316
+ });
317
+ });
@@ -0,0 +1,42 @@
1
+ import { PipeTransform, Injectable, BadRequestException, ArgumentMetadata } from '@nestjs/common';
2
+ import { VALIDATION_CONSTANTS } from '../../validation/dismissible-input.dto';
3
+
4
+ /**
5
+ * Validation pipe for userId and itemId route parameters.
6
+ * Validates:
7
+ * - Required (non-empty)
8
+ * - Length between 1-64 characters
9
+ * - Contains only alphanumeric characters, dashes, and underscores
10
+ */
11
+ @Injectable()
12
+ export class ParamValidationPipe implements PipeTransform<string, string> {
13
+ transform(value: string, metadata: ArgumentMetadata): string {
14
+ const paramName = metadata.data || 'parameter';
15
+
16
+ // Check if value exists
17
+ if (!value || value.trim() === '') {
18
+ throw new BadRequestException(`${paramName} is required`);
19
+ }
20
+
21
+ // Check minimum length
22
+ if (value.length < VALIDATION_CONSTANTS.ID_MIN_LENGTH) {
23
+ throw new BadRequestException(
24
+ `${paramName} must be at least ${VALIDATION_CONSTANTS.ID_MIN_LENGTH} character`,
25
+ );
26
+ }
27
+
28
+ // Check maximum length
29
+ if (value.length > VALIDATION_CONSTANTS.ID_MAX_LENGTH) {
30
+ throw new BadRequestException(
31
+ `${paramName} must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
32
+ );
33
+ }
34
+
35
+ // Check pattern
36
+ if (!VALIDATION_CONSTANTS.ID_PATTERN.test(value)) {
37
+ throw new BadRequestException(`${paramName} ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`);
38
+ }
39
+
40
+ return value;
41
+ }
42
+ }
@@ -0,0 +1,32 @@
1
+ import { Param } from '@nestjs/common';
2
+ import { ParamValidationPipe } from './param-validation.pipe';
3
+
4
+ /**
5
+ * Custom parameter decorator for userId.
6
+ * Combines @Param('userId') with ParamValidationPipe for validation.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * @Get(':itemId')
11
+ * async getOrCreate(
12
+ * @UserId() userId: string,
13
+ * @ItemId() itemId: string,
14
+ * )
15
+ * ```
16
+ */
17
+ export const UserId = () => Param('userId', ParamValidationPipe);
18
+
19
+ /**
20
+ * Custom parameter decorator for itemId.
21
+ * Combines @Param('itemId') with ParamValidationPipe for validation.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * @Get(':itemId')
26
+ * async getOrCreate(
27
+ * @UserId() userId: string,
28
+ * @ItemId() itemId: string,
29
+ * )
30
+ * ```
31
+ */
32
+ export const ItemId = () => Param('itemId', ParamValidationPipe);
@@ -7,7 +7,6 @@ import {
7
7
  ItemAlreadyDismissedException,
8
8
  ItemNotDismissedException,
9
9
  } from '../exceptions';
10
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
11
10
  import { DismissibleItemFactory } from '@dismissible/nestjs-dismissible-item';
12
11
  import { IDismissibleLogger } from '@dismissible/nestjs-logger';
13
12
  import { ValidationService } from '@dismissible/nestjs-validation';
@@ -16,8 +15,8 @@ import { DismissibleHelper } from '../utils/dismissible.helper';
16
15
  import { DateService } from '../utils/date/date.service';
17
16
 
18
17
  describe('DismissibleCoreService', () => {
19
- let service: DismissibleCoreService<BaseMetadata>;
20
- let storage: Mock<IDismissibleStorage<BaseMetadata>>;
18
+ let service: DismissibleCoreService;
19
+ let storage: Mock<IDismissibleStorage>;
21
20
  let mockDateService: Mock<DateService>;
22
21
  let mockLogger: Mock<IDismissibleLogger>;
23
22
  let itemFactory: Mock<DismissibleItemFactory>;
@@ -30,7 +29,7 @@ describe('DismissibleCoreService', () => {
30
29
  mockLogger = mock<IDismissibleLogger>({
31
30
  failIfMockNotProvided: false,
32
31
  });
33
- storage = mock<IDismissibleStorage<BaseMetadata>>({
32
+ storage = mock<IDismissibleStorage>({
34
33
  failIfMockNotProvided: false,
35
34
  });
36
35
  dismissibleHelper = mock(DismissibleHelper, { failIfMockNotProvided: false });
@@ -51,6 +50,78 @@ describe('DismissibleCoreService', () => {
51
50
  jest.clearAllMocks();
52
51
  });
53
52
 
53
+ describe('get', () => {
54
+ it('should return item when it exists', async () => {
55
+ const userId = 'user-123';
56
+ const existingItem = createTestItem({ id: 'existing-item', userId });
57
+ storage.get.mockResolvedValue(existingItem);
58
+
59
+ const result = await service.get('existing-item', userId);
60
+
61
+ expect(result).toEqual(existingItem);
62
+ expect(storage.get).toHaveBeenCalledWith(userId, 'existing-item');
63
+ });
64
+
65
+ it('should return null when item does not exist', async () => {
66
+ const userId = 'user-123';
67
+ storage.get.mockResolvedValue(null);
68
+
69
+ const result = await service.get('non-existent', userId);
70
+
71
+ expect(result).toBeNull();
72
+ expect(storage.get).toHaveBeenCalledWith(userId, 'non-existent');
73
+ });
74
+ });
75
+
76
+ describe('create', () => {
77
+ it('should create a new item', async () => {
78
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
79
+ const userId = 'user-123';
80
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
81
+
82
+ storage.create.mockResolvedValue(newItem);
83
+ mockDateService.getNow.mockReturnValue(testDate);
84
+ itemFactory.create.mockReturnValue(newItem);
85
+
86
+ const result = await service.create('new-item', userId);
87
+
88
+ expect(result.id).toBe('new-item');
89
+ expect(result.userId).toBe(userId);
90
+ expect(result.createdAt).toBeInstanceOf(Date);
91
+ expect(result.dismissedAt).toBeUndefined();
92
+ expect(storage.create).toHaveBeenCalledWith(newItem);
93
+ });
94
+
95
+ it('should validate item before storage', async () => {
96
+ const userId = 'user-123';
97
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
98
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
99
+
100
+ storage.create.mockResolvedValue(newItem);
101
+ mockDateService.getNow.mockReturnValue(testDate);
102
+ itemFactory.create.mockReturnValue(newItem);
103
+
104
+ await service.create('new-item', userId);
105
+
106
+ expect(validationService.validateInstance).toHaveBeenCalledWith(newItem);
107
+ });
108
+
109
+ it('should throw BadRequestException when validation fails', async () => {
110
+ const userId = 'user-123';
111
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
112
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
113
+
114
+ mockDateService.getNow.mockReturnValue(testDate);
115
+ itemFactory.create.mockReturnValue(newItem);
116
+ validationService.validateInstance.mockRejectedValue(
117
+ new BadRequestException('id must be a string'),
118
+ );
119
+
120
+ await expect(service.create('new-item', userId)).rejects.toThrow(BadRequestException);
121
+ expect(storage.create).not.toHaveBeenCalled();
122
+ });
123
+ });
124
+
54
125
  describe('getOrCreate', () => {
55
126
  it('should create a new item when it does not exist', async () => {
56
127
  const testDate = new Date('2024-01-15T10:00:00.000Z');
@@ -83,23 +154,6 @@ describe('DismissibleCoreService', () => {
83
154
  expect(result.item).toEqual(existingItem);
84
155
  expect(storage.get).toHaveBeenCalledWith(userId, 'existing-item');
85
156
  });
86
-
87
- it('should create item with metadata when provided', async () => {
88
- const userId = 'user-123';
89
- const metadata = { version: 2, category: 'test' };
90
- const testDate = new Date('2024-01-15T10:00:00.000Z');
91
- const newItem = createTestItem({ id: 'new-item', userId, metadata, createdAt: testDate });
92
-
93
- storage.get.mockResolvedValue(null);
94
- storage.create.mockResolvedValue(newItem);
95
- mockDateService.getNow.mockReturnValue(testDate);
96
- itemFactory.create.mockReturnValue(newItem);
97
-
98
- const result = await service.getOrCreate('new-item', userId, { metadata });
99
-
100
- expect(result.item.metadata).toEqual(metadata);
101
- expect(storage.create).toHaveBeenCalledWith(newItem);
102
- });
103
157
  });
104
158
 
105
159
  describe('dismiss', () => {
@@ -148,17 +202,14 @@ describe('DismissibleCoreService', () => {
148
202
  const item = createTestItem({
149
203
  id: 'test-item',
150
204
  userId,
151
- metadata: { key: 'value' },
152
205
  });
153
206
  const previousItem = createTestItem({
154
207
  id: 'test-item',
155
208
  userId,
156
- metadata: { key: 'value' },
157
209
  });
158
210
  const dismissedItem = createDismissedTestItem({
159
211
  id: 'test-item',
160
212
  userId,
161
- metadata: { key: 'value' },
162
213
  });
163
214
  const testDate = new Date('2024-01-15T12:00:00.000Z');
164
215
 
@@ -173,7 +224,6 @@ describe('DismissibleCoreService', () => {
173
224
 
174
225
  expect(result.previousItem.id).toBe(item.id);
175
226
  expect(result.previousItem.dismissedAt).toBeUndefined();
176
- expect(result.previousItem.metadata).toEqual({ key: 'value' });
177
227
  });
178
228
  });
179
229
 
@@ -221,17 +271,14 @@ describe('DismissibleCoreService', () => {
221
271
  const dismissedItem = createDismissedTestItem({
222
272
  id: 'dismissed-item',
223
273
  userId,
224
- metadata: { key: 'value' },
225
274
  });
226
275
  const previousItem = createDismissedTestItem({
227
276
  id: 'dismissed-item',
228
277
  userId,
229
- metadata: { key: 'value' },
230
278
  });
231
279
  const restoredItem = createTestItem({
232
280
  id: 'dismissed-item',
233
281
  userId,
234
- metadata: { key: 'value' },
235
282
  });
236
283
 
237
284
  storage.get.mockResolvedValue(dismissedItem);
@@ -244,7 +291,6 @@ describe('DismissibleCoreService', () => {
244
291
 
245
292
  expect(result.previousItem.id).toBe(dismissedItem.id);
246
293
  expect(result.previousItem.dismissedAt).toBeDefined();
247
- expect(result.previousItem.metadata).toEqual({ key: 'value' });
248
294
  });
249
295
  });
250
296
 
@@ -14,17 +14,16 @@ import {
14
14
  ItemNotDismissedException,
15
15
  } from '../exceptions';
16
16
  import { ValidationService } from '@dismissible/nestjs-validation';
17
- import { BaseMetadata, DismissibleItemFactory } from '@dismissible/nestjs-dismissible-item';
18
- import { ICreateItemOptions } from './create-options';
17
+ import { DismissibleItemDto, DismissibleItemFactory } from '@dismissible/nestjs-dismissible-item';
19
18
 
20
19
  /**
21
20
  * Core business logic service for dismissible operations.
22
21
  * Handles pure CRUD operations without side effects (hooks, events).
23
22
  */
24
23
  @Injectable()
25
- export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadata> {
24
+ export class DismissibleCoreService {
26
25
  constructor(
27
- @Inject(DISMISSIBLE_STORAGE_ADAPTER) private readonly storage: IDismissibleStorage<TMetadata>,
26
+ @Inject(DISMISSIBLE_STORAGE_ADAPTER) private readonly storage: IDismissibleStorage,
28
27
  private readonly dateService: DateService,
29
28
  @Inject(DISMISSIBLE_LOGGER) private readonly logger: IDismissibleLogger,
30
29
  private readonly itemFactory: DismissibleItemFactory,
@@ -33,41 +32,38 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
33
32
  ) {}
34
33
 
35
34
  /**
36
- * Get an existing item or create a new one.
35
+ * Get an existing item by user ID and item ID.
37
36
  * @param itemId The item identifier
38
37
  * @param userId The user identifier (required)
39
- * @param options Optional creation options (metadata)
38
+ * @returns The item or null if not found
40
39
  */
41
- async getOrCreate(
42
- itemId: string,
43
- userId: string,
44
- options?: ICreateItemOptions<TMetadata>,
45
- ): Promise<IGetOrCreateServiceResponse<TMetadata>> {
40
+ async get(itemId: string, userId: string): Promise<DismissibleItemDto | null> {
46
41
  this.logger.debug(`Looking up item in storage`, { itemId, userId });
47
-
48
- const existingItem = await this.storage.get(userId, itemId);
49
-
50
- if (existingItem) {
42
+ const item = await this.storage.get(userId, itemId);
43
+ if (item) {
51
44
  this.logger.debug(`Found existing item`, { itemId, userId });
52
- return {
53
- item: existingItem,
54
- created: false,
55
- };
56
45
  }
46
+ return item;
47
+ }
57
48
 
49
+ /**
50
+ * Create a new item.
51
+ * @param itemId The item identifier
52
+ * @param userId The user identifier (required)
53
+ * @returns The created item
54
+ */
55
+ async create(itemId: string, userId: string): Promise<DismissibleItemDto> {
58
56
  this.logger.debug(`Creating new item`, {
59
57
  itemId,
60
58
  userId,
61
- hasMetadata: !!options?.metadata,
62
59
  });
63
60
 
64
61
  // Create new item
65
62
  const now = this.dateService.getNow();
66
- const newItem = this.itemFactory.create<TMetadata>({
63
+ const newItem = this.itemFactory.create({
67
64
  id: itemId,
68
65
  createdAt: now,
69
66
  userId,
70
- metadata: options?.metadata,
71
67
  });
72
68
 
73
69
  // Validate the item before storage
@@ -77,6 +73,26 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
77
73
 
78
74
  this.logger.info(`Created new dismissible item`, { itemId, userId });
79
75
 
76
+ return createdItem;
77
+ }
78
+
79
+ /**
80
+ * Get an existing item or create a new one.
81
+ * @param itemId The item identifier
82
+ * @param userId The user identifier (required)
83
+ */
84
+ async getOrCreate(itemId: string, userId: string): Promise<IGetOrCreateServiceResponse> {
85
+ const existingItem = await this.get(itemId, userId);
86
+
87
+ if (existingItem) {
88
+ return {
89
+ item: existingItem,
90
+ created: false,
91
+ };
92
+ }
93
+
94
+ const createdItem = await this.create(itemId, userId);
95
+
80
96
  return {
81
97
  item: createdItem,
82
98
  created: true,
@@ -90,7 +106,7 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
90
106
  * @throws ItemNotFoundException if item doesn't exist
91
107
  * @throws ItemAlreadyDismissedException if item is already dismissed
92
108
  */
93
- async dismiss(itemId: string, userId: string): Promise<IDismissServiceResponse<TMetadata>> {
109
+ async dismiss(itemId: string, userId: string): Promise<IDismissServiceResponse> {
94
110
  this.logger.debug(`Attempting to dismiss item`, { itemId, userId });
95
111
 
96
112
  const existingItem = await this.storage.get(userId, itemId);
@@ -128,7 +144,7 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
128
144
  * @throws ItemNotFoundException if item doesn't exist
129
145
  * @throws ItemNotDismissedException if item is not dismissed
130
146
  */
131
- async restore(itemId: string, userId: string): Promise<IRestoreServiceResponse<TMetadata>> {
147
+ async restore(itemId: string, userId: string): Promise<IRestoreServiceResponse> {
132
148
  this.logger.debug(`Attempting to restore item`, { itemId, userId });
133
149
 
134
150
  const existingItem = await this.storage.get(userId, itemId);