@dismissible/nestjs-dismissible 0.0.2-canary.738340d.0 → 0.0.2-canary.c91edbc.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dismissible/nestjs-dismissible",
3
- "version": "0.0.2-canary.738340d.0",
3
+ "version": "0.0.2-canary.c91edbc.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.738340d.0",
23
- "@dismissible/nestjs-storage": "^0.0.2-canary.738340d.0",
22
+ "@dismissible/nestjs-dismissible-item": "^0.0.2-canary.c91edbc.0",
23
+ "@dismissible/nestjs-storage": "^0.0.2-canary.c91edbc.0",
24
24
  "class-validator": "^0.14.0",
25
25
  "class-transformer": "^0.5.0"
26
26
  },
@@ -34,7 +34,7 @@
34
34
  "@nestjs/swagger": {
35
35
  "optional": false
36
36
  },
37
- "dismissible/nestjs-dismissible-item": {
37
+ "@dismissible/nestjs-dismissible-item": {
38
38
  "optional": false
39
39
  },
40
40
  "@dismissible/nestjs-storage": {
@@ -58,6 +58,9 @@
58
58
  "license": "MIT",
59
59
  "repository": {
60
60
  "type": "git",
61
- "url": ""
61
+ "url": "https://github.com/DismissibleIo/dismissible-api"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
62
65
  }
63
66
  }
@@ -51,6 +51,94 @@ describe('DismissibleCoreService', () => {
51
51
  jest.clearAllMocks();
52
52
  });
53
53
 
54
+ describe('get', () => {
55
+ it('should return item when it exists', async () => {
56
+ const userId = 'user-123';
57
+ const existingItem = createTestItem({ id: 'existing-item', userId });
58
+ storage.get.mockResolvedValue(existingItem);
59
+
60
+ const result = await service.get('existing-item', userId);
61
+
62
+ expect(result).toEqual(existingItem);
63
+ expect(storage.get).toHaveBeenCalledWith(userId, 'existing-item');
64
+ });
65
+
66
+ it('should return null when item does not exist', async () => {
67
+ const userId = 'user-123';
68
+ storage.get.mockResolvedValue(null);
69
+
70
+ const result = await service.get('non-existent', userId);
71
+
72
+ expect(result).toBeNull();
73
+ expect(storage.get).toHaveBeenCalledWith(userId, 'non-existent');
74
+ });
75
+ });
76
+
77
+ describe('create', () => {
78
+ it('should create a new item', async () => {
79
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
80
+ const userId = 'user-123';
81
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
82
+
83
+ storage.create.mockResolvedValue(newItem);
84
+ mockDateService.getNow.mockReturnValue(testDate);
85
+ itemFactory.create.mockReturnValue(newItem);
86
+
87
+ const result = await service.create('new-item', userId);
88
+
89
+ expect(result.id).toBe('new-item');
90
+ expect(result.userId).toBe(userId);
91
+ expect(result.createdAt).toBeInstanceOf(Date);
92
+ expect(result.dismissedAt).toBeUndefined();
93
+ expect(storage.create).toHaveBeenCalledWith(newItem);
94
+ });
95
+
96
+ it('should create item with metadata when provided', async () => {
97
+ const userId = 'user-123';
98
+ const metadata = { version: 2, category: 'test' };
99
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
100
+ const newItem = createTestItem({ id: 'new-item', userId, metadata, createdAt: testDate });
101
+
102
+ storage.create.mockResolvedValue(newItem);
103
+ mockDateService.getNow.mockReturnValue(testDate);
104
+ itemFactory.create.mockReturnValue(newItem);
105
+
106
+ const result = await service.create('new-item', userId, { metadata });
107
+
108
+ expect(result.metadata).toEqual(metadata);
109
+ expect(storage.create).toHaveBeenCalledWith(newItem);
110
+ });
111
+
112
+ it('should validate item before storage', async () => {
113
+ const userId = 'user-123';
114
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
115
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
116
+
117
+ storage.create.mockResolvedValue(newItem);
118
+ mockDateService.getNow.mockReturnValue(testDate);
119
+ itemFactory.create.mockReturnValue(newItem);
120
+
121
+ await service.create('new-item', userId);
122
+
123
+ expect(validationService.validateInstance).toHaveBeenCalledWith(newItem);
124
+ });
125
+
126
+ it('should throw BadRequestException when validation fails', async () => {
127
+ const userId = 'user-123';
128
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
129
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
130
+
131
+ mockDateService.getNow.mockReturnValue(testDate);
132
+ itemFactory.create.mockReturnValue(newItem);
133
+ validationService.validateInstance.mockRejectedValue(
134
+ new BadRequestException('id must be a string'),
135
+ );
136
+
137
+ await expect(service.create('new-item', userId)).rejects.toThrow(BadRequestException);
138
+ expect(storage.create).not.toHaveBeenCalled();
139
+ });
140
+ });
141
+
54
142
  describe('getOrCreate', () => {
55
143
  it('should create a new item when it does not exist', async () => {
56
144
  const testDate = new Date('2024-01-15T10:00:00.000Z');
@@ -14,7 +14,11 @@ 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';
17
+ import {
18
+ BaseMetadata,
19
+ DismissibleItemDto,
20
+ DismissibleItemFactory,
21
+ } from '@dismissible/nestjs-dismissible-item';
18
22
  import { ICreateItemOptions } from './create-options';
19
23
 
20
24
  /**
@@ -33,28 +37,32 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
33
37
  ) {}
34
38
 
35
39
  /**
36
- * Get an existing item or create a new one.
40
+ * Get an existing item by user ID and item ID.
37
41
  * @param itemId The item identifier
38
42
  * @param userId The user identifier (required)
39
- * @param options Optional creation options (metadata)
43
+ * @returns The item or null if not found
40
44
  */
41
- async getOrCreate(
42
- itemId: string,
43
- userId: string,
44
- options?: ICreateItemOptions<TMetadata>,
45
- ): Promise<IGetOrCreateServiceResponse<TMetadata>> {
45
+ async get(itemId: string, userId: string): Promise<DismissibleItemDto<TMetadata> | null> {
46
46
  this.logger.debug(`Looking up item in storage`, { itemId, userId });
47
-
48
- const existingItem = await this.storage.get(userId, itemId);
49
-
50
- if (existingItem) {
47
+ const item = await this.storage.get(userId, itemId);
48
+ if (item) {
51
49
  this.logger.debug(`Found existing item`, { itemId, userId });
52
- return {
53
- item: existingItem,
54
- created: false,
55
- };
56
50
  }
51
+ return item;
52
+ }
57
53
 
54
+ /**
55
+ * Create a new item.
56
+ * @param itemId The item identifier
57
+ * @param userId The user identifier (required)
58
+ * @param options Optional creation options (metadata)
59
+ * @returns The created item
60
+ */
61
+ async create(
62
+ itemId: string,
63
+ userId: string,
64
+ options?: ICreateItemOptions<TMetadata>,
65
+ ): Promise<DismissibleItemDto<TMetadata>> {
58
66
  this.logger.debug(`Creating new item`, {
59
67
  itemId,
60
68
  userId,
@@ -77,6 +85,31 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
77
85
 
78
86
  this.logger.info(`Created new dismissible item`, { itemId, userId });
79
87
 
88
+ return createdItem;
89
+ }
90
+
91
+ /**
92
+ * Get an existing item or create a new one.
93
+ * @param itemId The item identifier
94
+ * @param userId The user identifier (required)
95
+ * @param options Optional creation options (metadata)
96
+ */
97
+ async getOrCreate(
98
+ itemId: string,
99
+ userId: string,
100
+ options?: ICreateItemOptions<TMetadata>,
101
+ ): Promise<IGetOrCreateServiceResponse<TMetadata>> {
102
+ const existingItem = await this.get(itemId, userId);
103
+
104
+ if (existingItem) {
105
+ return {
106
+ item: existingItem,
107
+ created: false,
108
+ };
109
+ }
110
+
111
+ const createdItem = await this.create(itemId, userId, options);
112
+
80
113
  return {
81
114
  item: createdItem,
82
115
  created: true,
@@ -34,22 +34,28 @@ describe('DismissibleService', () => {
34
34
  });
35
35
 
36
36
  describe('getOrCreate', () => {
37
- it('should run hooks, call core service, and emit ITEM_RETRIEVED event for existing item', async () => {
37
+ it('should run request and get hooks for existing item', async () => {
38
38
  const item = createTestItem({ id: 'existing-item' });
39
39
  const context = createTestContext();
40
40
 
41
- mockHookRunner.runPreGetOrCreate.mockResolvedValue(createHookResult('existing-item'));
42
- mockCoreService.getOrCreate.mockResolvedValue({ item, created: false });
41
+ mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('existing-item'));
42
+ mockHookRunner.runPreGet.mockResolvedValue(createHookResult('existing-item'));
43
+ mockCoreService.get.mockResolvedValue(item);
43
44
 
44
45
  const result = await service.getOrCreate('existing-item', testUserId, undefined, context);
45
46
 
46
- expect(mockHookRunner.runPreGetOrCreate).toHaveBeenCalled();
47
- expect(mockCoreService.getOrCreate).toHaveBeenCalledWith(
47
+ expect(mockHookRunner.runPreRequest).toHaveBeenCalled();
48
+ expect(mockCoreService.get).toHaveBeenCalledWith('existing-item', testUserId);
49
+ expect(mockHookRunner.runPreGet).toHaveBeenCalledWith(
48
50
  'existing-item',
51
+ item,
49
52
  testUserId,
50
- undefined,
53
+ expect.anything(),
51
54
  );
52
- expect(mockHookRunner.runPostGetOrCreate).toHaveBeenCalled();
55
+ expect(mockHookRunner.runPostGet).toHaveBeenCalled();
56
+ expect(mockHookRunner.runPostRequest).toHaveBeenCalled();
57
+ expect(mockCoreService.create).not.toHaveBeenCalled();
58
+ expect(mockHookRunner.runPreCreate).not.toHaveBeenCalled();
53
59
  expect(mockEventEmitter.emit).toHaveBeenCalledWith(
54
60
  DismissibleEvents.ITEM_RETRIEVED,
55
61
  expect.anything(),
@@ -57,24 +63,90 @@ describe('DismissibleService', () => {
57
63
  expect(result.created).toBe(false);
58
64
  });
59
65
 
60
- it('should run create hooks and emit ITEM_CREATED event for new item', async () => {
66
+ it('should run pre-create hooks BEFORE creating for new item', async () => {
61
67
  const item = createTestItem({ id: 'new-item' });
62
68
  const context = createTestContext();
63
-
64
- mockHookRunner.runPreGetOrCreate.mockResolvedValue(createHookResult('new-item'));
65
- mockHookRunner.runPreCreate.mockResolvedValue(createHookResult('new-item'));
66
- mockCoreService.getOrCreate.mockResolvedValue({ item, created: true });
69
+ const callOrder: string[] = [];
70
+
71
+ mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('new-item'));
72
+ mockCoreService.get.mockResolvedValue(null);
73
+ mockHookRunner.runPreCreate.mockImplementation(async () => {
74
+ callOrder.push('runPreCreate');
75
+ return createHookResult('new-item');
76
+ });
77
+ mockCoreService.create.mockImplementation(async () => {
78
+ callOrder.push('create');
79
+ return item;
80
+ });
67
81
 
68
82
  const result = await service.getOrCreate('new-item', testUserId, undefined, context);
69
83
 
84
+ // Verify pre-create hooks run BEFORE create
85
+ expect(callOrder).toEqual(['runPreCreate', 'create']);
86
+ expect(mockHookRunner.runPreRequest).toHaveBeenCalled();
70
87
  expect(mockHookRunner.runPreCreate).toHaveBeenCalled();
88
+ expect(mockCoreService.create).toHaveBeenCalledWith('new-item', testUserId, undefined);
71
89
  expect(mockHookRunner.runPostCreate).toHaveBeenCalled();
90
+ expect(mockHookRunner.runPostRequest).toHaveBeenCalled();
72
91
  expect(mockEventEmitter.emit).toHaveBeenCalledWith(
73
92
  DismissibleEvents.ITEM_CREATED,
74
93
  expect.anything(),
75
94
  );
76
95
  expect(result.created).toBe(true);
77
96
  });
97
+
98
+ it('should NOT create item when pre-create hook blocks the operation', async () => {
99
+ const context = createTestContext();
100
+
101
+ mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('new-item'));
102
+ mockCoreService.get.mockResolvedValue(null);
103
+ mockHookRunner.runPreCreate.mockResolvedValue({
104
+ proceed: false,
105
+ reason: 'Plan limit reached',
106
+ id: 'new-item',
107
+ userId: testUserId,
108
+ context: createTestContext(),
109
+ });
110
+
111
+ await expect(
112
+ service.getOrCreate('new-item', testUserId, undefined, context),
113
+ ).rejects.toThrow();
114
+
115
+ // Verify create was NOT called because pre-create hook blocked it
116
+ expect(mockCoreService.create).not.toHaveBeenCalled();
117
+ expect(mockHookRunner.runPostCreate).not.toHaveBeenCalled();
118
+ expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(
119
+ DismissibleEvents.ITEM_CREATED,
120
+ expect.anything(),
121
+ );
122
+ });
123
+
124
+ it('should NOT return existing item when pre-get hook blocks the operation', async () => {
125
+ const item = createTestItem({ id: 'existing-item' });
126
+ const context = createTestContext();
127
+
128
+ mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('existing-item'));
129
+ mockCoreService.get.mockResolvedValue(item);
130
+ mockHookRunner.runPreGet.mockResolvedValue({
131
+ proceed: false,
132
+ reason: 'Item access denied',
133
+ id: 'existing-item',
134
+ userId: testUserId,
135
+ context: createTestContext(),
136
+ });
137
+
138
+ await expect(
139
+ service.getOrCreate('existing-item', testUserId, undefined, context),
140
+ ).rejects.toThrow();
141
+
142
+ // Verify post-get hooks were NOT called because pre-get hook blocked
143
+ expect(mockHookRunner.runPostGet).not.toHaveBeenCalled();
144
+ expect(mockHookRunner.runPostRequest).not.toHaveBeenCalled();
145
+ expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(
146
+ DismissibleEvents.ITEM_RETRIEVED,
147
+ expect.anything(),
148
+ );
149
+ });
78
150
  });
79
151
 
80
152
  describe('dismiss', () => {
@@ -83,14 +155,17 @@ describe('DismissibleService', () => {
83
155
  const previousItem = createTestItem({ id: 'test-item' });
84
156
  const context = createTestContext();
85
157
 
158
+ mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('test-item'));
86
159
  mockHookRunner.runPreDismiss.mockResolvedValue(createHookResult('test-item'));
87
160
  mockCoreService.dismiss.mockResolvedValue({ item, previousItem });
88
161
 
89
162
  const result = await service.dismiss('test-item', testUserId, context);
90
163
 
164
+ expect(mockHookRunner.runPreRequest).toHaveBeenCalled();
91
165
  expect(mockHookRunner.runPreDismiss).toHaveBeenCalled();
92
166
  expect(mockCoreService.dismiss).toHaveBeenCalledWith('test-item', testUserId);
93
167
  expect(mockHookRunner.runPostDismiss).toHaveBeenCalled();
168
+ expect(mockHookRunner.runPostRequest).toHaveBeenCalled();
94
169
  expect(mockEventEmitter.emit).toHaveBeenCalledWith(
95
170
  DismissibleEvents.ITEM_DISMISSED,
96
171
  expect.anything(),
@@ -105,14 +180,17 @@ describe('DismissibleService', () => {
105
180
  const previousItem = createTestItem({ id: 'test-item' });
106
181
  const context = createTestContext();
107
182
 
183
+ mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('test-item'));
108
184
  mockHookRunner.runPreRestore.mockResolvedValue(createHookResult('test-item'));
109
185
  mockCoreService.restore.mockResolvedValue({ item, previousItem });
110
186
 
111
187
  const result = await service.restore('test-item', testUserId, context);
112
188
 
189
+ expect(mockHookRunner.runPreRequest).toHaveBeenCalled();
113
190
  expect(mockHookRunner.runPreRestore).toHaveBeenCalled();
114
191
  expect(mockCoreService.restore).toHaveBeenCalledWith('test-item', testUserId);
115
192
  expect(mockHookRunner.runPostRestore).toHaveBeenCalled();
193
+ expect(mockHookRunner.runPostRequest).toHaveBeenCalled();
116
194
  expect(mockEventEmitter.emit).toHaveBeenCalledWith(
117
195
  DismissibleEvents.ITEM_RESTORED,
118
196
  expect.anything(),
@@ -126,8 +204,9 @@ describe('DismissibleService', () => {
126
204
  const item = createTestItem({ id: 'test-item' });
127
205
  const context = createTestContext();
128
206
 
129
- mockHookRunner.runPreGetOrCreate.mockResolvedValue(createHookResult('test-item'));
130
- mockCoreService.getOrCreate.mockResolvedValue({ item, created: false });
207
+ mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('test-item'));
208
+ mockHookRunner.runPreGet.mockResolvedValue(createHookResult('test-item'));
209
+ mockCoreService.get.mockResolvedValue(item);
131
210
 
132
211
  await service.getOrCreate('test-item', testUserId, undefined, context);
133
212
 
@@ -48,54 +48,81 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
48
48
  ): Promise<IGetOrCreateServiceResponse<TMetadata>> {
49
49
  this.logger.debug(`getOrCreate called`, { itemId, userId });
50
50
 
51
- // Run pre-getOrCreate hooks
52
- const preResult = await this.hookRunner.runPreGetOrCreate(itemId, userId, context);
51
+ // Run global pre-request hooks (auth, rate limiting, validation)
52
+ const preResult = await this.hookRunner.runPreRequest(itemId, userId, context);
53
53
  HookRunner.throwIfBlocked(preResult);
54
54
 
55
55
  const resolvedId = preResult.id;
56
56
  const resolvedUserId = preResult.userId;
57
57
  const resolvedContext = preResult.context;
58
58
 
59
- // Check if we need to run create hooks (for potential new item)
60
- const result = await this.coreService.getOrCreate(resolvedId, resolvedUserId, options);
59
+ // Check if item already exists
60
+ const existingItem = await this.coreService.get(resolvedId, resolvedUserId);
61
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(
62
+ if (existingItem) {
63
+ // Item exists - run pre-get hooks (access control based on item state)
64
+ const preGetResult = await this.hookRunner.runPreGet(
66
65
  resolvedId,
66
+ existingItem,
67
67
  resolvedUserId,
68
68
  resolvedContext,
69
69
  );
70
- HookRunner.throwIfBlocked(preCreateResult);
70
+ HookRunner.throwIfBlocked(preGetResult);
71
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)
72
+ // Emit retrieved event
82
73
  this.eventEmitter.emit(
83
74
  DismissibleEvents.ITEM_RETRIEVED,
84
- new ItemRetrievedEvent(resolvedId, result.item, resolvedUserId, resolvedContext),
75
+ new ItemRetrievedEvent(resolvedId, existingItem, resolvedUserId, resolvedContext),
85
76
  );
77
+
78
+ // Run post-get hooks
79
+ await this.hookRunner.runPostGet(resolvedId, existingItem, resolvedUserId, resolvedContext);
80
+
81
+ // Run global post-request hooks
82
+ await this.hookRunner.runPostRequest(
83
+ resolvedId,
84
+ existingItem,
85
+ resolvedUserId,
86
+ resolvedContext,
87
+ );
88
+
89
+ this.logger.debug(`getOrCreate completed`, { itemId, created: false });
90
+
91
+ return {
92
+ item: existingItem,
93
+ created: false,
94
+ };
86
95
  }
87
96
 
88
- // Run post-getOrCreate hooks
89
- await this.hookRunner.runPostGetOrCreate(
97
+ // Item doesn't exist - run pre-create hooks BEFORE creating
98
+ const preCreateResult = await this.hookRunner.runPreCreate(
90
99
  resolvedId,
91
- result.item,
92
100
  resolvedUserId,
93
101
  resolvedContext,
94
102
  );
103
+ HookRunner.throwIfBlocked(preCreateResult);
95
104
 
96
- this.logger.debug(`getOrCreate completed`, { itemId, created: result.created });
105
+ // Now create the item
106
+ const createdItem = await this.coreService.create(resolvedId, resolvedUserId, options);
97
107
 
98
- return result;
108
+ // Run post-create hooks
109
+ await this.hookRunner.runPostCreate(resolvedId, createdItem, resolvedUserId, resolvedContext);
110
+
111
+ // Emit created event
112
+ this.eventEmitter.emit(
113
+ DismissibleEvents.ITEM_CREATED,
114
+ new ItemCreatedEvent(resolvedId, createdItem, resolvedUserId, resolvedContext),
115
+ );
116
+
117
+ // Run global post-request hooks
118
+ await this.hookRunner.runPostRequest(resolvedId, createdItem, resolvedUserId, resolvedContext);
119
+
120
+ this.logger.debug(`getOrCreate completed`, { itemId, created: true });
121
+
122
+ return {
123
+ item: createdItem,
124
+ created: true,
125
+ };
99
126
  }
100
127
 
101
128
  /**
@@ -111,13 +138,21 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
111
138
  ): Promise<IDismissServiceResponse<TMetadata>> {
112
139
  this.logger.debug(`dismiss called`, { itemId, userId });
113
140
 
114
- // Run pre-dismiss hooks
115
- const preResult = await this.hookRunner.runPreDismiss(itemId, userId, context);
116
- HookRunner.throwIfBlocked(preResult);
141
+ // Run global pre-request hooks (auth, rate limiting, validation)
142
+ const preRequestResult = await this.hookRunner.runPreRequest(itemId, userId, context);
143
+ HookRunner.throwIfBlocked(preRequestResult);
117
144
 
118
- const resolvedId = preResult.id;
119
- const resolvedUserId = preResult.userId;
120
- const resolvedContext = preResult.context;
145
+ const resolvedId = preRequestResult.id;
146
+ const resolvedUserId = preRequestResult.userId;
147
+ const resolvedContext = preRequestResult.context;
148
+
149
+ // Run pre-dismiss hooks
150
+ const preDismissResult = await this.hookRunner.runPreDismiss(
151
+ resolvedId,
152
+ resolvedUserId,
153
+ resolvedContext,
154
+ );
155
+ HookRunner.throwIfBlocked(preDismissResult);
121
156
 
122
157
  // Execute core dismiss operation
123
158
  const result = await this.coreService.dismiss(resolvedId, resolvedUserId);
@@ -125,7 +160,7 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
125
160
  // Run post-dismiss hooks
126
161
  await this.hookRunner.runPostDismiss(resolvedId, result.item, resolvedUserId, resolvedContext);
127
162
 
128
- // Emit dismissed event (async)
163
+ // Emit dismissed event
129
164
  this.eventEmitter.emit(
130
165
  DismissibleEvents.ITEM_DISMISSED,
131
166
  new ItemDismissedEvent(
@@ -137,6 +172,9 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
137
172
  ),
138
173
  );
139
174
 
175
+ // Run global post-request hooks
176
+ await this.hookRunner.runPostRequest(resolvedId, result.item, resolvedUserId, resolvedContext);
177
+
140
178
  this.logger.debug(`dismiss completed`, { itemId });
141
179
 
142
180
  return result;
@@ -155,13 +193,21 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
155
193
  ): Promise<IRestoreServiceResponse<TMetadata>> {
156
194
  this.logger.debug(`restore called`, { itemId, userId });
157
195
 
158
- // Run pre-restore hooks
159
- const preResult = await this.hookRunner.runPreRestore(itemId, userId, context);
160
- HookRunner.throwIfBlocked(preResult);
196
+ // Run global pre-request hooks (auth, rate limiting, validation)
197
+ const preRequestResult = await this.hookRunner.runPreRequest(itemId, userId, context);
198
+ HookRunner.throwIfBlocked(preRequestResult);
161
199
 
162
- const resolvedId = preResult.id;
163
- const resolvedUserId = preResult.userId;
164
- const resolvedContext = preResult.context;
200
+ const resolvedId = preRequestResult.id;
201
+ const resolvedUserId = preRequestResult.userId;
202
+ const resolvedContext = preRequestResult.context;
203
+
204
+ // Run pre-restore hooks
205
+ const preRestoreResult = await this.hookRunner.runPreRestore(
206
+ resolvedId,
207
+ resolvedUserId,
208
+ resolvedContext,
209
+ );
210
+ HookRunner.throwIfBlocked(preRestoreResult);
165
211
 
166
212
  // Execute core restore operation
167
213
  const result = await this.coreService.restore(resolvedId, resolvedUserId);
@@ -169,7 +215,7 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
169
215
  // Run post-restore hooks
170
216
  await this.hookRunner.runPostRestore(resolvedId, result.item, resolvedUserId, resolvedContext);
171
217
 
172
- // Emit restored event (async)
218
+ // Emit restored event
173
219
  this.eventEmitter.emit(
174
220
  DismissibleEvents.ITEM_RESTORED,
175
221
  new ItemRestoredEvent(
@@ -181,6 +227,9 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
181
227
  ),
182
228
  );
183
229
 
230
+ // Run global post-request hooks
231
+ await this.hookRunner.runPostRequest(resolvedId, result.item, resolvedUserId, resolvedContext);
232
+
184
233
  this.logger.debug(`restore completed`, { itemId });
185
234
 
186
235
  return result;
@@ -21,9 +21,9 @@ describe('HookRunner', () => {
21
21
  hookRunner = new HookRunner([], mockLogger);
22
22
  });
23
23
 
24
- it('should return proceed: true for pre-getOrCreate', async () => {
24
+ it('should return proceed: true for pre-request', async () => {
25
25
  const context = createTestContext();
26
- const result = await hookRunner.runPreGetOrCreate('test-id', testUserId, context);
26
+ const result = await hookRunner.runPreRequest('test-id', testUserId, context);
27
27
 
28
28
  expect(result.proceed).toBe(true);
29
29
  expect(result.id).toBe('test-id');
@@ -31,12 +31,12 @@ describe('HookRunner', () => {
31
31
  expect(result.context).toEqual(context);
32
32
  });
33
33
 
34
- it('should complete post-getOrCreate without error', async () => {
34
+ it('should complete post-request without error', async () => {
35
35
  const item = createTestItem();
36
36
  const context = createTestContext();
37
37
 
38
38
  await expect(
39
- hookRunner.runPostGetOrCreate('test-id', item, testUserId, context),
39
+ hookRunner.runPostRequest('test-id', item, testUserId, context),
40
40
  ).resolves.not.toThrow();
41
41
  });
42
42
  });
@@ -47,7 +47,7 @@ describe('HookRunner', () => {
47
47
 
48
48
  const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
49
49
  priority: 10,
50
- onBeforeGetOrCreate: jest.fn(async () => {
50
+ onBeforeRequest: jest.fn(async () => {
51
51
  executionOrder.push(10);
52
52
  return { proceed: true };
53
53
  }),
@@ -55,7 +55,7 @@ describe('HookRunner', () => {
55
55
 
56
56
  const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
57
57
  priority: 5,
58
- onBeforeGetOrCreate: jest.fn(async () => {
58
+ onBeforeRequest: jest.fn(async () => {
59
59
  executionOrder.push(5);
60
60
  return { proceed: true };
61
61
  }),
@@ -63,14 +63,14 @@ describe('HookRunner', () => {
63
63
 
64
64
  const hook3: IDismissibleLifecycleHook<BaseMetadata> = {
65
65
  priority: 15,
66
- onBeforeGetOrCreate: jest.fn(async () => {
66
+ onBeforeRequest: jest.fn(async () => {
67
67
  executionOrder.push(15);
68
68
  return { proceed: true };
69
69
  }),
70
70
  };
71
71
 
72
72
  hookRunner = new HookRunner([hook1, hook2, hook3], mockLogger);
73
- await hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext());
73
+ await hookRunner.runPreRequest('test-id', testUserId, createTestContext());
74
74
 
75
75
  expect(executionOrder).toEqual([5, 10, 15]);
76
76
  });
@@ -80,46 +80,41 @@ describe('HookRunner', () => {
80
80
 
81
81
  const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
82
82
  priority: 10,
83
- onAfterGetOrCreate: jest.fn(async () => {
83
+ onAfterRequest: jest.fn(async () => {
84
84
  executionOrder.push(10);
85
85
  }),
86
86
  };
87
87
 
88
88
  const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
89
89
  priority: 5,
90
- onAfterGetOrCreate: jest.fn(async () => {
90
+ onAfterRequest: jest.fn(async () => {
91
91
  executionOrder.push(5);
92
92
  }),
93
93
  };
94
94
 
95
95
  const hook3: IDismissibleLifecycleHook<BaseMetadata> = {
96
96
  priority: 15,
97
- onAfterGetOrCreate: jest.fn(async () => {
97
+ onAfterRequest: jest.fn(async () => {
98
98
  executionOrder.push(15);
99
99
  }),
100
100
  };
101
101
 
102
102
  hookRunner = new HookRunner([hook1, hook2, hook3], mockLogger);
103
- await hookRunner.runPostGetOrCreate(
104
- 'test-id',
105
- createTestItem(),
106
- testUserId,
107
- createTestContext(),
108
- );
103
+ await hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext());
109
104
 
110
105
  expect(executionOrder).toEqual([15, 10, 5]);
111
106
  });
112
107
 
113
108
  it('should block operation when pre-hook returns proceed: false', async () => {
114
109
  const blockingHook: IDismissibleLifecycleHook<BaseMetadata> = {
115
- onBeforeGetOrCreate: jest.fn(async () => ({
110
+ onBeforeRequest: jest.fn(async () => ({
116
111
  proceed: false,
117
112
  reason: 'Rate limit exceeded',
118
113
  })),
119
114
  };
120
115
 
121
116
  hookRunner = new HookRunner([blockingHook], mockLogger);
122
- const result = await hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext());
117
+ const result = await hookRunner.runPreRequest('test-id', testUserId, createTestContext());
123
118
 
124
119
  expect(result.proceed).toBe(false);
125
120
  expect(result.reason).toBe('Rate limit exceeded');
@@ -127,7 +122,7 @@ describe('HookRunner', () => {
127
122
 
128
123
  it('should apply mutations from pre-hooks', async () => {
129
124
  const mutatingHook: IDismissibleLifecycleHook<BaseMetadata> = {
130
- onBeforeGetOrCreate: jest.fn(async () => ({
125
+ onBeforeRequest: jest.fn(async () => ({
131
126
  proceed: true,
132
127
  mutations: {
133
128
  id: 'mutated-id',
@@ -137,11 +132,7 @@ describe('HookRunner', () => {
137
132
  };
138
133
 
139
134
  hookRunner = new HookRunner([mutatingHook], mockLogger);
140
- const result = await hookRunner.runPreGetOrCreate(
141
- 'original-id',
142
- testUserId,
143
- createTestContext(),
144
- );
135
+ const result = await hookRunner.runPreRequest('original-id', testUserId, createTestContext());
145
136
 
146
137
  expect(result.id).toBe('mutated-id');
147
138
  expect(result.userId).toBe('mutated-user');
@@ -150,7 +141,7 @@ describe('HookRunner', () => {
150
141
  it('should pass mutations through multiple hooks', async () => {
151
142
  const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
152
143
  priority: 1,
153
- onBeforeGetOrCreate: jest.fn(async (itemId) => ({
144
+ onBeforeRequest: jest.fn(async (itemId) => ({
154
145
  proceed: true,
155
146
  mutations: { id: `${itemId}-hook1` },
156
147
  })),
@@ -158,18 +149,14 @@ describe('HookRunner', () => {
158
149
 
159
150
  const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
160
151
  priority: 2,
161
- onBeforeGetOrCreate: jest.fn(async (itemId) => ({
152
+ onBeforeRequest: jest.fn(async (itemId) => ({
162
153
  proceed: true,
163
154
  mutations: { id: `${itemId}-hook2` },
164
155
  })),
165
156
  };
166
157
 
167
158
  hookRunner = new HookRunner([hook1, hook2], mockLogger);
168
- const result = await hookRunner.runPreGetOrCreate(
169
- 'original',
170
- testUserId,
171
- createTestContext(),
172
- );
159
+ const result = await hookRunner.runPreRequest('original', testUserId, createTestContext());
173
160
 
174
161
  expect(result.id).toBe('original-hook1-hook2');
175
162
  });
@@ -178,7 +165,7 @@ describe('HookRunner', () => {
178
165
  describe('error handling', () => {
179
166
  it('should throw error from pre-hook', async () => {
180
167
  const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
181
- onBeforeGetOrCreate: jest.fn(async () => {
168
+ onBeforeRequest: jest.fn(async () => {
182
169
  throw new Error('Hook error');
183
170
  }),
184
171
  };
@@ -186,7 +173,7 @@ describe('HookRunner', () => {
186
173
  hookRunner = new HookRunner([errorHook], mockLogger);
187
174
 
188
175
  await expect(
189
- hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext()),
176
+ hookRunner.runPreRequest('test-id', testUserId, createTestContext()),
190
177
  ).rejects.toThrow('Hook error');
191
178
 
192
179
  expect(mockLogger.error).toHaveBeenCalled();
@@ -194,7 +181,7 @@ describe('HookRunner', () => {
194
181
 
195
182
  it('should log but not throw errors from post-hooks', async () => {
196
183
  const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
197
- onAfterGetOrCreate: jest.fn(async () => {
184
+ onAfterRequest: jest.fn(async () => {
198
185
  throw new Error('Post-hook error');
199
186
  }),
200
187
  };
@@ -202,7 +189,7 @@ describe('HookRunner', () => {
202
189
  hookRunner = new HookRunner([errorHook], mockLogger);
203
190
 
204
191
  await expect(
205
- hookRunner.runPostGetOrCreate('test-id', createTestItem(), testUserId, createTestContext()),
192
+ hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext()),
206
193
  ).resolves.not.toThrow();
207
194
 
208
195
  expect(mockLogger.error).toHaveBeenCalled();
@@ -234,14 +221,83 @@ describe('HookRunner', () => {
234
221
  });
235
222
  });
236
223
 
224
+ describe('runPreGet and runPostGet', () => {
225
+ it('should pass item to onBeforeGet hook', async () => {
226
+ const item = createTestItem();
227
+ const context = createTestContext();
228
+
229
+ const hook: IDismissibleLifecycleHook<BaseMetadata> = {
230
+ onBeforeGet: jest.fn().mockResolvedValue({ proceed: true }),
231
+ };
232
+
233
+ hookRunner = new HookRunner([hook], mockLogger);
234
+ await hookRunner.runPreGet('test-id', item, testUserId, context);
235
+
236
+ expect(hook.onBeforeGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
237
+ });
238
+
239
+ it('should block operation when onBeforeGet returns proceed: false', async () => {
240
+ const item = createTestItem();
241
+ const context = createTestContext();
242
+
243
+ const blockingHook: IDismissibleLifecycleHook<BaseMetadata> = {
244
+ onBeforeGet: jest.fn().mockResolvedValue({
245
+ proceed: false,
246
+ reason: 'Item is in invalid state',
247
+ }),
248
+ };
249
+
250
+ hookRunner = new HookRunner([blockingHook], mockLogger);
251
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, context);
252
+
253
+ expect(result.proceed).toBe(false);
254
+ expect(result.reason).toBe('Item is in invalid state');
255
+ });
256
+
257
+ it('should run onAfterGet hook', async () => {
258
+ const item = createTestItem();
259
+ const context = createTestContext();
260
+
261
+ const hook: IDismissibleLifecycleHook<BaseMetadata> = {
262
+ onAfterGet: jest.fn(),
263
+ };
264
+
265
+ hookRunner = new HookRunner([hook], mockLogger);
266
+ await hookRunner.runPostGet('test-id', item, testUserId, context);
267
+
268
+ expect(hook.onAfterGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
269
+ });
270
+
271
+ it('should apply mutations from onBeforeGet hook', async () => {
272
+ const item = createTestItem();
273
+ const context = createTestContext();
274
+
275
+ const mutatingHook: IDismissibleLifecycleHook<BaseMetadata> = {
276
+ onBeforeGet: jest.fn().mockResolvedValue({
277
+ proceed: true,
278
+ mutations: {
279
+ id: 'mutated-id',
280
+ },
281
+ }),
282
+ };
283
+
284
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
285
+ const result = await hookRunner.runPreGet('original-id', item, testUserId, context);
286
+
287
+ expect(result.id).toBe('mutated-id');
288
+ });
289
+ });
290
+
237
291
  describe('all hook methods', () => {
238
292
  let allMethodsHook: IDismissibleLifecycleHook<BaseMetadata>;
239
293
 
240
294
  beforeEach(() => {
241
295
  allMethodsHook = {
242
296
  priority: 0,
243
- onBeforeGetOrCreate: jest.fn().mockResolvedValue({ proceed: true }),
244
- onAfterGetOrCreate: jest.fn(),
297
+ onBeforeRequest: jest.fn().mockResolvedValue({ proceed: true }),
298
+ onAfterRequest: jest.fn(),
299
+ onBeforeGet: jest.fn().mockResolvedValue({ proceed: true }),
300
+ onAfterGet: jest.fn(),
245
301
  onBeforeCreate: jest.fn().mockResolvedValue({ proceed: true }),
246
302
  onAfterCreate: jest.fn(),
247
303
  onBeforeDismiss: jest.fn().mockResolvedValue({ proceed: true }),
@@ -253,6 +309,33 @@ describe('HookRunner', () => {
253
309
  hookRunner = new HookRunner([allMethodsHook], mockLogger);
254
310
  });
255
311
 
312
+ it('should run pre and post request hooks', async () => {
313
+ const context = createTestContext();
314
+ const item = createTestItem();
315
+
316
+ await hookRunner.runPreRequest('test-id', testUserId, context);
317
+ await hookRunner.runPostRequest('test-id', item, testUserId, context);
318
+
319
+ expect(allMethodsHook.onBeforeRequest).toHaveBeenCalledWith('test-id', testUserId, context);
320
+ expect(allMethodsHook.onAfterRequest).toHaveBeenCalledWith(
321
+ 'test-id',
322
+ item,
323
+ testUserId,
324
+ context,
325
+ );
326
+ });
327
+
328
+ it('should run pre and post get hooks', async () => {
329
+ const context = createTestContext();
330
+ const item = createTestItem();
331
+
332
+ await hookRunner.runPreGet('test-id', item, testUserId, context);
333
+ await hookRunner.runPostGet('test-id', item, testUserId, context);
334
+
335
+ expect(allMethodsHook.onBeforeGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
336
+ expect(allMethodsHook.onAfterGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
337
+ });
338
+
256
339
  it('should run pre and post create hooks', async () => {
257
340
  const context = createTestContext();
258
341
  const item = createTestItem();
@@ -46,29 +46,68 @@ export class HookRunner<TMetadata extends BaseMetadata = BaseMetadata> {
46
46
  this.sortedHooks = [...hooks].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
47
47
  }
48
48
 
49
+ // ─────────────────────────────────────────────────────────────────
50
+ // Global Request Hooks
51
+ // ─────────────────────────────────────────────────────────────────
52
+
49
53
  /**
50
- * Run pre-getOrCreate hooks.
54
+ * Run pre-request hooks (global - runs at start of any operation).
55
+ * Use for authentication, rate limiting, request validation.
51
56
  */
52
- async runPreGetOrCreate(
57
+ async runPreRequest(
53
58
  itemId: string,
54
59
  userId: string,
55
60
  context?: IRequestContext,
56
61
  ): Promise<IHookRunResult> {
57
- return this.runPreHooks('onBeforeGetOrCreate', itemId, userId, context);
62
+ return this.runPreHooks('onBeforeRequest', itemId, userId, context);
58
63
  }
59
64
 
60
65
  /**
61
- * Run post-getOrCreate hooks.
66
+ * Run post-request hooks (global - runs at end of any operation).
67
+ * Use for audit logging, metrics, cleanup.
62
68
  */
63
- async runPostGetOrCreate(
69
+ async runPostRequest(
64
70
  itemId: string,
65
71
  item: DismissibleItemDto<TMetadata>,
66
72
  userId: string,
67
73
  context?: IRequestContext,
68
74
  ): Promise<void> {
69
- await this.runPostHooks('onAfterGetOrCreate', itemId, item, userId, context);
75
+ await this.runPostHooks('onAfterRequest', itemId, item, userId, context);
70
76
  }
71
77
 
78
+ // ─────────────────────────────────────────────────────────────────
79
+ // Get Hooks
80
+ // ─────────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Run pre-get hooks (when item exists and is about to be returned).
84
+ * Receives the item for access control based on item state.
85
+ */
86
+ async runPreGet(
87
+ itemId: string,
88
+ item: DismissibleItemDto<TMetadata>,
89
+ userId: string,
90
+ context?: IRequestContext,
91
+ ): Promise<IHookRunResult> {
92
+ return this.runPreHooksWithItem('onBeforeGet', itemId, item, userId, context);
93
+ }
94
+
95
+ /**
96
+ * Run post-get hooks (after item is returned).
97
+ */
98
+ async runPostGet(
99
+ itemId: string,
100
+ item: DismissibleItemDto<TMetadata>,
101
+ userId: string,
102
+ context?: IRequestContext,
103
+ ): Promise<void> {
104
+ await this.runPostHooks('onAfterGet', itemId, item, userId, context);
105
+ }
106
+
107
+ // ─────────────────────────────────────────────────────────────────
108
+ // Create Hooks
109
+ // ─────────────────────────────────────────────────────────────────
110
+
72
111
  /**
73
112
  * Run pre-create hooks.
74
113
  */
@@ -92,6 +131,10 @@ export class HookRunner<TMetadata extends BaseMetadata = BaseMetadata> {
92
131
  await this.runPostHooks('onAfterCreate', itemId, item, userId, context);
93
132
  }
94
133
 
134
+ // ─────────────────────────────────────────────────────────────────
135
+ // Dismiss Hooks
136
+ // ─────────────────────────────────────────────────────────────────
137
+
95
138
  /**
96
139
  * Run pre-dismiss hooks.
97
140
  */
@@ -115,6 +158,10 @@ export class HookRunner<TMetadata extends BaseMetadata = BaseMetadata> {
115
158
  await this.runPostHooks('onAfterDismiss', itemId, item, userId, context);
116
159
  }
117
160
 
161
+ // ─────────────────────────────────────────────────────────────────
162
+ // Restore Hooks
163
+ // ─────────────────────────────────────────────────────────────────
164
+
118
165
  /**
119
166
  * Run pre-restore hooks.
120
167
  */
@@ -214,6 +261,85 @@ export class HookRunner<TMetadata extends BaseMetadata = BaseMetadata> {
214
261
  };
215
262
  }
216
263
 
264
+ /**
265
+ * Internal method to run pre-hooks that receive the item (e.g., onBeforeGet).
266
+ * Unlike standard pre-hooks, these receive the item for inspection/access control.
267
+ */
268
+ private async runPreHooksWithItem(
269
+ hookName: keyof IDismissibleLifecycleHook<TMetadata>,
270
+ itemId: string,
271
+ item: DismissibleItemDto<TMetadata>,
272
+ userId: string,
273
+ context?: IRequestContext,
274
+ ): Promise<IHookRunResult> {
275
+ let currentId = itemId;
276
+ let currentUserId = userId;
277
+ let currentContext = context ? { ...context } : undefined;
278
+
279
+ for (const hook of this.sortedHooks) {
280
+ const hookFn = hook[hookName] as
281
+ | ((
282
+ itemId: string,
283
+ item: DismissibleItemDto<TMetadata>,
284
+ userId: string,
285
+ context?: IRequestContext,
286
+ ) => Promise<IHookResult> | IHookResult)
287
+ | undefined;
288
+
289
+ if (hookFn) {
290
+ try {
291
+ const result = await hookFn.call(hook, currentId, item, currentUserId, currentContext);
292
+
293
+ if (!result.proceed) {
294
+ this.logger.debug(`Hook ${hook.constructor.name}.${hookName} blocked operation`, {
295
+ itemId: currentId,
296
+ userId: currentUserId,
297
+ reason: result.reason,
298
+ });
299
+
300
+ return {
301
+ proceed: false,
302
+ id: currentId,
303
+ userId: currentUserId,
304
+ context: currentContext,
305
+ reason: result.reason,
306
+ };
307
+ }
308
+
309
+ // Apply mutations if present
310
+ if (result.mutations) {
311
+ if (result.mutations.id !== undefined) {
312
+ currentId = result.mutations.id;
313
+ }
314
+ if (result.mutations.userId !== undefined) {
315
+ currentUserId = result.mutations.userId;
316
+ }
317
+ if (result.mutations.context && currentContext) {
318
+ currentContext = { ...currentContext, ...result.mutations.context };
319
+ }
320
+ }
321
+ } catch (error) {
322
+ this.logger.error(
323
+ `Error in hook ${hook.constructor.name}.${hookName}`,
324
+ error instanceof Error ? error : new Error(String(error)),
325
+ {
326
+ itemId: currentId,
327
+ userId: currentUserId,
328
+ },
329
+ );
330
+ throw error;
331
+ }
332
+ }
333
+ }
334
+
335
+ return {
336
+ proceed: true,
337
+ id: currentId,
338
+ userId: currentUserId,
339
+ context: currentContext,
340
+ };
341
+ }
342
+
217
343
  /**
218
344
  * Internal method to run post-hooks.
219
345
  * Post-hooks run in reverse priority order.
@@ -44,27 +44,65 @@ export interface IDismissibleLifecycleHook<TMetadata extends BaseMetadata = Base
44
44
  */
45
45
  readonly priority?: number;
46
46
 
47
+ // ─────────────────────────────────────────────────────────────────
48
+ // Global Request Hooks (run on ALL operations)
49
+ // ─────────────────────────────────────────────────────────────────
50
+
47
51
  /**
48
- * Called before getOrCreate operation.
52
+ * Called at the start of any operation (getOrCreate, dismiss, restore).
53
+ * Use for global concerns like authentication, rate limiting, request validation.
49
54
  */
50
- onBeforeGetOrCreate?(
55
+ onBeforeRequest?(
51
56
  itemId: string,
52
57
  userId: string,
53
58
  context?: IRequestContext,
54
59
  ): Promise<IHookResult> | IHookResult;
55
60
 
56
61
  /**
57
- * Called after getOrCreate operation.
62
+ * Called at the end of any operation (getOrCreate, dismiss, restore).
63
+ * Use for global concerns like audit logging, metrics, cleanup.
58
64
  */
59
- onAfterGetOrCreate?(
65
+ onAfterRequest?(
60
66
  itemId: string,
61
67
  item: DismissibleItemDto<TMetadata>,
62
68
  userId: string,
63
69
  context?: IRequestContext,
64
70
  ): Promise<void> | void;
65
71
 
72
+ // ─────────────────────────────────────────────────────────────────
73
+ // Get Hooks (when retrieving existing item)
74
+ // ─────────────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Called before returning an existing item.
78
+ * Only called when item exists in storage.
79
+ * Use for access control based on item state (e.g., block dismissed items).
80
+ */
81
+ onBeforeGet?(
82
+ itemId: string,
83
+ item: DismissibleItemDto<TMetadata>,
84
+ userId: string,
85
+ context?: IRequestContext,
86
+ ): Promise<IHookResult> | IHookResult;
87
+
88
+ /**
89
+ * Called after returning an existing item.
90
+ * Only called when item exists in storage.
91
+ */
92
+ onAfterGet?(
93
+ itemId: string,
94
+ item: DismissibleItemDto<TMetadata>,
95
+ userId: string,
96
+ context?: IRequestContext,
97
+ ): Promise<void> | void;
98
+
99
+ // ─────────────────────────────────────────────────────────────────
100
+ // Create Hooks (when creating new item)
101
+ // ─────────────────────────────────────────────────────────────────
102
+
66
103
  /**
67
104
  * Called before creating a new item.
105
+ * Use for plan limits, quota checks, etc.
68
106
  */
69
107
  onBeforeCreate?(
70
108
  itemId: string,
@@ -82,6 +120,10 @@ export interface IDismissibleLifecycleHook<TMetadata extends BaseMetadata = Base
82
120
  context?: IRequestContext,
83
121
  ): Promise<void> | void;
84
122
 
123
+ // ─────────────────────────────────────────────────────────────────
124
+ // Dismiss Hooks
125
+ // ─────────────────────────────────────────────────────────────────
126
+
85
127
  /**
86
128
  * Called before dismissing an item.
87
129
  */
@@ -101,6 +143,10 @@ export interface IDismissibleLifecycleHook<TMetadata extends BaseMetadata = Base
101
143
  context?: IRequestContext,
102
144
  ): Promise<void> | void;
103
145
 
146
+ // ─────────────────────────────────────────────────────────────────
147
+ // Restore Hooks
148
+ // ─────────────────────────────────────────────────────────────────
149
+
104
150
  /**
105
151
  * Called before restoring an item.
106
152
  */
@@ -1,12 +1,12 @@
1
1
  import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
2
- import { Response } from 'express';
2
+ import { FastifyReply } from 'fastify';
3
3
  import { IErrorResponseDto } from './dtos';
4
4
 
5
5
  @Catch(HttpException)
6
6
  export class HttpExceptionFilter implements ExceptionFilter {
7
7
  catch(exception: HttpException, host: ArgumentsHost) {
8
8
  const ctx = host.switchToHttp();
9
- const response = ctx.getResponse<Response>();
9
+ const response = ctx.getResponse<FastifyReply>();
10
10
  const status = exception.getStatus();
11
11
 
12
12
  const errorResponse: IErrorResponseDto = {
@@ -16,6 +16,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
16
16
  },
17
17
  };
18
18
 
19
- response.status(status).json(errorResponse);
19
+ response.status(status).send(errorResponse);
20
20
  }
21
21
  }