Files
dinggadingga-backend/src/services/hiboutik/hiboutik.service.ts
MastersGame 05911f4230 feat(security): harden the API and lock synchronizations across instances
- Validate the environment at startup (src/config): required variables
  must be present, HIBOUTIK_BASE_URL must be HTTPS.
- Global ValidationPipe (whitelist, forbidNonWhitelisted, transform).
- Helmet, CORS disabled unless CORS_ORIGINS is set, TRUST_PROXY support,
  HTTP header/request timeouts, shutdown hooks.
- Rate limiting: 60 req/min/IP globally, 5 req/min on POST /sync.
- AllExceptionsFilter: unexpected errors are logged (message + stack,
  never the raw error object) and answered with a generic 500.
- Postgres advisory lock (dedicated connection) so only one
  synchronization runs at a time across all instances; released by
  Postgres itself if the process dies.
- Fix the Jest moduleNameMapper (unescaped dot also matched .cjs files).
- Document the input validation and security rules in CLAUDE.md.
- Add class-validator, class-transformer, helmet and @nestjs/throttler;
  npm audit fix refreshed package-lock.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 10:11:35 +01:00

97 lines
3.8 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { BrandHiboutikService } from './brand.hiboutik.service';
import { CategoryHiboutikService } from './category.hiboutik.service';
import { CustomerHiboutikService } from './customer.hiboutik.service';
import { SupplierHiboutikService } from './supplier.hiboutik.service';
import { ProductHiboutikService } from './product.hiboutik.service';
import { ReleaseLock, SyncLockDatabaseRepository } from '../../database/syncLock.database.repository';
/** Entry point of the Hiboutik → database synchronization: runs each entity sync in dependency order. */
@Injectable()
export class HiboutikService {
private readonly logger = new Logger('HiboutikService');
/** Cheap in-process guard, checked before the database lock (which is the one that holds across instances). */
private syncInProgress = false;
constructor(
private readonly brandHiboutikService: BrandHiboutikService,
private readonly categoryHiboutikService: CategoryHiboutikService,
private readonly customerHiboutikService: CustomerHiboutikService,
private readonly productHiboutikService: ProductHiboutikService,
private readonly supplierHiboutikService: SupplierHiboutikService,
private readonly syncLock: SyncLockDatabaseRepository,
) {}
get isSyncInProgress(): boolean {
return this.syncInProgress;
}
/**
* Starts a full synchronization in the background, if no other one is running on this instance or any other
* (Postgres advisory lock, held until the synchronization ends).
* @returns false if a synchronization is already running (nothing is started).
* @throws if the lock cannot be requested (e.g. database unreachable).
*/
async startSync(): Promise<boolean> {
if (this.syncInProgress) return false;
// Set before the first await so that concurrent calls on this instance are refused while the lock is requested
this.syncInProgress = true;
let release: ReleaseLock | null;
try {
release = await this.syncLock.tryAcquire();
} catch (error) {
this.syncInProgress = false;
throw error;
}
if (!release) {
this.syncInProgress = false;
return false;
}
void this.runLocked(release);
return true;
}
/** Runs the synchronization, then always releases the lock. */
private async runLocked(release: ReleaseLock): Promise<void> {
try {
await this.syncAll();
} catch (error) {
this.logger.error('Global Hiboutik synchronization crashed', (error as Error)?.stack);
} finally {
await release();
this.syncInProgress = false;
}
}
/** Runs every sync step in order. A failing step is logged and does not stop the next ones. */
async syncAll() {
this.logger.log('Starting global Hiboutik synchronization');
// Order matters: products connect to brands, categories and suppliers by hiboutikId.
const steps: [string, () => Promise<void>][] = [
['brands', () => this.brandHiboutikService.syncBrands()],
['categories', () => this.categoryHiboutikService.syncCategories()],
['suppliers', () => this.supplierHiboutikService.syncSuppliers()],
['products', () => this.productHiboutikService.syncProducts()],
['customers', () => this.customerHiboutikService.syncCustomers()],
];
const failedSteps: string[] = [];
for (const [name, step] of steps) {
try {
await step();
} catch (error) {
failedSteps.push(name);
this.logger.error(`Hiboutik ${name} sync failed`, (error as Error)?.stack);
}
}
if (failedSteps.length > 0) {
this.logger.warn(`Global Hiboutik synchronization ended with errors (failed steps: ${failedSteps.join(', ')})`);
} else {
this.logger.log('Global Hiboutik synchronization ended successfully');
}
}
}