diff --git a/src/auth/auth-controllers/actor/actor.auth.controller.ts b/src/auth/auth-controllers/actor/actor.auth.controller.ts index dd83ea8..3434a0f 100644 --- a/src/auth/auth-controllers/actor/actor.auth.controller.ts +++ b/src/auth/auth-controllers/actor/actor.auth.controller.ts @@ -31,7 +31,6 @@ import { LegalRegisterDto, } from "src/auth/dto/actor/register.actor.dto"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; -import { ClientKey } from "src/decorators/clientKey.decorator"; import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; @@ -136,9 +135,17 @@ export class ActorAuthController { }, }, }) + @ApiResponse({ + status: 404, + description: "No actor account exists for the given email and role", + }) + @ApiResponse({ + status: 401, + description: "Wrong password or role mismatch", + }) @ApiAcceptedResponse() - async login(@Body() body, @Req() req, @ClientKey() client) { - return await this.actorAuthService.loginActors(req.user); + async login(@Req() req: { user: Record }) { + return req.user; } @Post("forget-password") diff --git a/src/auth/auth-services/actor.auth.service.ts b/src/auth/auth-services/actor.auth.service.ts index 41d0655..53fd3c5 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -97,48 +97,95 @@ export class ActorAuthService { return res; } - async validateActor(username: string, pass: string, role): Promise { - const user = await this.dynamicDbController(role, username); - if (user) { - if (user.role !== role) { - throw new UnauthorizedException("user not assigned to this role"); - } - if (!(await this.hashService.compare(pass, user.password))) { - throw new UnauthorizedException( - "password is incorrect or access Denied", - ); - } else { - return user; - } + /** Normalizes `role` from login body (string or single-element array). */ + parseActorLoginRole(role: unknown): RoleEnum { + const raw = Array.isArray(role) ? role[0] : role; + if ( + typeof raw !== "string" || + !Object.values(RoleEnum).includes(raw as RoleEnum) + ) { + throw new BadRequestException( + `Invalid role. Expected one of: ${Object.values(RoleEnum).join(", ")}`, + ); } - return null; + return raw as RoleEnum; } - async loginActors(user: any) { - let foundedUser = await this.dynamicDbController(user.role, user.username); - if (foundedUser) { - const payload = { - username: foundedUser.username || foundedUser.email, - sub: foundedUser._id, - fullName: - `${foundedUser.firstName || ""} ${foundedUser.lastName || ""}`.trim(), - role: foundedUser.role || "User", - userType: foundedUser.userType || "UserType", - clientKey: foundedUser.clientKey || null, - }; - - const accToken = this.jwtService.sign(payload, { - secret: `${process.env.SECRET}`, - expiresIn: "1h", - }); - - return { - ...payload, - access_token: accToken, - }; - } else { - throw new UnauthorizedException("expert or damage_expert not found"); + parseActorLoginUsername(body: Record): string { + const username = body?.username ?? body?.email; + if (typeof username !== "string" || !username.trim()) { + throw new BadRequestException("username (email) is required"); } + return username.trim(); + } + + issueActorTokens(actor: { + _id: Types.ObjectId; + username?: string; + email?: string; + firstName?: string; + lastName?: string; + role?: string; + userType?: string; + clientKey?: Types.ObjectId | string | null; + }) { + const payload = { + username: actor.username || actor.email, + sub: actor._id, + fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + role: actor.role || "User", + userType: actor.userType || "UserType", + clientKey: actor.clientKey ?? null, + }; + + const access_token = this.jwtService.sign(payload, { + secret: `${process.env.SECRET}`, + expiresIn: "1h", + }); + + return { ...payload, access_token }; + } + + async validateActor( + username: string, + pass: string, + role: RoleEnum, + ): Promise { + const user = await this.dynamicDbController(role, username); + if (!user) { + throw new NotFoundException("Actor account not found"); + } + if (user.role !== role) { + throw new UnauthorizedException("user not assigned to this role"); + } + if (!(await this.hashService.compare(pass, user.password))) { + throw new UnauthorizedException( + "password is incorrect or access Denied", + ); + } + return user; + } + + /** + * Authenticates an actor from a login request body (role, username/email, password). + */ + async loginFromCredentials(body: Record) { + const role = this.parseActorLoginRole(body?.role); + const username = this.parseActorLoginUsername(body); + const password = body?.password; + if (typeof password !== "string" || !password) { + throw new BadRequestException("password is required"); + } + const actor = await this.validateActor(username, password, role); + return this.issueActorTokens(actor); + } + + /** @deprecated Prefer {@link loginFromCredentials}. Kept for internal callers. */ + async loginActors(user: any) { + if (user?.access_token && user?.sub) { + return user; + } + return this.loginFromCredentials(user as Record); } async registerActors( diff --git a/src/auth/guards/actor-local.guard.ts b/src/auth/guards/actor-local.guard.ts index f8e188a..8e58ae6 100644 --- a/src/auth/guards/actor-local.guard.ts +++ b/src/auth/guards/actor-local.guard.ts @@ -23,10 +23,12 @@ export class LocalActorAuthGuard extends AuthGuard("actor") { const path = request.url; if (!token) { - if (path === "/actor/login") { - const loginData = await this.actorAuthService.loginActors(request.body); + if (this.isActorLoginRequest(request)) { + const loginData = await this.actorAuthService.loginFromCredentials( + request.body ?? {}, + ); request.user = loginData; - request.identity = request; + request.identity = loginData; return true; } else { throw new UnauthorizedException("Token not found"); @@ -59,6 +61,17 @@ export class LocalActorAuthGuard extends AuthGuard("actor") { return true; } + private isActorLoginRequest(request: { + url?: string; + path?: string; + route?: { path?: string }; + }): boolean { + const path = (request.route?.path ?? request.url ?? request.path ?? "") + .split("?")[0] + .replace(/\/+$/, ""); + return path === "/actor/login" || path.endsWith("/actor/login"); + } + private extractTokenFromHeader(request: Request): string | undefined { //@ts-ignore const [type, token] = request.headers.authorization?.split(" ") ?? []; diff --git a/src/client/client-panel.controller.ts b/src/client/client-panel.controller.ts new file mode 100644 index 0000000..dcba0a8 --- /dev/null +++ b/src/client/client-panel.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + Get, + Patch, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; +import { RolesGuard } from "src/auth/guards/role.guard"; +import { Roles } from "src/decorators/roles.decorator"; +import { CurrentUser } from "src/decorators/user.decorator"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { ClientService } from "./client.service"; +import { + ClientSettingsPanelResponseDto, + UpdateClientSettingsDto, +} from "./dto/client-settings.dto"; + +/** + * Insurer (company) panel: per-tenant upload limits and CAR_BODY accident window. + * Scoped to the JWT `clientKey` — tenants cannot edit other clients. + */ +@Controller("client-panel") +@ApiTags("insurer-client-panel") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.COMPANY) +export class ClientPanelController { + constructor(private readonly clientService: ClientService) {} + + @Get("settings") + @ApiOperation({ + summary: "Get tenant settings (media limits & CAR_BODY accident window)", + description: + "Returns configured overrides, effective values (with system defaults), and route upload ceilings for the authenticated insurer's client.", + }) + @ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto }) + async getSettings( + @CurrentUser() insurer: { clientKey?: string }, + ): Promise { + if (!insurer?.clientKey) { + throw new UnauthorizedException("Insurer client key is missing."); + } + return this.clientService.getPanelSettings(insurer.clientKey); + } + + @Patch("settings") + @ApiOperation({ + summary: "Update tenant settings", + description: + "Partial update of `settings.carBodyAccidentMaxAgeDays` and/or `settings.media` (video, image, voice). " + + "Only fields sent in the body are changed. `maxBytes` cannot exceed the route ceiling returned by GET.", + }) + @ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto }) + async updateSettings( + @CurrentUser() insurer: { clientKey?: string }, + @Body() body: UpdateClientSettingsDto, + ): Promise { + if (!insurer?.clientKey) { + throw new UnauthorizedException("Insurer client key is missing."); + } + return this.clientService.updatePanelSettings(insurer.clientKey, body); + } +} diff --git a/src/client/client.module.ts b/src/client/client.module.ts index b5ec324..7f7b863 100644 --- a/src/client/client.module.ts +++ b/src/client/client.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; import { ClientController } from "./client.controller"; +import { ClientPanelController } from "./client-panel.controller"; import { ClientService } from "./client.service"; import { BranchDbService } from "./entities/db-service/branch.db.service"; import { ClientDbService } from "./entities/db-service/client.db.service"; @@ -17,7 +18,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema"; }, ]), ], - controllers: [ClientController], + controllers: [ClientController, ClientPanelController], providers: [ClientService, ClientDbService, BranchDbService], exports: [ClientService, ClientDbService, BranchDbService], }) diff --git a/src/client/client.service.ts b/src/client/client.service.ts index aa4b479..6401555 100644 --- a/src/client/client.service.ts +++ b/src/client/client.service.ts @@ -1,10 +1,21 @@ -import { BadGatewayException, GoneException, Injectable } from "@nestjs/common"; +import { + BadGatewayException, + BadRequestException, + GoneException, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { Types } from "mongoose"; import { ClientDto, ClientDtoRs, ClientLists, } from "src/client/dto/create-client.dto"; +import { + type ClientSettingsPanelResponseDto, + UpdateClientSettingsDto, +} from "src/client/dto/client-settings.dto"; +import type { ClientMediaLimits } from "./entities/schema/client.schema"; import { ClientDbService } from "./entities/db-service/client.db.service"; /** @@ -42,6 +53,18 @@ export const DEFAULT_MEDIA_LIMITS: Record = { voice: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 }, }; +/** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */ +export const MEDIA_ROUTE_MAX_BYTES: Record = { + video: 50 * 1024 * 1024, + image: 10 * 1024 * 1024, + voice: 10 * 1024 * 1024, +}; + +export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1; +export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX = 365; + +const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"]; + @Injectable() export class ClientService { constructor(private readonly clientDbService: ClientDbService) {} @@ -155,4 +178,138 @@ export class ClientService { } return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS; } + + private resolveClientObjectId(clientKey: string | Types.ObjectId): Types.ObjectId { + const raw = String(clientKey ?? "").trim(); + if (!Types.ObjectId.isValid(raw)) { + throw new BadRequestException("Invalid client key"); + } + return new Types.ObjectId(raw); + } + + private async loadClientOrThrow(clientKey: string | Types.ObjectId) { + const id = this.resolveClientObjectId(clientKey); + const client = await this.clientDbService.findOne({ _id: id }); + if (!client) { + throw new NotFoundException("Client not found"); + } + return client; + } + + private configuredMediaLimits( + stored: ClientMediaLimits | undefined, + ): { minBytes?: number; maxBytes?: number } | null { + if (!stored) return null; + const hasMin = typeof stored.minBytes === "number"; + const hasMax = typeof stored.maxBytes === "number"; + if (!hasMin && !hasMax) return null; + return { + ...(hasMin ? { minBytes: stored.minBytes } : {}), + ...(hasMax ? { maxBytes: stored.maxBytes } : {}), + }; + } + + private validateMediaLimitsPatch( + kind: MediaKind, + patch: { minBytes?: number; maxBytes?: number }, + ): { minBytes?: number; maxBytes?: number } { + const defaults = DEFAULT_MEDIA_LIMITS[kind]; + const routeMax = MEDIA_ROUTE_MAX_BYTES[kind]; + const minBytes = + patch.minBytes !== undefined ? patch.minBytes : defaults.minBytes; + const maxBytes = + patch.maxBytes !== undefined ? patch.maxBytes : defaults.maxBytes; + + if (maxBytes > routeMax) { + throw new BadRequestException( + `${kind} maxBytes (${maxBytes}) cannot exceed the route upload limit of ${routeMax} bytes.`, + ); + } + if (minBytes > maxBytes) { + throw new BadRequestException( + `${kind} minBytes (${minBytes}) cannot be greater than maxBytes (${maxBytes}).`, + ); + } + + const out: { minBytes?: number; maxBytes?: number } = {}; + if (patch.minBytes !== undefined) out.minBytes = patch.minBytes; + if (patch.maxBytes !== undefined) out.maxBytes = patch.maxBytes; + return out; + } + + async getPanelSettings( + clientKey: string | Types.ObjectId, + ): Promise { + const client = await this.loadClientOrThrow(clientKey); + const clientId = client._id.toString(); + + const configuredDays = client.settings?.carBodyAccidentMaxAgeDays; + const effectiveDays = await this.getCarBodyAccidentMaxAgeDays(client._id); + + const media = {} as ClientSettingsPanelResponseDto["media"]; + for (const kind of MEDIA_KINDS) { + const effective = await this.getMediaLimits(client._id, kind); + media[kind] = { + configured: this.configuredMediaLimits(client.settings?.media?.[kind]), + effective, + systemDefault: { ...DEFAULT_MEDIA_LIMITS[kind] }, + routeMaxBytes: MEDIA_ROUTE_MAX_BYTES[kind], + }; + } + + return { + clientId, + carBodyAccidentMaxAgeDays: { + configured: + typeof configuredDays === "number" && Number.isFinite(configuredDays) + ? configuredDays + : null, + effective: effectiveDays, + systemDefault: DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS, + minDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN, + maxDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX, + }, + media, + }; + } + + async updatePanelSettings( + clientKey: string | Types.ObjectId, + body: UpdateClientSettingsDto, + ): Promise { + const client = await this.loadClientOrThrow(clientKey); + const $set: Record = {}; + + if (body.carBodyAccidentMaxAgeDays !== undefined) { + $set["settings.carBodyAccidentMaxAgeDays"] = body.carBodyAccidentMaxAgeDays; + } + + if (body.media) { + for (const kind of MEDIA_KINDS) { + const patch = body.media[kind]; + if (!patch) continue; + const validated = this.validateMediaLimitsPatch(kind, patch); + if (validated.minBytes !== undefined) { + $set[`settings.media.${kind}.minBytes`] = validated.minBytes; + } + if (validated.maxBytes !== undefined) { + $set[`settings.media.${kind}.maxBytes`] = validated.maxBytes; + } + } + } + + if (Object.keys($set).length === 0) { + throw new BadRequestException("No settings fields to update."); + } + + const updated = await this.clientDbService.findByIdAndUpdate( + client._id.toString(), + { $set }, + ); + if (!updated) { + throw new NotFoundException("Client not found"); + } + + return this.getPanelSettings(client._id); + } } diff --git a/src/client/dto/client-settings.dto.ts b/src/client/dto/client-settings.dto.ts new file mode 100644 index 0000000..fbc7997 --- /dev/null +++ b/src/client/dto/client-settings.dto.ts @@ -0,0 +1,131 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsInt, + IsOptional, + Max, + Min, + ValidateNested, +} from "class-validator"; +import type { MediaKind } from "../client.service"; + +export class ClientMediaLimitsDto { + @ApiPropertyOptional({ + description: "Minimum upload size in bytes (inclusive). Omit to keep default.", + example: 5120, + }) + @IsOptional() + @IsInt() + @Min(0) + minBytes?: number; + + @ApiPropertyOptional({ + description: "Maximum upload size in bytes (inclusive). Cannot exceed route ceiling.", + example: 8388608, + }) + @IsOptional() + @IsInt() + @Min(1) + maxBytes?: number; +} + +export class ClientMediaSettingsDto { + @ApiPropertyOptional({ type: ClientMediaLimitsDto }) + @IsOptional() + @ValidateNested() + @Type(() => ClientMediaLimitsDto) + video?: ClientMediaLimitsDto; + + @ApiPropertyOptional({ type: ClientMediaLimitsDto }) + @IsOptional() + @ValidateNested() + @Type(() => ClientMediaLimitsDto) + image?: ClientMediaLimitsDto; + + @ApiPropertyOptional({ type: ClientMediaLimitsDto }) + @IsOptional() + @ValidateNested() + @Type(() => ClientMediaLimitsDto) + voice?: ClientMediaLimitsDto; +} + +export class UpdateClientSettingsDto { + @ApiPropertyOptional({ + description: + "Max days between CAR_BODY accident date and submission. Omit to leave unchanged.", + example: 7, + minimum: 1, + maximum: 365, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(365) + carBodyAccidentMaxAgeDays?: number; + + @ApiPropertyOptional({ type: ClientMediaSettingsDto }) + @IsOptional() + @ValidateNested() + @Type(() => ClientMediaSettingsDto) + media?: ClientMediaSettingsDto; +} + +export class MediaLimitsResolvedDto { + @ApiProperty({ example: 5120 }) + minBytes: number; + + @ApiProperty({ example: 8388608 }) + maxBytes: number; +} + +export class MediaKindSettingsViewDto { + @ApiPropertyOptional({ type: ClientMediaLimitsDto }) + configured: { minBytes?: number; maxBytes?: number } | null; + + @ApiProperty({ type: MediaLimitsResolvedDto }) + effective: MediaLimitsResolvedDto; + + @ApiProperty({ type: MediaLimitsResolvedDto }) + systemDefault: MediaLimitsResolvedDto; + + @ApiProperty({ + description: "Hard multer ceiling for this kind on upload routes (bytes)", + example: 10485760, + }) + routeMaxBytes: number; +} + +export class CarBodyAccidentWindowViewDto { + @ApiPropertyOptional({ example: 14, nullable: true }) + configured: number | null; + + @ApiProperty({ example: 14 }) + effective: number; + + @ApiProperty({ example: 7 }) + systemDefault: number; + + @ApiProperty({ example: 1 }) + minDays: number; + + @ApiProperty({ example: 365 }) + maxDays: number; +} + +export class ClientSettingsPanelResponseDto { + @ApiProperty({ example: "507f1f77bcf86cd799439011" }) + clientId: string; + + @ApiProperty({ type: CarBodyAccidentWindowViewDto }) + carBodyAccidentMaxAgeDays: CarBodyAccidentWindowViewDto; + + @ApiProperty({ + description: "Per-kind upload bounds (video / image / voice)", + example: { + video: {}, + image: {}, + voice: {}, + }, + }) + media: Record; +} diff --git a/src/client/entities/db-service/client.db.service.ts b/src/client/entities/db-service/client.db.service.ts index e218faa..24978f7 100644 --- a/src/client/entities/db-service/client.db.service.ts +++ b/src/client/entities/db-service/client.db.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; -import { FilterQuery, Model } from "mongoose"; +import { FilterQuery, Model, UpdateQuery } from "mongoose"; import { ClientModel, ClientDocument } from "../schema/client.schema"; @Injectable() @@ -25,4 +25,14 @@ export class ClientDbService { async findAll(): Promise { return await this.clientModel.find(); } + + async findByIdAndUpdate( + id: string, + update: UpdateQuery, + ): Promise { + return this.clientModel + .findByIdAndUpdate(id, update, { new: true }) + .lean() + .exec(); + } }