@xbg.solutions/create-backend 1.0.0 → 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.
@@ -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
+ ```