@xbg.solutions/create-backend 1.0.1 → 1.0.2
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/lib/commands/init.js +55 -12
- package/lib/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/src/project-template/.claude/settings.local.json +57 -0
- package/src/project-template/.claude/skills/bpbe/api/skill.md +403 -0
- package/src/project-template/.claude/skills/bpbe/data/skill.md +442 -0
- package/src/project-template/.claude/skills/bpbe/services/skill.md +497 -0
- package/src/project-template/.claude/skills/bpbe/setup/skill.md +301 -0
- package/src/project-template/.claude/skills/bpbe/skill.md +153 -0
- package/src/project-template/.claude/skills/bpbe/utils/skill.md +527 -0
- package/src/project-template/.claude/skills/skill.md +30 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Services layer for the XBG boilerplate backend: implementing BaseService, lifecycle hooks, access control, event bus publishing and subscribing, and auth patterns."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# XBG Boilerplate Backend — Services Layer
|
|
6
|
+
|
|
7
|
+
Covers: `BaseService`, lifecycle hooks, access control, events (`eventBus`, `EventType`), and auth middleware patterns.
|
|
8
|
+
|
|
9
|
+
All base classes are imported from `@xbg.solutions/backend-core`. Events from `@xbg.solutions/utils-events`.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## BaseService — Business Logic Layer
|
|
14
|
+
|
|
15
|
+
**Package:** `@xbg.solutions/backend-core`
|
|
16
|
+
|
|
17
|
+
Services sit between controllers and repositories. They own:
|
|
18
|
+
- Business validation (beyond field-level validation)
|
|
19
|
+
- Authorization / access control
|
|
20
|
+
- Pre/post operation hooks
|
|
21
|
+
- Event publishing
|
|
22
|
+
- Orchestration across multiple repositories
|
|
23
|
+
|
|
24
|
+
### Implementing a Service
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { BaseService, RequestContext, ServiceResult } from '@xbg.solutions/backend-core';
|
|
28
|
+
import { Product } from '../entities/Product';
|
|
29
|
+
import { ProductRepository } from '../repositories/ProductRepository';
|
|
30
|
+
import { eventBus, EventType } from '@xbg.solutions/utils-events';
|
|
31
|
+
|
|
32
|
+
export class ProductService extends BaseService<Product> {
|
|
33
|
+
protected entityName = 'Product';
|
|
34
|
+
|
|
35
|
+
constructor(private productRepo: ProductRepository) {
|
|
36
|
+
super(productRepo);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// REQUIRED: build a new entity instance from raw data
|
|
40
|
+
protected async buildEntity(data: Partial<Product>): Promise<Product> {
|
|
41
|
+
return new Product({
|
|
42
|
+
name: data.name ?? '',
|
|
43
|
+
price: data.price ?? 0,
|
|
44
|
+
status: data.status ?? 'active',
|
|
45
|
+
categoryId: data.categoryId ?? '',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// REQUIRED: apply partial updates to an existing entity
|
|
50
|
+
protected async mergeEntity(existing: Product, updates: Partial<Product>): Promise<Product> {
|
|
51
|
+
if (updates.name !== undefined) existing.name = updates.name;
|
|
52
|
+
if (updates.price !== undefined) existing.price = updates.price;
|
|
53
|
+
if (updates.status !== undefined) existing.status = updates.status;
|
|
54
|
+
if (updates.categoryId !== undefined) existing.categoryId = updates.categoryId;
|
|
55
|
+
return existing;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Core Methods (from BaseService — don't re-implement unless overriding)
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
await service.create(data, context); // validates, creates, fires afterCreate, publishes event
|
|
64
|
+
await service.findById(id, context); // fetches + checks read access
|
|
65
|
+
await service.findAll(options, context); // applies user filters + fetches
|
|
66
|
+
await service.findPaginated(page, size, opts, context);
|
|
67
|
+
await service.update(id, data, context); // checks update access, merges, saves, publishes event
|
|
68
|
+
await service.delete(id, context, hardDelete?); // checks delete access, deletes, publishes event
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
All return `ServiceResult<T>`:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
interface ServiceResult<T> {
|
|
75
|
+
success: boolean;
|
|
76
|
+
data?: T;
|
|
77
|
+
error?: {
|
|
78
|
+
code: string; // 'NOT_FOUND' | 'FORBIDDEN' | 'VALIDATION_ERROR' | 'INTERNAL_ERROR'
|
|
79
|
+
message: string;
|
|
80
|
+
details?: Record<string, any>;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Lifecycle Hooks
|
|
88
|
+
|
|
89
|
+
Override any hook in your service to add custom logic. All hooks are no-ops in the base class.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
export class ProductService extends BaseService<Product> {
|
|
93
|
+
protected entityName = 'Product';
|
|
94
|
+
|
|
95
|
+
// Called BEFORE create — good for business validation, data enrichment
|
|
96
|
+
protected async beforeCreate(data: Partial<Product>, context: RequestContext): Promise<void> {
|
|
97
|
+
// Example: validate that the category exists
|
|
98
|
+
const category = await this.categoryRepo.findById(data.categoryId!);
|
|
99
|
+
if (!category) {
|
|
100
|
+
throw new Error('Category does not exist');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Called AFTER create — good for side effects, notifications
|
|
105
|
+
protected async afterCreate(entity: Product, context: RequestContext): Promise<void> {
|
|
106
|
+
// Example: update category product count
|
|
107
|
+
await this.categoryRepo.incrementProductCount(entity.categoryId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Called BEFORE update — good for business rule enforcement
|
|
111
|
+
protected async beforeUpdate(
|
|
112
|
+
existing: Product,
|
|
113
|
+
data: Partial<Product>,
|
|
114
|
+
context: RequestContext
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
// Example: prevent price change if product has pending orders
|
|
117
|
+
if (data.price !== undefined && data.price !== existing.price) {
|
|
118
|
+
const pendingOrders = await this.orderRepo.countPendingForProduct(existing.id!);
|
|
119
|
+
if (pendingOrders > 0) {
|
|
120
|
+
throw new Error('Cannot change price while orders are pending');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Called AFTER update
|
|
126
|
+
protected async afterUpdate(entity: Product, context: RequestContext): Promise<void> {
|
|
127
|
+
// Publish price change notification, etc.
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Called BEFORE delete
|
|
131
|
+
protected async beforeDelete(entity: Product, context: RequestContext): Promise<void> {
|
|
132
|
+
// Example: check for dependencies before deletion
|
|
133
|
+
const orderCount = await this.orderRepo.countForProduct(entity.id!);
|
|
134
|
+
if (orderCount > 0) {
|
|
135
|
+
throw new Error('Cannot delete product with existing orders');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Called AFTER delete
|
|
140
|
+
protected async afterDelete(entity: Product, context: RequestContext): Promise<void> {
|
|
141
|
+
// Cleanup: remove product from search index, etc.
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ... buildEntity, mergeEntity
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Hook error handling:** If a hook throws, the operation fails and returns `{ success: false, error: { code: 'INTERNAL_ERROR', message: ... } }`. The exception is logged.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Access Control
|
|
153
|
+
|
|
154
|
+
Override the check methods to implement authorization. Default is open (returns `true`).
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
export class ProductService extends BaseService<Product> {
|
|
158
|
+
protected entityName = 'Product';
|
|
159
|
+
|
|
160
|
+
// Who can read this product?
|
|
161
|
+
protected async checkReadAccess(entity: Product, context: RequestContext): Promise<boolean> {
|
|
162
|
+
// Public products: anyone
|
|
163
|
+
if (entity.status === 'active') return true;
|
|
164
|
+
// Archived products: admin only
|
|
165
|
+
return context.userRole === 'admin';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Who can update this product?
|
|
169
|
+
protected async checkUpdateAccess(entity: Product, context: RequestContext): Promise<boolean> {
|
|
170
|
+
return context.userRole === 'admin';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Who can delete this product?
|
|
174
|
+
protected async checkDeleteAccess(entity: Product, context: RequestContext): Promise<boolean> {
|
|
175
|
+
return context.userRole === 'admin';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Apply query-level filtering (e.g., show only user's own items)
|
|
179
|
+
protected async applyUserFilters(
|
|
180
|
+
options: QueryOptions,
|
|
181
|
+
context: RequestContext
|
|
182
|
+
): Promise<QueryOptions> {
|
|
183
|
+
if (context.userRole !== 'admin') {
|
|
184
|
+
// Non-admins only see active products
|
|
185
|
+
return {
|
|
186
|
+
...options,
|
|
187
|
+
where: [
|
|
188
|
+
...(options.where ?? []),
|
|
189
|
+
{ field: 'status', operator: '==', value: 'active' },
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return options;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### RequestContext
|
|
199
|
+
|
|
200
|
+
Every service method receives a `RequestContext` populated by the controller:
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
interface RequestContext {
|
|
204
|
+
requestId: string; // Correlation ID from X-Request-ID header
|
|
205
|
+
userId?: string; // Firebase Auth UID (undefined if unauthenticated)
|
|
206
|
+
userRole?: string; // Custom claim role (e.g., 'admin', 'user')
|
|
207
|
+
timestamp: Date;
|
|
208
|
+
metadata?: {
|
|
209
|
+
ip?: string;
|
|
210
|
+
userAgent?: string;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Event Bus
|
|
218
|
+
|
|
219
|
+
**Package:** `@xbg.solutions/utils-events`
|
|
220
|
+
|
|
221
|
+
The event bus is a singleton Node.js `EventEmitter`. Services publish events; subscribers react to them.
|
|
222
|
+
|
|
223
|
+
### Publishing Events (in Services)
|
|
224
|
+
|
|
225
|
+
`BaseService.publishEvent()` handles this automatically on create/update/delete. For custom events:
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
import { eventBus, EventType } from '@xbg.solutions/utils-events';
|
|
229
|
+
|
|
230
|
+
// In your service method:
|
|
231
|
+
eventBus.publish(EventType.USER_CREATED, {
|
|
232
|
+
userUID: user.id!,
|
|
233
|
+
email: user.email,
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Adding Event Types
|
|
238
|
+
|
|
239
|
+
Add new events to `@xbg.solutions/utils-events` event types (or extend locally in your project):
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
// In EventType enum:
|
|
243
|
+
PRODUCT_PRICE_CHANGED = 'product.price_changed',
|
|
244
|
+
INVENTORY_LOW = 'inventory.low',
|
|
245
|
+
|
|
246
|
+
// Add payload interface:
|
|
247
|
+
export interface ProductPriceChangedPayload extends BaseEventPayload {
|
|
248
|
+
productUID: string;
|
|
249
|
+
oldPrice: number;
|
|
250
|
+
newPrice: number;
|
|
251
|
+
changedBy: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Add to EventPayloadMap:
|
|
255
|
+
export interface EventPayloadMap {
|
|
256
|
+
[EventType.PRODUCT_PRICE_CHANGED]: ProductPriceChangedPayload;
|
|
257
|
+
// ...
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Subscribing to Events
|
|
262
|
+
|
|
263
|
+
Register subscribers in your project's `src/subscribers/` directory or at app startup:
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
import { eventBus, EventType, ProductPriceChangedPayload } from '@xbg.solutions/utils-events';
|
|
267
|
+
import { emailConnector } from '@xbg.solutions/utils-email-connector';
|
|
268
|
+
|
|
269
|
+
// Register in subscribers/product-subscribers.ts
|
|
270
|
+
export function registerProductSubscribers(): void {
|
|
271
|
+
eventBus.subscribe<ProductPriceChangedPayload>(
|
|
272
|
+
EventType.PRODUCT_PRICE_CHANGED,
|
|
273
|
+
async (payload) => {
|
|
274
|
+
// Send email notification to watchers
|
|
275
|
+
await emailConnector.sendEmail({
|
|
276
|
+
to: 'watcher@example.com',
|
|
277
|
+
subject: `Price drop: ${payload.productUID}`,
|
|
278
|
+
html: `Price changed from ${payload.oldPrice} to ${payload.newPrice}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Call in app.ts or index.ts:
|
|
285
|
+
registerProductSubscribers();
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Important:** The event bus is synchronous. Errors in subscribers are silently caught (by design) so one failing subscriber doesn't block others. For reliable async processing, use Firebase Pub/Sub or a queue.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Authentication Patterns
|
|
293
|
+
|
|
294
|
+
**Package:** `@xbg.solutions/backend-core` (middleware exports)
|
|
295
|
+
|
|
296
|
+
All middleware functions accept a typed `ITokenHandler` — they call `verifyAndUnpack()` (which includes blacklist checking).
|
|
297
|
+
|
|
298
|
+
### Middleware Functions Available
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
import {
|
|
302
|
+
createAuthMiddleware,
|
|
303
|
+
optionalAuth,
|
|
304
|
+
requiredAuth,
|
|
305
|
+
requireRoles,
|
|
306
|
+
requireAdmin,
|
|
307
|
+
requireOwnership,
|
|
308
|
+
requireApiKey,
|
|
309
|
+
} from '@xbg.solutions/backend-core';
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Using in Routes (in Controller)
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
import { tokenHandler } from '@xbg.solutions/utils-token-handler';
|
|
316
|
+
|
|
317
|
+
export class ProductController extends BaseController<Product> {
|
|
318
|
+
protected registerRoutes(): void {
|
|
319
|
+
// Public read
|
|
320
|
+
this.router.get('/', this.handleFindAll.bind(this));
|
|
321
|
+
this.router.get('/:id', this.handleFindById.bind(this));
|
|
322
|
+
|
|
323
|
+
// Require auth for writes
|
|
324
|
+
this.router.post('/', requiredAuth(tokenHandler), this.handleCreate.bind(this));
|
|
325
|
+
|
|
326
|
+
// Admin only (default role: 'admin', configurable per project)
|
|
327
|
+
this.router.put('/:id', requireAdmin(tokenHandler), this.handleUpdate.bind(this));
|
|
328
|
+
this.router.delete('/:id', requireAdmin(tokenHandler), this.handleDelete.bind(this));
|
|
329
|
+
|
|
330
|
+
// Custom admin roles (not hardcoded — pass your project's roles)
|
|
331
|
+
this.router.put('/:id', requireAdmin(tokenHandler, ['admin', 'sysAdmin']), this.handleUpdate.bind(this));
|
|
332
|
+
|
|
333
|
+
// Owner or admin (admin bypass roles also configurable)
|
|
334
|
+
this.router.get(
|
|
335
|
+
'/:id/my-orders',
|
|
336
|
+
requiredAuth(tokenHandler),
|
|
337
|
+
requireOwnership((req) => req.params.id, ['admin', 'sysAdmin']),
|
|
338
|
+
this.handleGetMyOrders.bind(this)
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### RBAC Is Configurable, Not Hardcoded
|
|
345
|
+
|
|
346
|
+
Roles are defined per project through data model specs and custom claims. The auth middleware defaults (`['admin']`) are overridable:
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
// Default: only 'admin' role
|
|
350
|
+
requireAdmin(tokenHandler);
|
|
351
|
+
|
|
352
|
+
// Your project's admin roles
|
|
353
|
+
requireAdmin(tokenHandler, ['admin', 'sysAdmin', 'superAdmin']);
|
|
354
|
+
|
|
355
|
+
// Ownership with custom bypass roles
|
|
356
|
+
requireOwnership((req) => req.params.userId, ['admin', 'manager']);
|
|
357
|
+
|
|
358
|
+
// Arbitrary role requirements
|
|
359
|
+
requireRoles(tokenHandler, ['consultant', 'admin']);
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Accessing User in Service
|
|
363
|
+
|
|
364
|
+
After `requiredAuth` middleware runs, `req.user` is populated. `BaseController.createContext()` extracts it:
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
// In BaseController.createContext():
|
|
368
|
+
protected createContext(req: Request): RequestContext {
|
|
369
|
+
return {
|
|
370
|
+
requestId: req.headers['x-request-id'] as string || 'unknown',
|
|
371
|
+
userId: (req as AuthenticatedRequest).user?.uid,
|
|
372
|
+
userRole: (req as AuthenticatedRequest).user?.role,
|
|
373
|
+
timestamp: new Date(),
|
|
374
|
+
metadata: { ip: req.ip, userAgent: req.headers['user-agent'] },
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
In your service, use `context.userId` and `context.userRole`:
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
protected async checkUpdateAccess(entity: Product, context: RequestContext): Promise<boolean> {
|
|
383
|
+
if (!context.userId) return false;
|
|
384
|
+
if (context.userRole === 'admin') return true;
|
|
385
|
+
return entity.ownerId === context.userId;
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## Service-to-Service Calls
|
|
392
|
+
|
|
393
|
+
Services can be injected into other services. Pass repositories/services via constructor:
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
export class OrderService extends BaseService<Order> {
|
|
397
|
+
protected entityName = 'Order';
|
|
398
|
+
|
|
399
|
+
constructor(
|
|
400
|
+
private orderRepo: OrderRepository,
|
|
401
|
+
private productService: ProductService,
|
|
402
|
+
private emailConnector: EmailConnector,
|
|
403
|
+
) {
|
|
404
|
+
super(orderRepo);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
protected async beforeCreate(data: Partial<Order>, context: RequestContext): Promise<void> {
|
|
408
|
+
// Check product availability via ProductService
|
|
409
|
+
const productResult = await this.productService.findById(data.productId!, context);
|
|
410
|
+
if (!productResult.success || !productResult.data) {
|
|
411
|
+
throw new Error('Product not found or unavailable');
|
|
412
|
+
}
|
|
413
|
+
if (productResult.data.status !== 'active') {
|
|
414
|
+
throw new Error('Product is not available for purchase');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Custom Service Methods
|
|
423
|
+
|
|
424
|
+
Add domain-specific methods beyond CRUD:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
export class ProductService extends BaseService<Product> {
|
|
428
|
+
protected entityName = 'Product';
|
|
429
|
+
|
|
430
|
+
// Domain-specific action
|
|
431
|
+
async archiveProduct(id: string, context: RequestContext): Promise<ServiceResult<Product>> {
|
|
432
|
+
try {
|
|
433
|
+
const existing = await this.repository.findById(id);
|
|
434
|
+
if (!existing) {
|
|
435
|
+
return { success: false, error: { code: 'NOT_FOUND', message: 'Product not found' } };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (context.userRole !== 'admin') {
|
|
439
|
+
return { success: false, error: { code: 'FORBIDDEN', message: 'Admins only' } };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
existing.status = 'archived';
|
|
443
|
+
const updated = await this.repository.update(existing);
|
|
444
|
+
|
|
445
|
+
eventBus.publish(EventType.PRODUCT_ARCHIVED, {
|
|
446
|
+
productUID: id,
|
|
447
|
+
archivedBy: context.userId!,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
return { success: true, data: updated };
|
|
451
|
+
} catch (error) {
|
|
452
|
+
return { success: false, error: this.handleError(error) };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Anti-Examples
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
// ❌ Don't put HTTP logic in services
|
|
464
|
+
async createProduct(req: Request, res: Response) { // ← this belongs in controller
|
|
465
|
+
const product = ...;
|
|
466
|
+
res.status(201).json(product);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ❌ Don't bypass ServiceResult — always return it
|
|
470
|
+
async createProduct(data, context): Promise<Product> { // ← wrong return type
|
|
471
|
+
return this.repository.create(...);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ✅ Always return ServiceResult
|
|
475
|
+
async createProduct(data, context): Promise<ServiceResult<Product>> {
|
|
476
|
+
try {
|
|
477
|
+
const entity = await this.buildEntity(data);
|
|
478
|
+
const created = await this.repository.create(entity);
|
|
479
|
+
return { success: true, data: created };
|
|
480
|
+
} catch (error) {
|
|
481
|
+
return { success: false, error: this.handleError(error) };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ❌ Don't call Firestore directly in a service
|
|
486
|
+
const snap = await this.db.collection('products').get(); // ← use repository
|
|
487
|
+
|
|
488
|
+
// ❌ Don't throw from access control methods — return false
|
|
489
|
+
protected async checkUpdateAccess(entity, context): Promise<boolean> {
|
|
490
|
+
throw new Error('Forbidden'); // ← wrong; return false instead
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ✅ Return false from access control
|
|
494
|
+
protected async checkUpdateAccess(entity, context): Promise<boolean> {
|
|
495
|
+
return context.userRole === 'admin';
|
|
496
|
+
}
|
|
497
|
+
```
|