Merge branch 'dev' into SP-1355-powr-clap-historical-data

This commit is contained in:
faris Aljohari
2025-04-24 15:04:11 +03:00
16 changed files with 488 additions and 71 deletions

View File

@ -0,0 +1,8 @@
import { AsyncLocalStorage } from 'async_hooks';
export interface RequestContextStore {
requestId?: string;
userId?: string;
}
export const requestContext = new AsyncLocalStorage<RequestContextStore>();

View File

@ -43,6 +43,9 @@ import { SubspaceProductAllocationEntity } from '../modules/space/entities/subsp
import { SubspaceEntity } from '../modules/space/entities/subspace/subspace.entity';
import { TagEntity } from '../modules/space/entities/tag.entity';
import { ClientEntity } from '../modules/client/entities';
import { TypeOrmWinstonLogger } from '@app/common/logger/services/typeorm.logger';
import { createLogger } from 'winston';
import { winstonLoggerOptions } from '../logger/services/winston.logger';
import {
PowerClampDailyEntity,
PowerClampHourlyEntity,
@ -53,7 +56,10 @@ import {
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
useFactory: (configService: ConfigService) => {
const winstonLogger = createLogger(winstonLoggerOptions);
const typeOrmLogger = new TypeOrmWinstonLogger(winstonLogger);
return {
name: 'default',
type: 'postgres',
host: configService.get('DB_HOST'),
@ -106,18 +112,22 @@ import {
],
namingStrategy: new SnakeNamingStrategy(),
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),
logging: false,
logging: ['query', 'error', 'warn', 'schema', 'migration'],
logger: typeOrmLogger,
extra: {
charset: 'utf8mb4',
max: 20, // set pool max size
idleTimeoutMillis: 5000, // close idle clients after 5 second
connectionTimeoutMillis: 11_000, // return an error after 11 second if connection could not be established
connectionTimeoutMillis: 12_000, // return an error after 11 second if connection could not be established
maxUses: 7500, // close (and replace) a connection after it has been used 7500 times (see below for discussion)
},
continuationLocalStorage: true,
ssl: Boolean(JSON.parse(configService.get('DB_SSL'))),
}),
};
},
}),
],
providers: [TypeOrmWinstonLogger],
})
export class DatabaseModule {}

View File

@ -0,0 +1,12 @@
// src/common/logger/logger.module.ts
import { Module } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import { winstonLoggerOptions } from './services/winston.logger';
import { TypeOrmWinstonLogger } from './services/typeorm.logger';
@Module({
imports: [WinstonModule.forRoot(winstonLoggerOptions)],
providers: [TypeOrmWinstonLogger],
exports: [TypeOrmWinstonLogger],
})
export class LoggerModule {}

View File

@ -0,0 +1,72 @@
import { Logger as WinstonLogger } from 'winston';
import { Logger as TypeOrmLogger } from 'typeorm';
import { requestContext } from '@app/common/context/request-context';
const ERROR_THRESHOLD = 2000;
export class TypeOrmWinstonLogger implements TypeOrmLogger {
constructor(private readonly logger: WinstonLogger) {}
private getContext() {
const context = requestContext.getStore();
return {
requestId: context?.requestId ?? 'N/A',
userId: context?.userId ?? null,
};
}
private extractTable(query: string): string {
const match =
query.match(/from\s+["`]?(\w+)["`]?/i) ||
query.match(/into\s+["`]?(\w+)["`]?/i);
return match?.[1] ?? 'unknown';
}
logQuery(query: string, parameters?: any[]) {
const context = this.getContext();
this.logger.debug(`[DB][QUERY] ${query}`, {
...context,
table: this.extractTable(query),
parameters,
});
}
logQueryError(error: string | Error, query: string, parameters?: any[]) {
const context = this.getContext();
this.logger.error(`[DB][ERROR] ${query}`, {
...context,
table: this.extractTable(query),
parameters,
error,
});
}
logQuerySlow(time: number, query: string, parameters?: any[]) {
const context = this.getContext();
const severity = time > ERROR_THRESHOLD ? 'error' : 'warn';
const label = severity === 'error' ? 'VERY SLOW' : 'SLOW';
this.logger[severity](`[DB][${label} > ${time}ms] ${query}`, {
...context,
table: this.extractTable(query),
parameters,
duration: `${time}ms`,
severity,
});
}
logSchemaBuild(message: string) {
this.logger.info(`[DB][SCHEMA] ${message}`);
}
logMigration(message: string) {
this.logger.info(`[DB][MIGRATION] ${message}`);
}
log(level: 'log' | 'info' | 'warn', message: any) {
this.logger.log({
level,
message: `[DB] ${message}`,
});
}
}

View File

@ -2,6 +2,8 @@ import { utilities as nestWinstonModuleUtilities } from 'nest-winston';
import * as winston from 'winston';
export const winstonLoggerOptions: winston.LoggerOptions = {
level:
process.env.AZURE_POSTGRESQL_DATABASE === 'development' ? 'debug' : 'error',
transports: [
new winston.transports.Console({
format: winston.format.combine(

View File

@ -0,0 +1,14 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { requestContext } from '../context/request-context';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
const context = {
requestId: req.headers['x-request-id'] || uuidv4(),
};
requestContext.run(context, () => next());
}
}

189
package-lock.json generated
View File

@ -10,6 +10,7 @@
"license": "UNLICENSED",
"dependencies": {
"@fast-csv/format": "^5.0.2",
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.0.0",
@ -18,6 +19,8 @@
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.8",
"@tuya/tuya-connector-nodejs": "^2.1.2",
@ -2232,6 +2235,17 @@
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
"license": "MIT"
},
"node_modules/@nestjs/axios": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.0.tgz",
"integrity": "sha512-1cB+Jyltu/uUPNQrpUimRHEQHrnQrpLzVj6dU3dgn6iDDDdahr10TgHFGTmw5VuJ9GzKZsCLDL78VSwJAs/9JQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"axios": "^1.3.1",
"rxjs": "^7.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "10.4.9",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
@ -2581,6 +2595,76 @@
}
}
},
"node_modules/@nestjs/terminus": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-11.0.0.tgz",
"integrity": "sha512-c55LOo9YGovmQHtFUMa/vDaxGZ2cglMTZejqgHREaApt/GArTfgYYGwhRXPLq8ZwiQQlLuYB+79e9iA8mlDSLA==",
"license": "MIT",
"dependencies": {
"boxen": "5.1.2",
"check-disk-space": "3.4.0"
},
"peerDependencies": {
"@grpc/grpc-js": "*",
"@grpc/proto-loader": "*",
"@mikro-orm/core": "*",
"@mikro-orm/nestjs": "*",
"@nestjs/axios": "^2.0.0 || ^3.0.0 || ^4.0.0",
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
"@nestjs/microservices": "^10.0.0 || ^11.0.0",
"@nestjs/mongoose": "^11.0.0",
"@nestjs/sequelize": "^10.0.0 || ^11.0.0",
"@nestjs/typeorm": "^10.0.0 || ^11.0.0",
"@prisma/client": "*",
"mongoose": "*",
"reflect-metadata": "0.1.x || 0.2.x",
"rxjs": "7.x",
"sequelize": "*",
"typeorm": "*"
},
"peerDependenciesMeta": {
"@grpc/grpc-js": {
"optional": true
},
"@grpc/proto-loader": {
"optional": true
},
"@mikro-orm/core": {
"optional": true
},
"@mikro-orm/nestjs": {
"optional": true
},
"@nestjs/axios": {
"optional": true
},
"@nestjs/microservices": {
"optional": true
},
"@nestjs/mongoose": {
"optional": true
},
"@nestjs/sequelize": {
"optional": true
},
"@nestjs/typeorm": {
"optional": true
},
"@prisma/client": {
"optional": true
},
"mongoose": {
"optional": true
},
"sequelize": {
"optional": true
},
"typeorm": {
"optional": true
}
}
},
"node_modules/@nestjs/testing": {
"version": "10.4.15",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.15.tgz",
@ -2609,6 +2693,17 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.4.0.tgz",
"integrity": "sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/typeorm": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
@ -3743,6 +3838,15 @@
"ajv": "^8.8.2"
}
},
"node_modules/ansi-align": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
"integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
"license": "ISC",
"dependencies": {
"string-width": "^4.1.0"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@ -4407,6 +4511,57 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/boxen": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
"integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==",
"license": "MIT",
"dependencies": {
"ansi-align": "^3.0.0",
"camelcase": "^6.2.0",
"chalk": "^4.1.0",
"cli-boxes": "^2.2.1",
"string-width": "^4.2.2",
"type-fest": "^0.20.2",
"widest-line": "^3.1.0",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/boxen/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/boxen/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@ -4670,6 +4825,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/check-disk-space": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz",
"integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -4745,6 +4909,18 @@
"validator": "^13.9.0"
}
},
"node_modules/cli-boxes": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
"integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@ -12903,7 +13079,6 @@
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@ -13672,6 +13847,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/widest-line": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
"integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
"license": "MIT",
"dependencies": {
"string-width": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/winston": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",

View File

@ -21,6 +21,7 @@
},
"dependencies": {
"@fast-csv/format": "^5.0.2",
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.0.0",
@ -29,6 +30,8 @@
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.8",
"@tuya/tuya-connector-nodejs": "^2.1.2",

View File

@ -12,7 +12,7 @@ import { UserNotificationModule } from './user-notification/user-notification.mo
import { DeviceMessagesSubscriptionModule } from './device-messages/device-messages.module';
import { SceneModule } from './scene/scene.module';
import { DoorLockModule } from './door-lock/door.lock.module';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { AutomationModule } from './automation/automation.module';
import { RegionModule } from './region/region.module';
@ -33,12 +33,18 @@ import { ClientModule } from './client/client.module';
import { DeviceCommissionModule } from './commission-device/commission-device.module';
import { PowerClampModule } from './power-clamp/power-clamp.module';
import { WinstonModule } from 'nest-winston';
import { winstonLoggerOptions } from './common/filters/http-exception/logger/winston.logger';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { HealthModule } from './health/health.module';
import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston.logger';
@Module({
imports: [
ConfigModule.forRoot({
load: config,
}),
ThrottlerModule.forRoot({
throttlers: [{ ttl: 100000, limit: 30 }],
}),
WinstonModule.forRoot(winstonLoggerOptions),
ClientModule,
AuthenticationModule,
@ -70,12 +76,17 @@ import { winstonLoggerOptions } from './common/filters/http-exception/logger/win
TagModule,
DeviceCommissionModule,
PowerClampModule,
HealthModule,
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View File

@ -582,6 +582,9 @@ export class DeviceService {
return { successResults, failedResults };
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
error.message || 'Device Not Found',
error.status || HttpStatus.NOT_FOUND,
@ -685,7 +688,7 @@ export class DeviceService {
return {
...rest,
productUuid: product.uuid,
name: deviceDetails.name,
name: deviceDetails?.name,
productName: product.name,
} as GetDeviceDetailsInterface;
} catch (error) {
@ -1116,6 +1119,9 @@ export class DeviceService {
});
}
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
error.message || 'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,

View File

@ -0,0 +1,37 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
HealthCheck,
HealthCheckService,
TypeOrmHealthIndicator,
DiskHealthIndicator,
MemoryHealthIndicator,
HttpHealthIndicator,
} from '@nestjs/terminus';
@ApiTags('Health Module')
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private memory: MemoryHealthIndicator,
private disk: DiskHealthIndicator,
private http: HttpHealthIndicator,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.db.pingCheck('database'),
() =>
this.disk.checkStorage('disk', {
thresholdPercent: 0.9,
path: '/',
}),
() => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
() => this.http.pingCheck('tuya', process.env.TUYA_EU_URL),
]);
}
}

View File

@ -0,0 +1 @@
export * from './health.controller';

View File

@ -0,0 +1,13 @@
// src/health/health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HealthController } from './controllers';
import { HttpModule } from '@nestjs/axios';
import { HealthService } from './services';
@Module({
imports: [TerminusModule, HttpModule, TypeOrmModule],
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}

View File

@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import {
HealthCheckService,
TypeOrmHealthIndicator,
DiskHealthIndicator,
MemoryHealthIndicator,
HttpHealthIndicator,
HealthCheckResult,
} from '@nestjs/terminus';
@Injectable()
export class HealthService {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private memory: MemoryHealthIndicator,
private disk: DiskHealthIndicator,
private http: HttpHealthIndicator,
) {}
check(): Promise<HealthCheckResult> {
return this.health.check([
() => this.db.pingCheck('database'),
() =>
this.disk.checkStorage('disk', {
thresholdPercent: 0.9,
path: '/',
}),
() => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
() => this.http.pingCheck('tuya', process.env.TUYA_EU_URL),
]);
}
}

View File

@ -0,0 +1 @@
export * from './health.service';

View File

@ -7,6 +7,9 @@ import { ValidationPipe } from '@nestjs/common';
import { json, urlencoded } from 'body-parser';
import { SeederService } from '@app/common/seed/services/seeder.service';
import { HttpExceptionFilter } from './common/filters/http-exception/http-exception.filter';
import { Logger } from '@nestjs/common';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { RequestContextMiddleware } from '@app/common/middleware/request-context.middleware';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -18,6 +21,8 @@ async function bootstrap() {
app.use(urlencoded({ limit: '1mb', extended: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.use(new RequestContextMiddleware().use);
app.use(
rateLimit({
windowMs: 5 * 60 * 1000,
@ -43,14 +48,16 @@ async function bootstrap() {
);
const seederService = app.get(SeederService);
const logger = app.get<Logger>(WINSTON_MODULE_NEST_PROVIDER);
try {
await seederService.seed();
console.log('Seeding complete!');
logger.log('Seeding complete!');
} catch (error) {
console.error('Seeding failed!', error);
logger.error('Seeding failed!', error.stack || error);
}
console.log('Starting auth at port ...', process.env.PORT || 4000);
logger.log('Starting auth at port ...', process.env.PORT || 4000);
await app.listen(process.env.PORT || 4000);
}
bootstrap();