diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index d6f1f85c..b071dc62 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -21,6 +21,10 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }], }, }; diff --git a/backend/src/api-keys/api-key.entity.ts b/backend/src/api-keys/api-key.entity.ts new file mode 100644 index 00000000..f930bdd0 --- /dev/null +++ b/backend/src/api-keys/api-key.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity('api_keys') +export class ApiKey { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + name: string; + + @Column() + keyHash: string; + + @Column({ length: 8 }) + prefix: string; + + @Column('text', { array: true, default: [] }) + scopes: string[]; + + @Column({ nullable: true, type: 'timestamp' }) + lastUsedAt: Date; + + @Column({ nullable: true, type: 'timestamp' }) + expiresAt: Date; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/api-keys/api-keys.controller.ts b/backend/src/api-keys/api-keys.controller.ts new file mode 100644 index 00000000..898ee212 --- /dev/null +++ b/backend/src/api-keys/api-keys.controller.ts @@ -0,0 +1,41 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiKeysService } from './api-keys.service'; + +@Controller('api-keys') +@UseGuards(AuthGuard('jwt')) +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + @Post() + create( + @Body() body: { name: string; scopes: string[]; expiresAt?: Date }, + @Req() req: any, + ) { + return this.apiKeysService.create( + req.user?.id, + body.name, + body.scopes, + body.expiresAt, + ); + } + + @Get() + findAll(@Req() req: any) { + return this.apiKeysService.findByUser(req.user?.id); + } + + @Delete(':id') + revoke(@Param('id') id: string, @Req() req: any) { + return this.apiKeysService.revoke(id, req.user?.id); + } +} diff --git a/backend/src/api-keys/api-keys.module.ts b/backend/src/api-keys/api-keys.module.ts new file mode 100644 index 00000000..51af593a --- /dev/null +++ b/backend/src/api-keys/api-keys.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApiKey } from './api-key.entity'; +import { ApiKeysService } from './api-keys.service'; +import { ApiKeysController } from './api-keys.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([ApiKey])], + controllers: [ApiKeysController], + providers: [ApiKeysService], + exports: [ApiKeysService], +}) +export class ApiKeysModule {} diff --git a/backend/src/api-keys/api-keys.service.ts b/backend/src/api-keys/api-keys.service.ts new file mode 100644 index 00000000..ef7d15ab --- /dev/null +++ b/backend/src/api-keys/api-keys.service.ts @@ -0,0 +1,65 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as crypto from 'crypto'; +import * as bcrypt from 'bcrypt'; +import { ApiKey } from './api-key.entity'; + +@Injectable() +export class ApiKeysService { + constructor( + @InjectRepository(ApiKey) + private readonly repo: Repository, + ) {} + + async create( + userId: string, + name: string, + scopes: string[], + expiresAt?: Date, + ) { + const rawKey = `ak_${crypto.randomBytes(32).toString('hex')}`; + const prefix = rawKey.substring(3, 11); + const keyHash = await bcrypt.hash(rawKey, 10); + const entity = this.repo.create({ + userId, + name, + keyHash, + prefix, + scopes, + expiresAt, + }); + const saved = await this.repo.save(entity); + return { + id: saved.id, + name: saved.name, + prefix: saved.prefix, + scopes: saved.scopes, + key: rawKey, + createdAt: saved.createdAt, + }; + } + + async findByUser(userId: string) { + return this.repo.find({ + where: { userId, isActive: true }, + select: [ + 'id', + 'name', + 'prefix', + 'scopes', + 'lastUsedAt', + 'expiresAt', + 'createdAt', + ], + }); + } + + async revoke(id: string, userId: string) { + const key = await this.repo.findOne({ where: { id, userId } }); + if (!key) throw new NotFoundException('API key not found'); + key.isActive = false; + await this.repo.save(key); + return { message: 'API key revoked' }; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a46c653c..1898a70d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -26,6 +26,8 @@ import { ContractsModule } from './contracts/contracts.module'; import { LicensesModule } from './licenses/licenses.module'; import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module'; import { TasksModule } from './tasks/tasks.module'; +import { ApiKeysModule } from './api-keys/api-keys.module'; +import { StellarModule } from './stellar/stellar.module'; import { NotificationModule } from './notifications/notification.module'; @Module({ @@ -76,6 +78,20 @@ import { NotificationModule } from './notifications/notification.module'; }, }), + ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]), + + I18nModule.forRoot({ + fallbackLanguage: 'en', + loaderOptions: { + path: path.join(__dirname, '/i18n/'), + watch: true, + }, + resolvers: [ + { use: QueryResolver, options: ['lang'] }, + AcceptLanguageResolver, + ], + }), + AssetsExtendedModule, AssetsOpsModule, CheckinModule, @@ -94,6 +110,8 @@ import { NotificationModule } from './notifications/notification.module'; InventoryModule, VendorsModule, DashboardModule, + ApiKeysModule, + StellarModule, NotificationModule, ], controllers: [AppController], diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index e236b876..00f4c0b4 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; diff --git a/backend/src/auth/providers/two-factor.provider.ts b/backend/src/auth/providers/two-factor.provider.ts index a55b5da8..ac3f6c9e 100644 --- a/backend/src/auth/providers/two-factor.provider.ts +++ b/backend/src/auth/providers/two-factor.provider.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; - @Injectable() export class TwoFactorProvider { async generateSecret(_email: string) {} diff --git a/backend/src/common/filters/global-exception.filter.ts b/backend/src/common/filters/global-exception.filter.ts index 847d9596..9719442a 100644 --- a/backend/src/common/filters/global-exception.filter.ts +++ b/backend/src/common/filters/global-exception.filter.ts @@ -28,7 +28,8 @@ export class GlobalExceptionFilter implements ExceptionFilter { : 'Internal server error'; this.logger.error( - `${request.method} ${request.url} - ${exception instanceof Error ? exception.stack : String(exception)}`, + `${request.method} - ${request.url}`, + exception instanceof Error ? exception.stack : String(exception), ); response.status(status).json({ diff --git a/backend/src/contracts/contracts.service.ts b/backend/src/contracts/contracts.service.ts index 40f5cf99..30d23749 100644 --- a/backend/src/contracts/contracts.service.ts +++ b/backend/src/contracts/contracts.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @@ -22,13 +22,13 @@ export class ContractsService { ); } - async create(dto: CreateContractDto, userId?: string): Promise { + async create(dto: CreateContractDto, _userId?: string): Promise { const prefix = this.configService.get('CONTRACT_ID_PREFIX', 'CTR'); const contractId = `${prefix}-${this.nextNumber++}`; const contract = this.contractRepository.create({ ...dto, contractId, - createdById: userId, + createdById: _userId, }); return this.contractRepository.save(contract); } diff --git a/backend/src/licenses/licenses.service.ts b/backend/src/licenses/licenses.service.ts index db7bf37c..0f837ad2 100644 --- a/backend/src/licenses/licenses.service.ts +++ b/backend/src/licenses/licenses.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { License } from './entities/license.entity'; @@ -13,12 +13,12 @@ export class LicensesService { private readonly licenseRepository: Repository, ) {} - async create(dto: CreateLicenseDto, userId?: string): Promise { + async create(dto: CreateLicenseDto, _userId?: string): Promise { const licenseKey = `LIC-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; const license = this.licenseRepository.create({ ...dto, licenseKey, - createdById: userId, + createdById: _userId, }); return this.licenseRepository.save(license); } diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index efe36e8a..186bab41 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; @@ -175,7 +175,7 @@ export class MailService { const render = templates[template]; if (!render) { this.logger.warn(`Unknown email template: ${template}`); - return `

Email notification

`; + return `

Unknown template: ${template}

`; } return render(context); } diff --git a/backend/src/stellar/stellar.controller.ts b/backend/src/stellar/stellar.controller.ts new file mode 100644 index 00000000..77dd2697 --- /dev/null +++ b/backend/src/stellar/stellar.controller.ts @@ -0,0 +1,94 @@ +import { + Controller, + Get, + Post, + Body, + Param, + UseGuards, + Req, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { StellarService } from './stellar.service'; + +@Controller('stellar') +@UseGuards(AuthGuard('jwt')) +export class StellarController { + constructor( + private readonly stellarService: StellarService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + @Post('assets/register') + async registerAsset( + @Body() body: { assetId: string; metadata?: Record }, + ) { + return this.stellarService.registerAsset(body.assetId, body.metadata ?? {}); + } + + @Get('assets/:id') + async getAsset(@Param('id') id: string) { + const rows = await this.dataSource.query<{ id: string }[]>( + 'SELECT id, "assetId", name FROM assets WHERE id = $1', + [id], + ); + return rows[0] ?? null; + } + + @Post('assets/:id/transfer') + async transferAsset( + @Param('id') id: string, + @Body() body: { toAddress: string; amount: number }, + ) { + return this.stellarService.transferAsset(id, body.toAddress, body.amount); + } + + @Post('assets/:id/tokenize') + async tokenizeAsset( + @Param('id') id: string, + @Body() body: { totalSupply: number; tokenName: string }, + ) { + return this.stellarService.tokenizeAsset( + id, + body.totalSupply, + body.tokenName, + ); + } + + @Get('assets/:id/tokens') + async getTokens(@Param('id') id: string, @Req() req: any) { + const balance = await this.stellarService.getTokenBalance( + id, + String(req.user?.id), + ); + return { assetId: id, balance }; + } + + @Post('assets/:id/tokens/transfer') + async transferTokens( + @Param('id') id: string, + @Body() body: { toAddress: string; amount: number }, + @Req() req: any, + ) { + return this.stellarService.transferTokens( + id, + String(req.user?.id), + body.toAddress, + body.amount, + ); + } + + @Post('assets/:id/tokens/lock') + async lockTokens(@Param('id') id: string, @Body() body: { amount: number }) { + return this.stellarService.lockTokens(id, body.amount); + } + + @Post('assets/:id/tokens/unlock') + async unlockTokens( + @Param('id') id: string, + @Body() body: { amount: number }, + ) { + return this.stellarService.unlockTokens(id, body.amount); + } +} diff --git a/backend/src/stellar/stellar.module.ts b/backend/src/stellar/stellar.module.ts new file mode 100644 index 00000000..34310290 --- /dev/null +++ b/backend/src/stellar/stellar.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StellarService } from './stellar.service'; +import { StellarController } from './stellar.controller'; + +@Module({ + imports: [ConfigModule], + controllers: [StellarController], + providers: [StellarService], + exports: [StellarService], +}) +export class StellarModule {} diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts new file mode 100644 index 00000000..ea194cbb --- /dev/null +++ b/backend/src/stellar/stellar.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Keypair, Networks } from '@stellar/stellar-sdk'; + +@Injectable() +export class StellarService { + private readonly networkPassphrase: string; + + constructor(private readonly config: ConfigService) { + this.networkPassphrase = config.get('STELLAR_NETWORK', Networks.TESTNET); + } + + getNetworkPassphrase(): string { + return this.networkPassphrase; + } + + generateKeypair(): { publicKey: string; secretKey: string } { + const pair = Keypair.random(); + return { publicKey: pair.publicKey(), secretKey: pair.secret() }; + } + + async registerAsset( + assetId: string, + metadata: Record, + ): Promise<{ txHash: string; status: string }> { + void assetId; + void metadata; + return { txHash: '', status: 'pending' }; + } + + async transferAsset( + assetId: string, + toAddress: string, + amount: number, + ): Promise<{ txHash: string }> { + void assetId; + void toAddress; + void amount; + return { txHash: '' }; + } + + async tokenizeAsset( + assetId: string, + totalSupply: number, + tokenName: string, + ): Promise<{ contractId: string; txHash: string }> { + void assetId; + void totalSupply; + void tokenName; + return { contractId: '', txHash: '' }; + } + + async getTokenBalance(contractId: string, address: string): Promise { + void contractId; + void address; + return 0; + } + + async transferTokens( + contractId: string, + fromAddress: string, + toAddress: string, + amount: number, + ): Promise<{ txHash: string }> { + void contractId; + void fromAddress; + void toAddress; + void amount; + return { txHash: '' }; + } + + async lockTokens( + contractId: string, + amount: number, + ): Promise<{ txHash: string }> { + void contractId; + void amount; + return { txHash: '' }; + } + + async unlockTokens( + contractId: string, + amount: number, + ): Promise<{ txHash: string }> { + void contractId; + void amount; + return { txHash: '' }; + } + + async distributeDividends( + contractId: string, + amount: number, + ): Promise<{ txHash: string }> { + void contractId; + void amount; + return { txHash: '' }; + } + + async castVote( + contractId: string, + proposalId: string, + vote: boolean, + ): Promise<{ txHash: string }> { + void contractId; + void proposalId; + void vote; + return { txHash: '' }; + } +} diff --git a/backend/src/storage/storage.controller.ts b/backend/src/storage/storage.controller.ts index 3af28d7b..4480f537 100644 --- a/backend/src/storage/storage.controller.ts +++ b/backend/src/storage/storage.controller.ts @@ -5,6 +5,7 @@ import { Param, UseInterceptors, UploadedFile, + Body, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { StorageService } from './storage.service'; @@ -15,7 +16,10 @@ export class StorageController { @Post('upload') @UseInterceptors(FileInterceptor('file')) - async uploadFile(@UploadedFile() file: Express.Multer.File) { + async uploadFile( + @UploadedFile() file: Express.Multer.File, + @Body() _body: any, + ) { const key = await this.storageService.upload(file); return { key, url: await this.storageService.getSignedUrl(key) }; } diff --git a/backend/src/storage/storage.service.ts b/backend/src/storage/storage.service.ts index a3cb82ce..2230400f 100644 --- a/backend/src/storage/storage.service.ts +++ b/backend/src/storage/storage.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { S3Client, diff --git a/backend/src/users/dtos/create-user.dto.ts b/backend/src/users/dtos/create-user.dto.ts index 9c9d07fe..e1995d88 100644 --- a/backend/src/users/dtos/create-user.dto.ts +++ b/backend/src/users/dtos/create-user.dto.ts @@ -1,6 +1,17 @@ -import { IsEmail, IsOptional, IsString, IsBoolean } from 'class-validator'; +import { IsEmail, IsOptional, IsString, IsBoolean } from 'class-validator'; export class CreateUserDto { + @IsOptional() + @IsString() + passwordHash?: string; + + @IsOptional() + @IsString() + googleId?: string; + + @IsOptional() + @IsString() + microsoftId?: string; @IsEmail() email: string; diff --git a/backend/src/users/dtos/update-user.dto.ts b/backend/src/users/dtos/update-user.dto.ts index 38b4cdef..eb4e778c 100644 --- a/backend/src/users/dtos/update-user.dto.ts +++ b/backend/src/users/dtos/update-user.dto.ts @@ -1,6 +1,21 @@ -import { IsOptional, IsString, IsBoolean } from 'class-validator'; +import { IsOptional, IsString, IsBoolean } from 'class-validator'; export class UpdateUserDto { + @IsOptional() + @IsString() + passwordHash?: string; + + @IsOptional() + @IsString() + avatarUrl?: string; + + @IsOptional() + @IsString() + googleId?: string; + + @IsOptional() + @IsString() + microsoftId?: string; @IsOptional() @IsString() firstName?: string; diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 189c6629..429242a2 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity';