@dismissible/nestjs-dismissible 0.0.2-canary.c91edbc.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 (41) hide show
  1. package/README.md +51 -67
  2. package/package.json +3 -3
  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 +3 -45
  19. package/src/core/dismissible-core.service.ts +10 -27
  20. package/src/core/dismissible.service.spec.ts +23 -16
  21. package/src/core/dismissible.service.ts +28 -11
  22. package/src/core/hook-runner.service.spec.ts +369 -19
  23. package/src/core/hook-runner.service.ts +17 -17
  24. package/src/core/index.ts +0 -1
  25. package/src/core/lifecycle-hook.interface.ts +8 -8
  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/testing/factories.ts +5 -8
  35. package/src/utils/dismissible.helper.ts +2 -2
  36. package/src/validation/dismissible-input.dto.ts +47 -0
  37. package/src/validation/index.ts +1 -0
  38. package/tsconfig.json +3 -0
  39. package/tsconfig.spec.json +12 -0
  40. package/src/api/use-cases/get-or-create/get-or-create.request.dto.ts +0 -17
  41. package/src/core/create-options.ts +0 -9
package/README.md CHANGED
@@ -7,10 +7,12 @@ A powerful NestJS library for managing dismissible state in your applications. P
7
7
  ## Features
8
8
 
9
9
  - 🚀 **Simple API** - Easy-to-use service methods for get-or-create, dismiss, and restore operations
10
+ - ⚡ **High Performance** - Built on Fastify for maximum throughput
10
11
  - 💾 **Flexible Storage** - Default in-memory storage with support for custom storage adapters (PostgreSQL, Redis, etc.)
11
12
  - 🔌 **Lifecycle Hooks** - Intercept and customize operations with pre/post hooks
13
+ - 🔐 **JWT Authentication** - Optional JWT auth hook for securing endpoints with OIDC providers
12
14
  - 📡 **Event-Driven** - Built-in event emission for all operations
13
- - 🎯 **Type-Safe** - Full TypeScript support with generic metadata types
15
+ - 🎯 **Type-Safe** - Full TypeScript support
14
16
  - 🛡️ **Validation** - Automatic validation of dismissible items
15
17
  - 📝 **Swagger Integration** - Auto-generated API documentation
16
18
  - ⚛️ **React Client** - Works out of the box with [@dismissible/react-client](https://www.npmjs.com/package/@dismissible/react-client)
@@ -41,21 +43,21 @@ export class AppModule {}
41
43
 
42
44
  The module automatically registers REST endpoints for all operations:
43
45
 
44
- - `GET /v1/user/:userId/dismissible-item/:itemId` - Get or create an item
45
- - `DELETE /v1/user/:userId/dismissible-item/:itemId` - Dismiss an item
46
- - `POST /v1/user/:userId/dismissible-item/:itemId` - Restore a dismissed item
46
+ - `GET /v1/users/:userId/items/:itemId` - Get or create an item
47
+ - `DELETE /v1/users/:userId/items/:itemId` - Dismiss an item
48
+ - `POST /v1/users/:userId/items/:itemId` - Restore a dismissed item
47
49
 
48
50
  Example request:
49
51
 
50
52
  ```bash
51
53
  # Get or create an item
52
- curl http://localhost:3000/v1/user/user-123/dismissible-item/welcome-banner?metadata=version:2&metadata=category:onboarding
54
+ curl http://localhost:3000/v1/users/user-123/items/welcome-banner
53
55
 
54
56
  # Dismiss an item
55
- curl -X DELETE http://localhost:3000/v1/user/user-123/dismissible-item/welcome-banner
57
+ curl -X DELETE http://localhost:3000/v1/users/user-123/items/welcome-banner
56
58
 
57
59
  # Restore a dismissed item
58
- curl -X POST http://localhost:3000/v1/user/user-123/dismissible-item/welcome-banner
60
+ curl -X POST http://localhost:3000/v1/users/user-123/items/welcome-banner
59
61
  ```
60
62
 
61
63
  ### React Client Integration
@@ -156,9 +158,6 @@ export class FeaturesController {
156
158
  const result = await this.dismissibleService.getOrCreate(
157
159
  itemId,
158
160
  userId,
159
- {
160
- metadata: { version: '1.0', category: 'onboarding' },
161
- },
162
161
  undefined, // optional request context
163
162
  );
164
163
 
@@ -182,6 +181,33 @@ export class FeaturesController {
182
181
  }
183
182
  ```
184
183
 
184
+ ### JWT Authentication
185
+
186
+ Secure your API endpoints using the JWT Auth Hook with any OIDC-compliant identity provider:
187
+
188
+ ```typescript
189
+ import { Module } from '@nestjs/common';
190
+ import { DismissibleModule } from '@dismissible/nestjs-dismissible';
191
+ import { JwtAuthHookModule, JwtAuthHook } from '@dismissible/nestjs-jwt-auth-hook';
192
+
193
+ @Module({
194
+ imports: [
195
+ JwtAuthHookModule.forRoot({
196
+ enabled: true,
197
+ wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
198
+ issuer: 'https://auth.example.com',
199
+ audience: 'my-api',
200
+ }),
201
+ DismissibleModule.forRoot({
202
+ hooks: [JwtAuthHook],
203
+ }),
204
+ ],
205
+ })
206
+ export class AppModule {}
207
+ ```
208
+
209
+ See the [@dismissible/nestjs-jwt-auth-hook](https://www.npmjs.com/package/@dismissible/nestjs-jwt-auth-hook) package for detailed configuration options.
210
+
185
211
  ### Custom Lifecycle Hooks
186
212
 
187
213
  Lifecycle hooks allow you to intercept operations and add custom logic, validation, or mutations:
@@ -189,10 +215,8 @@ Lifecycle hooks allow you to intercept operations and add custom logic, validati
189
215
  ```typescript
190
216
  import { Injectable } from '@nestjs/common';
191
217
  import { IDismissibleLifecycleHook, IHookResult } from '@dismissible/nestjs-dismissible';
192
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
193
-
194
218
  @Injectable()
195
- export class AuditHook implements IDismissibleLifecycleHook<BaseMetadata> {
219
+ export class AuditHook implements IDismissibleLifecycleHook {
196
220
  // Lower priority runs first (default is 0)
197
221
  readonly priority = 10;
198
222
 
@@ -215,7 +239,7 @@ export class AuditHook implements IDismissibleLifecycleHook<BaseMetadata> {
215
239
 
216
240
  async onAfterCreate(
217
241
  itemId: string,
218
- item: DismissibleItemDto<BaseMetadata>,
242
+ item: DismissibleItemDto,
219
243
  userId: string,
220
244
  context?: IRequestContext,
221
245
  ): Promise<void> {
@@ -293,42 +317,6 @@ export class AnalyticsService {
293
317
  }
294
318
  ```
295
319
 
296
- ### Custom Metadata Types
297
-
298
- Define custom metadata types for type-safe item properties:
299
-
300
- ```typescript
301
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
302
-
303
- interface OnboardingMetadata extends BaseMetadata {
304
- step: number;
305
- completedAt?: string;
306
- skipped: boolean;
307
- }
308
-
309
- // Use in your service
310
- @Injectable()
311
- export class OnboardingService {
312
- constructor(private readonly dismissibleService: DismissibleService<OnboardingMetadata>) {}
313
-
314
- async trackStep(userId: string, step: number) {
315
- const result = await this.dismissibleService.getOrCreate(
316
- `onboarding-step-${step}`,
317
- userId,
318
- {
319
- metadata: {
320
- step,
321
- skipped: false,
322
- },
323
- },
324
- undefined,
325
- );
326
-
327
- return result.item;
328
- }
329
- }
330
- ```
331
-
332
320
  ### Custom Logger
333
321
 
334
322
  Provide a custom logger implementation:
@@ -376,17 +364,15 @@ The main service for interacting with dismissible items.
376
364
 
377
365
  #### Methods
378
366
 
379
- **`getOrCreate(itemId, userId, options?, context?)`**
367
+ **`getOrCreate(itemId, userId, context?)`**
380
368
 
381
369
  Retrieves an existing item or creates a new one if it doesn't exist.
382
370
 
383
371
  - `itemId: string` - Unique identifier for the item
384
372
  - `userId: string` - User identifier (required)
385
- - `options?: ICreateItemOptions<TMetadata>` - Optional creation options
386
- - `metadata?: TMetadata` - Custom metadata to attach to the item
387
373
  - `context?: IRequestContext` - Optional request context for tracing
388
374
 
389
- Returns: `Promise<IGetOrCreateServiceResponse<TMetadata>>`
375
+ Returns: `Promise<IGetOrCreateServiceResponse>`
390
376
 
391
377
  **`dismiss(itemId, userId, context?)`**
392
378
 
@@ -396,7 +382,7 @@ Marks an item as dismissed.
396
382
  - `userId: string` - User identifier
397
383
  - `context?: IRequestContext` - Optional request context
398
384
 
399
- Returns: `Promise<IDismissServiceResponse<TMetadata>>`
385
+ Returns: `Promise<IDismissServiceResponse>`
400
386
 
401
387
  **`restore(itemId, userId, context?)`**
402
388
 
@@ -406,12 +392,12 @@ Restores a previously dismissed item.
406
392
  - `userId: string` - User identifier
407
393
  - `context?: IRequestContext` - Optional request context
408
394
 
409
- Returns: `Promise<IRestoreServiceResponse<TMetadata>>`
395
+ Returns: `Promise<IRestoreServiceResponse>`
410
396
 
411
397
  ### Module Configuration
412
398
 
413
399
  ```typescript
414
- interface IDismissibleModuleOptions<TMetadata extends BaseMetadata> {
400
+ interface IDismissibleModuleOptions {
415
401
  // Custom storage module (defaults to in-memory storage)
416
402
  storage?: DynamicModule | Type<any>;
417
403
 
@@ -419,7 +405,7 @@ interface IDismissibleModuleOptions<TMetadata extends BaseMetadata> {
419
405
  logger?: Type<IDismissibleLogger>;
420
406
 
421
407
  // Lifecycle hooks to register
422
- hooks?: Type<IDismissibleLifecycleHook<TMetadata>>[];
408
+ hooks?: Type<IDismissibleLifecycleHook>[];
423
409
  }
424
410
  ```
425
411
 
@@ -435,13 +421,13 @@ The library emits the following events:
435
421
  All events include:
436
422
 
437
423
  - `id: string` - The item identifier
438
- - `item: DismissibleItemDto<TMetadata>` - The current item state
424
+ - `item: DismissibleItemDto` - The current item state
439
425
  - `userId: string` - The user identifier
440
426
  - `context?: IRequestContext` - Optional request context
441
427
 
442
428
  Dismiss and restore events also include:
443
429
 
444
- - `previousItem: DismissibleItemDto<TMetadata>` - The item state before the operation
430
+ - `previousItem: DismissibleItemDto` - The item state before the operation
445
431
 
446
432
  ## Lifecycle Hooks
447
433
 
@@ -481,21 +467,19 @@ Implement the `IDismissibleStorage` interface to create a custom storage adapter
481
467
  ```typescript
482
468
  import { Injectable } from '@nestjs/common';
483
469
  import { IDismissibleStorage } from '@dismissible/nestjs-storage';
484
- import { DismissibleItemDto, BaseMetadata } from '@dismissible/nestjs-dismissible-item';
470
+ import { DismissibleItemDto } from '@dismissible/nestjs-dismissible-item';
485
471
 
486
472
  @Injectable()
487
- export class RedisStorageAdapter<
488
- TMetadata extends BaseMetadata,
489
- > implements IDismissibleStorage<TMetadata> {
490
- async get(userId: string, itemId: string): Promise<DismissibleItemDto<TMetadata> | null> {
473
+ export class RedisStorageAdapter implements IDismissibleStorage {
474
+ async get(userId: string, itemId: string): Promise<DismissibleItemDto | null> {
491
475
  // Your implementation
492
476
  }
493
477
 
494
- async create(userId: string, item: DismissibleItemDto<TMetadata>): Promise<void> {
478
+ async create(userId: string, item: DismissibleItemDto): Promise<void> {
495
479
  // Your implementation
496
480
  }
497
481
 
498
- async update(userId: string, item: DismissibleItemDto<TMetadata>): Promise<void> {
482
+ async update(userId: string, item: DismissibleItemDto): Promise<void> {
499
483
  // Your implementation
500
484
  }
501
485
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dismissible/nestjs-dismissible",
3
- "version": "0.0.2-canary.c91edbc.0",
3
+ "version": "0.0.2-canary.d2f56d7.0",
4
4
  "description": "Dismissible state management library for NestJS applications",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -19,8 +19,8 @@
19
19
  "@nestjs/common": "^11.0.0",
20
20
  "@nestjs/core": "^11.0.0",
21
21
  "@nestjs/swagger": "^11.0.0",
22
- "@dismissible/nestjs-dismissible-item": "^0.0.2-canary.c91edbc.0",
23
- "@dismissible/nestjs-storage": "^0.0.2-canary.c91edbc.0",
22
+ "@dismissible/nestjs-dismissible-item": "^0.0.2-canary.d2f56d7.0",
23
+ "@dismissible/nestjs-storage": "^0.0.2-canary.d2f56d7.0",
24
24
  "class-validator": "^0.14.0",
25
25
  "class-transformer": "^0.5.0"
26
26
  },
@@ -27,12 +27,4 @@ export class DismissibleItemResponseDto {
27
27
  example: '2024-01-15T12:00:00.000Z',
28
28
  })
29
29
  dismissedAt?: string;
30
-
31
- @ApiPropertyOptional({
32
- description: 'Optional metadata associated with the item',
33
- example: { version: 2, category: 'promotional' },
34
- type: 'object',
35
- additionalProperties: true,
36
- })
37
- metadata?: Record<string, unknown>;
38
30
  }
@@ -22,7 +22,6 @@ describe('DismissibleItemMapper', () => {
22
22
  expect(dto.userId).toBe('test-user-id');
23
23
  expect(dto.createdAt).toBe('2024-01-15T10:00:00.000Z');
24
24
  expect(dto.dismissedAt).toBeUndefined();
25
- expect(dto.metadata).toBeUndefined();
26
25
  });
27
26
 
28
27
  it('should convert dates to ISO strings', () => {
@@ -48,16 +47,5 @@ describe('DismissibleItemMapper', () => {
48
47
 
49
48
  expect(dto.userId).toBe('user-123');
50
49
  });
51
-
52
- it('should include metadata when present', () => {
53
- const item = createTestItem({
54
- id: 'test-item',
55
- metadata: { version: 2, category: 'test' },
56
- });
57
-
58
- const dto = mapper.toResponseDto(item);
59
-
60
- expect(dto.metadata).toEqual({ version: 2, category: 'test' });
61
- });
62
50
  });
63
51
  });
@@ -1,5 +1,5 @@
1
1
  import { Injectable } from '@nestjs/common';
2
- import { BaseMetadata, DismissibleItemDto } from '@dismissible/nestjs-dismissible-item';
2
+ import { DismissibleItemDto } from '@dismissible/nestjs-dismissible-item';
3
3
  import { DismissibleItemResponseDto } from './dismissible-item-response.dto';
4
4
 
5
5
  /**
@@ -11,9 +11,7 @@ export class DismissibleItemMapper {
11
11
  * Convert a dismissible item to a response DTO.
12
12
  * Converts Date objects to ISO 8601 strings.
13
13
  */
14
- toResponseDto<TMetadata extends BaseMetadata>(
15
- item: DismissibleItemDto<TMetadata>,
16
- ): DismissibleItemResponseDto {
14
+ toResponseDto(item: DismissibleItemDto): DismissibleItemResponseDto {
17
15
  const dto = new DismissibleItemResponseDto();
18
16
 
19
17
  dto.itemId = item.id;
@@ -24,10 +22,6 @@ export class DismissibleItemMapper {
24
22
  dto.dismissedAt = item.dismissedAt.toISOString();
25
23
  }
26
24
 
27
- if (item.metadata) {
28
- dto.metadata = item.metadata as Record<string, unknown>;
29
- }
30
-
31
25
  return dto;
32
26
  }
33
27
  }
package/src/api/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  // Use cases
2
2
  export * from './use-cases';
3
3
 
4
+ // Validation
5
+ export * from './validation';
6
+
4
7
  // Cross-cutting
5
8
  export * from '../request/request-context.decorator';
6
9
  export * from './dismissible-item.mapper';
@@ -2,13 +2,12 @@ import { mock } from 'ts-jest-mocker';
2
2
  import { DismissController } from './dismiss.controller';
3
3
  import { DismissibleService } from '../../../core/dismissible.service';
4
4
  import { DismissibleItemMapper } from '../../dismissible-item.mapper';
5
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
6
5
  import { createTestItem, createTestContext } from '../../../testing/factories';
7
6
  import { ResponseService } from '../../../response';
8
7
 
9
8
  describe('DismissController', () => {
10
9
  let controller: DismissController;
11
- let mockService: jest.Mocked<DismissibleService<BaseMetadata>>;
10
+ let mockService: jest.Mocked<DismissibleService>;
12
11
  let mockResponseService: jest.Mocked<ResponseService>;
13
12
  let mapper: DismissibleItemMapper;
14
13
 
@@ -1,23 +1,23 @@
1
- import { Controller, Delete, Param, UseFilters } from '@nestjs/common';
1
+ import { Controller, Delete, UseFilters } from '@nestjs/common';
2
2
  import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
3
3
  import { DismissibleService } from '../../../core/dismissible.service';
4
4
  import { DismissibleItemMapper } from '../../dismissible-item.mapper';
5
5
  import { RequestContext } from '../../../request/request-context.decorator';
6
6
  import { IRequestContext } from '../../../request/request-context.interface';
7
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
8
7
  import { DismissResponseDto } from './dismiss.response.dto';
9
8
  import { ResponseService } from '../../../response/response.service';
10
9
  import { HttpExceptionFilter } from '../../../response/http-exception-filter';
11
10
  import { API_TAG_DISMISSIBLE } from '../api-tags.constants';
11
+ import { UserId, ItemId } from '../../validation';
12
12
 
13
13
  /**
14
14
  * Controller for dismiss dismissible item operations.
15
15
  */
16
16
  @ApiTags(API_TAG_DISMISSIBLE)
17
- @Controller('v1/user/:userId/dismissible-item')
17
+ @Controller('v1/users/:userId/items')
18
18
  export class DismissController {
19
19
  constructor(
20
- private readonly dismissibleService: DismissibleService<BaseMetadata>,
20
+ private readonly dismissibleService: DismissibleService,
21
21
  private readonly mapper: DismissibleItemMapper,
22
22
  private readonly responseService: ResponseService,
23
23
  ) {}
@@ -29,12 +29,12 @@ export class DismissController {
29
29
  })
30
30
  @ApiParam({
31
31
  name: 'userId',
32
- description: 'User identifier',
32
+ description: 'User identifier (max length: 32 characters)',
33
33
  example: 'user-123',
34
34
  })
35
35
  @ApiParam({
36
36
  name: 'itemId',
37
- description: 'Unique identifier for the dismissible item',
37
+ description: 'Unique identifier for the dismissible item (max length: 32 characters)',
38
38
  example: 'welcome-banner-v2',
39
39
  })
40
40
  @ApiResponse({
@@ -52,8 +52,8 @@ export class DismissController {
52
52
  })
53
53
  @UseFilters(HttpExceptionFilter)
54
54
  async dismiss(
55
- @Param('userId') userId: string,
56
- @Param('itemId') itemId: string,
55
+ @UserId() userId: string,
56
+ @ItemId() itemId: string,
57
57
  @RequestContext() context: IRequestContext,
58
58
  ): Promise<DismissResponseDto> {
59
59
  const result = await this.dismissibleService.dismiss(itemId, userId, context);
@@ -2,13 +2,12 @@ import { mock } from 'ts-jest-mocker';
2
2
  import { GetOrCreateController } from './get-or-create.controller';
3
3
  import { DismissibleService } from '../../../core/dismissible.service';
4
4
  import { DismissibleItemMapper } from '../../dismissible-item.mapper';
5
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
6
5
  import { createTestItem, createTestContext } from '../../../testing/factories';
7
6
  import { ResponseService } from '../../../response';
8
7
 
9
8
  describe('GetOrCreateController', () => {
10
9
  let controller: GetOrCreateController;
11
- let mockService: jest.Mocked<DismissibleService<BaseMetadata>>;
10
+ let mockService: jest.Mocked<DismissibleService>;
12
11
  let mockResponseService: jest.Mocked<ResponseService>;
13
12
  let mapper: DismissibleItemMapper;
14
13
 
@@ -28,49 +27,10 @@ describe('GetOrCreateController', () => {
28
27
 
29
28
  mockService.getOrCreate.mockResolvedValue({ item, created: true });
30
29
 
31
- const result = await controller.getOrCreate('test-user-id', 'test-item', context, undefined);
30
+ const result = await controller.getOrCreate('test-user-id', 'test-item', context);
32
31
 
33
32
  expect(result.data.itemId).toBe('test-item');
34
33
  expect(mockResponseService.success).toHaveBeenCalled();
35
34
  });
36
-
37
- it('should parse metadata from query params', async () => {
38
- const item = createTestItem({ id: 'test-item' });
39
- const context = createTestContext();
40
-
41
- mockService.getOrCreate.mockResolvedValue({ item, created: true });
42
-
43
- await controller.getOrCreate('test-user-id', 'test-item', context, [
44
- 'version:2',
45
- 'category:test',
46
- ]);
47
-
48
- expect(mockService.getOrCreate).toHaveBeenCalledWith(
49
- 'test-item',
50
- 'test-user-id',
51
- expect.objectContaining({
52
- metadata: { version: 2, category: 'test' },
53
- }),
54
- context,
55
- );
56
- });
57
-
58
- it('should handle single metadata value', async () => {
59
- const item = createTestItem({ id: 'test-item' });
60
- const context = createTestContext();
61
-
62
- mockService.getOrCreate.mockResolvedValue({ item, created: true });
63
-
64
- await controller.getOrCreate('test-user-id', 'test-item', context, 'version:2');
65
-
66
- expect(mockService.getOrCreate).toHaveBeenCalledWith(
67
- 'test-item',
68
- 'test-user-id',
69
- expect.objectContaining({
70
- metadata: { version: 2 },
71
- }),
72
- context,
73
- );
74
- });
75
35
  });
76
36
  });
@@ -1,23 +1,23 @@
1
- import { Controller, Get, Param, Query, UseFilters } from '@nestjs/common';
2
- import { ApiTags, ApiOperation, ApiParam, ApiResponse, ApiQuery } from '@nestjs/swagger';
1
+ import { Controller, Get, UseFilters } from '@nestjs/common';
2
+ import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
3
3
  import { DismissibleService } from '../../../core/dismissible.service';
4
4
  import { DismissibleItemMapper } from '../../dismissible-item.mapper';
5
5
  import { RequestContext } from '../../../request/request-context.decorator';
6
6
  import { IRequestContext } from '../../../request/request-context.interface';
7
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
8
7
  import { GetOrCreateResponseDto } from './get-or-create.response.dto';
9
8
  import { ResponseService } from '../../../response/response.service';
10
9
  import { HttpExceptionFilter } from '../../../response/http-exception-filter';
11
10
  import { API_TAG_DISMISSIBLE } from '../api-tags.constants';
11
+ import { UserId, ItemId } from '../../validation';
12
12
 
13
13
  /**
14
14
  * Controller for get-or-create dismissible item operations.
15
15
  */
16
16
  @ApiTags(API_TAG_DISMISSIBLE)
17
- @Controller('v1/user/:userId/dismissible-item')
17
+ @Controller('v1/users/:userId/items')
18
18
  export class GetOrCreateController {
19
19
  constructor(
20
- private readonly dismissibleService: DismissibleService<BaseMetadata>,
20
+ private readonly dismissibleService: DismissibleService,
21
21
  private readonly mapper: DismissibleItemMapper,
22
22
  private readonly responseService: ResponseService,
23
23
  ) {}
@@ -30,22 +30,14 @@ export class GetOrCreateController {
30
30
  })
31
31
  @ApiParam({
32
32
  name: 'userId',
33
- description: 'User identifier',
33
+ description: 'User identifier (max length: 32 characters)',
34
34
  example: 'user-123',
35
35
  })
36
36
  @ApiParam({
37
37
  name: 'itemId',
38
- description: 'Unique identifier for the dismissible item',
38
+ description: 'Unique identifier for the dismissible item (max length: 32 characters)',
39
39
  example: 'welcome-banner-v2',
40
40
  })
41
- @ApiQuery({
42
- name: 'metadata',
43
- description: 'Optional metadata as key:value pairs (can be repeated)',
44
- required: false,
45
- isArray: true,
46
- type: String,
47
- example: 'version:2',
48
- })
49
41
  @ApiResponse({
50
42
  status: 200,
51
43
  description: 'The dismissible item (retrieved or created)',
@@ -57,50 +49,12 @@ export class GetOrCreateController {
57
49
  })
58
50
  @UseFilters(HttpExceptionFilter)
59
51
  async getOrCreate(
60
- @Param('userId') userId: string,
61
- @Param('itemId') itemId: string,
52
+ @UserId() userId: string,
53
+ @ItemId() itemId: string,
62
54
  @RequestContext() context: IRequestContext,
63
- @Query('metadata') metadataRaw?: string | string[],
64
55
  ): Promise<GetOrCreateResponseDto> {
65
- // Parse metadata from query params
66
- const metadata = this.parseMetadata(metadataRaw);
67
-
68
- const result = await this.dismissibleService.getOrCreate(
69
- itemId,
70
- userId,
71
- {
72
- metadata: metadata as BaseMetadata,
73
- },
74
- context,
75
- );
56
+ const result = await this.dismissibleService.getOrCreate(itemId, userId, context);
76
57
 
77
58
  return this.responseService.success(this.mapper.toResponseDto(result.item));
78
59
  }
79
-
80
- /**
81
- * Parse metadata from query parameter format.
82
- * Supports: `?metadata=key1:value1&metadata=key2:value2`
83
- */
84
- private parseMetadata(raw?: string | string[]): Record<string, string | number> | undefined {
85
- if (!raw) {
86
- return undefined;
87
- }
88
-
89
- const items = Array.isArray(raw) ? raw : [raw];
90
- const metadata: Record<string, string | number> = {};
91
-
92
- for (const item of items) {
93
- const colonIndex = item.indexOf(':');
94
- if (colonIndex > 0) {
95
- const key = item.substring(0, colonIndex);
96
- const value = item.substring(colonIndex + 1);
97
-
98
- // Try to parse as number if possible
99
- const numValue = Number(value);
100
- metadata[key] = isNaN(numValue) ? value : numValue;
101
- }
102
- }
103
-
104
- return Object.keys(metadata).length > 0 ? metadata : undefined;
105
- }
106
60
  }
@@ -1,3 +1,2 @@
1
- export * from './get-or-create.request.dto';
2
1
  export * from './get-or-create.response.dto';
3
2
  export * from './get-or-create.controller';
@@ -2,13 +2,12 @@ import { mock } from 'ts-jest-mocker';
2
2
  import { RestoreController } from './restore.controller';
3
3
  import { DismissibleService } from '../../../core/dismissible.service';
4
4
  import { DismissibleItemMapper } from '../../dismissible-item.mapper';
5
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
6
5
  import { createTestItem, createTestContext } from '../../../testing/factories';
7
6
  import { ResponseService } from '../../../response';
8
7
 
9
8
  describe('RestoreController', () => {
10
9
  let controller: RestoreController;
11
- let mockService: jest.Mocked<DismissibleService<BaseMetadata>>;
10
+ let mockService: jest.Mocked<DismissibleService>;
12
11
  let mockResponseService: jest.Mocked<ResponseService>;
13
12
  let mapper: DismissibleItemMapper;
14
13
 
@@ -1,23 +1,23 @@
1
- import { Controller, Post, Param, UseFilters } from '@nestjs/common';
1
+ import { Controller, Post, UseFilters } from '@nestjs/common';
2
2
  import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
3
3
  import { DismissibleService } from '../../../core/dismissible.service';
4
4
  import { DismissibleItemMapper } from '../../dismissible-item.mapper';
5
5
  import { RequestContext } from '../../../request/request-context.decorator';
6
6
  import { IRequestContext } from '../../../request/request-context.interface';
7
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
8
7
  import { RestoreResponseDto } from './restore.response.dto';
9
8
  import { ResponseService } from '../../../response/response.service';
10
9
  import { HttpExceptionFilter } from '../../../response/http-exception-filter';
11
10
  import { API_TAG_DISMISSIBLE } from '../api-tags.constants';
11
+ import { UserId, ItemId } from '../../validation';
12
12
 
13
13
  /**
14
14
  * Controller for restore dismissible item operations.
15
15
  */
16
16
  @ApiTags(API_TAG_DISMISSIBLE)
17
- @Controller('v1/user/:userId/dismissible-item')
17
+ @Controller('v1/users/:userId/items')
18
18
  export class RestoreController {
19
19
  constructor(
20
- private readonly dismissibleService: DismissibleService<BaseMetadata>,
20
+ private readonly dismissibleService: DismissibleService,
21
21
  private readonly mapper: DismissibleItemMapper,
22
22
  private readonly responseService: ResponseService,
23
23
  ) {}
@@ -29,12 +29,12 @@ export class RestoreController {
29
29
  })
30
30
  @ApiParam({
31
31
  name: 'userId',
32
- description: 'User identifier',
32
+ description: 'User identifier (max length: 32 characters)',
33
33
  example: 'user-123',
34
34
  })
35
35
  @ApiParam({
36
36
  name: 'itemId',
37
- description: 'Unique identifier for the dismissible item',
37
+ description: 'Unique identifier for the dismissible item (max length: 32 characters)',
38
38
  example: 'welcome-banner-v2',
39
39
  })
40
40
  @ApiResponse({
@@ -52,8 +52,8 @@ export class RestoreController {
52
52
  })
53
53
  @UseFilters(HttpExceptionFilter)
54
54
  async restore(
55
- @Param('userId') userId: string,
56
- @Param('itemId') itemId: string,
55
+ @UserId() userId: string,
56
+ @ItemId() itemId: string,
57
57
  @RequestContext() context: IRequestContext,
58
58
  ): Promise<RestoreResponseDto> {
59
59
  const result = await this.dismissibleService.restore(itemId, userId, context);
@@ -0,0 +1,2 @@
1
+ export * from './param-validation.pipe';
2
+ export * from './param.decorators';